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 tracked and shown (new STATUS column) and CLI "status" option.
    • Before/after lease lifecycle hooks with robust execution and failure modes.
    • Exporter readiness checks block client calls until exporter reports ready.
    • Enhanced logging with per-source classification and logger-scoped sources.
  • Tests

    • New/updated tests for hook execution, status handling, and exporter listing/rendering.
  • Chores

    • Added generated protobuf/gRPC Python typing stubs for v1 APIs.

✏️ 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 4a391ce
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/692852299751670007c5f1a3
😎 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

Adds exporter lifecycle management: exported status enums and GetStatus, hook execution (before/after lease) and orchestration, controller registration/unregistration and lease handling, extensive generated protobuf/myPy .pyi stubs, enhanced logging with LogSource and per-logger sources, CLI/CLI-admin plumbing for showing exporter status, and tests for hooks and status serialization.

Changes

Cohort / File(s) Summary
Buf config
\buf.gen.yaml``
Added MyPy and MyPy-grpc plugin entries (v3.7.0).
Protocol stubs (client/server)
\packages/jumpstarter-protocol/.../client/v1/client_pb2.pyi`, `packages/jumpstarter-protocol/.../client/v1/client_pb2_grpc.pyi``
New generated mypy .pyi stubs for v1 client messages and gRPC client/server interfaces.
Protocol stubs (v1 common/jumpstarter/router/kubernetes)
\packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1/.pyi`, `.../_grpc.pyi``
New generated .pyi files: common enums (ExporterStatus, LogSource), jumpstarter messages, router frames, kubernetes types, and gRPC service stubs.
Common types & enums
\packages/jumpstarter/jumpstarter/common/enums.py`, `packages/jumpstarter/jumpstarter/common/types.py`, `packages/jumpstarter/jumpstarter/common/init.py``
Added ExporterStatus and LogSource enums with proto conversions; gRPC-related type aliases; exported them in package public API.
Exporter config & tests
\packages/jumpstarter/jumpstarter/config/exporter.py`, `packages/jumpstarter/jumpstarter/config/exporter_test.py``
Added HookConfig/HookInstance models and hooks field on ExporterConfig, forward-type annotations, YAML dumps using by_alias=True, hook parsing/round-trip tests, and hook wiring into create_exporter.
Client config & CLI
\packages/jumpstarter/jumpstarter/config/client.py`, `packages/jumpstarter-cli/jumpstarter_cli/get.py``
list_exporters gained include_status parameter; channel annotated; CLI get option "status" added and propagated to listing.
Client core & CLI admin tests
\packages/jumpstarter/jumpstarter/client/core.py`, `packages/jumpstarter-cli-admin/.../create_test.py`, `packages/jumpstarter-cli-admin/.../get_test.py``
Added ExporterNotReady exception and check_exporter_status preflight for driver calls; updated admin tests/expected outputs to include exporterStatus/statusMessage.
Client gRPC models/UI
\packages/jumpstarter/jumpstarter/client/grpc.py``
Exporter models extended with status fields; ExporterList include_status flag; rich rendering updated to show STATUS column when requested; stub storage adjusted.
Exporter core, hooks, lease context
\packages/jumpstarter/jumpstarter/exporter/exporter.py`, `packages/jumpstarter/jumpstarter/exporter/hooks.py`, `packages/jumpstarter/jumpstarter/exporter/lease_context.py`, `packages/jumpstarter/jumpstarter/exporter/hooks_test.py``
Major exporter refactor: controller registration/unregistration, status reporting, lease lifecycle and LeaseContext, HookExecutor with hook execution, timeouts and on_failure semantics, per-lease handlers, integration of hooks into lifecycle, and tests.
Exporter logging & session
\packages/jumpstarter/jumpstarter/exporter/logging.py`, `packages/jumpstarter/jumpstarter/exporter/logging_protocol.py`, `packages/jumpstarter/jumpstarter/exporter/session.py``
LogHandler extended with LogSource mapping, child logger management, context manager, get_logger helper; Session tracks status/message and exposes GetStatus and logger-source APIs.
Driver base & utils
\packages/jumpstarter/jumpstarter/driver/base.py`, `packages/jumpstarter/jumpstarter/common/utils.py`, `packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py``
Driver logging initialized via get_logger (LogSource.DRIVER); serve functions use forward refs and local test sessions set LEASE_READY.
Kubernetes exporter tests & model
\packages/jumpstarter-kubernetes/.../exporters.py`, `packages/jumpstarter-kubernetes/.../exporters_test.py``
V1Alpha1ExporterStatus gains exporter_status/status_message fields; rendering and tests updated to include exporterStatus/statusMessage and a new STATUS column.

Sequence Diagram(s)

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

    Note over Exporter: Exporter startup & controller registration
    Exporter->>Controller: Register(session metadata)
    Controller-->>Exporter: Assign UUID / ack
    Exporter->>Controller: Start status & listen streams (retry enabled)

    Note over Controller,Exporter: Lease allocation flow
    Client->>Controller: RequestLease
    Controller->>Exporter: Listen stream -> lease_name event
    Exporter->>LeaseCtx: create LeaseContext(lease_name)

    rect rgb(220,245,220)
      Note over HookExec: before_lease phase
      Exporter->>HookExec: run_before_lease_hook(lease_ctx)
      HookExec->>Exporter: report status BEFORE_LEASE_HOOK
      HookExec-->>Exporter: completion/failure signal
    end

    Exporter->>Exporter: set status LEASE_READY
    Client->>Exporter: DriverCall request
    Exporter->>Exporter: check_exporter_status via GetStatus
    Exporter-->>Client: Driver response (or ExporterNotReady)
Loading

Estimated code review effort

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

Areas requiring extra attention:

  • packages/jumpstarter/jumpstarter/exporter/exporter.py — concurrency, retry logic, correct cleanup/unregister, lease transitions.
  • packages/jumpstarter/jumpstarter/exporter/hooks.py and hooks_test.py — subprocess execution, timeouts, on_failure semantics, logging and shutdown interactions.
  • packages/jumpstarter/jumpstarter/exporter/logging.py and session.py — thread-safety, per-logger source mappings, and GetStatus implementation.
  • Large generated .pyi files in packages/jumpstarter-protocol — spot-check types for ExporterStatus, Exporter, Lease, GetStatus and service signatures.

Possibly related issues

Possibly related PRs

Suggested reviewers

  • mangelajo

Poem

🐰 I nudged the hooks and tuned the status light,

Before the lease I danced into the night.
Logs hop to sources, shells sing with delight,
Controller calls, leases bloom — all set to right.
Hooray, the exporter’s ready — off we flight!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.65% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ 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 clearly and concisely summarizes the main change: addition of pre/post lease hooks functionality to the Jumpstarter exporter configuration system.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch add-hooks

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.

@kirkbrauer kirkbrauer marked this pull request as ready for review November 26, 2025 04:49
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 +207 to +244
async def test_real_time_output_logging(self, mock_device_factory, lease_scope) -> None:
"""Test that hook output is logged in real-time at INFO level."""
hook_config = HookConfigV1Alpha1(
before_lease=HookInstanceConfigV1Alpha1(script="echo 'Line 1'; echo 'Line 2'; echo 'Line 3'", timeout=10),
)

mock_process = AsyncMock()
mock_process.returncode = 0
# Mock multiple lines of output to verify streaming
mock_process.stdout.readline.side_effect = [
b"Line 1\n",
b"Line 2\n",
b"Line 3\n",
b"", # EOF
]
mock_process.wait = AsyncMock(return_value=None)

# Mock the logger to capture log calls
with (
patch("jumpstarter.exporter.hooks.logger") as mock_logger,
patch("asyncio.create_subprocess_shell", return_value=mock_process),
):
executor = HookExecutor(
config=hook_config,
device_factory=mock_device_factory,
)

result = await executor.execute_before_lease_hook(lease_scope)

assert result is None

# Verify that output lines were logged in real-time at INFO level
expected_calls = [
call("Executing before-lease hook for lease %s", "test-lease-123"),
call("Executing hook: %s", "echo 'Line 1'; echo 'Line 2'; echo 'Line 3'"),
call("Hook executed successfully"),
]
mock_logger.info.assert_has_calls(expected_calls, any_order=False)
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

🧩 Analysis chain

Script executed:

#!/bin/bash
# Find and examine the hooks.py implementation
wc -l packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter


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

stdout:

402 packages/jumpstarter/jumpstarter/exporter/hooks.py

</shell_result>


Script executed:

#!/bin/bash
# Search for how output is logged in hooks.py
rg -n "readline|stdout" packages/jumpstarter/jumpstarter/exporter/hooks.py -A3 -B1

Repository: jumpstarter-dev/jumpstarter


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

stdout:

164-                env=hook_env,
165:                stdout=asyncio.subprocess.PIPE,
166-                stderr=asyncio.subprocess.STDOUT,
167-            )
168-
--
177-                    while True:
178:                        line = await process.stdout.readline()
179-                        if not line:
180-                            break
181-                        line_decoded = line.decode().rstrip()

</shell_result>


Script executed:

#!/bin/bash
# Get the context around the readline logic
sed -n '175,195p' packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter


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

stdout:


                async def read_output():
                    while True:
                        line = await process.stdout.readline()
                        if not line:
                            break
                        line_decoded = line.decode().rstrip()
                        output_lines.append(line_decoded)
                        # Route hook output through the logging system
                        hook_logger.info(line_decoded)

                # Run output reading and process waiting concurrently with timeout
                await asyncio.wait_for(asyncio.gather(read_output(), process.wait()), timeout=timeout)

                # Check if hook succeeded (exit code 0)
                if process.returncode == 0:
                    logger.info("Hook executed successfully")
                    return

                # Non-zero exit code is a failure
                error_msg = f"Hook failed with exit code {process.returncode}"

</shell_result>


Script executed:

#!/bin/bash
# Find hook_logger definition
rg -n "hook_logger" packages/jumpstarter/jumpstarter/exporter/hooks.py | head -20

Repository: jumpstarter-dev/jumpstarter


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

stdout:

171:                hook_logger = get_logger(f"hook.{lease_scope.lease_name}", log_source, logging_session)
184:                        hook_logger.info(line_decoded)

</shell_result>


Script executed:

#!/bin/bash
# Get the full context of the execute_before_lease_hook method signature and output handling
sed -n '150,190p' packages/jumpstarter/jumpstarter/exporter/hooks.py

Repository: jumpstarter-dev/jumpstarter


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

stdout:

    ) -> None:
        """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

        # Exception handling
        error_msg: str | None = None
        cause: Exception | None = None

        try:
            # Execute the hook command using shell
            process = await asyncio.create_subprocess_shell(
                command,
                env=hook_env,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.STDOUT,
            )

            try:
                # 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
                output_lines = []

                async def read_output():
                    while True:
                        line = await process.stdout.readline()
                        if not line:
                            break
                        line_decoded = line.decode().rstrip()
                        output_lines.append(line_decoded)
                        # Route hook output through the logging system
                        hook_logger.info(line_decoded)

                # Run output reading and process waiting concurrently with timeout
                await asyncio.wait_for(asyncio.gather(read_output(), process.wait()), timeout=timeout)

                # Check if hook succeeded (exit code 0)
                if process.returncode == 0:

</shell_result>


Script executed:

#!/bin/bash
# Examine the test closely
sed -n '205,245p' packages/jumpstarter/jumpstarter/exporter/hooks_test.py

Repository: jumpstarter-dev/jumpstarter


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

stdout:

            assert env[JMP_DRIVERS_ALLOW] == "UNSAFE"

    async def test_real_time_output_logging(self, mock_device_factory, lease_scope) -> None:
        """Test that hook output is logged in real-time at INFO level."""
        hook_config = HookConfigV1Alpha1(
            before_lease=HookInstanceConfigV1Alpha1(script="echo 'Line 1'; echo 'Line 2'; echo 'Line 3'", timeout=10),
        )

        mock_process = AsyncMock()
        mock_process.returncode = 0
        # Mock multiple lines of output to verify streaming
        mock_process.stdout.readline.side_effect = [
            b"Line 1\n",
            b"Line 2\n",
            b"Line 3\n",
            b"",  # EOF
        ]
        mock_process.wait = AsyncMock(return_value=None)

        # Mock the logger to capture log calls
        with (
            patch("jumpstarter.exporter.hooks.logger") as mock_logger,
            patch("asyncio.create_subprocess_shell", return_value=mock_process),
        ):
            executor = HookExecutor(
                config=hook_config,
                device_factory=mock_device_factory,
            )

            result = await executor.execute_before_lease_hook(lease_scope)

            assert result is None

            # Verify that output lines were logged in real-time at INFO level
            expected_calls = [
                call("Executing before-lease hook for lease %s", "test-lease-123"),
                call("Executing hook: %s", "echo 'Line 1'; echo 'Line 2'; echo 'Line 3'"),
                call("Hook executed successfully"),
            ]
            mock_logger.info.assert_has_calls(expected_calls, any_order=False)

</shell_result>


Script executed:

#!/bin/bash
# Check the imports and module-level logger setup
head -60 packages/jumpstarter/jumpstarter/exporter/hooks.py | cat -n

Repository: jumpstarter-dev/jumpstarter


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

stdout:

     1	"""Lifecycle hooks for Jumpstarter exporters."""
     2	
     3	import asyncio
     4	import logging
     5	import os
     6	from collections.abc import Awaitable
     7	from dataclasses import dataclass
     8	from typing import TYPE_CHECKING, Callable, Literal
     9	
    10	from jumpstarter.common import ExporterStatus, LogSource
    11	from jumpstarter.config.env import JMP_DRIVERS_ALLOW, JUMPSTARTER_HOST
    12	from jumpstarter.config.exporter import HookConfigV1Alpha1, HookInstanceConfigV1Alpha1
    13	from jumpstarter.exporter.logging import get_logger
    14	from jumpstarter.exporter.session import Session
    15	
    16	if TYPE_CHECKING:
    17	    from jumpstarter.driver import Driver
    18	    from jumpstarter.exporter.lease_context import LeaseContext
    19	
    20	logger = logging.getLogger(__name__)
    21	
    22	
    23	@dataclass
    24	class HookExecutionError(Exception):
    25	    """Raised when a hook fails and on_failure is set to 'endLease' or 'exit'.
    26	
    27	    Attributes:
    28	        message: Error message describing the failure
    29	        on_failure: The on_failure mode that triggered this error ('endLease' or 'exit')
    30	        hook_type: The type of hook that failed ('before_lease' or 'after_lease')
    31	    """
    32	
    33	    message: str
    34	    on_failure: Literal["endLease", "exit"]
    35	    hook_type: Literal["before_lease", "after_lease"]
    36	
    37	    def __str__(self) -> str:
    38	        return self.message
    39	
    40	    def should_shutdown_exporter(self) -> bool:
    41	        """Returns True if the exporter should be shut down entirely."""
    42	        return self.on_failure == "exit"
    43	
    44	    def should_end_lease(self) -> bool:
    45	        """Returns True if the lease should be ended."""
    46	        return self.on_failure in ("endLease", "exit")
    47	
    48	
    49	@dataclass(kw_only=True)
    50	class HookExecutor:
    51	    """Executes lifecycle hooks with access to the j CLI."""
    52	
    53	    config: HookConfigV1Alpha1
    54	    device_factory: Callable[[], "Driver"]
    55	
    56	    def _create_hook_env(self, lease_scope: "LeaseContext") -> dict[str, str]:
    57	        """Create standardized hook environment variables.
    58	
    59	        Args:
    60	            lease_scope: LeaseScope containing lease metadata and socket path

</shell_result>


Perfect. Now I have all the information needed to rewrite the review comment accurately.

Test doesn't verify output lines are logged because it mocks the wrong logger.

The implementation logs output lines via hook_logger.info(line_decoded) (line 171-184), where hook_logger is a per-lease logger created inside the method. However, the test only patches the module-level logger (line 230), so output lines logged to hook_logger are never captured.

The suggested fix in the review comment won't work as written—adding output lines to expected_calls would still fail because those lines are logged via hook_logger, not logger.

To properly test "real-time output logging," either:

  • Mock get_logger() to return mock_logger so output line calls are captured
  • Mock and verify the hook-specific logger separately
  • Rename/clarify the test if it's only intended to verify wrapper messages (not output lines)
🤖 Prompt for AI Agents
In packages/jumpstarter/jumpstarter/exporter/hooks_test.py around lines 207 to
244, the test patches the module-level logger but the hook implementation logs
output lines via a per-lease logger returned by get_logger(), so the streamed
output lines are never captured; update the test to patch
jumpstarter.exporter.hooks.get_logger (or the class/method that creates the
per-lease logger) to return mock_logger (or separately create and patch the
hook-specific logger) before instantiating HookExecutor, then assert
mock_logger.info was called with the streamed lines in order along with the
existing wrapper messages; alternatively, if you only intend to verify wrapper
messages, remove the streamed-line assertions and rename the test to reflect
that narrower scope.

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

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
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

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

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