Skip to content

Conversation

@kirkbrauer
Copy link
Member

@kirkbrauer kirkbrauer commented Sep 5, 2025

Implement new hook scripts that can be configured in the exporter config to run j commands within a temporary lease environment.

The following environment variables are exposed to hook scripts:

  • JUMPSTARTER_HOST - Path to the Unix socket for j CLI access
  • JMP_DRIVERS_ALLOW=UNSAFE - Allows all drivers for local access
  • LEASE_NAME - The name of the current lease
  • CLIENT_NAME - Name of the client that acquired the lease

Example YAML config:

apiVersion: jumpstarter.dev/v1alpha1
kind: ExporterConfig
metadata:
  namespace: default
  name: test-exporter
endpoint: grpc.jumpstarter.100.123.60.47.nip.io:8082
hooks:
  beforeLease:
    script: |
      echo "Starting lease setup for ${CLIENT_NAME}"
      echo "Setting up power management..."
      j power on
      echo "Initializing device connections..."
      echo "Running pre-lease diagnostics..."
      sleep 5
      echo "Pre-lease setup completed successfully!"
    timeout: 120
    onFailure: exit
  afterLease:
    script: |
      echo "Starting lease cleanup for ${CLIENT_NAME}"
      echo "Shutting down power..."
      j power off
      echo "Cleaning up device connections..."
      echo "Running post-lease diagnostics..."
      sleep 5
      echo "Post-lease cleanup completed successfully!"
    timeout: 60
    onFailure: warn
tls:
  ca: ''
  insecure: true
token: abc123
grpcOptions: {}
export:
  power:
    type: "jumpstarter_driver_power.driver.MockPower"
    config: {}

Summary by CodeRabbit

  • New Features
    • Exporter status is now visible (STATUS column, CLI "status" option) and affects client readiness; exporter exit codes can determine run outcomes. Added before/after lease hook lifecycle with timeouts and configurable failure modes. Session end handling (EndSession) supported.
  • Tests
    • New and updated tests for hooks, status reporting, rendering, readiness gating, and logging.
  • Chores
    • Added generated typing stubs and new public enums/type aliases for status and log sources.

✏️ Tip: You can customize this high-level summary in your review settings.

@netlify
Copy link

netlify bot commented Sep 5, 2025

Deploy Preview for jumpstarter-docs ready!

Name Link
🔨 Latest commit 8d1a510
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/6968eb810b0c6f000892714e
😎 Deploy Preview https://deploy-preview-606--jumpstarter-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 5, 2025

📝 Walkthrough

Walkthrough

Adds exporter lifecycle and readiness: new ExporterStatus/LogSource enums and protobuf fields/stubs; controller registration, listen/status streams, and EndSession RPC; per-lease LeaseContext and session orchestration; HookExecutor for before/after-lease hooks; logging source mapping; client preflight GetStatus gating; tests and CLI/status display updates.

Changes

Cohort / File(s) Summary
Buf config
\buf.gen.yaml``
Added mypy and mypy-grpc plugin entries (v3.7.0).
Generated protocol stubs (.pyi)
\packages/jumpstarter-protocol/.../*.pyi``
Added many mypy-protobuf-generated type stubs for v1 (common, jumpstarter, router, kubernetes) and gRPC client/server .pyi files — typing surface only.
Generated protobufs (.py/.pyi)
\packages/jumpstarter-protocol/.../client_pb2*.py*, `\`.../jumpstarter_pb2*.py*, \.../pb2_grpc.py*``
Added Exporter.status_message field and EndSession RPC wiring; updated serialized descriptor offsets and corresponding generated stubs (.py / .pyi).
Common enums & types
\packages/jumpstarter/jumpstarter/common/enums.py`, `packages/jumpstarter/jumpstarter/common/types.py`, `packages/jumpstarter/jumpstarter/common/init.py``
New ExporterStatus and LogSource enums; new gRPC-related type aliases; exported via common package.
Client config & CLI
\packages/jumpstarter/jumpstarter/config/client.py`, `packages/jumpstarter-cli/jumpstarter_cli/get.py``
list_exporters gains include_status and channel typing; CLI adds "status" option and forwards include_status.
Client core & admin tests
\packages/jumpstarter/jumpstarter/client/core.py`, `packages/jumpstarter-cli-admin/.../create_test.py`, `packages/jumpstarter-cli-admin/.../get_test.py``
Added ExporterNotReady, get_status/check_exporter_status/end_session APIs and gating before driver calls; admin tests updated to expect exporterStatus/statusMessage fields.
Client gRPC models/UI
\packages/jumpstarter/jumpstarter/client/grpc.py`, `packages/jumpstarter-protocol/.../client_pb2.pyi``
Exporter model includes status; lists and rendering support STATUS column; model_dump respects include_status; generated client proto stubs added.
Exporter core & lifecycle
\packages/jumpstarter/jumpstarter/exporter/exporter.py`, `packages/jumpstarter/jumpstarter/exporter/lease_context.py`, `packages/jumpstarter/jumpstarter/exporter/session.py`, `packages/jumpstarter/jumpstarter/exporter/logging.py`, `packages/jumpstarter/jumpstarter/exporter/logging_protocol.py``
Major refactor: controller registration/unregistration, retrying listen/status streams, per-lease LeaseContext, session orchestration, status reporting, EndSession handling, logging source mapping, and exit-code handling.
Hooks & config/tests
\packages/jumpstarter/jumpstarter/exporter/hooks.py`, `packages/jumpstarter/jumpstarter/exporter/hooks_test.py`, `packages/jumpstarter/jumpstarter/config/exporter.py`, `packages/jumpstarter/jumpstarter/config/exporter_test.py``
New HookExecutor and HookExecutionError; HookConfig/HookInstanceConfig added; hook execution (before/after) with timeouts/on_failure handling and tests; hooks wired into exporter instantiation.
Kubernetes exporter model & tests
\packages/jumpstarter-kubernetes/.../exporters.py`, `packages/jumpstarter-kubernetes/.../exporters_test.py``
V1Alpha1ExporterStatus adds exporter_status/status_message (aliases exporterStatus/statusMessage); rich renderer and tests updated to include STATUS column and serialized fields.
CLI runtime & shell
\packages/jumpstarter-cli/jumpstarter_cli/run.py`, `packages/jumpstarter-cli/jumpstarter_cli/shell.py``
Run now checks exporter.exit_code for process return priority; shell flow splits shell-only helper and async lease-aware shell runner that awaits EndSession after logs.
Testing adjustments
\packages/jumpstarter-testing/.../pytest_test.py`, `packages/jumpstarter-kubernetes/.../exporters_test.py``, tests under cli-admin/hooks etc.
Tests updated to set/expect exporter status and new serialized fields; hook executor tests added.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Exporter
  participant Controller
  participant HookExec as HookExecutor
  participant LeaseCtx as LeaseContext

  Note over Exporter,Controller: Exporter registers and streams with Controller
  Exporter->>Controller: Register(metadata)
  Controller-->>Exporter: Ack (uuid)

  Controller->>Exporter: Listen stream -> lease assignment
  Exporter->>LeaseCtx: create LeaseContext(lease_name)

  Exporter->>HookExec: run_before_lease_hook(lease_ctx)
  HookExec->>Controller: report_status(BEFORE_LEASE_HOOK)
  HookExec-->>Exporter: success / HookExecutionError

  Exporter->>Exporter: update session -> LEASE_READY
  Client->>Exporter: DriverCall (preflight)
  Exporter->>Controller: GetStatus (if needed)
  alt status in [LEASE_READY, BEFORE_LEASE_HOOK, AFTER_LEASE_HOOK]
    Exporter-->>Client: forward DriverCall -> driver response
  else
    Exporter-->>Client: raise ExporterNotReady
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120+ minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • mangelajo
  • NickCao

Poem

🐰 I hopped through streams and hooks with cheer,

I nudged exporter status so callers know near.
Controllers hand out leases, hooks hum a tune,
Logs wear labels by source beneath the moon.
Hooray — leases start when readiness is clear.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add pre/post lease hooks' directly and clearly describes the main change in the pull request, which implements configurable hook scripts for lease lifecycle management.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mangelajo
Copy link
Member

Nice!! this will be really really useful

@mangelajo
Copy link
Member

https://github.com/jumpstarter-dev/jumpstarter/pull/681/files#diff-33252c222f4635c1d7362569aeb3f7355a001cb7f4851ffa18313dc9088bfc56R194

This would be a hook point when the connection is ready and you can start talking to the exporter.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (1)

1-20: Generated gRPC typing stub looks correct

The new _MaybeAsyncIterator, _ServicerContext, and GRPC version symbols follow the same pattern as existing *_pb2_grpc.pyi files and are clearly marked as generated; no manual changes needed here.

🧹 Nitpick comments (17)
packages/jumpstarter/jumpstarter/exporter/logging_protocol.py (1)

1-22: LoggerRegistration protocol is clear and minimal

The LoggerRegistration Protocol cleanly captures the one needed capability (add_logger_source) and avoids additional coupling; good fit for typing and circular‑dependency avoidance. If you ever need isinstance checks on this, consider adding @runtime_checkable, but it’s not required today.

packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py (1)

303-309: Exporter status test expectations are updated consistently

The fixtures now populate exporter_status="Available" and the JSON/YAML expectations include exporterStatus plus statusMessage: null, with corresponding assertions that "Available" appears in human‑readable output. This keeps the admin CLI tests in sync with the extended exporter status surface across single exporters, device‑rich exporters, and list views.

It may be worth adding a future test case where statusMessage is non‑null to exercise that branch of the serialization as well, but the current coverage is fine for this change.

Also applies to: 324-327, 331-345, 358-359, 400-408, 437-440, 445-463, 476-477, 523-540, 560-563, 588-603, 614-616, 636-637, 677-684, 691-697, 725-727, 750-753, 760-777, 791-793, 812-813

packages/jumpstarter/jumpstarter/config/client.py (1)

123-133: channel typing + include_status wiring look good, but double‑check status propagation with leases

  • The explicit grpc.aio.Channel return type on channel() and the new include_status flag on list_exporters() are both clean and consistent with the CLI changes.
  • Mirroring the existing include_online behavior by tagging exporters_response.include_status is reasonable for display logic.

One thing to verify: when include_leases is True, you rebuild exporters_response.exporters using Exporter(namespace=..., name=..., labels=..., online=exporter.online, lease=lease). If Exporter has newly added status fields (e.g., exporter status / message), they are not currently forwarded into this wrapper, which could drop status information precisely in the --with leases,status case.

It’s worth confirming the Exporter type and, if it now carries status, passing those fields through when constructing Exporter so status remains visible when leases are included.

Also applies to: 156-171, 184-197

packages/jumpstarter/jumpstarter/config/exporter_test.py (1)

140-149: Consider adding timeout assertions for completeness.

The test verifies script content and round-trip behavior but doesn't assert the timeout values. Since the YAML specifies timeout: 600 for both hooks, consider adding assertions to verify timeout parsing:

     assert config.hooks.before_lease.script == 'echo "Pre-lease hook for $LEASE_NAME"\nj power on\n'
     assert config.hooks.after_lease.script == 'echo "Post-lease hook for $LEASE_NAME"\nj power off\n'
+    assert config.hooks.before_lease.timeout == 600
+    assert config.hooks.after_lease.timeout == 600

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

     assert reloaded_config.hooks.before_lease.script == config.hooks.before_lease.script
     assert reloaded_config.hooks.after_lease.script == config.hooks.after_lease.script
+    assert reloaded_config.hooks.before_lease.timeout == config.hooks.before_lease.timeout
+    assert reloaded_config.hooks.after_lease.timeout == config.hooks.after_lease.timeout
packages/jumpstarter/jumpstarter/client/grpc.py (1)

96-99: Consider simplifying the status parsing logic.

The hasattr check is likely unnecessary since protobuf message fields are always defined (they default to their zero value). You could simplify to just check if data.status is truthy:

-        status = None
-        if hasattr(data, "status") and data.status:
-            status = ExporterStatus.from_proto(data.status)
-        return cls(namespace=namespace, name=name, labels=data.labels, online=data.online, status=status)
+        status = ExporterStatus.from_proto(data.status) if data.status else None
+        return cls(namespace=namespace, name=name, labels=data.labels, online=data.online, status=status)

However, if this is defensive against older controller versions that may not send the field, the current approach is fine.

packages/jumpstarter/jumpstarter/common/utils.py (1)

22-27: Consider moving the import to module level.

The ExporterStatus import inside serve_async works but is unconventional. Since ExporterStatus is a simple enum (not causing circular imports), consider importing it at the module level for consistency:

+from jumpstarter.common import ExporterStatus
 from jumpstarter.client import client_from_path
 from jumpstarter.config.env import JMP_DRIVERS_ALLOW, JUMPSTARTER_HOST
 from jumpstarter.exporter import Session
 from jumpstarter.utils.env import env

 if TYPE_CHECKING:
     from jumpstarter.driver import Driver

 ...

 @asynccontextmanager
 async def serve_async(root_device: "Driver", portal: BlockingPortal, stack: ExitStack):
-    from jumpstarter.common import ExporterStatus
-
     with Session(root_device=root_device) as session:

Otherwise, the status update logic and comment explaining the local testing context are clear.

packages/jumpstarter/jumpstarter/common/enums.py (1)

8-76: Tidy up docstring typo; consider unknown-status fallback (optional)

The enum wiring against common_pb2 looks good. Two small nits:

  • There’s a typo in the AFTER_LEASE_HOOK docstring: "Lease was releaseed""Lease was released".
  • Optional: ExporterStatus.from_proto will raise if a future proto introduces a new status value; if you expect newer servers/clients to mix, you might want to catch ValueError and map unknowns to UNSPECIFIED.

A minimal docstring fix could look like:

-    AFTER_LEASE_HOOK = common_pb2.EXPORTER_STATUS_AFTER_LEASE_HOOK
-    """Lease was releaseed, but exporter is executing after lease hook"""
+    AFTER_LEASE_HOOK = common_pb2.EXPORTER_STATUS_AFTER_LEASE_HOOK
+    """Lease was released, but exporter is executing after lease hook"""
packages/jumpstarter/jumpstarter/client/core.py (1)

53-57: Exporter readiness gating looks correct; watch per-call GetStatus overhead

The new ExporterNotReady plus check_exporter_status() flow is a good fit for guarding driver calls on LEASE_READY and handling older exporters by treating UNIMPLEMENTED as “ready”. The integration into both call_async and streamingcall_async is clean.

One thing to keep an eye on: this adds an extra unary GetStatus RPC before every driver RPC. If you ever have very chatty drivers (tight loops of small calls), you may want to cache a “known-good” LEASE_READY state and invalidate it on failures or via a status stream, rather than checking on every single call. That’s a future optimization, not a blocker.

Also applies to: 87-102, 103-137

packages/jumpstarter/jumpstarter/exporter/lease_context.py (1)

1-5: Align naming and docs for LeaseContext (vs LeaseScope) and socket_path semantics

Functionally this looks good, but there are a couple of inconsistencies worth tightening up:

  • The module/class docs and __post_init__ assertion messages still refer to LeaseScope while the class is LeaseContext.
  • The attribute docs say socket_path: str | None, but the field is a plain str that uses "" as “unset”.

A minimal cleanup:

-"""LeaseScope: Context manager for lease-related resources.
+"""LeaseContext: Context manager for lease-related resources.

@@
-        socket_path: Unix socket path where the session is serving (set in handle_lease)
+        socket_path: Unix socket path where the session is serving (set in handle_lease)
@@
-        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"
+        assert self.before_lease_hook is not None, "LeaseContext requires a before_lease_hook event"
+        assert self.lease_name, "LeaseContext requires a non-empty lease_name"

And either adjust the docstring to say socket_path: str with "" meaning “not set”, or change the field type/default to str | None = None so the docs and type align.

Also applies to: 26-34, 44-48

packages/jumpstarter/jumpstarter/config/exporter.py (1)

28-28: Consider adding script validation.

The script field has no default value, which is fine since it's required, but there's no validation for minimum content. An empty string would pass model validation but would be silently skipped at runtime (line 90-92 in hooks.py). Consider adding a min_length=1 validator for early feedback.

-    script: str = Field(alias="script", description="The j script to execute for this hook")
+    script: str = Field(alias="script", min_length=1, description="The j script to execute for this hook")
packages/jumpstarter/jumpstarter/exporter/hooks.py (3)

49-54: device_factory field appears unused.

The HookExecutor class declares a device_factory field but it's never referenced within the class methods. The device is accessed via lease_scope.session instead. If this is dead code, consider removing it; if it's intended for future use, document the intent.

#!/bin/bash
# Verify device_factory is not used anywhere in the HookExecutor
rg -n 'device_factory' packages/jumpstarter/jumpstarter/exporter/hooks.py

279-281: Using assert for runtime validation may be stripped in optimized mode.

Python's -O flag removes assert statements. If this validation is critical for safety, consider using an explicit if not ... raise pattern instead.

-            assert lease_scope.is_ready(), "LeaseScope must be fully initialized before running before-lease hooks"
+            if not lease_scope.is_ready():
+                raise RuntimeError("LeaseScope must be fully initialized before running before-lease hooks")

Also applies to: 353-355


319-319: Acknowledged: TODO for controller-side lease termination.

The TODO notes that a controller-side mechanism is needed to actually end the lease when on_failure='endLease'. Currently, the status is reported but the lease isn't forcibly terminated.

Would you like me to open an issue to track implementing the controller-side lease termination mechanism?

packages/jumpstarter/jumpstarter/exporter/logging.py (2)

31-39: Potential performance concern with prefix matching.

The get_source_for_record method iterates through all child handlers for every log record. If many loggers are registered, this could become a bottleneck in high-frequency logging scenarios.

For now this is likely fine given the expected number of hooks, but consider using a more efficient lookup structure (like a trie or sorted list with binary search) if performance becomes an issue.


36-38: Prefix matching order is non-deterministic.

Dictionary iteration order is insertion order in Python 3.7+, but if multiple prefixes could match the same logger name (e.g., hook and hook.lease1), the first match wins. This behavior may be surprising. Consider documenting this or using longest-prefix matching.

packages/jumpstarter/jumpstarter/exporter/exporter.py (2)

178-215: Retry logic is resilient but consider narrowing exception scope.

The _retry_stream method provides good retry semantics with backoff. The retry counter reset on success (line 215) ensures sustained connectivity issues are handled well.

The broad Exception catch at line 201 is pragmatic for gRPC streaming resilience, but could mask non-retriable programming errors. Consider catching grpc.RpcError and OSError specifically to allow genuine bugs to surface during development.

-            except Exception as e:
+            except (grpc.RpcError, OSError, ConnectionError) as e:

268-287: Consider lazy string formatting for logging.

Lines 285 and 287 use f-strings for logging, which evaluates the string regardless of whether the log level is enabled. While not critical for this infrequent code path, using % formatting is the idiomatic pattern.

-            logger.info(f"Updated status to {status}: {message}")
+            logger.info("Updated status to %s: %s", status, message)
         except Exception as e:
-            logger.error(f"Failed to update status: {e}")
+            logger.error("Failed to update status: %s", e)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6f78e5c and a129ff7.

📒 Files selected for processing (33)
  • buf.gen.yaml (1 hunks)
  • packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py (2 hunks)
  • packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py (22 hunks)
  • packages/jumpstarter-cli/jumpstarter_cli/get.py (2 hunks)
  • packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py (4 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi (1 hunks)
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (1 hunks)
  • packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py (2 hunks)
  • packages/jumpstarter/jumpstarter/client/core.py (5 hunks)
  • packages/jumpstarter/jumpstarter/client/grpc.py (9 hunks)
  • packages/jumpstarter/jumpstarter/common/__init__.py (1 hunks)
  • packages/jumpstarter/jumpstarter/common/enums.py (1 hunks)
  • packages/jumpstarter/jumpstarter/common/types.py (1 hunks)
  • packages/jumpstarter/jumpstarter/common/utils.py (2 hunks)
  • packages/jumpstarter/jumpstarter/config/client.py (2 hunks)
  • packages/jumpstarter/jumpstarter/config/exporter.py (12 hunks)
  • packages/jumpstarter/jumpstarter/config/exporter_test.py (1 hunks)
  • packages/jumpstarter/jumpstarter/driver/base.py (2 hunks)
  • packages/jumpstarter/jumpstarter/exporter/exporter.py (5 hunks)
  • packages/jumpstarter/jumpstarter/exporter/hooks.py (1 hunks)
  • packages/jumpstarter/jumpstarter/exporter/hooks_test.py (1 hunks)
  • packages/jumpstarter/jumpstarter/exporter/lease_context.py (1 hunks)
  • packages/jumpstarter/jumpstarter/exporter/logging.py (1 hunks)
  • packages/jumpstarter/jumpstarter/exporter/logging_protocol.py (1 hunks)
  • packages/jumpstarter/jumpstarter/exporter/session.py (5 hunks)
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.
📚 Learning: 2025-09-02T07:32:04.548Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py:0-0
Timestamp: 2025-09-02T07:32:04.548Z
Learning: *_pb2.py files in the jumpstarter codebase are auto-generated from gRPC/protobuf definitions and should not be manually modified. Issues with these files should be addressed at the protobuf generation tooling level or runtime environment setup.

Applied to files:

  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi
📚 Learning: 2025-10-02T15:29:09.070Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 681
File: packages/jumpstarter/jumpstarter/client/lease.py:198-220
Timestamp: 2025-10-02T15:29:09.070Z
Learning: In the jumpstarter codebase, `client_from_path` and `client_from_channel` functions in `packages/jumpstarter/jumpstarter/client/client.py` automatically call `GetReport(empty_pb2.Empty())` during client initialization. This means that successfully entering the client context manager guarantees the exporter is responsive and has answered the GetReport RPC.

Applied to files:

  • packages/jumpstarter/jumpstarter/config/client.py
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi
📚 Learning: 2025-10-06T15:44:51.111Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 690
File: packages/jumpstarter/jumpstarter/config/exporter.py:51-56
Timestamp: 2025-10-06T15:44:51.111Z
Learning: In jumpstarter config instantiation (packages/jumpstarter/jumpstarter/config/exporter.py), passing **self.root.config to driver_class constructors is intentional validation behavior: since Driver classes are dataclasses, the __init__ will reject any config keys that don't match declared fields with a TypeError, ensuring only declared/supported fields can be used in config YAML files. This is the desired behavior for config safety.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter/jumpstarter/exporter/session.py
  • packages/jumpstarter/jumpstarter/config/exporter.py
📚 Learning: 2025-08-14T13:11:35.034Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.

Applied to files:

  • packages/jumpstarter/jumpstarter/exporter/hooks.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
🧬 Code graph analysis (15)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (4)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (28-28)
  • _ServicerContext (30-31)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (22-22)
  • _ServicerContext (24-25)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py (1)
packages/jumpstarter/jumpstarter/exporter/session.py (1)
  • update_status (152-158)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (4)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (28-28)
  • _ServicerContext (30-31)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (22-22)
  • _ServicerContext (24-25)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (3)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (22-22)
  • _ServicerContext (24-25)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter/jumpstarter/client/core.py (4)
packages/jumpstarter/jumpstarter/common/enums.py (3)
  • ExporterStatus (8-45)
  • from_proto (39-41)
  • from_proto (70-72)
packages/jumpstarter/jumpstarter/exporter/session.py (1)
  • GetStatus (172-178)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.py (2)
  • GetStatus (613-617)
  • GetStatus (801-825)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1)
  • GetStatusRequest (690-695)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (1)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (1)
  • _MaybeAsyncIterator (14-14)
packages/jumpstarter/jumpstarter/common/enums.py (1)
packages/jumpstarter/jumpstarter/common/metadata.py (1)
  • name (13-14)
packages/jumpstarter/jumpstarter/exporter/hooks.py (3)
packages/jumpstarter/jumpstarter/config/exporter.py (2)
  • HookConfigV1Alpha1 (44-50)
  • HookInstanceConfigV1Alpha1 (23-41)
packages/jumpstarter/jumpstarter/exporter/session.py (1)
  • Session (33-178)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (2)
  • LeaseContext (19-84)
  • is_ready (49-55)
packages/jumpstarter/jumpstarter/client/grpc.py (3)
packages/jumpstarter/jumpstarter/common/enums.py (3)
  • ExporterStatus (8-45)
  • from_proto (39-41)
  • from_proto (70-72)
packages/jumpstarter/jumpstarter/common/grpc.py (1)
  • translate_grpc_exceptions (164-182)
packages/jumpstarter/jumpstarter/exporter/exporter.py (1)
  • Exporter (39-541)
packages/jumpstarter-cli/jumpstarter_cli/get.py (1)
packages/jumpstarter/jumpstarter/config/client.py (1)
  • list_exporters (156-197)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (4)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (28-28)
  • _ServicerContext (30-31)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter/jumpstarter/exporter/logging.py (4)
packages/jumpstarter/jumpstarter/exporter/logging_protocol.py (2)
  • LoggerRegistration (8-22)
  • add_logger_source (15-22)
packages/jumpstarter/jumpstarter/common/enums.py (1)
  • LogSource (48-76)
packages/jumpstarter/jumpstarter/common/metadata.py (1)
  • name (13-14)
packages/jumpstarter/jumpstarter/exporter/session.py (2)
  • context_log_source (168-170)
  • add_logger_source (160-162)
packages/jumpstarter/jumpstarter/config/exporter.py (4)
packages/jumpstarter/jumpstarter/exporter/exporter.py (1)
  • session (353-373)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (1)
  • update_status (73-84)
packages/jumpstarter/jumpstarter/exporter/session.py (1)
  • update_status (152-158)
packages/jumpstarter/jumpstarter/exporter/hooks.py (1)
  • HookExecutor (50-402)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi (1)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (4)
  • ClearField (44-44)
  • ClearField (67-67)
  • ClearField (89-89)
  • ClearField (105-105)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py (1)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py (4)
  • rich_add_columns (81-91)
  • rich_add_columns (139-140)
  • rich_add_rows (110-125)
  • rich_add_rows (142-144)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: e2e
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
🔇 Additional comments (39)
buf.gen.yaml (1)

9-12: Mypy plugin additions look consistent

The extra mypy / mypy‑grpc plugins are configured consistently with the existing buf plugins (same out, pinned versions) and should integrate cleanly into codegen.

packages/jumpstarter-cli/jumpstarter_cli/get.py (1)

24-38: Status option wiring for get exporters looks correct

Including "status" in the with options, deriving include_status, and passing it through to config.list_exporters aligns with the updated client config API and keeps the help text accurate.

packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py (1)

224-258: Exporter JSON/YAML expectations updated consistently

The additional exporterStatus and statusMessage: null fields in the exporter status JSON/YAML fixtures align with the extended status model and mirror the formats used elsewhere in the test suite.

packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py (1)

4-5: Explicitly setting LEASE_READY in tests is a sensible shortcut

Importing ExporterStatus and calling session.update_status(ExporterStatus.LEASE_READY) inside the serve_unix() context is a straightforward way to emulate a ready exporter in local pytest runs where there’s no real lease/hook flow. This keeps the test focused on the client behavior rather than full lifecycle orchestration.

Also applies to: 20-27

packages/jumpstarter/jumpstarter/driver/base.py (1)

30-31: LGTM! Logging refactored to use centralized exporter logging infrastructure.

The driver now uses the centralized get_logger with LogSource.DRIVER for consistent log source tagging across the exporter. The logger name pattern driver.{ClassName} provides good granularity for filtering.

Also applies to: 38-39, 89-90

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (1)

1-20: Auto-generated protobuf stub file.

This file is auto-generated by mypy-protobuf as indicated by the header comment. The structure matches other generated *_pb2_grpc.pyi stubs in the codebase.

packages/jumpstarter/jumpstarter/client/grpc.py (2)

15-16: LGTM! Status display capability properly integrated.

The exporter status feature is consistently integrated across:

  • WithOptions with the new show_status flag
  • Column/row rendering functions
  • Exporter model with the new status field
  • ExporterList with conditional status inclusion in serialization

The rendering gracefully handles None status by displaying "UNKNOWN".

Also applies to: 23-24, 32-34, 48-51, 90-91, 257-258, 267-275, 292-307


489-489: Good type refinement.

Changing dict[Channel, Any] to dict[Channel, SimpleNamespace] provides better type safety for the internal stubs dictionary.

packages/jumpstarter/jumpstarter/common/__init__.py (1)

1-22: LGTM! Clean public API expansion.

The __all__ list is properly updated and alphabetically sorted, making the new ExporterStatus, LogSource, and type aliases (AsyncChannel, ControllerStub, ExporterStub, RouterStub) available as part of the public API.

packages/jumpstarter/jumpstarter/exporter/hooks_test.py (2)

61-117: LGTM! Comprehensive test coverage for hook execution scenarios.

The test suite covers key scenarios:

  • Basic executor creation and empty config handling
  • Successful execution with environment variable verification
  • Failed execution with on_failure="endLease" raising HookExecutionError
  • Timeout handling with process termination
  • Environment variable sanitization (verifying removed fields)
  • Post-lease hook execution
  • Timeout with on_failure="warn" not raising

Good use of AsyncMock and patch for subprocess simulation.

Also applies to: 119-172, 174-205, 246-303


43-58: I'll help you verify the review comment about the socket_path usage in the fixture. Let me search for how HookExecutor accesses the socket path from the lease context.
<function_calls>

#!/bin/bash

Verify how HookExecutor accesses socket_path from lease context

rg -n "socket_path" packages/jumpstarter/jumpstarter/exporter/hooks.py -B2 -A2


</function_calls>

Let me also check the HookExecutor class definition and its usage of lease_scope:
<function_calls>

#!/bin/bash

Search for HookExecutor class and its initialization/usage of lease_scope

rg -n "HookExecutor" packages/jumpstarter/jumpstarter/exporter/hooks.py -B1 -A5


</function_calls>

Now let me check the LeaseContext class to understand its structure:
<function_calls>

#!/bin/bash

Find LeaseContext definition to understand its structure

rg -n "class LeaseContext" packages/jumpstarter/jumpstarter/exporter/lease_context.py -A10


</function_calls>

Let me also verify if there are any other tests using the lease_scope fixture to understand the pattern:
<function_calls>

#!/bin/bash

Check how the lease_scope fixture is used in tests

rg -n "lease_scope" packages/jumpstarter/jumpstarter/exporter/hooks_test.py -B1 -A3


</function_calls>

packages/jumpstarter/jumpstarter/common/types.py (1)

8-25: Centralized gRPC type aliases look consistent

The stub and channel aliases are straightforward and keep imports tidy; exporting them via __all__ makes the public surface clear. No issues from my side here.

packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py (1)

25-31: Exporter STATUS fields and table rendering look coherent

Mapping exporterStatus/statusMessage into V1Alpha1ExporterStatus and surfacing a STATUS column (with "Unknown" fallback) fits well with the existing list output and avoids new failure modes. The devices vs non-devices views both use the same status value, which keeps the UX consistent.

Also applies to: 60-62, 65-106

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi (1)

1-96: Generated protobuf enum stubs look fine

This file is clearly marked as mypy-protobuf generated and matches the ExporterStatus/LogSource usage elsewhere. Treating it as generated artifact and not hand-editing it is the right approach.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (1)

1-96: Generated RouterService gRPC typing surface is consistent

The RouterService stub/servicer typings follow the same pattern as the other v1 *_pb2_grpc.pyi files and are correctly marked as generated. Looks good to keep checked in as-is.

packages/jumpstarter/jumpstarter/config/exporter.py (5)

23-41: Well-structured hook configuration model.

The HookInstanceConfigV1Alpha1 model has sensible defaults: 120s timeout and "warn" on failure which provides safe defaults for users. The use of aliases (onFailure) ensures YAML compatibility with the example config in the PR description.


44-50: LGTM!

The HookConfigV1Alpha1 model cleanly separates before and after lease hooks with proper optional typing and aliases.


163-163: Good fix for YAML serialization consistency.

Adding by_alias=True ensures that the exported YAML uses the camelCase field names (e.g., beforeLease, onFailure) that match the documented configuration format.

Also applies to: 174-174


186-198: Appropriate status initialization for local serving.

Setting LEASE_READY status directly for local/Unix socket usage makes sense since there's no controller or lease lifecycle involved.


224-246: I'll verify the device_factory usage in HookExecutor to determine if the difference in configuration parameters is intentional.
<function_calls>

#!/bin/bash

Check HookExecutor implementation and how device_factory is used

rg -n -A 20 'class HookExecutor' packages/jumpstarter/jumpstarter/exporter/hooks.py


</function_calls>
<function_calls>

#!/bin/bash

Check how device_factory is used within HookExecutor

rg -n -B 5 -A 15 'device_factory' packages/jumpstarter/jumpstarter/exporter/hooks.py


</function_calls>

packages/jumpstarter/jumpstarter/exporter/hooks.py (6)

23-47: Well-designed error type with clear semantics.

The HookExecutionError dataclass provides clear predicates (should_shutdown_exporter, should_end_lease) that make the control flow in callers much more readable.


56-74: Security: Hooks run with elevated driver permissions.

Setting JMP_DRIVERS_ALLOW: "UNSAFE" grants full driver access to hook scripts. This is documented in the PR objectives, but ensure operators understand this grants significant access to the system. Consider adding a security note in the docstring.


160-167: Shell injection risk is acceptable here by design.

Using create_subprocess_shell with user-provided commands is intentional per the PR design (hook scripts are meant to run arbitrary commands). Ensure documentation clarifies that hook scripts should only be configured by trusted administrators.


176-184: Potential issue: stdout.readline() may not be available if process exits.

If the process exits or crashes before output reading completes, process.stdout could be None. However, since stdout=asyncio.subprocess.PIPE is explicitly set on line 165, this should always be available. The implementation looks correct.


197-208: Good defensive timeout handling on process termination.

The two-phase termination (SIGTERM with 5s grace period, then SIGKILL) is a robust pattern for cleaning up hung processes.


329-331: Critical: Event is always set, ensuring connections aren't blocked.

The finally block ensures before_lease_hook.set() is called regardless of success or failure, preventing deadlocks where clients wait indefinitely for the hook to complete.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi (1)

1-73: Auto-generated protobuf stub file.

This file is generated by mypy-protobuf and follows the standard patterns observed in other protocol stub files. No manual changes should be made here.

Based on learnings, issues with these files should be addressed at the protobuf generation tooling level.

packages/jumpstarter/jumpstarter/exporter/logging.py (3)

13-19: Good thread-safety initialization.

Using RLock is appropriate here since it allows the same thread to acquire the lock multiple times (reentrant), which could happen if logging triggers nested logging calls.


59-66: Context manager ensures cleanup on exception.

The context_log_source properly uses try/finally to remove the handler mapping even if an exception occurs, preventing resource leaks.


69-89: Clean helper for logger creation with source registration.

The get_logger function provides a clean API for obtaining a logger with automatic source mapping when a session is provided. This simplifies hook logging setup.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (1)

1-307: Auto-generated gRPC stub file.

This file is generated by mypy-protobuf for the client service and follows the standard patterns for typed gRPC stubs. The structure is consistent with other *_pb2_grpc.pyi files in the codebase.

Based on learnings, issues with these files should be addressed at the protobuf generation tooling level.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2.pyi (1)

1-148: Auto-generated protobuf stub looks correct.

This file is generated by mypy-protobuf and follows the standard patterns for Kubernetes v1 message type stubs. The type definitions for LabelSelectorRequirement, LabelSelector, Time, and Condition are consistent with other generated stubs in the codebase. Based on learnings, any issues should be addressed at the protobuf generation tooling level.

packages/jumpstarter/jumpstarter/exporter/exporter.py (4)

1-46: Clean import organization and enhanced documentation.

The new imports for Event, HookExecutor, LeaseContext, and ExporterStatus are well-organized. Using TYPE_CHECKING for the Driver import is the correct pattern to avoid circular imports while maintaining type hints.


81-151: Well-documented lifecycle state fields.

The new internal state fields (_registered, _exporter_status, _lease_context, etc.) are properly declared with init=False and appropriate defaults. The comprehensive docstrings clearly explain each field's purpose and lifecycle.


375-437: Clean lease handling implementation.

The handle_lease method properly coordinates the session lifecycle with hook execution. The pattern of waiting for the before_lease_hook event before accepting connections ensures clients don't connect during setup. The status synchronization after hook completion (line 424) correctly propagates the final status to the session.


502-531: Robust hook execution with proper shielding.

The before-lease hook is correctly spawned asynchronously (line 504-509), allowing the lease handling to continue preparing while the hook runs. The after-lease hook uses CancelScope(shield=True) to ensure cleanup runs even during task cancellation - this is essential for reliable teardown.

The has_client() check (lines 523, 466) properly gates after-lease hooks to only run when a client actually connected, avoiding unnecessary cleanup for unused leases.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (1)

1-752: Auto-generated gRPC stub follows established patterns.

This file is generated by mypy-protobuf and provides type-safe stubs for ControllerService and ExporterService. The _MaybeAsyncIterator and _ServicerContext scaffolding matches the patterns in other v1 gRPC stubs (as seen in the relevant code snippets). Based on learnings, any issues should be addressed at the protobuf generation tooling level.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi (1)

1-318: Auto-generated client protobuf stub is well-structured.

This file is generated by mypy-protobuf for the v1 client API. The message definitions (Exporter, Lease, request/response types) properly reference the common protobuf types like ExporterStatus and kubernetes_pb2.Condition. The structure aligns with protobuf 6.30.1 conventions. Based on learnings, any issues should be addressed at the protobuf generation tooling level.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1)

1-717: Comprehensive auto-generated protocol message stubs.

This file is generated by mypy-protobuf and provides type stubs for all core Jumpstarter v1 protocol messages. The definitions properly integrate with related types (ExporterStatus, LogSource, LabelSelector, Condition) and follow the established patterns for nested message types and map fields. Based on learnings, any issues should be addressed at the protobuf generation tooling level.

Comment on lines +44 to 47
_current_status: ExporterStatus = field(init=False, default=ExporterStatus.AVAILABLE)
_status_message: str = field(init=False, default="")
_status_update_event: Event = field(init=False)

Copy link
Contributor

@coderabbitai coderabbitai bot Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Script executed:

#!/bin/bash
# Check for uses of _status_update_event across the repo
rg -n "_status_update_event" -C2

Repository: jumpstarter-dev/jumpstarter


<shell_result>
Repository: jumpstarter-dev/jumpstarter
Exit code: 0

stdout:

packages/jumpstarter/jumpstarter/exporter/session.py-44-    _current_status: ExporterStatus = field(init=False, default=ExporterStatus.AVAILABLE)
packages/jumpstarter/jumpstarter/exporter/session.py-45-    _status_message: str = field(init=False, default="")
packages/jumpstarter/jumpstarter/exporter/session.py:46:    _status_update_event: Event = field(init=False)
packages/jumpstarter/jumpstarter/exporter/session.py-47-
packages/jumpstarter/jumpstarter/exporter/session.py-48-    @contextmanager
--
packages/jumpstarter/jumpstarter/exporter/session.py-74-        self._logging_queue = deque(maxlen=32)
packages/jumpstarter/jumpstarter/exporter/session.py-75-        self._logging_handler = LogHandler(self._logging_queue, LogSource.SYSTEM)
packages/jumpstarter/jumpstarter/exporter/session.py:76:        self._status_update_event = Event()
packages/jumpstarter/jumpstarter/exporter/session.py-77-
packages/jumpstarter/jumpstarter/exporter/session.py-78-        # Map all driver logs to DRIVER source

</shell_result>


Script executed:

#!/bin/bash
# Read the relevant sections of session.py
sed -n '1,100p' packages/jumpstarter/jumpstarter/exporter/session.py

Repository: jumpstarter-dev/jumpstarter


<shell_result>
Repository: jumpstarter-dev/jumpstarter
Exit code: 0

stdout:

import logging
from collections import deque
from collections.abc import Generator
from contextlib import asynccontextmanager, contextmanager, suppress
from dataclasses import dataclass, field
from logging.handlers import QueueHandler
from typing import TYPE_CHECKING, Self
from uuid import UUID

import grpc
from anyio import ContextManagerMixin, Event, TypedAttributeLookupError, sleep
from anyio.from_thread import start_blocking_portal
from jumpstarter_protocol import (
    jumpstarter_pb2,
    jumpstarter_pb2_grpc,
    router_pb2_grpc,
)

from .logging import LogHandler
from jumpstarter.common import ExporterStatus, LogSource, Metadata, TemporarySocket
from jumpstarter.common.streams import StreamRequestMetadata
from jumpstarter.streams.common import forward_stream
from jumpstarter.streams.metadata import MetadataStreamAttributes
from jumpstarter.streams.router import RouterStream

if TYPE_CHECKING:
    from jumpstarter.driver import Driver

logger = logging.getLogger(__name__)


@dataclass(kw_only=True)
class Session(
    jumpstarter_pb2_grpc.ExporterServiceServicer,
    router_pb2_grpc.RouterServiceServicer,
    Metadata,
    ContextManagerMixin,
):
    root_device: "Driver"
    mapping: dict[UUID, "Driver"]

    _logging_queue: deque = field(init=False)
    _logging_handler: QueueHandler = field(init=False)
    _current_status: ExporterStatus = field(init=False, default=ExporterStatus.AVAILABLE)
    _status_message: str = field(init=False, default="")
    _status_update_event: Event = field(init=False)

    @contextmanager
    def __contextmanager__(self) -> Generator[Self]:
        logging.getLogger().addHandler(self._logging_handler)
        self.root_device.reset()
        try:
            yield self
        finally:
            try:
                self.root_device.close()
            except Exception as e:
                # Get driver name from report for more descriptive logging
                try:
                    report = self.root_device.report()
                    driver_name = report.labels.get("jumpstarter.dev/name", self.root_device.__class__.__name__)
                except Exception:
                    driver_name = self.root_device.__class__.__name__
                logger.error("Error closing driver %s: %s", driver_name, e, exc_info=True)
            finally:
                logging.getLogger().removeHandler(self._logging_handler)

    def __init__(self, *args, root_device, **kwargs):
        super().__init__(*args, **kwargs)

        self.root_device = root_device
        self.mapping = {u: i for (u, _, _, i) in self.root_device.enumerate()}

        self._logging_queue = deque(maxlen=32)
        self._logging_handler = LogHandler(self._logging_queue, LogSource.SYSTEM)
        self._status_update_event = Event()

        # Map all driver logs to DRIVER source
        self._logging_handler.add_child_handler("driver.", LogSource.DRIVER)

    @asynccontextmanager
    async def serve_port_async(self, port):
        server = grpc.aio.server()
        server.add_insecure_port(port)

        jumpstarter_pb2_grpc.add_ExporterServiceServicer_to_server(self, server)
        router_pb2_grpc.add_RouterServiceServicer_to_server(self, server)

        await server.start()
        try:
            yield
        finally:
            await server.stop(grace=None)

    @asynccontextmanager
    async def serve_unix_async(self):
        with TemporarySocket() as path:
            async with self.serve_port_async(f"unix://{path}"):
                yield path

</shell_result>


Script executed:

#!/bin/bash
# Read the method implementations around lines 152-178
sed -n '150,200p' packages/jumpstarter/jumpstarter/exporter/session.py

Repository: jumpstarter-dev/jumpstarter


<shell_result>
Repository: jumpstarter-dev/jumpstarter
Exit code: 0

stdout:

                await sleep(0.5)

    def update_status(self, status: int | ExporterStatus, message: str = ""):
        """Update the current exporter status for the session."""
        if isinstance(status, int):
            self._current_status = ExporterStatus.from_proto(status)
        else:
            self._current_status = status
        self._status_message = message

    def add_logger_source(self, logger_name: str, source: LogSource):
        """Add a log source mapping for a specific logger."""
        self._logging_handler.add_child_handler(logger_name, source)

    def remove_logger_source(self, logger_name: str):
        """Remove a log source mapping for a specific logger."""
        self._logging_handler.remove_child_handler(logger_name)

    def context_log_source(self, logger_name: str, source: LogSource):
        """Context manager to temporarily set a log source for a specific logger."""
        return self._logging_handler.context_log_source(logger_name, source)

    async def GetStatus(self, request, context):
        """Get the current exporter status."""
        logger.debug("GetStatus() -> %s", self._current_status)
        return jumpstarter_pb2.GetStatusResponse(
            status=self._current_status.to_proto(),
            message=self._status_message,
        )

</shell_result>


Script executed:

#!/bin/bash
# Search for all references to _status_update_event in the entire repo to confirm it's never used
rg -n "_status_update_event" --type py

Repository: jumpstarter-dev/jumpstarter


<shell_result>
Repository: jumpstarter-dev/jumpstarter
Exit code: 0

stdout:

packages/jumpstarter/jumpstarter/exporter/session.py:46:    _status_update_event: Event = field(init=False)
packages/jumpstarter/jumpstarter/exporter/session.py:76:        self._status_update_event = Event()

</shell_result>


Remove unused _status_update_event field or wire it into update_status() to signal changes

The verification confirms the original concern: _status_update_event is initialized in __init__ (line 76) but never referenced anywhere in the codebase. It is not called in update_status(), not awaited in GetStatus(), and not used by any other module.

Either remove the field entirely if it's no longer needed, or if it's intended to signal status changes to waiting consumers, wire it into update_status() like:

def update_status(self, status: int | ExporterStatus, message: str = ""):
    """Update the current exporter status for the session."""
    if isinstance(status, int):
        self._current_status = ExporterStatus.from_proto(status)
    else:
        self._current_status = status
    self._status_message = message
    self._status_update_event.set()

(Note: This assumes an async consumer pattern; adjust as needed for your use case.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kirkbrauer I believe it's correct, it's unused.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py (2)

215-224: Consider asserting exporter list column names, not just count (no devices)

Right now this test only checks add_column is called 5 times. To catch regressions where the STATUS column is dropped or reordered, you could also assert the actual column labels, mirroring the single-exporter test:

     mock_table = MagicMock()
     V1Alpha1ExporterList.rich_add_columns(mock_table, devices=False)
-    assert mock_table.add_column.call_count == 5
+    assert mock_table.add_column.call_count == 5
+    mock_table.add_column.assert_any_call("NAME", no_wrap=True)
+    mock_table.add_column.assert_any_call("STATUS")
+    mock_table.add_column.assert_any_call("ENDPOINT")
+    mock_table.add_column.assert_any_call("DEVICES")
+    mock_table.add_column.assert_any_call("AGE")

226-235: Similarly strengthen exporter list column assertions for devices=True

For the devices=True variant you only assert 6 columns. Mirroring the exporter-level test would better lock in the STATUS column and device columns:

     mock_table = MagicMock()
     V1Alpha1ExporterList.rich_add_columns(mock_table, devices=True)
-    assert mock_table.add_column.call_count == 6
+    assert mock_table.add_column.call_count == 6
+    mock_table.add_column.assert_any_call("NAME", no_wrap=True)
+    mock_table.add_column.assert_any_call("STATUS")
+    mock_table.add_column.assert_any_call("ENDPOINT")
+    mock_table.add_column.assert_any_call("AGE")
+    mock_table.add_column.assert_any_call("LABELS")
+    mock_table.add_column.assert_any_call("UUID")
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a129ff7 and 6aa6a1f.

📒 Files selected for processing (1)
  • packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py (8 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: e2e
🔇 Additional comments (6)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py (6)

24-55: JSON dump expectations updated for new status fields look correct

The JSON expectation now asserts exporterStatus and statusMessage as null when unset, which cleanly fixes the schema for clients expecting those keys to always be present.


58-81: YAML serialization correctly mirrors exporterStatus/statusMessage shape

Including exporterStatus: null and statusMessage: null in the YAML snapshot keeps JSON/YAML aligned and makes the presence of the new fields explicit even when unset.


112-125: STATUS column added for exporter tables without devices is well covered

The test now enforces 5 columns and explicitly checks for a STATUS column alongside NAME, ENDPOINT, DEVICES, and AGE, which matches the expected table layout.


128-142: Exporter columns with devices correctly include STATUS and device metadata

Asserting 6 columns and the presence of STATUS plus the devices-related columns (LABELS, UUID) provides good coverage of the richer table layout with devices.


145-159: Row layout without devices validates STATUS fallback semantics

The row assertions (NAME, "Unknown" status when exporter_status is None, endpoint, device count, age) accurately exercise the new STATUS column and its defaulting behavior.


161-176: Row layout with devices exercises STATUS plus device fields

Checking the row as (name, "Unknown", endpoint, age, labels, uuid) confirms both the STATUS default and the correct positioning of devices-related fields in the table.

@mangelajo
Copy link
Member

This is green!, nice! :D

exporters = config.list_exporters(filter=selector, include_leases=include_leases, include_online=include_online)
include_status = "status" in with_options
exporters = config.list_exporters(
filter=selector, include_leases=include_leases, include_online=include_online, include_status=include_status
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not your patches fault, but I hate this API, we need to improve it hehehe :D

Copy link
Member

@mangelajo mangelajo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great in general amazing work

"""Make DriverCall by method name and arguments"""

# Check exporter status before making the call
await self.check_exporter_status()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we cache this and assume it will stay ready once it declares itself at ready?

This would duplicate the grpc calls for any call impacting performance.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also delay, if the controller is 200ms Away, you would need 300ms at least to get it working, in contrast to 100ms before

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if we want to do this exporter side instead (the exporter knows and will prevent the call if not possible)

I am ok to just open an Issue, and tackle this later, but document it because we will forget of this otherwise :D

super().__post_init__()

self.logger = logging.getLogger(self.__class__.__name__)
self.logger = get_logger(f"driver.{self.__class__.__name__}", LogSource.DRIVER)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤟

channel_factory: Callable[[], Awaitable[grpc.aio.Channel]]
device_factory: Callable[[], Driver]
lease_name: str = field(init=False, default="")
"""Factory function for creating gRPC channels to communicate with the controller.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice docs!!

# Create a logger with automatic source registration
hook_logger = get_logger(f"hook.{lease_scope.lease_name}", log_source, logging_session)

# Stream output line-by-line for real-time logging
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow! nice! I was checking if you had time to implement this :)

out: ./packages/jumpstarter-protocol/jumpstarter_protocol
- remote: buf.build/grpc/python
out: ./packages/jumpstarter-protocol/jumpstarter_protocol
- remote: buf.build/community/nipunn1313-mypy:v3.7.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh that's where the improved stubs came from, nice!

@mangelajo
Copy link
Member

For a follow up no need to address anything of that in this PR, I was trying with

jumpstarter git:(add-hooks) ✗ uv run jmp shell --exporter-config test-hooks.yaml

would it make sense to execute the hooks if they exist in a local shell too?

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (5)
packages/jumpstarter/jumpstarter/exporter/exporter.py (5)

77-83: Align grpc_options typing with actual usage and gRPC expectations

grpc_options is declared as dict[str, str], but:

  • _handle_client_conn() accepts grpc_options: dict[str, Any] | None.
  • gRPC channel options can legitimately be non-string (e.g. ints, booleans).

To keep type-checkers happy and match the call site, it’s better to type this as dict[str, Any] on the dataclass as well.

-    grpc_options: dict[str, str] = field(default_factory=dict)
+    grpc_options: dict[str, Any] = field(default_factory=dict)

Also applies to: 347-360


139-145: Fix stale doc references (_update_status, LeaseScope vs LeaseContext)

There are a couple of small doc inconsistencies:

  • _exporter_status doc mentions _update_status(), but status is now maintained via _report_status().
  • _lease_context’s doc and handle_lease()’s arg doc still refer to “LeaseScope” even though the type and field are LeaseContext.

These don’t affect behavior but can confuse future readers.

You could tighten the docs like:

-    """Current status of the exporter.
-
-    Updated via _update_status() and reported to controller and session.
+    """Current status of the exporter.
+
+    Updated via _report_status() and reported to controller and session.
@@
-    The session and socket are managed by the context manager in handle_lease(),
-    ensuring proper cleanup when the lease ends. The LeaseScope itself is just
+    The session and socket are managed by the context manager in handle_lease(),
+    ensuring proper cleanup when the lease ends. The LeaseContext itself is just
@@
-            lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
+            lease_scope: LeaseContext with before_lease_hook event (session/socket set here)

Also applies to: 156-172, 397-415


290-310: Avoid creating two controller channels during unregistration

In _unregister_with_controller() you:

  • Open a channel via channel = await self.channel_factory() and use it for controller.Unregister(...).
  • Also call _report_status(...), which internally calls _get_controller_stub(), which in turn calls channel_factory() again.

If channel_factory() creates new channels, this path will allocate two channels for a single shutdown, and only the one bound to channel is explicitly closed. It’s not a correctness bug per se (the process is exiting), but it’s wasteful and makes resource ownership harder to reason about.

One way to tidy this up is to let _report_status() optionally reuse the controller stub/channel:

-    async def _report_status(self, status: ExporterStatus, message: str = ""):
+    async def _report_status(
+        self,
+        status: ExporterStatus,
+        message: str = "",
+        controller: jumpstarter_pb2_grpc.ControllerServiceStub | None = None,
+    ):
@@
-        try:
-            controller = await self._get_controller_stub()
+        try:
+            if controller is None:
+                controller = await self._get_controller_stub()
             await controller.ReportStatus(
@@
     async def _unregister_with_controller(self):
@@
-            with move_on_after(10):  # 10 second timeout
-                channel = await self.channel_factory()
-                try:
-                    controller = jumpstarter_pb2_grpc.ControllerServiceStub(channel)
-                    await self._report_status(ExporterStatus.OFFLINE, "Exporter shutting down")
-                    await controller.Unregister(
+            with move_on_after(10):  # 10 second timeout
+                channel = await self.channel_factory()
+                try:
+                    controller = jumpstarter_pb2_grpc.ControllerServiceStub(channel)
+                    await self._report_status(
+                        ExporterStatus.OFFLINE,
+                        "Exporter shutting down",
+                        controller=controller,
+                    )
+                    await controller.Unregister(

This keeps a single controller channel in play during shutdown and makes ownership explicit.

Also applies to: 311-333


480-499: Ensure before/after hooks can’t overlap on lease-name changes

The lease-change branch:

lease_changed = (
    self._lease_context
    and self._lease_context.is_active()
    and self._lease_context.lease_name != status.lease_name
)
if lease_changed:
    if self.hook_executor and self._lease_context.has_client():
        with CancelScope(shield=True):
            await self.hook_executor.run_after_lease_hook(...)

runs after_lease solely based on lease_name and has_client(), independent of whether the before_lease hook has completed (its event may not yet be set). In a scenario where the controller changes lease_name while a before_lease hook is still running, this could cause before/after hooks to run concurrently on the same exporter (e.g. “power on” and “power off” scripts racing).

I don’t know whether your controller ever sends such a sequence, but the code path allows it.

Consider gating the after_lease hook on the before-hook having finished, or otherwise ensuring serialization, for example:

-        if lease_changed:
-            # After-lease hook for the previous lease (lease name changed)
-            if self.hook_executor and self._lease_context.has_client():
+        if lease_changed:
+            # After-lease hook for the previous lease (lease name changed)
+            if (
+                self.hook_executor
+                and self._lease_context.has_client()
+                # Optional: only run after-hook once before-lease hook has finished
+                and self._lease_context.before_lease_hook.is_set()
+            ):

or by letting HookExecutor handle cancellation/ordering explicitly. It’d be good to add a test for “lease_name changes while before-hook is in progress” to confirm intended behavior.

Also applies to: 503-538


423-431: Optionally close memory object streams explicitly

Both in handle_lease() and serve():

listen_tx, listen_rx = create_memory_object_stream[ListenResponse]()
status_tx, status_rx = create_memory_object_stream[StatusResponse]()

the send/receive streams are used without async with / aclose(). Because the enclosing TaskGroup cancellation will eventually tear everything down and the process is short-lived (per retrieved learnings), this isn’t a correctness issue, but explicitly closing the streams is a bit cleaner and makes lifetime more obvious.

For example:

-        listen_tx, listen_rx = create_memory_object_stream[jumpstarter_pb2.ListenResponse]()
+        async with create_memory_object_stream[jumpstarter_pb2.ListenResponse]() as (listen_tx, listen_rx):
@@
-        status_tx, status_rx = create_memory_object_stream[jumpstarter_pb2.StatusResponse]()
+        async with create_memory_object_stream[jumpstarter_pb2.StatusResponse]() as (status_tx, status_rx):

and keep the rest of the logic the same. This makes ownership and shutdown behavior explicit and plays nicer with linters.

Also applies to: 468-478

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6aa6a1f and 4a391ce.

📒 Files selected for processing (1)
  • packages/jumpstarter/jumpstarter/exporter/exporter.py (5 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.cursor/rules/project-structure.mdc)

Ruff should be used for code formatting and linting, excluding jumpstarter-protocol package

Files:

  • packages/jumpstarter/jumpstarter/exporter/exporter.py
🧠 Learnings (1)
📚 Learning: 2025-08-14T13:11:35.034Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.

Applied to files:

  • packages/jumpstarter/jumpstarter/exporter/exporter.py
🧬 Code graph analysis (1)
packages/jumpstarter/jumpstarter/exporter/exporter.py (5)
packages/jumpstarter/jumpstarter/common/enums.py (3)
  • ExporterStatus (8-45)
  • to_proto (43-45)
  • to_proto (74-76)
packages/jumpstarter/jumpstarter/common/streams.py (1)
  • connect_router_stream (37-48)
packages/jumpstarter/jumpstarter/exporter/hooks.py (3)
  • HookExecutor (50-402)
  • run_after_lease_hook (333-402)
  • run_before_lease_hook (259-331)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (5)
  • LeaseContext (19-84)
  • update_status (73-84)
  • is_active (57-59)
  • has_client (61-63)
  • update_client (65-67)
packages/jumpstarter/jumpstarter/exporter/session.py (3)
  • Session (33-178)
  • GetReport (110-119)
  • update_status (152-158)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: e2e

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/jumpstarter/jumpstarter/exporter/hooks.py (2)

296-307: Consider using an event-based wait instead of polling.

The busy-wait loop with anyio.sleep(0.1) works but is less efficient than an event-based approach. If LeaseContext could expose an anyio.Event that fires when ready, you could use with anyio.fail_after(30): await lease_scope.ready_event.wait() for cleaner synchronization.


51-57: Remove or document the unused device_factory field.

The device_factory field is defined but never referenced within this class. Consider removing it if no longer needed, or add a comment explaining its intended purpose if it's reserved for future use.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b35e10a and a302f24.

📒 Files selected for processing (1)
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.cursor/rules/project-structure.mdc)

Ruff should be used for code formatting and linting, excluding jumpstarter-protocol package

Files:

  • packages/jumpstarter/jumpstarter/exporter/hooks.py
🧠 Learnings (1)
📚 Learning: 2025-08-14T13:11:35.034Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.

Applied to files:

  • packages/jumpstarter/jumpstarter/exporter/hooks.py
🪛 GitHub Actions: Lint
packages/jumpstarter/jumpstarter/exporter/hooks.py

[error] 144-144: Ruff check failed: C901 _execute_hook_process is too complex (12 > 10).

🪛 GitHub Check: ruff
packages/jumpstarter/jumpstarter/exporter/hooks.py

[failure] 144-144: Ruff (C901)
packages/jumpstarter/jumpstarter/exporter/hooks.py:144:15: C901 _execute_hook_process is too complex (12 > 10)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: e2e
🔇 Additional comments (5)
packages/jumpstarter/jumpstarter/exporter/hooks.py (5)

1-49: LGTM!

The imports are well-organized and the HookExecutionError dataclass provides a clean interface with helpful predicates for determining failure behavior. The exception chaining support via the cause parameter in _handle_hook_failure is a good practice.


78-142: LGTM!

The _execute_hook method properly validates empty commands and the _handle_hook_failure method correctly implements the three failure modes with appropriate exception chaining.


233-271: LGTM!

Clean wrapper methods that properly check for configured hooks and delegate to _execute_hook with the appropriate LogSource.


359-432: LGTM!

The run_after_lease_hook method correctly handles the post-lease semantics where on_failure='endLease' just reports the failure (since the lease has already ended) while the exporter remains available. The error handling is comprehensive and consistent with run_before_lease_hook.


144-231: This review comment contains incorrect technical claims and should be reconsidered.

The primary issue claimed—that anyio.open_process with a string command uses shlex.split() on POSIX and won't handle shell features—is factually incorrect. According to anyio's official documentation, a string command is passed directly to the system shell (equivalent to subprocess with shell=True), not split via shlex. The current code comment stating "Pass the command as a string to use shell mode" is accurate, and the implementation correctly executes shell commands as intended.

The proposed fix to use ["sh", "-c", command] would also work but is redundant given the current approach.

The cyclomatic complexity claim (C901) could not be verified due to tooling timeout, so that aspect remains unconfirmed.

If refactoring this method is still desired for code clarity and maintainability (extracting helper methods for output reading and process termination), that should be justified on code quality grounds alone, not on correcting a non-existent shell execution bug.

Likely an incorrect or invalid review comment.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI Agents
In @packages/jumpstarter/jumpstarter/exporter/hooks.py:
- Around line 51-56: HookExecutor defines an unused field device_factory; remove
the device_factory attribute from the dataclass declaration and any
constructor/initializer parameters and tests that reference it (leave
HookExecutor, config: HookConfigV1Alpha1 intact), and update any instantiations
of HookExecutor so they no longer pass a device_factory argument; ensure imports
or type annotations referencing "Driver" are cleaned up if no longer needed.
🧹 Nitpick comments (2)
packages/jumpstarter/jumpstarter/exporter/exporter.py (2)

206-243: Consider excluding CancelledError from retry logic.

The except Exception block catches all exceptions including CancelledError. This could prevent clean task cancellation during shutdown.

🔎 Proposed fix
             try:
                 controller = await self._get_controller_stub()
                 async for item in stream_factory(controller):
                     await send_tx.send(item)
+            except anyio.get_cancelled_exc_class():
+                raise  # Don't retry on cancellation
             except Exception as e:
                 if retries_left > 0:

313-315: Consider using lazy % formatting for logger calls.

Using f-strings in logger calls evaluates the string even when the log level is disabled.

🔎 Proposed fix
-            logger.info(f"Updated status to {status}: {message}")
+            logger.info("Updated status to %s: %s", status, message)
         except Exception as e:
-            logger.error(f"Failed to update status: {e}")
+            logger.error("Failed to update status: %s", e)
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a302f24 and ce4f2aa.

📒 Files selected for processing (3)
  • packages/jumpstarter-cli/jumpstarter_cli/run.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.cursor/rules/project-structure.mdc)

Ruff should be used for code formatting and linting, excluding jumpstarter-protocol package

Files:

  • packages/jumpstarter-cli/jumpstarter_cli/run.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
🧠 Learnings (3)
📚 Learning: 2025-08-14T13:11:35.034Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.

Applied to files:

  • packages/jumpstarter-cli/jumpstarter_cli/run.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
📚 Learning: 2025-10-06T15:44:51.111Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 690
File: packages/jumpstarter/jumpstarter/config/exporter.py:51-56
Timestamp: 2025-10-06T15:44:51.111Z
Learning: In jumpstarter config instantiation (packages/jumpstarter/jumpstarter/config/exporter.py), passing **self.root.config to driver_class constructors is intentional validation behavior: since Driver classes are dataclasses, the __init__ will reject any config keys that don't match declared fields with a TypeError, ensuring only declared/supported fields can be used in config YAML files. This is the desired behavior for config safety.

Applied to files:

  • packages/jumpstarter/jumpstarter/exporter/exporter.py
📚 Learning: 2025-10-02T15:29:09.070Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 681
File: packages/jumpstarter/jumpstarter/client/lease.py:198-220
Timestamp: 2025-10-02T15:29:09.070Z
Learning: In the jumpstarter codebase, `client_from_path` and `client_from_channel` functions in `packages/jumpstarter/jumpstarter/client/client.py` automatically call `GetReport(empty_pb2.Empty())` during client initialization. This means that successfully entering the client context manager guarantees the exporter is responsive and has answered the GetReport RPC.

Applied to files:

  • packages/jumpstarter/jumpstarter/exporter/exporter.py
🧬 Code graph analysis (2)
packages/jumpstarter/jumpstarter/exporter/exporter.py (4)
packages/jumpstarter/jumpstarter/common/enums.py (3)
  • ExporterStatus (8-45)
  • to_proto (43-45)
  • to_proto (74-76)
packages/jumpstarter/jumpstarter/exporter/hooks.py (2)
  • HookExecutor (52-434)
  • run_before_lease_hook (273-358)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (5)
  • LeaseContext (19-84)
  • update_status (73-84)
  • is_active (57-59)
  • has_client (61-63)
  • update_client (65-67)
packages/jumpstarter/jumpstarter/exporter/session.py (3)
  • Session (33-178)
  • GetReport (110-119)
  • update_status (152-158)
packages/jumpstarter/jumpstarter/exporter/hooks.py (2)
packages/jumpstarter/jumpstarter/config/exporter.py (2)
  • HookConfigV1Alpha1 (44-50)
  • HookInstanceConfigV1Alpha1 (23-41)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (2)
  • LeaseContext (19-84)
  • is_ready (49-55)
🪛 GitHub Actions: Lint
packages/jumpstarter/jumpstarter/exporter/hooks.py

[error] 144-144: Ruff check failed: C901 _execute_hook_process is too complex (12 > 10).

🪛 GitHub Check: ruff
packages/jumpstarter/jumpstarter/exporter/hooks.py

[failure] 144-144: Ruff (C901)
packages/jumpstarter/jumpstarter/exporter/hooks.py:144:15: C901 _execute_hook_process is too complex (12 > 10)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: e2e
🔇 Additional comments (9)
packages/jumpstarter/jumpstarter/exporter/exporter.py (5)

1-34: LGTM! Clean imports and type organization.

Good use of TYPE_CHECKING to avoid circular imports for Driver. The imports are well-organized and the forward reference pattern is correctly applied.


38-166: Excellent documentation and field organization.

The dataclass fields are well-documented with clear lifecycle explanations. The consolidation of per-lease state into LeaseContext improves maintainability. The _previous_leased field is now properly declared as a dataclass field (addressing the previous review feedback).


168-200: LGTM! Stop method correctly handles exit code propagation.

The stop() method now properly accepts and stores an exit code, enabling hooks with on_failure='exit' to signal permanent termination. The property accessor is clean.


403-465: LGTM! Clean lease handling with proper hook synchronization.

The handle_lease method correctly:

  1. Populates the LeaseContext with session and socket path
  2. Waits for before-lease hook completion before accepting connections
  3. Syncs status to session after hook completes

The coordination between concurrent tasks (handle_lease and run_before_lease_hook) via the before_lease_hook event is well-designed.


467-569: LGTM! Comprehensive lease lifecycle orchestration.

The serve() method correctly handles:

  • Lease state transitions with proper hook invocation
  • Before-lease hooks spawned as concurrent tasks when transitioning to leased state
  • After-lease hooks shielded from cancellation with CancelScope(shield=True)
  • Lease context cleanup after hook completion

The distinction between lease name changes (line 487-506) and lease state transitions (line 508-568) is well-handled.

packages/jumpstarter-cli/jumpstarter_cli/run.py (1)

79-96: LGTM! Correct exit code priority handling.

The exit code logic correctly prioritizes:

  1. Signal number (for signal-based termination)
  2. Exporter's exit code (for hook failures with on_failure='exit')
  3. Default 0 (triggers restart in fork architecture)

This integrates well with the fork-based restart architecture where exit code 0 triggers a restart and non-zero prevents restart.

packages/jumpstarter/jumpstarter/exporter/hooks.py (3)

25-48: LGTM! Well-designed exception class.

The HookExecutionError dataclass provides clear semantics for hook failures with:

  • Meaningful helper methods (should_shutdown_exporter, should_end_lease)
  • Proper __str__ implementation
  • Clear distinction between failure modes

273-358: LGTM! Robust before-lease hook orchestration.

The method handles all edge cases well:

  • Timeout waiting for lease scope readiness
  • Proper status reporting through the lifecycle
  • Correct error handling for different on_failure modes
  • Critical: The finally block ensures before_lease_hook.set() is always called, preventing deadlocks

The polling approach for is_ready() is acceptable given the short expected wait time.


360-433: LGTM! Correct after-lease hook semantics.

The after-lease hook correctly:

  • Validates lease scope is ready defensively
  • Reports AFTER_LEASE_HOOK_FAILED on failure but returns to AVAILABLE status (since lease is already ended)
  • Only triggers shutdown for on_failure='exit', not endLease (which is a no-op for after-lease since the lease already ended)

Good distinction between before-lease and after-lease failure semantics.

Comment on lines +51 to +56
@dataclass(kw_only=True)
class HookExecutor:
"""Executes lifecycle hooks with access to the j CLI."""

config: HookConfigV1Alpha1
device_factory: Callable[[], "Driver"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the HookExecutor class structure
cd packages/jumpstarter && cat -n jumpstarter/exporter/hooks.py | head -100

Repository: jumpstarter-dev/jumpstarter

Length of output: 4070


🏁 Script executed:

# Search for device_factory references in the hooks.py file
rg -n "device_factory" jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 140


🏁 Script executed:

# Search for HookExecutor instantiation across the codebase
rg -n "HookExecutor\(" --type=py -B2 -A5

Repository: jumpstarter-dev/jumpstarter

Length of output: 7953


🏁 Script executed:

# Search for all device_factory references in the entire jumpstarter package
rg -n "device_factory" --type=py packages/jumpstarter/

Repository: jumpstarter-dev/jumpstarter

Length of output: 3297


🏁 Script executed:

# Check the full implementation of HookExecutor methods to see if device_factory is used
rg -n "device_factory" packages/jumpstarter/jumpstarter/exporter/hooks.py -B2 -A10

Repository: jumpstarter-dev/jumpstarter

Length of output: 513


🏁 Script executed:

# Get the complete hooks.py file to see all methods
wc -l packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 125


🏁 Script executed:

# Read the full HookExecutor class implementation
head -200 packages/jumpstarter/jumpstarter/exporter/hooks.py | tail -150

Repository: jumpstarter-dev/jumpstarter

Length of output: 5508


🏁 Script executed:

# Search the entire hooks.py file for any method that might use device_factory
rg -n "self\.device_factory" packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 53


🏁 Script executed:

# Also check if there are any other methods in the file that might reference device_factory
grep -n "def " packages/jumpstarter/jumpstarter/exporter/hooks.py | head -20

Repository: jumpstarter-dev/jumpstarter

Length of output: 695


🏁 Script executed:

# Check the public execute_before_lease_hook and execute_after_lease_hook methods
sed -n '233,272p' packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 1439


🏁 Script executed:

# Check if there are any other uses of device_factory in the entire file
grep -n "device_factory" packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 116


Remove unused device_factory field.

The device_factory field is declared but never used within HookExecutor. All hook execution uses lease_scope.session instead. This field can be safely removed unless there are plans for future use.

🤖 Prompt for AI Agents
In @packages/jumpstarter/jumpstarter/exporter/hooks.py around lines 51-56,
HookExecutor defines an unused field device_factory; remove the device_factory
attribute from the dataclass declaration and any constructor/initializer
parameters and tests that reference it (leave HookExecutor, config:
HookConfigV1Alpha1 intact), and update any instantiations of HookExecutor so
they no longer pass a device_factory argument; ensure imports or type
annotations referencing "Driver" are cleaned up if no longer needed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/jumpstarter/jumpstarter/client/core.py (1)

171-202: Add status checks to stream_async and resource_async methods.

The streaming methods at lines 171 and 182 should include await self.check_exporter_status() calls before establishing connections, matching the pattern used in call_async (line 119) and streamingcall_async (line 148). Both streaming methods interact with self.stub.Stream(), which requires the exporter to be ready—the same precondition enforced by the status checks in the RPC call methods.

🤖 Fix all issues with AI Agents
In @packages/jumpstarter/jumpstarter/client/core.py:
- Line 87: The async method check_exporter_status is missing a return type
annotation; update its signature (async def check_exporter_status(self) ->
<Type>: ) to include the correct return type—use -> bool if it returns a success
flag, -> None if it never returns a value, or the specific type it yields—so add
the appropriate annotation on the check_exporter_status definition to match its
implementation.
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ce4f2aa and 75236e2.

📒 Files selected for processing (1)
  • packages/jumpstarter/jumpstarter/client/core.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.cursor/rules/project-structure.mdc)

Ruff should be used for code formatting and linting, excluding jumpstarter-protocol package

Files:

  • packages/jumpstarter/jumpstarter/client/core.py
🧠 Learnings (4)
📚 Learning: 2025-10-02T15:29:09.070Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 681
File: packages/jumpstarter/jumpstarter/client/lease.py:198-220
Timestamp: 2025-10-02T15:29:09.070Z
Learning: In the jumpstarter codebase, `client_from_path` and `client_from_channel` functions in `packages/jumpstarter/jumpstarter/client/client.py` automatically call `GetReport(empty_pb2.Empty())` during client initialization. This means that successfully entering the client context manager guarantees the exporter is responsive and has answered the GetReport RPC.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
📚 Learning: 2025-11-27T09:58:55.346Z
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/project-structure.mdc:0-0
Timestamp: 2025-11-27T09:58:55.346Z
Learning: Applies to packages/jumpstarter-driver-*/jumpstarter_driver_*/ : Driver packages must implement a `client.py` file containing the client implementation

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
📚 Learning: 2025-11-27T09:58:41.875Z
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/creating-new-drivers.mdc:0-0
Timestamp: 2025-11-27T09:58:41.875Z
Learning: Applies to packages/jumpstarter-driver-*/jumpstarter_driver_*/client.py : Driver client CLIs should either implement known classes that provide a CLI interface, or implement their own CLI interface. Use `CompositeClient` from `jumpstarter_driver_composite.client` for composite drivers with child drivers.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
📚 Learning: 2025-09-02T07:32:04.548Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py:0-0
Timestamp: 2025-09-02T07:32:04.548Z
Learning: *_pb2.py files in the jumpstarter codebase are auto-generated from gRPC/protobuf definitions and should not be manually modified. Issues with these files should be addressed at the protobuf generation tooling level or runtime environment setup.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
🧬 Code graph analysis (1)
packages/jumpstarter/jumpstarter/client/core.py (5)
packages/jumpstarter/jumpstarter/common/enums.py (3)
  • ExporterStatus (8-45)
  • from_proto (39-41)
  • from_proto (70-72)
packages/jumpstarter/jumpstarter/common/metadata.py (1)
  • Metadata (8-14)
packages/jumpstarter/jumpstarter/exporter/session.py (1)
  • GetStatus (172-178)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.py (2)
  • GetStatus (613-617)
  • GetStatus (801-825)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1)
  • GetStatusRequest (690-695)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: build
  • GitHub Check: e2e
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
🔇 Additional comments (6)
packages/jumpstarter/jumpstarter/client/core.py (6)

5-6: LGTM!

Standard import for postponed evaluation of type annotations, enabling cleaner forward references.


19-19: LGTM!

The ExporterStatus import is necessary for the new readiness check functionality.


53-57: LGTM!

The ExporterNotReady exception follows the existing exception hierarchy and provides a clear signal for readiness issues.


101-113: Excellent backward compatibility handling.

The graceful fallback when GetStatus is unimplemented ensures compatibility with older exporters while enabling the new hook functionality for updated exporters.


118-119: Status check properly integrated.

The readiness check is correctly placed before the driver call, ensuring operations are only attempted when the exporter is in an appropriate state.


147-148: Status check properly integrated.

The readiness check is correctly placed before the streaming driver call, consistent with the call_async implementation.

@mangelajo mangelajo self-requested a review January 13, 2026 10:05
@mangelajo
Copy link
Member

mangelajo commented Jan 13, 2026

Nice, first I tested it with an older version of the server:

➜  jumpstarter git:(add-hooks) ✗ uv run jmp run --exporter-config exporter-test-hooks.yaml
      Built jumpstarter-all @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-all
      Built jumpstarter @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter
      Built jumpstarter-cli @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-cli
      Built jumpstarter-cli-admin @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-cli-admin
      Built jumpstarter-driver-ble @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-driver-ble
      Built jumpstarter-driver-iscsi @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-driver-iscsi
      Built jumpstarter-driver-ridesx @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-driver-ridesx
      Built jumpstarter-driver-ssh-mitm @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-driver-ssh-mitm
      Built jumpstarter-driver-vnc @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-driver-vnc
      Built jumpstarter-kubernetes @ file:///Users/ajo/work/jumpstarter/jumpstarter/packages/jumpstarter-kubernetes
Uninstalled 10 packages in 55ms
Installed 10 packages in 9ms
INFO:jumpstarter.exporter.exporter:Registering exporter with controller
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>
INFO:jumpstarter.exporter.exporter:Currently leased by ajo under 019bb6f0-8232-7a7d-aa78-f4419eeb4ac1
INFO:jumpstarter.exporter.exporter:Listening for incoming connection requests on lease 019bb6f0-8232-7a7d-aa78-f4419eeb4ac1
INFO:jumpstarter.exporter.exporter:Registering exporter with controller
INFO:jumpstarter.exporter.exporter:Waiting for before-lease hook to complete before accepting connections
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>
INFO:jumpstarter.exporter.hooks:Executing before-lease hook for lease 019bb6f0-8232-7a7d-aa78-f4419eeb4ac1
INFO:jumpstarter.exporter.hooks:Executing hook: echo "Starting lease setup for ${CLIENT_NAME}"
INFO:jumpstarter.exporter.hooks:[hook output] Starting lease setup for ajo
INFO:jumpstarter.exporter.hooks:[hook output] Setting up power management...
INFO:driver.MockPower:power on
INFO:jumpstarter.exporter.hooks:[hook output] Initializing device connections...
INFO:jumpstarter.exporter.hooks:[hook output] Running pre-lease diagnostics...
INFO:jumpstarter.exporter.hooks:[hook output] Pre-lease setup completed successfully!
INFO:jumpstarter.exporter.hooks:Hook executed successfully
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_message:"method ReportStatus not implemented", grpc_status:12}"
>
INFO:jumpstarter.exporter.hooks:beforeLease hook completed successfully
INFO:jumpstarter.exporter.exporter:Before-lease hook completed, now accepting connections
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>
INFO:jumpstarter.exporter.hooks:Executing after-lease hook for lease 019bb6f0-8232-7a7d-aa78-f4419eeb4ac1
INFO:jumpstarter.exporter.hooks:Executing hook: echo "Starting lease cleanup for ${CLIENT_NAME}"
INFO:jumpstarter.exporter.hooks:[hook output] Starting lease cleanup for ajo
INFO:jumpstarter.exporter.hooks:[hook output] Shutting down power...
INFO:driver.MockPower:power off
INFO:jumpstarter.exporter.hooks:[hook output] Cleaning up device connections...
INFO:jumpstarter.exporter.hooks:[hook output] Running post-lease diagnostics...
INFO:jumpstarter.exporter.hooks:[hook output] Post-lease cleanup completed successfully!
INFO:jumpstarter.exporter.hooks:Hook executed successfully
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_message:"method ReportStatus not implemented", grpc_status:12}"
>
INFO:jumpstarter.exporter.hooks:afterLease hook completed successfully
INFO:jumpstarter.exporter.exporter:Lease status changed, killing existing connections
INFO:jumpstarter.exporter.exporter:Stopping exporter immediately, will not unregister from controller
INFO:jumpstarter.exporter.exporter:Registering exporter with controller
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>
INFO:jumpstarter.exporter.exporter:Currently not leased
INFO:jumpstarter.exporter.exporter:Currently leased by ajo under 019bb6f1-d549-7051-b3b9-86425f934900
INFO:jumpstarter.exporter.exporter:Listening for incoming connection requests on lease 019bb6f1-d549-7051-b3b9-86425f934900
INFO:jumpstarter.exporter.exporter:Registering exporter with controller
INFO:jumpstarter.exporter.exporter:Waiting for before-lease hook to complete before accepting connections
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>
INFO:jumpstarter.exporter.hooks:Executing before-lease hook for lease 019bb6f1-d549-7051-b3b9-86425f934900
INFO:jumpstarter.exporter.hooks:Executing hook: echo "Starting lease setup for ${CLIENT_NAME}"
INFO:jumpstarter.exporter.hooks:[hook output] Starting lease setup for ajo
INFO:jumpstarter.exporter.hooks:[hook output] Setting up power management...
INFO:driver.MockPower:power on
INFO:jumpstarter.exporter.hooks:[hook output] Initializing device connections...
INFO:jumpstarter.exporter.hooks:[hook output] Running pre-lease diagnostics...
INFO:jumpstarter.exporter.hooks:[hook output] Pre-lease setup completed successfully!
INFO:jumpstarter.exporter.hooks:Hook executed successfully
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>
INFO:jumpstarter.exporter.hooks:beforeLease hook completed successfully
INFO:jumpstarter.exporter.exporter:Before-lease hook completed, now accepting connections
INFO:jumpstarter.exporter.exporter:Handling new connection request on lease 019bb6f1-d549-7051-b3b9-86425f934900

it complains for the missing "ReportStatus", but it all works

Sometimes I see the post hook hanging a bit before it gets to connect/work, i.e.:

INFO:jumpstarter.exporter.hooks:Executing hook: echo "Starting lease cleanup for ${CLIENT_NAME}"
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1768301188.830195  191826 ssl_transport_security_utils.cc:114] Corruption detected.
E0000 00:00:1768301188.830312  191826 ssl_transport_security_utils.cc:71] error:100003fc:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_RECORD_MAC
E0000 00:00:1768301188.830316  191826 secure_endpoint.cc:241] Decryption error: TSI_DATA_CORRUPTED
E0000 00:00:1768301188.831590  191826 ssl_transport_security_utils.cc:114] Corruption detected.
E0000 00:00:1768301188.831610  191826 ssl_transport_security_utils.cc:71] error:100003fc:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_RECORD_MAC
E0000 00:00:1768301188.831613  191826 secure_endpoint.cc:241] Decryption error: TSI_DATA_CORRUPTED
INFO:jumpstarter.exporter.exporter:Status stream interrupted, restarting in 3.0s, 4 retries left: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNKNOWN
	details = "Stream removed"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:2, grpc_message:"Stream removed"}"
>
INFO:jumpstarter.exporter.exporter:Listen stream interrupted, restarting in 3.0s, 4 retries left: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNKNOWN
	details = "Stream removed"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:2, grpc_message:"Stream removed"}"
>
INFO:jumpstarter.exporter.hooks:[hook output] Starting lease cleanup for ajo
INFO:jumpstarter.exporter.hooks:[hook output] Shutting down power...
INFO:driver.MockPower:power off
INFO:jumpstarter.exporter.hooks:[hook output] Cleaning up device connections...
INFO:jumpstarter.exporter.hooks:[hook output] Running post-lease diagnostics...
INFO:jumpstarter.exporter.hooks:[hook output] Post-lease cleanup completed successfully!
INFO:jumpstarter.exporter.hooks:Hook executed successfully
ERROR:jumpstarter.exporter.exporter:Failed to update status: <AioRpcError of RPC that terminated with:
	status = StatusCode.UNIMPLEMENTED
	details = "method ReportStatus not implemented"
	debug_error_string = "UNKNOWN:Error received from peer ipv4:192.168.1.55:443 {grpc_status:12, grpc_message:"method ReportStatus not implemented"}"
>

We can will have to figure this out, but in follow up/separate PR/issue.

# Statuses that allow driver commands
ALLOWED_STATUSES = {
ExporterStatus.LEASE_READY,
ExporterStatus.BEFORE_LEASE_HOOK,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can check that later, bud do we want to let commands during before/after when a call is remote?

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/jumpstarter/jumpstarter/exporter/hooks_test.py`:
- Around line 159-162: The test currently patches asyncio.wait_for but the
implementation uses anyio.move_on_after, so update the test to either (A) mock
anyio.move_on_after instead of asyncio.wait_for — patch the symbol
anyio.move_on_after and have the mock act as an async context manager that
triggers cancellation (e.g., yields a context with .cancelled True / raises
CancellationError inside) while keeping the patch of
asyncio.create_subprocess_shell returning mock_process, or (B) convert the test
to an integration-style run using a real short timeout and a subprocess that
sleeps briefly to trigger the timeout; locate the test that patches
asyncio.create_subprocess_shell and replace the wait_for patch with a
mock/behavior for anyio.move_on_after (or switch to a real short-timeout run) so
the timeout mock actually affects the code path.
♻️ Duplicate comments (5)
packages/jumpstarter/jumpstarter/client/core.py (2)

87-113: Missing return type annotation.

The method should include a return type annotation for completeness:

-    async def check_exporter_status(self):
+    async def check_exporter_status(self) -> None:

118-119: Status check integration is correct, but note prior performance discussion.

The status checks are correctly placed before driver calls. However, as noted in past review comments, calling check_exporter_status() before every driver call introduces additional gRPC round-trips that may impact latency, especially with remote controllers. The suggestion to cache the status or move this check exporter-side was raised previously.

Also applies to: 147-148

packages/jumpstarter/jumpstarter/exporter/session.py (1)

44-46: _status_update_event remains unused.

As noted in the previous review (and acknowledged by the maintainer), _status_update_event is initialized but never used. Since the maintainer confirmed this is intentional dead code, consider removing it to avoid confusion, or add a TODO comment if it's planned for future use.

packages/jumpstarter/jumpstarter/exporter/hooks.py (2)

51-56: Remove unused device_factory field.

The device_factory field is declared but never referenced within HookExecutor. All hook execution uses lease_scope.session instead. This should be removed to avoid confusion.

Suggested fix
 `@dataclass`(kw_only=True)
 class HookExecutor:
     """Executes lifecycle hooks with access to the j CLI."""

     config: HookConfigV1Alpha1
-    device_factory: Callable[[], "Driver"]

144-231: Reduce complexity to fix Ruff C901 violation (pipeline blocking).

The _execute_hook_process method has cyclomatic complexity of 12, exceeding the limit of 10. This is causing the pipeline to fail. Extract the output reading logic into a separate method.

Suggested refactor
+    async def _read_process_output(self, process) -> list[str]:
+        """Read stdout line by line and log each line."""
+        output_lines: list[str] = []
+        assert process.stdout is not None
+        buffer = b""
+        async for chunk in process.stdout:
+            buffer += chunk
+            while b"\n" in buffer:
+                line, buffer = buffer.split(b"\n", 1)
+                line_decoded = line.decode().rstrip()
+                output_lines.append(line_decoded)
+                logger.info("[hook output] %s", line_decoded)
+        # Handle any remaining data without newline
+        if buffer:
+            line_decoded = buffer.decode().rstrip()
+            if line_decoded:
+                output_lines.append(line_decoded)
+                logger.info("[hook output] %s", line_decoded)
+        return output_lines
+
     async def _execute_hook_process(
         self,
         hook_config: HookInstanceConfigV1Alpha1,
         lease_scope: "LeaseContext",
         log_source: LogSource,
         hook_env: dict[str, str],
         logging_session: Session,
         hook_type: Literal["before_lease", "after_lease"],
     ) -> None:
-        """Execute the hook process with the given environment and logging session.
-
-        Uses anyio for subprocess execution to be compatible with the anyio-based exporter.
-        """
-
+        """Execute the hook process with the given environment and logging session."""
         command = hook_config.script
         timeout = hook_config.timeout
         on_failure = hook_config.on_failure
@@ ... @@
             async with await open_process(
                 command,
                 env=hook_env,
                 stdout=subprocess.PIPE,
                 stderr=subprocess.STDOUT,
             ) as process:
-                output_lines: list[str] = []
-
-                async def read_output() -> None:
-                    """Read stdout line by line."""
-                    assert process.stdout is not None
-                    buffer = b""
-                    async for chunk in process.stdout:
-                        buffer += chunk
-                        while b"\n" in buffer:
-                            line, buffer = buffer.split(b"\n", 1)
-                            line_decoded = line.decode().rstrip()
-                            output_lines.append(line_decoded)
-                            logger.info("[hook output] %s", line_decoded)
-                    # Handle any remaining data without newline
-                    if buffer:
-                        line_decoded = buffer.decode().rstrip()
-                        if line_decoded:
-                            output_lines.append(line_decoded)
-                            logger.info("[hook output] %s", line_decoded)
-
                 # Use move_on_after for timeout
                 with anyio.move_on_after(timeout) as cancel_scope:
-                    await read_output()
+                    await self._read_process_output(process)
                     await process.wait()
🧹 Nitpick comments (5)
packages/jumpstarter/jumpstarter/common/types.py (1)

16-18: Remove empty TYPE_CHECKING block or add planned content.

The TYPE_CHECKING guard with only pass inside serves no current purpose. Consider removing it or adding the async stub type aliases mentioned in the comment.

♻️ Proposed fix
-# Async stub type aliases are only available for type checking (defined in .pyi files)
-if TYPE_CHECKING:
-    pass
packages/jumpstarter/jumpstarter/config/exporter_test.py (1)

106-156: LGTM! Comprehensive hook configuration test.

The test effectively validates:

  • Parsing of beforeLease and afterLease hooks from YAML
  • Multiline script content preservation
  • Round-trip save/load functionality
  • Correct use of camelCase aliases in YAML output

Consider adding assertions for timeout and onFailure fields to ensure all hook configuration properties are tested:

💡 Optional: Add timeout/onFailure assertions
     assert config.hooks.before_lease.script == 'echo "Pre-lease hook for $LEASE_NAME"\nj power on\n'
+    assert config.hooks.before_lease.timeout == 600
+    assert config.hooks.before_lease.on_failure == "warn"  # default value
     assert config.hooks.after_lease.script == 'echo "Post-lease hook for $LEASE_NAME"\nj power off\n'
+    assert config.hooks.after_lease.timeout == 600
+    assert config.hooks.after_lease.on_failure == "warn"  # default value
packages/jumpstarter/jumpstarter/exporter/hooks_test.py (1)

207-244: Test name doesn't match what's being verified.

The test is named test_real_time_output_logging but only verifies wrapper messages ("Executing before-lease hook...", "Hook executed successfully"), not the actual output lines being logged in real-time.

Looking at hooks.py, output lines are logged via logger.info("[hook output] %s", line_decoded). If the intent is to verify real-time output logging, the expected calls should include:

call("[hook output] %s", "Line 1"),
call("[hook output] %s", "Line 2"),
call("[hook output] %s", "Line 3"),

However, since the mocks target the wrong subprocess API (as noted above), fixing the mock infrastructure should be prioritized first.

packages/jumpstarter/jumpstarter/exporter/logging.py (1)

31-39: Prefix matching uses first-match semantics — consider if this is intentional.

The loop returns on the first prefix match. If multiple prefixes could match (e.g., "driver." and "driver.power."), the result depends on insertion order rather than specificity.

Current usage appears safe since mappings don't overlap ("driver." vs "hook.{lease_name}"), but if future use cases require hierarchical overrides, consider sorting by prefix length (longest first) or documenting the first-match behavior.

packages/jumpstarter/jumpstarter/exporter/exporter.py (1)

296-315: Consider using % formatting for logger calls.

The f-strings in logger calls (lines 313, 315) eagerly evaluate even when the log level is disabled. Using % formatting allows lazy evaluation.

Suggested fix
-            logger.info(f"Updated status to {status}: {message}")
+            logger.info("Updated status to %s: %s", status, message)
         except Exception as e:
-            logger.error(f"Failed to update status: {e}")
+            logger.error("Failed to update status: %s", e)
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 75236e2 and 5ccc484.

📒 Files selected for processing (35)
  • buf.gen.yaml
  • packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py
  • packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py
  • packages/jumpstarter-cli/jumpstarter_cli/get.py
  • packages/jumpstarter-cli/jumpstarter_cli/run.py
  • packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py
  • packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi
  • packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter/jumpstarter/client/grpc.py
  • packages/jumpstarter/jumpstarter/common/__init__.py
  • packages/jumpstarter/jumpstarter/common/enums.py
  • packages/jumpstarter/jumpstarter/common/types.py
  • packages/jumpstarter/jumpstarter/common/utils.py
  • packages/jumpstarter/jumpstarter/config/client.py
  • packages/jumpstarter/jumpstarter/config/exporter.py
  • packages/jumpstarter/jumpstarter/config/exporter_test.py
  • packages/jumpstarter/jumpstarter/driver/base.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
  • packages/jumpstarter/jumpstarter/exporter/hooks_test.py
  • packages/jumpstarter/jumpstarter/exporter/lease_context.py
  • packages/jumpstarter/jumpstarter/exporter/logging.py
  • packages/jumpstarter/jumpstarter/exporter/logging_protocol.py
  • packages/jumpstarter/jumpstarter/exporter/session.py
🚧 Files skipped from review as they are similar to previous changes (11)
  • buf.gen.yaml
  • packages/jumpstarter/jumpstarter/driver/base.py
  • packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py
  • packages/jumpstarter/jumpstarter/exporter/lease_context.py
  • packages/jumpstarter/jumpstarter/common/init.py
  • packages/jumpstarter-cli/jumpstarter_cli/get.py
  • packages/jumpstarter/jumpstarter/config/client.py
  • packages/jumpstarter/jumpstarter/common/enums.py
  • packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py
  • packages/jumpstarter/jumpstarter/common/utils.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2.pyi
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.cursor/rules/project-structure.mdc)

Ruff should be used for code formatting and linting, excluding jumpstarter-protocol package

Files:

  • packages/jumpstarter-cli/jumpstarter_cli/run.py
  • packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py
  • packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter/jumpstarter/exporter/session.py
  • packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py
  • packages/jumpstarter/jumpstarter/config/exporter_test.py
  • packages/jumpstarter/jumpstarter/exporter/logging_protocol.py
  • packages/jumpstarter/jumpstarter/exporter/hooks_test.py
  • packages/jumpstarter/jumpstarter/exporter/logging.py
  • packages/jumpstarter/jumpstarter/common/types.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
  • packages/jumpstarter/jumpstarter/config/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/client/grpc.py
🧠 Learnings (8)
📓 Common learnings
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.
📚 Learning: 2025-08-14T13:11:35.034Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.

Applied to files:

  • packages/jumpstarter-cli/jumpstarter_cli/run.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
📚 Learning: 2025-09-15T08:18:48.571Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 610
File: packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client.py:488-491
Timestamp: 2025-09-15T08:18:48.571Z
Learning: In the jumpstarter project, code review suggestions should stay focused on the specific scope of the PR. Suggestions about general improvements like timeout handling or error handling that are unrelated to the core changes being made should be avoided, even if they apply to modified code lines.

Applied to files:

  • packages/jumpstarter-cli/jumpstarter_cli/run.py
📚 Learning: 2025-10-02T15:29:09.070Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 681
File: packages/jumpstarter/jumpstarter/client/lease.py:198-220
Timestamp: 2025-10-02T15:29:09.070Z
Learning: In the jumpstarter codebase, `client_from_path` and `client_from_channel` functions in `packages/jumpstarter/jumpstarter/client/client.py` automatically call `GetReport(empty_pb2.Empty())` during client initialization. This means that successfully entering the client context manager guarantees the exporter is responsive and has answered the GetReport RPC.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
📚 Learning: 2025-11-27T09:58:55.346Z
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/project-structure.mdc:0-0
Timestamp: 2025-11-27T09:58:55.346Z
Learning: Applies to packages/jumpstarter-driver-*/jumpstarter_driver_*/ : Driver packages must implement a `client.py` file containing the client implementation

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
📚 Learning: 2025-11-27T09:58:41.875Z
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/creating-new-drivers.mdc:0-0
Timestamp: 2025-11-27T09:58:41.875Z
Learning: Applies to packages/jumpstarter-driver-*/jumpstarter_driver_*/client.py : Driver client CLIs should either implement known classes that provide a CLI interface, or implement their own CLI interface. Use `CompositeClient` from `jumpstarter_driver_composite.client` for composite drivers with child drivers.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
📚 Learning: 2025-09-02T07:32:04.548Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py:0-0
Timestamp: 2025-09-02T07:32:04.548Z
Learning: *_pb2.py files in the jumpstarter codebase are auto-generated from gRPC/protobuf definitions and should not be manually modified. Issues with these files should be addressed at the protobuf generation tooling level or runtime environment setup.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi
📚 Learning: 2025-10-06T15:44:51.111Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 690
File: packages/jumpstarter/jumpstarter/config/exporter.py:51-56
Timestamp: 2025-10-06T15:44:51.111Z
Learning: In jumpstarter config instantiation (packages/jumpstarter/jumpstarter/config/exporter.py), passing **self.root.config to driver_class constructors is intentional validation behavior: since Driver classes are dataclasses, the __init__ will reject any config keys that don't match declared fields with a TypeError, ensuring only declared/supported fields can be used in config YAML files. This is the desired behavior for config safety.

Applied to files:

  • packages/jumpstarter/jumpstarter/config/exporter.py
🧬 Code graph analysis (12)
packages/jumpstarter-cli/jumpstarter_cli/run.py (1)
packages/jumpstarter/jumpstarter/exporter/exporter.py (1)
  • exit_code (194-200)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py (1)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py (4)
  • rich_add_columns (81-91)
  • rich_add_columns (139-140)
  • rich_add_rows (110-125)
  • rich_add_rows (142-144)
packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py (3)
packages/jumpstarter/jumpstarter/exporter/exporter.py (1)
  • session (381-401)
packages/jumpstarter/jumpstarter/exporter/session.py (1)
  • update_status (152-158)
packages/jumpstarter/jumpstarter/client/lease.py (1)
  • update_status (376-399)
packages/jumpstarter/jumpstarter/config/exporter_test.py (1)
packages/jumpstarter/jumpstarter/config/exporter.py (4)
  • ExporterConfigV1Alpha1 (112-260)
  • load (148-151)
  • save (166-175)
  • dump_yaml (162-163)
packages/jumpstarter/jumpstarter/exporter/hooks_test.py (2)
packages/jumpstarter/jumpstarter/exporter/hooks.py (4)
  • HookExecutionError (26-48)
  • HookExecutor (52-434)
  • execute_before_lease_hook (233-251)
  • execute_after_lease_hook (253-271)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (1)
  • LeaseContext (19-84)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (4)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (28-28)
  • _ServicerContext (30-31)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (22-22)
  • _ServicerContext (24-25)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter/jumpstarter/exporter/logging.py (4)
packages/jumpstarter/jumpstarter/exporter/logging_protocol.py (2)
  • LoggerRegistration (8-22)
  • add_logger_source (15-22)
packages/jumpstarter/jumpstarter/common/enums.py (1)
  • LogSource (48-76)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1)
  • LogStreamResponse (499-521)
packages/jumpstarter/jumpstarter/exporter/session.py (2)
  • context_log_source (168-170)
  • add_logger_source (160-162)
packages/jumpstarter/jumpstarter/config/exporter.py (4)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi (1)
  • ExporterStatus (42-46)
packages/jumpstarter/jumpstarter/exporter/exporter.py (1)
  • session (381-401)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (1)
  • update_status (73-84)
packages/jumpstarter/jumpstarter/exporter/hooks.py (1)
  • HookExecutor (52-434)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (4)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (28-28)
  • _ServicerContext (30-31)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (22-22)
  • _ServicerContext (24-25)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (3)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (22-22)
  • _ServicerContext (24-25)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter/jumpstarter/exporter/exporter.py (5)
packages/jumpstarter/jumpstarter/common/enums.py (3)
  • ExporterStatus (8-45)
  • to_proto (43-45)
  • to_proto (74-76)
packages/jumpstarter/jumpstarter/common/streams.py (1)
  • connect_router_stream (37-48)
packages/jumpstarter/jumpstarter/exporter/hooks.py (3)
  • HookExecutor (52-434)
  • run_after_lease_hook (360-434)
  • run_before_lease_hook (273-358)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (5)
  • LeaseContext (19-84)
  • update_status (73-84)
  • is_active (57-59)
  • has_client (61-63)
  • update_client (65-67)
packages/jumpstarter/jumpstarter/exporter/session.py (4)
  • Session (33-178)
  • GetReport (110-119)
  • update_status (152-158)
  • serve_unix_async (96-99)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi (2)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi (1)
  • ExporterStatus (42-46)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2.pyi (9)
  • Condition (107-146)
  • HasField (97-97)
  • HasField (133-133)
  • WhichOneof (100-100)
  • WhichOneof (102-102)
  • WhichOneof (136-136)
  • WhichOneof (138-138)
  • WhichOneof (140-140)
  • WhichOneof (142-142)
🪛 GitHub Actions: Lint
packages/jumpstarter/jumpstarter/exporter/hooks.py

[error] 144-144: C901 _execute_hook_process is too complex (12 > 10)

🪛 GitHub Check: ruff
packages/jumpstarter/jumpstarter/exporter/hooks.py

[failure] 144-144: Ruff (C901)
packages/jumpstarter/jumpstarter/exporter/hooks.py:144:15: C901 _execute_hook_process is too complex (12 > 10)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: e2e
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
🔇 Additional comments (54)
packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py (3)

29-30: LGTM!

The new optional fields are properly defined with Pydantic aliases to match the API's camelCase convention (exporterStatus, statusMessage) while using snake_case internally.


60-61: LGTM!

Safe extraction of optional fields using .get() is consistent with the existing pattern for other optional fields like credential and devices.


81-106: LGTM!

The status rendering logic correctly handles both cases where self.status might be missing and where exporter_status is None, defaulting to "Unknown" in either case. The STATUS column placement after NAME is consistent with the pattern in leases.py.

packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py (5)

299-309: LGTM!

Test fixture correctly updated to include the new exporter_status field with the expected value "Available".


324-326: LGTM!

Expected JSON and YAML outputs correctly include both exporterStatus and statusMessage fields with proper casing to match the Pydantic field aliases.

Also applies to: 342-343


354-360: LGTM!

Test assertion correctly verifies that "Available" status appears in the CLI table output.


517-542: LGTM!

Exporter list fixtures consistently include exporter_status="Available" for all items, maintaining test consistency.


671-700: LGTM!

Device list fixtures updated consistently with the status field.

packages/jumpstarter-cli/jumpstarter_cli/run.py (1)

79-94: LGTM! Exit code priority logic is clear and correct.

The implementation properly handles the three exit scenarios in priority order: signal-based termination, hook failure exit codes, and normal restart. The code is well-documented with comments explaining the priority order.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (1)

1-20: Auto-generated stub file is consistent with existing patterns.

This file follows the same structure as other *_pb2_grpc.pyi files in the codebase (e.g., common_pb2_grpc.pyi, router_pb2_grpc.pyi). Based on learnings, these files are auto-generated from gRPC/protobuf definitions and should not be manually modified.

packages/jumpstarter/jumpstarter/client/core.py (1)

53-57: Clean exception class definition.

The ExporterNotReady exception is well-placed in the exception hierarchy, inheriting from DriverError for consistent error handling.

packages/jumpstarter/jumpstarter/common/types.py (1)

8-14: LGTM! Useful centralization of gRPC type aliases.

The type aliases provide a clean abstraction for gRPC stubs and channels, making imports more consistent across the codebase.

Also applies to: 20-25

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (1)

1-20: Auto-generated stub file follows established patterns.

This file is generated by mypy-protobuf and matches the structure of other *_pb2_grpc.pyi files in the protocol package. Based on learnings, these files should not be manually modified.

packages/jumpstarter/jumpstarter/client/grpc.py (7)

15-23: LGTM! Status display capability added correctly.

The import of ExporterStatus and the new show_status flag in WithOptions align with the broader status integration across the PR.


32-33: LGTM!

The STATUS column is conditionally added based on the show_status option, consistent with the existing pattern for show_online and show_leases.


48-50: LGTM!

The status string rendering handles the None case gracefully by defaulting to "UNKNOWN".


90-99: LGTM! Backward-compatible status field handling.

The from_protobuf method defensively checks for status field presence using hasattr and truthiness before converting, ensuring backward compatibility with older protobuf messages that may lack the status field.


257-275: LGTM!

The include_status field and its propagation through rich_add_columns and rich_add_rows follows the established pattern for include_online and include_leases.


292-305: LGTM!

The serialization logic correctly excludes the status field when include_status is False, maintaining consistent behavior with other conditional fields.


491-491: LGTM!

The type annotation change from untyped to dict[Channel, SimpleNamespace] improves type safety for the internal stubs dictionary.

packages/jumpstarter/jumpstarter/config/exporter.py (7)

19-20: LGTM!

The TYPE_CHECKING guard for the Driver import avoids circular import issues at runtime while preserving type annotation support.


23-51: LGTM! Well-structured hook configuration models.

The HookInstanceConfigV1Alpha1 and HookConfigV1Alpha1 models are cleanly defined with:

  • Proper field aliases (onFailure, beforeLease, afterLease) for YAML/JSON serialization
  • Sensible defaults (120s timeout, warn on failure)
  • Clear descriptions for each field

The on_failure literal types (warn, endLease, exit) align with the handling in HookExecutor._handle_hook_failure from the relevant code snippets.


76-88: LGTM!

The return type annotation -> "Driver" provides better type information for callers of instantiate().


128-128: LGTM!

The hooks field with Field(default_factory=HookConfigV1Alpha1) ensures a default empty hooks configuration, maintaining backward compatibility with existing config files that don't specify hooks.


163-174: LGTM! Consistent serialization with aliases.

Adding by_alias=True to both dump_yaml and save ensures YAML output uses camelCase keys (e.g., beforeLease, afterLease, onFailure) matching the expected config file format.


184-199: LGTM!

Setting ExporterStatus.LEASE_READY for local usage via serve_unix_async is appropriate since there's no lease/hook lifecycle flow in local mode—the exporter is immediately ready for commands.


224-246: LGTM! Conditional HookExecutor creation.

The HookExecutor is only instantiated when at least one hook (before_lease or after_lease) is configured, avoiding unnecessary overhead when hooks aren't used. The executor is correctly passed to the Exporter constructor.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (1)

1-96: Auto-generated file - no manual modifications needed.

This file is generated by mypy-protobuf as indicated in the header. Per project learnings, *_pb2.py and related stub files are auto-generated from gRPC/protobuf definitions and should not be manually modified. Any issues should be addressed at the protobuf generation tooling level.

The generated types provide proper sync/async gRPC stub support for RouterService, which aligns with the broader typed protocol surface introduced in this PR.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi (1)

1-307: Auto-generated file - no manual modifications needed.

This file is generated by mypy-protobuf as indicated in the header. Per project learnings, *_pb2.py and related stub files are auto-generated from gRPC/protobuf definitions and should not be manually modified. Any issues should be addressed at the protobuf generation tooling level.

The generated types provide a complete typed surface for ClientService with proper sync/async support, consistent with other v1 protocol stubs in this PR.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2.pyi (1)

1-96: LGTM - Well-formed auto-generated protobuf stubs.

The ExporterStatus and LogSource enum stubs are correctly generated with appropriate values supporting the new hook lifecycle states (BEFORE_LEASE_HOOK, AFTER_LEASE_HOOK, and their failed variants). The type aliases and docstrings are properly defined. Based on learnings, these files should not be manually modified.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi (1)

1-318: LGTM - Well-formed auto-generated client protobuf stubs.

The client message stubs are correctly generated with proper type references to common_pb2.ExporterStatus and kubernetes_pb2.Condition. The Lease and Exporter message structures support the new status reporting capabilities introduced by this PR. Based on learnings, issues with these files should be addressed at the protobuf generation tooling level.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1)

1-717: LGTM - Well-formed auto-generated jumpstarter protobuf stubs.

The generated stubs correctly define:

  • ReportStatusRequest with ExporterStatus.ValueType for hook status reporting
  • LogStreamResponse with optional source field using LogSource.ValueType to distinguish log origins
  • GetStatusRequest/GetStatusResponse for client status preflight checks

These align with the PR's hook execution and status propagation features. Based on learnings, these files should not be manually modified.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2.pyi (1)

1-73: LGTM - Well-formed auto-generated router protobuf stubs.

The router streaming message stubs are correctly generated with FrameType enum values following HTTP/2 conventions and symmetric StreamRequest/StreamResponse structures. Based on learnings, these files should not be manually modified.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (1)

1-752: LGTM - Well-formed auto-generated gRPC service stubs.

The gRPC service stubs are correctly generated with:

  • ControllerService.ReportStatus RPC for exporters to report hook execution status
  • ExporterService.GetStatus RPC for client preflight status checks
  • Proper generic typing supporting both sync (grpc.Channel) and async (grpc.aio.Channel) channels
  • Abstract servicer classes with correct Union[T, Awaitable[T]] return types for sync/async compatibility

Based on learnings, these files should not be manually modified.

packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py (1)

4-4: LGTM!

The import and status update are correctly integrated. Setting ExporterStatus.LEASE_READY for local testing appropriately simulates the post-hook state that would normally be set after the before-lease hook completes in production.

Also applies to: 22-23

packages/jumpstarter/jumpstarter/exporter/logging_protocol.py (1)

1-22: LGTM!

Clean protocol definition that properly decouples the logger registration interface from concrete implementations. This enables get_logger() in logging.py to accept any object implementing add_logger_source() without creating circular imports.

packages/jumpstarter/jumpstarter/exporter/session.py (1)

152-178: LGTM!

The new status and logging methods are well-implemented:

  • update_status() correctly handles both proto int values and ExporterStatus enum
  • Logger source management methods properly delegate to the underlying LogHandler
  • GetStatus() gRPC handler returns the appropriate proto response

The API aligns with the LoggerRegistration protocol defined in logging_protocol.py.

packages/jumpstarter/jumpstarter/exporter/logging.py (2)

69-89: LGTM!

The get_logger function provides a clean API for obtaining loggers with automatic source registration. The optional session parameter allows flexible usage — callers can get a plain logger or register it with a session in one call.


12-19: Thread-safe initialization looks good.

Using RLock for _child_handlers access is appropriate since the same thread might call get_source_for_record while holding the lock from add_child_handler (though current code paths don't do this).

packages/jumpstarter/jumpstarter/exporter/hooks.py (6)

25-49: LGTM!

The HookExecutionError dataclass properly encapsulates hook failure context with appropriate helper methods for determining shutdown behavior based on the on_failure setting.


58-76: LGTM!

The environment setup correctly passes the Unix socket path, driver permissions, and lease metadata to hook scripts.


78-106: LGTM!

The hook execution entry point properly validates the command and delegates to the process execution method.


108-142: LGTM!

The failure handling logic properly differentiates between warning-only and fatal failures with correct exception chaining.


273-358: LGTM!

The before-lease hook orchestration is well-structured with proper timeout handling, status reporting, and the critical finally block that always signals the event to prevent deadlocks.


360-433: LGTM!

The after-lease hook orchestration correctly handles the cleanup phase with appropriate error handling and status reporting.

packages/jumpstarter/jumpstarter/exporter/exporter.py (9)

1-34: LGTM!

The imports are well-organized with proper use of TYPE_CHECKING for the Driver forward reference to avoid circular imports.


38-166: Excellent documentation on fields.

The comprehensive docstrings for each field significantly improve code maintainability and developer onboarding. The _previous_leased field is now properly declared as a dataclass field (addressing the earlier review comment).


168-191: LGTM!

The stop() method properly integrates the new exit_code parameter with clear logging about unregistration behavior.


206-243: LGTM!

The generic retry wrapper correctly handles stream reconnection with backoff. The retry counter reset after successful iteration ensures long-running streams maintain resilience.


269-294: LGTM!

The registration logic correctly handles the conditional status reporting to avoid race conditions with hook execution.


317-339: LGTM!

The unregistration logic is properly defensive with timeout handling and shielded cleanup.


403-465: LGTM!

The lease handling correctly orchestrates session creation, hook synchronization, and connection processing. The status sync after hook completion ensures the session receives the correct LEASE_READY status.


467-568: LGTM!

The serve() method correctly orchestrates the status stream handling, lease transitions, and hook execution. The shielded CancelScope blocks ensure after-lease hooks complete even during cancellation. Based on learnings, the fork-based architecture restarts the exporter process on lease changes, which explains the not self._started check for handle_lease.


380-401: LGTM!

The session() context manager cleanly encapsulates session creation, Unix socket serving, and controller registration.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +159 to +162
with (
patch("asyncio.create_subprocess_shell", return_value=mock_process),
patch("asyncio.wait_for", side_effect=asyncio.TimeoutError()),
):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Timeout mock also targets wrong API.

The implementation uses anyio.move_on_after for timeout handling, not asyncio.wait_for. This mock won't affect the actual timeout behavior.

🔧 Suggested approach

For anyio timeout testing, consider either:

  1. Using a real short timeout with a command that actually sleeps (integration test approach)
  2. Mocking anyio.move_on_after to simulate cancellation, though this requires careful handling of the cancel scope pattern
🤖 Prompt for AI Agents
In `@packages/jumpstarter/jumpstarter/exporter/hooks_test.py` around lines 159 -
162, The test currently patches asyncio.wait_for but the implementation uses
anyio.move_on_after, so update the test to either (A) mock anyio.move_on_after
instead of asyncio.wait_for — patch the symbol anyio.move_on_after and have the
mock act as an async context manager that triggers cancellation (e.g., yields a
context with .cancelled True / raises CancellationError inside) while keeping
the patch of asyncio.create_subprocess_shell returning mock_process, or (B)
convert the test to an integration-style run using a real short timeout and a
subprocess that sleeps briefly to trigger the timeout; locate the test that
patches asyncio.create_subprocess_shell and replace the wait_for patch with a
mock/behavior for anyio.move_on_after (or switch to a real short-timeout run) so
the timeout mock actually affects the code path.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/jumpstarter-cli/jumpstarter_cli/shell.py (1)

144-200: Address linter complexity warning (C901).

The pipeline reports _shell_with_signal_handling has cyclomatic complexity 12 > 10. Consider extracting helpers to reduce nesting:

  1. Extract token validation (lines 153-162) into a helper like _validate_token_not_expired(token)
  2. Extract the token expiry warning logic (lines 186-194) into _check_token_expiry_on_cancel(token, lease_used, selector)
♻️ Suggested refactor to reduce complexity
def _validate_token_not_expired(token: str | None) -> None:
    """Raise ConnectionError if token is expired."""
    if not token:
        return
    try:
        from jumpstarter_cli_common.oidc import get_token_remaining_seconds
        remaining = get_token_remaining_seconds(token)
        if remaining is not None and remaining <= 0:
            from jumpstarter.common.exceptions import ConnectionError
            raise ConnectionError("token is expired")
    except ImportError:
        pass


def _handle_cancel_token_expiry(token: str | None, lease_used, selector: str) -> None:
    """Warn about token expiry if cancellation was due to expired token."""
    if not (lease_used and token):
        return
    try:
        from jumpstarter_cli_common.oidc import get_token_remaining_seconds
        remaining = get_token_remaining_seconds(token)
        if remaining is not None and remaining <= 0:
            _warn_about_expired_token(lease_used.name, selector)
    except ImportError:
        pass

Then in _shell_with_signal_handling:

     # Check token before starting
     token = getattr(config, "token", None)
-    if token:
-        try:
-            from jumpstarter_cli_common.oidc import get_token_remaining_seconds
-            remaining = get_token_remaining_seconds(token)
-            if remaining is not None and remaining <= 0:
-                from jumpstarter.common.exceptions import ConnectionError
-                raise ConnectionError("token is expired")
-        except ImportError:
-            pass
+    _validate_token_not_expired(token)
             except cancelled_exc_class:
                 # Check if cancellation was due to token expiry
                 token = getattr(config, "token", None)
-                if lease_used and token:
-                    try:
-                        from jumpstarter_cli_common.oidc import get_token_remaining_seconds
-                        remaining = get_token_remaining_seconds(token)
-                        if remaining is not None and remaining <= 0:
-                            _warn_about_expired_token(lease_used.name, selector)
-                    except ImportError:
-                        pass
+                _handle_cancel_token_expiry(token, lease_used, selector)
                 exit_code = 2
♻️ Duplicate comments (4)
packages/jumpstarter/jumpstarter/client/core.py (2)

103-103: Add return type annotation.

The method is missing the -> None return type annotation for completeness.

Proposed fix
-    async def check_exporter_status(self):
+    async def check_exporter_status(self) -> None:

148-149: Status check adds latency per call.

Each call_async now incurs an additional GetStatus RPC roundtrip. As noted in prior discussions, consider caching the status or moving this check server-side to avoid the overhead, particularly when the controller has significant network latency.

packages/jumpstarter/jumpstarter/exporter/hooks.py (2)

51-56: Remove unused device_factory field.

The device_factory field is declared but never used within HookExecutor. All hook execution uses lease_scope.session instead. This field should be removed to avoid confusion.

♻️ Proposed fix
 `@dataclass`(kw_only=True)
 class HookExecutor:
     """Executes lifecycle hooks with access to the j CLI."""

     config: HookConfigV1Alpha1
-    device_factory: Callable[[], "Driver"]

Also update any instantiation sites to remove the device_factory argument.


144-235: Reduce complexity to fix Ruff C901 violation and address test failure.

Two issues need attention:

  1. Complexity (C901): This method has cyclomatic complexity of 12, exceeding the limit of 10. Extract the read_output nested function to a separate method.

  2. Test failure: The pipeline shows TypeError: 'Mock' object does not support the context manager protocol at line 169. Tests mocking logging_session need to provide a proper context manager for context_log_source.

♻️ Suggested refactor for complexity
+    async def _read_process_output(self, process, hook_label: str) -> list[str]:
+        """Read stdout line by line and log each line."""
+        output_lines: list[str] = []
+        assert process.stdout is not None
+        buffer = b""
+        async for chunk in process.stdout:
+            buffer += chunk
+            while b"\n" in buffer:
+                line, buffer = buffer.split(b"\n", 1)
+                line_decoded = line.decode().rstrip()
+                output_lines.append(line_decoded)
+                logger.info("[%s hook] %s", hook_label, line_decoded)
+        if buffer:
+            line_decoded = buffer.decode().rstrip()
+            if line_decoded:
+                output_lines.append(line_decoded)
+                logger.info("[%s hook] %s", hook_label, line_decoded)
+        return output_lines
+
     async def _execute_hook_process(
         ...
     ) -> None:
         ...
-                    async def read_output() -> None:
-                        """Read stdout line by line."""
-                        ...
-
                     with anyio.move_on_after(timeout) as cancel_scope:
-                        await read_output()
+                        hook_label = "beforeLease" if hook_type == "before_lease" else "afterLease"
+                        await self._read_process_output(process, hook_label)
                         await process.wait()
🧪 Test fix for context manager mock

In test files, update the mock for logging_session.context_log_source:

from contextlib import nullcontext
from unittest.mock import MagicMock

# When creating the mock session:
mock_session = MagicMock()
mock_session.context_log_source.return_value = nullcontext()
🧹 Nitpick comments (4)
packages/jumpstarter/jumpstarter/client/core.py (1)

110-115: Consider promoting ALLOWED_STATUSES to a class-level constant.

The set is recreated on every call. Since this method is invoked before each driver call, moving the constant to the class level avoids repeated allocations.

Proposed refactor

Add at the class level (e.g., after line 74):

    _ALLOWED_STATUSES = frozenset({
        ExporterStatus.LEASE_READY,
        ExporterStatus.BEFORE_LEASE_HOOK,
        ExporterStatus.AFTER_LEASE_HOOK,
    })

Then in the method:

-        # Statuses that allow driver commands
-        ALLOWED_STATUSES = {
-            ExporterStatus.LEASE_READY,
-            ExporterStatus.BEFORE_LEASE_HOOK,
-            ExporterStatus.AFTER_LEASE_HOOK,
-        }
-
         status = await self.get_status_async()
         if status is None:
             # GetStatus not implemented, assume ready for backward compatibility
             return

-        if status not in ALLOWED_STATUSES:
+        if status not in self._ALLOWED_STATUSES:
             raise ExporterNotReady(f"Exporter status is {status}")
packages/jumpstarter/jumpstarter/exporter/lease_context.py (1)

48-51: Consider using explicit exceptions instead of assertions for validation.

Assertions can be disabled when Python runs with optimization flags (-O), which could allow invalid LeaseContext instances to be created in production. Since this validates constructor arguments, explicit exceptions would be more robust.

♻️ Suggested alternative
     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"
+        if self.before_lease_hook is None:
+            raise ValueError("LeaseContext requires a before_lease_hook event")
+        if not self.lease_name:
+            raise ValueError("LeaseContext requires a non-empty lease_name")
packages/jumpstarter/jumpstarter/exporter/hooks.py (1)

277-362: LGTM with optional improvement suggestion.

The orchestration logic is well-structured with proper error handling and the finally block correctly ensures the event is always set. The TODO at line 350 is noted for follow-up.

💡 Optional: Simplify polling with anyio primitives

The manual polling loop (lines 300-311) could be simplified using anyio.fail_after or a condition variable pattern, but the current implementation is readable and functional.

# Alternative approach using fail_after:
with anyio.fail_after(30):
    while not lease_scope.is_ready():
        await anyio.sleep(0.1)
packages/jumpstarter/jumpstarter/exporter/exporter.py (1)

296-315: Consider using % formatting for logger calls.

Lines 313 and 315 use f-strings in logger.info and logger.error. While functionally correct, %-style formatting allows lazy evaluation (string only constructed if log level is enabled).

♻️ Suggested change
-            logger.info(f"Updated status to {status}: {message}")
+            logger.info("Updated status to %s: %s", status, message)
         except Exception as e:
-            logger.error(f"Failed to update status: {e}")
+            logger.error("Failed to update status: %s", e)
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5ccc484 and 8d1a510.

📒 Files selected for processing (12)
  • packages/jumpstarter-cli/jumpstarter_cli/shell.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
  • packages/jumpstarter/jumpstarter/exporter/lease_context.py
  • packages/jumpstarter/jumpstarter/exporter/session.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/jumpstarter/jumpstarter/exporter/session.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.cursor/rules/project-structure.mdc)

Ruff should be used for code formatting and linting, excluding jumpstarter-protocol package

Files:

  • packages/jumpstarter/jumpstarter/exporter/lease_context.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.py
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.py
  • packages/jumpstarter-cli/jumpstarter_cli/shell.py
  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
🧠 Learnings (5)
📚 Learning: 2025-10-02T15:29:09.070Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 681
File: packages/jumpstarter/jumpstarter/client/lease.py:198-220
Timestamp: 2025-10-02T15:29:09.070Z
Learning: In the jumpstarter codebase, `client_from_path` and `client_from_channel` functions in `packages/jumpstarter/jumpstarter/client/client.py` automatically call `GetReport(empty_pb2.Empty())` during client initialization. This means that successfully entering the client context manager guarantees the exporter is responsive and has answered the GetReport RPC.

Applied to files:

  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.py
  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
📚 Learning: 2025-11-27T09:58:41.875Z
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/creating-new-drivers.mdc:0-0
Timestamp: 2025-11-27T09:58:41.875Z
Learning: Applies to packages/jumpstarter-driver-*/jumpstarter_driver_*/client.py : Driver client CLIs should either implement known classes that provide a CLI interface, or implement their own CLI interface. Use `CompositeClient` from `jumpstarter_driver_composite.client` for composite drivers with child drivers.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
📚 Learning: 2025-09-02T07:32:04.548Z
Learnt from: mangelajo
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py:0-0
Timestamp: 2025-09-02T07:32:04.548Z
Learning: *_pb2.py files in the jumpstarter codebase are auto-generated from gRPC/protobuf definitions and should not be manually modified. Issues with these files should be addressed at the protobuf generation tooling level or runtime environment setup.

Applied to files:

  • packages/jumpstarter/jumpstarter/client/core.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.py
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi
📚 Learning: 2025-08-14T13:11:35.034Z
Learnt from: michalskrivanek
Repo: jumpstarter-dev/jumpstarter PR: 589
File: packages/jumpstarter/jumpstarter/exporter/exporter.py:147-155
Timestamp: 2025-08-14T13:11:35.034Z
Learning: In the jumpstarter fork-based architecture, when the exporter's serve() method exits (e.g., due to lease changes), the child process terminates and the parent process automatically restarts it, eliminating concerns about orphaned tasks since the entire process is restarted.

Applied to files:

  • packages/jumpstarter/jumpstarter/exporter/exporter.py
  • packages/jumpstarter/jumpstarter/exporter/hooks.py
📚 Learning: 2025-11-27T09:58:55.346Z
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/project-structure.mdc:0-0
Timestamp: 2025-11-27T09:58:55.346Z
Learning: Applies to packages/jumpstarter-driver-*/jumpstarter_driver_*/ : Driver packages must implement a `client.py` file containing the client implementation

Applied to files:

  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi
  • packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
🧬 Code graph analysis (3)
packages/jumpstarter-cli/jumpstarter_cli/shell.py (4)
packages/jumpstarter/jumpstarter/common/utils.py (1)
  • launch_shell (87-163)
packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py (3)
  • format_duration (196-214)
  • get_token_remaining_seconds (161-172)
  • client (55-56)
packages/jumpstarter/jumpstarter/client/core.py (2)
  • log_stream (238-240)
  • end_session_async (125-143)
packages/jumpstarter/jumpstarter/common/metadata.py (1)
  • name (13-14)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (3)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/common_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/kubernetes_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (14-14)
  • _ServicerContext (16-17)
packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/router_pb2_grpc.pyi (2)
  • _MaybeAsyncIterator (21-21)
  • _ServicerContext (23-24)
packages/jumpstarter/jumpstarter/exporter/hooks.py (3)
packages/jumpstarter/jumpstarter/config/exporter.py (2)
  • HookConfigV1Alpha1 (44-50)
  • HookInstanceConfigV1Alpha1 (23-41)
packages/jumpstarter/jumpstarter/exporter/session.py (2)
  • Session (34-214)
  • context_log_source (170-172)
packages/jumpstarter/jumpstarter/exporter/lease_context.py (2)
  • LeaseContext (19-88)
  • is_ready (53-59)
🪛 GitHub Actions: Lint
packages/jumpstarter-cli/jumpstarter_cli/shell.py

[error] 144-144: C901 _shell_with_signal_handling is too complex (12 > 10).

🪛 GitHub Actions: Run Tests
packages/jumpstarter/jumpstarter/exporter/hooks.py

[error] 169-169: TypeError: 'Mock' object does not support the context manager protocol when using 'logging_session.context_log_source'. The tests mock logging_session but do not provide a compatible context manager, causing failures in real-time hook logging.

🪛 GitHub Check: ruff
packages/jumpstarter/jumpstarter/exporter/hooks.py

[failure] 144-144: Ruff (C901)
packages/jumpstarter/jumpstarter/exporter/hooks.py:144:15: C901 _execute_hook_process is too complex (12 > 10)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
  • GitHub Check: build
  • GitHub Check: e2e
🔇 Additional comments (40)
packages/jumpstarter/jumpstarter/client/core.py (5)

5-6: LGTM!

The __future__.annotations import enables PEP 563 deferred evaluation, and ExporterStatus is correctly imported to support the new status-checking functionality.

Also applies to: 19-19


53-57: LGTM!

The ExporterNotReady exception appropriately inherits from DriverError, ensuring it integrates with existing error-handling patterns.


87-101: LGTM!

The get_status_async method has proper backward compatibility handling for older exporters that don't implement GetStatus, and consistent error wrapping.


125-143: LGTM!

The end_session_async method has proper backward compatibility handling and consistent error wrapping, matching the pattern established by get_status_async.


177-178: Consistent with call_async.

The same status check is applied before streaming calls. The performance considerations noted for call_async apply here as well.

packages/jumpstarter-cli/jumpstarter_cli/shell.py (5)

1-1: LGTM!

Standard module-level logger setup using __name__ is correct.

Also applies to: 18-19


21-26: LGTM!

Good extraction of shell launch logic into a reusable helper. Parameter mapping to launch_shell is correct.


38-46: LGTM!

Defensive ImportError handling makes OIDC monitoring optional, improving resilience in environments without OIDC support.


80-89: LGTM!

Clean refactor using _run_shell_only helper. The docstring clarification about no afterLease hook waiting is helpful.


92-141: Well-structured async lease workflow.

The implementation correctly orchestrates the shell execution with afterLease hook handling. The docstring clearly documents the behavior including Ctrl+C handling.

One observation: the except Exception at line 133 is broad but acceptable here since EndSession errors shouldn't prevent shell exit. The logger.warning provides visibility into failures.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.py (1)

1-131: Auto-generated protobuf code looks correct.

This file is auto-generated from protobuf definitions. The changes correctly add the EndSessionRequest and EndSessionResponse messages and update the serialized descriptor offsets for the new EndSession RPC in ExporterService. Based on learnings, any issues with *_pb2.py files should be addressed at the protobuf generation tooling level.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py (1)

53-54: Auto-generated protobuf code correctly adds status_message field.

The new status_message field is properly wired into the Exporter message with OUTPUT_ONLY field behavior options. This aligns with the PR's goal of exposing exporter status information. Based on learnings, this file is auto-generated and should not be manually modified.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.py (1)

575-579: Auto-generated gRPC code correctly adds EndSession RPC.

The EndSession RPC is properly wired across all layers:

  • Stub (ExporterServiceStub.EndSession)
  • Servicer (ExporterServiceServicer.EndSession) with appropriate docstring explaining the afterLease hook trigger
  • Server registration (add_ExporterServiceServicer_to_server)
  • Experimental API (ExporterService.EndSession)

The unary_unary pattern is correct for this RPC design where the client waits for the hook to complete.

Also applies to: 624-631, 666-670, 845-871

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi (1)

1-321: Auto-generated type stubs look correct.

This new file provides comprehensive type stubs for the client protobuf messages. The Exporter class correctly includes the new status_message field (lines 56, 60, 70), and all message types follow the standard mypy-protobuf generation patterns with proper DESCRIPTOR, field constants, typed __init__, and accessor methods.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2_grpc.pyi (3)

603-705: Auto-generated EndSession type definitions look correct.

The EndSession RPC is properly typed across:

  • Type variable _ExporterServiceEndSessionType (lines 603-617)
  • ExporterServiceStub generic parameter and overloads (lines 619-686)
  • Stub attribute with docstring (lines 701-705)

The UnaryUnaryMultiCallable typing is appropriate for this synchronous RPC pattern.


786-795: ExporterServiceServicer.EndSession abstract method correctly defined.

The servicer abstract method properly types the request/response and preserves the docstring explaining the afterLease hook behavior. This matches the implementation in jumpstarter_pb2_grpc.py.


15-18: > Likely an incorrect or invalid review comment.

packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/jumpstarter_pb2.pyi (1)

1-747: Auto-generated protobuf stub file looks correct.

This file is auto-generated by mypy-protobuf (as noted in line 2) and should not be manually modified. The generated stubs correctly expose the new protocol messages supporting the hooks feature:

  • ReportStatusRequest/ReportStatusResponse for exporter status reporting
  • GetStatusRequest/GetStatusResponse for client preflight status checks
  • EndSessionRequest/EndSessionResponse for session termination
  • LogStreamResponse with new optional source field for log source identification

The structure follows standard mypy-protobuf output patterns with proper type annotations, field number constants, and accessor methods. Based on learnings, any issues with these files should be addressed at the protobuf generation tooling level.

packages/jumpstarter/jumpstarter/exporter/lease_context.py (2)

1-16: LGTM!

Clean module setup with appropriate imports. The TYPE_CHECKING guard for the Session import correctly avoids circular dependencies.


53-88: LGTM!

The predicate methods (is_ready, is_active, has_client) and mutators (update_client, clear_client, update_status) are well-designed. The update_status method correctly handles the race condition where status needs to be available before the session is created.

packages/jumpstarter/jumpstarter/exporter/hooks.py (6)

25-49: LGTM!

Well-designed exception class that cleanly encapsulates hook failure semantics. The should_shutdown_exporter and should_end_lease methods provide clear intent for error handling logic.


58-76: LGTM!

The environment setup correctly provides the hook script with necessary context (socket path, lease metadata) and intentionally allows unsafe drivers for local CLI access.


78-106: LGTM!

Clean implementation with proper validation of empty commands and clear delegation to _execute_hook_process.


108-142: LGTM!

Proper failure handling with appropriate logging levels and correct exception chaining using raise error from cause.


237-275: LGTM!

Clean wrapper methods with proper configuration checks and delegation. The early returns when no hook is configured avoid unnecessary work.


364-437: LGTM!

Well-structured after-lease hook orchestration with proper error handling. The comment at line 430 correctly notes that endLease failures don't trigger shutdown since the lease has already ended.

packages/jumpstarter/jumpstarter/exporter/exporter.py (14)

1-46: LGTM!

Clean imports and well-documented class definition. The TYPE_CHECKING guard correctly avoids circular imports for the Driver type.


47-88: LGTM!

Excellent documentation for all public configuration fields. The hook_executor field is appropriately optional with clear explanation of when it's created.


90-166: LGTM!

Internal state fields are well-documented. The _previous_leased field is now properly declared as a dataclass field (addressing past review feedback), and _lease_context cleanly consolidates lease-related state.


168-200: LGTM!

The stop() method extension with exit_code parameter cleanly supports the hook failure shutdown mechanism, and the property provides appropriate read access.


202-243: LGTM!

Clean abstraction for gRPC stream retry logic. The retry counter correctly resets after successful iteration, allowing streams to recover from transient failures during long-running operation.


245-267: LGTM!

Clean factory pattern that enables the generic _retry_stream to work with different gRPC stream types.


269-294: LGTM!

The conditional status reporting at lines 291-294 correctly avoids overwriting LEASE_READY with AVAILABLE during per-lease registration, as clearly documented in the comment.


317-339: LGTM!

Robust unregistration with proper timeout handling, shielded channel cleanup, and defensive error logging.


341-350: LGTM!

Proper async context manager implementation with cleanup that doesn't mask original exceptions.


352-378: LGTM!

Correct connection proxying implementation. Minor note: line 378 uses .format() style while other logging uses % formatting, but this is a minor inconsistency.


380-409: LGTM!

Well-implemented EndSession handler. The CancelScope(shield=True) correctly protects the after-lease hook from cancellation, and the finally block ensures the done event is always signaled.


411-432: LGTM!

Clean session context manager that properly creates the device session, serves on Unix socket, and registers with the controller before yielding the session and path for LeaseContext population.


434-501: LGTM!

Well-orchestrated lease handling that:

  1. Populates lease_scope with session and socket path
  2. Waits for the before-lease hook before accepting connections
  3. Syncs status after hook completion to fix the race condition
  4. Spawns the EndSession handler for after-lease hooks

503-612: Well-structured serve loop with hook orchestration.

The implementation correctly handles:

  • Lease change detection (lines 523-527) with after-lease hook execution
  • Before-lease hook on unleased → leased transition (lines 571-583)
  • After-lease hook on leased → unleased transition (lines 589-602) with duplicate prevention via after_lease_hook_done.is_set() check

The complexity is inherent to the state machine, and the checks at lines 531-535 and 589-594 correctly prevent duplicate after-lease hook execution when triggered via both EndSession and status change.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@mangelajo mangelajo self-requested a review January 19, 2026 09:52
@mangelajo
Copy link
Member

Ok, this is close, but we need passing linter and unit tests :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants