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

driver-ssh: SSHWrapperClient.run return stdout and stderr#748

Merged
mangelajo merged 3 commits intojumpstarter-dev:mainfrom
aesteve-rh:ssh-wrapper-return
Nov 20, 2025
Merged

driver-ssh: SSHWrapperClient.run return stdout and stderr#748
mangelajo merged 3 commits intojumpstarter-dev:mainfrom
aesteve-rh:ssh-wrapper-return

Conversation

@aesteve-rh
Copy link
Contributor

@aesteve-rh aesteve-rh commented Nov 19, 2025

Add SSHCommandExecResult to wrap the subprocess SSH command
return and use it as return type for SSHWrapperClient.run,
so that the output of the command can be inspected after
the call.

Summary by CodeRabbit

  • New Features

    • Command execution now returns a structured result with exit code, stdout and stderr.
    • New run options to control direct vs forwarded mode and output capture; CLI streams and prints command output and exits with the observed code.
    • Automatic fallback between direct SSH and port-forwarding preserved.
  • Bug Fixes

    • Missing SSH executable yields a descriptive failure result instead of crashing.
    • Improved logging around identity handling and command execution.
  • Tests

    • Tests updated to assert on structured result fields rather than raw return codes.

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

@netlify
Copy link

netlify bot commented Nov 19, 2025

Deploy Preview for jumpstarter-docs ready!

Name Link
🔨 Latest commit 627d1fc
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/691ee09bc4f56a00086e3942
😎 Deploy Preview https://deploy-preview-748--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 Nov 19, 2025

Walkthrough

Adds dataclasses SSHCommandRunResult and SSHCommandRunOptions; changes run() and _execute_ssh_command() signatures to use them; threads options through local and port-forward SSH paths; _execute_ssh_command returns SSHCommandRunResult (FileNotFoundError -> return_code 127); logging made parameterized.

Changes

Cohort / File(s) Summary
SSH Client Refactor
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py
Added SSHCommandRunResult and SSHCommandRunOptions; updated run(...) and _execute_ssh_command(...) signatures and return types; threaded options through _run_ssh_local, port-forwarding, and command-construction helpers; _execute_ssh_command builds SSHCommandRunResult (FileNotFoundError -> return_code=127); logging converted to parameterized messages; identity-file lifecycle preserved.
Test Updates
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
Tests updated to import/use SSHCommandRunOptions and SSHCommandRunResult; calls changed to pass SSHCommandRunOptions(direct=...); assertions now check result.return_code, result.stdout, and result.stderr; call-arg expectations adjusted for new invocation pattern.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant SSHClient
  participant LocalSSH as _run_ssh_local
  participant RemoteSSH as port_forward_path
  participant Subproc as _execute_ssh_command

  Caller->>SSHClient: run(options)
  alt options.direct == true
    SSHClient->>LocalSSH: _run_ssh_local(options)
    LocalSSH->>Subproc: _execute_ssh_command(ssh_args, options)
    Subproc-->>LocalSSH: SSHCommandRunResult
    LocalSSH-->>SSHClient: SSHCommandRunResult
  else options.direct == false
    SSHClient->>RemoteSSH: attempt port-forward/proxy (options)
    RemoteSSH->>Subproc: _execute_ssh_command(ssh_args, options)
    Subproc-->>RemoteSSH: SSHCommandRunResult
    alt failure & fallback to direct
      SSHClient->>SSHClient: run(options with direct=true)  %% recursive fallback
    end
    RemoteSSH-->>SSHClient: SSHCommandRunResult
  end
  SSHClient-->>Caller: SSHCommandRunResult
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Verify FileNotFoundError -> return_code=127 and stderr message.
  • Confirm recursive fallback updates SSHCommandRunOptions correctly.
  • Ensure all call sites/tests updated to new run(options, ...) signature.
  • Review logging for accidental sensitive output (identity file paths, full commands).

Poem

🐰
Keys in paw, I tunnel through the night,
Options set, outputs caught just right.
Return codes cradled, stderr tucked in,
Logs hum softly of the work within.
A tiny hop — the SSH run's delight.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: SSHWrapperClient.run now returns stdout and stderr via a new SSHCommandRunResult type, which is the central objective.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9cbdd0d and 627d1fc.

📒 Files selected for processing (2)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (32 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (42-52)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
  • SSHCommandRunOptions (33-47)
  • SSHCommandRunResult (17-29)
  • run (97-130)
⏰ 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: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: build
  • GitHub Check: e2e
  • GitHub Check: Pages changed - jumpstarter-docs
🔇 Additional comments (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1)

8-694: LGTM! Comprehensive test coverage for the new API.

All tests have been systematically updated to use the new SSHCommandRunOptions and SSHCommandRunResult types. The test coverage is thorough, including:

  • Various username scenarios (default, override, none)
  • Port configuration
  • Direct connection flag
  • Error handling (FileNotFoundError, permission errors)
  • Identity file handling (string and file-based)
  • Multiple SSH options and edge cases

The mock setup and assertions correctly reflect the new API design.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)

16-47: LGTM! Clean API design with proper type safety.

The dataclass definitions are well-structured:

  • SSHCommandRunResult properly coerces None to "" in from_completed_process, ensuring type consistency when capture_output=False
  • SSHCommandRunOptions provides clear, documented configuration with sensible defaults
  • The separation of concerns makes the API intuitive and extensible for future enhancements

67-85: LGTM! Interactive behavior properly preserved.

The CLI handler correctly uses capture_output=False to ensure output streams directly to the terminal, preserving the interactive shell behavior for commands like plain j ssh. The conditional echo statements (lines 77-80) are harmless when output isn't captured and provide good defensive programming for potential future changes.


97-130: LGTM! Options properly threaded through execution paths.

The run() method correctly:

  • Threads options through both direct and port-forwarding paths
  • Preserves capture_output and capture_as_text when falling back from direct to port-forwarding (lines 117-121)
  • Returns SSHCommandRunResult consistently

The fallback behavior is well-implemented and maintains the caller's intent for output handling.


266-280: LGTM! Consistent result handling with appropriate error recovery.

The _execute_ssh_command method correctly:

  • Uses options.capture_output and options.capture_as_text for subprocess configuration
  • Returns SSHCommandRunResult consistently via the factory method
  • Handles FileNotFoundError by returning a result with return code 127 (standard for "command not found") rather than raising, allowing callers to inspect the error programmatically

The error handling approach aligns well with the PR's objective of enabling callers to inspect command results.

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


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.

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

🧹 Nitpick comments (4)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)

72-104: run fallback recursion is safe but could be flatter

The run method’s recursive fallback from direct=True to run(False, args) on DriverMethodNotImplemented/ValueError is functionally correct and will only recurse once, but it slightly obscures the control flow.

If you prefer flatter logic, you could convert the direct-connection attempt into a conditional block that falls through to the port-forwarding path on failure instead of re-calling run. Behavior would remain the same but be a bit easier to trace.


118-121: Be cautious about logging potentially sensitive SSH details

The new debug/error logs around:

  • Temporary identity file creation/cleanup (lines 118, 145–147),
  • Parsed SSH options and command args (lines 219–220),
  • The final SSH command (line 236),

are very helpful for troubleshooting, but they may reveal:

  • Full command lines, including arguments that could embed secrets (tokens, passwords),
  • The filesystem path of the temporary private key file.

If logs can be collected centrally or accessed by broader audiences, consider:

  • Redacting or truncating particularly sensitive args,
  • Logging only a subset (e.g., first N characters) or hashes of arguments,
  • Downgrading some of these logs to trace-level, if your logging infra supports it.

Not mandatory, but worth thinking about from a security and compliance standpoint.

Also applies to: 145-147, 219-220, 236-237


149-221: SSH option parsing has edge cases and double parsing

The _build_ssh_command_args and _separate_ssh_options_and_command_args helpers are a nice improvement, but there are a couple of subtleties:

  1. Combined -v flags (-vv, -vvv, …)
    The parser only treats -v as a no-param flag. Common SSH invocations like -vv or -vvv will fall into the “unknown flag → start of command args” branch and end up being treated as part of the remote command instead of SSH options. This will surprise users who are accustomed to ssh -vvv.

    A small robustness tweak could be:

    -                if arg in ssh_flags_no_param:
    +                if arg in ssh_flags_no_param or (
    +                    arg.startswith("-v") and all(ch == "v" for ch in arg[1:])
    +                ):

    so that repeated v flags are still treated as SSH options.

  2. Double parsing (and double debug logging)
    _build_ssh_command_args calls _separate_ssh_options_and_command_args(args) just to detect a -l username flag, and _run_ssh_local calls the same helper again to get ssh_options/command_args. This both repeats the work and causes the “SSH options / Command args” debug logs to be emitted twice per invocation.

    You could avoid this by parsing once in _run_ssh_local and passing ssh_options down:

    -        # Build SSH command arguments
    -        ssh_args = self._build_ssh_command_args(ssh_command, port, default_username, identity_file, args)
    -
    -        # Separate SSH options from command arguments
    -        ssh_options, command_args = self._separate_ssh_options_and_command_args(args)
    +        # Separate SSH options from command arguments
    +        ssh_options, command_args = self._separate_ssh_options_and_command_args(args)
    +
    +        # Build SSH command arguments
    +        ssh_args = self._build_ssh_command_args(
    +            ssh_command, port, default_username, identity_file, ssh_options
    +        )

    and update _build_ssh_command_args to take ssh_options instead of full args:

    -    def _build_ssh_command_args(self, ssh_command, port, default_username, identity_file, args):
    +    def _build_ssh_command_args(self, ssh_command, port, default_username, identity_file, ssh_options):
    ...
    -        # Check if user already provided a username with -l flag in SSH options only
    -        # We need to separate SSH options from command args first to avoid false positives
    -        ssh_options, _ = self._separate_ssh_options_and_command_args(args)
            has_user_flag = any(
                ssh_options[i] == "-l" and i + 1 < len(ssh_options)
                for i in range(len(ssh_options))
            )

    This keeps the behavior the same while reducing overhead and log noise.


239-253: FileNotFoundError handling is solid; consider standardizing stderr text

The explicit handling of FileNotFoundError with exit code 127 and an explanatory stderr message is a nice touch and aligns with common Unix conventions.

If other parts of the system depend on parsing stderr, you may want to ensure this message shape is documented or reused (e.g., via a helper or constant) so downstream callers can rely on it without being surprised by future wording changes. Otherwise this is good as-is.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 68125a7 and 794c4dd.

📒 Files selected for processing (1)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
⏰ 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). (10)
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: e2e
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
🔇 Additional comments (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

16-29: SSHCommandExecResult abstraction looks good

The dataclass cleanly encapsulates the subprocess result, and from_completed_process is a straightforward, type-safe factory with no obvious pitfalls. This should make call sites much clearer.

@aesteve-rh aesteve-rh force-pushed the ssh-wrapper-return branch 3 times, most recently from ccc395b to 4525d8b Compare November 19, 2025 09:17
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 (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

177-237: Avoid double parsing/logging of SSH options and command args

_separate_ssh_options_and_command_args(args) is invoked both in _build_ssh_command_args (to detect -l) and again in _run_ssh_local (to get ssh_options/command_args), which also means the new debug logs fire twice per call with identical content. Consider computing ssh_options/command_args once in _run_ssh_local and passing them into _build_ssh_command_args (or factoring a small helper) to remove the redundant work and duplicate logging.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dc27aeb and ccc395b.

📒 Files selected for processing (2)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (33 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py
🧰 Additional context used
🧬 Code graph analysis (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
⏰ 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.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • GitHub Check: e2e
🔇 Additional comments (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (5)

16-29: SSHCommandExecResult wrapper is clear and correctly encapsulates subprocess output

The dataclass and from_completed_process helper cleanly model the SSH execution result (code, stdout, stderr) and keep the subprocess details localized; no issues here.


48-60: CLI now correctly surfaces SSH stdout/stderr while preserving exit semantics

Using SSHWrapperClient.run and then echoing stdout/stderr via click.echo ensures CLI users see the command output again, while callers still get the structured SSHCommandExecResult and the CLI exits on non‑zero codes. This effectively resolves the earlier regression around hidden output mentioned in past reviews.


72-104: Direct vs port‑forward flow and fallback look logically sound

run cleanly chooses between direct TCP and SSH port‑forwarding, with a bounded recursive fallback (self.run(False, args)) on DriverMethodNotImplemented/ValueError and useful debug/error logs for both paths; I don’t see functional or resiliency issues in this control flow.


108-147: Temporary identity file handling and cleanup are robust

Creating a non‑deleted NamedTemporaryFile, forcing 0o600, logging the path at debug level, and unconditionally unlinking it in a finally block (with a guarded warning on failure) gives a good balance of security, observability, and reliability for SSH identities.


239-253: Subprocess execution wrapper and 127 “command not found” mapping look correct

Wrapping subprocess.run with capture_output=True, text=True and returning an SSHCommandExecResult, plus mapping FileNotFoundError to an exit code of 127 with a clear stderr message, provides a predictable, testable contract for callers and consistent CLI behaviour.

@mangelajo
Copy link
Member

you probably need a different run method, I believe this will prevent the regular

"j ssh" that should drop you into a shell. I haven't tried it yet with your patch, so I could be wrong.

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

🧹 Nitpick comments (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

40-61: CLI behavior and SSH run flow are consistent and user-friendly.

The refactor to have run() return SSHCommandExecResult, echo stdout/stderr in the Click handler, and exit based on return_code gives both programmatic callers and CLI users the information they need, while the added logging around connection mode, temp identity files, and argument parsing should make debugging SSH issues much easier.

If you later want to micro-optimize, you could consider passing the ssh_options/command_args already computed in _build_ssh_command_args into _run_ssh_local instead of re-parsing args, but this is purely optional and not a correctness concern.

Also applies to: 72-75, 90-94, 101-103, 118-121, 145-147, 219-221, 236-237

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccc395b and 4525d8b.

📒 Files selected for processing (2)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (33 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)
  • run (72-103)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
🪛 GitHub Actions: Run Tests
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py

[error] 8-8: ImportError: cannot import name 'SSHCommandExecutionResult' from 'jumpstarter_driver_ssh.client'

⏰ 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). (1)
  • GitHub Check: e2e
🔇 Additional comments (1)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

16-21: SSHCommandExecResult and subprocess wrapping look solid.

The dataclass cleanly encapsulates return_code, stdout, and stderr, and _execute_ssh_command correctly wraps subprocess.run (with captured text output) plus a sensible 127 result on FileNotFoundError. This matches the new tests’ expectations and provides a much nicer API for callers.

Also applies to: 23-29, 239-253


def run(self, direct, args):
"""Run SSH command with the given parameters and arguments"""
def run(self, direct, args) -> SSHCommandExecResult:
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
def run(self, direct, args) -> SSHCommandExecResult:
def run(self, direct, args) -> SSHCommandRunResult:

to allow more things in the future we should break the api now (early before any users using this), and change direct for:

SSHCommandRunOptions:
direct: bool
capture_output: bool
capture_as_text: bool

then we can take some extra parameters if we need in the future.



@dataclass
class SSHCommandExecResult:
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
class SSHCommandExecResult:
class SSHCommandRunResult:

what do you think about calling it like this to be aligned with the method name?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good to me, naming is hard to nail on the first iteration :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.

some comments, thank you @aesteve-rh !! :D

@aesteve-rh aesteve-rh force-pushed the ssh-wrapper-return branch 2 times, most recently from ad5b755 to d56492c Compare November 19, 2025 10:40
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-driver-ssh/jumpstarter_driver_ssh/client.py (1)

48-69: CLI ssh() is now broken (wrong run signature) and risks breaking interactive j ssh

SSHWrapperClient.run now expects options: SSHCommandRunOptions, but the CLI still calls it with a bare bool:

def ssh(direct, args):
    result = self.run(direct, args)  # direct is a bool

Inside run you immediately access options.direct, so invoking j ssh will raise AttributeError: 'bool' object has no attribute 'direct'. This is a hard runtime break for the CLI.

Independently, if you simply start passing SSHCommandRunOptions(direct=direct) here, you’ll use capture_output=True and will no longer stream SSH I/O to the terminal, which breaks the interactive shell use case that j ssh likely relies on.

A minimal fix that restores previous interactive behavior while still exposing the richer API to programmatic callers:

 def cli(self):
@@
         @click.option("--direct", is_flag=True, help="Use direct TCP address")
         @click.argument("args", nargs=-1)
         def ssh(direct, args):
-            result = self.run(direct, args)
+            # CLI path: preserve interactive behavior by *not* capturing output
+            options = SSHCommandRunOptions(
+                direct=direct,
+                capture_output=False,
+                capture_as_text=True,
+            )
+            result = self.run(options, args)

Programmatic use (e.g. tests) can keep using SSHCommandRunOptions(direct=False) with capture_output=True to collect stdout/stderr.

Also applies to: 80-105

🧹 Nitpick comments (4)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)

16-37: Result/options dataclasses look good; consider loosening stdout/stderr types

The new SSHCommandRunResult / SSHCommandRunOptions abstraction is clean and fits the refactor. One nuance: when options.capture_output is False, subprocess.run will typically leave stdout/stderr as None, but the dataclass annotates them as str. This is harmless at runtime, but misleading for type checkers and callers that may assume str.

If you plan to rely on typing here, consider:

-    stdout: str
-    stderr: str
+    stdout: str | None
+    stderr: str | None

and adjust any call sites that assume non‑None. Otherwise, the implementation is consistent and the error‑path wrapping (return_code=127, message in stderr) looks solid.

Also applies to: 249-263


87-105: Consider preserving future SSHCommandRunOptions fields when falling back from direct → port‑forward

The fallback from direct=True to port‑forwarding re-creates an SSHCommandRunOptions with only the current fields:

return self.run(SSHCommandRunOptions(
    direct=False,
    capture_output=options.capture_output,
    capture_as_text=options.capture_as_text,
), args)

This works today but is brittle if you later add fields to SSHCommandRunOptions (they’ll silently reset to defaults on fallback).

A small robustness improvement is to clone and override only direct, e.g. using dataclasses.replace:

from dataclasses import dataclass, replace
@@
-                return self.run(SSHCommandRunOptions(
-                    direct=False,
-                    capture_output=options.capture_output,
-                    capture_as_text=options.capture_as_text,
-                ), args)
+                fallback_options = replace(options, direct=False)
+                return self.run(fallback_options, args)

This keeps the options model future‑proof.


229-230: Debug logging of SSH args/options is useful; be mindful of potential secrets

The added debug logs for ssh_options, command_args, and the full SSH command are very helpful for troubleshooting, but they may include credentials or tokens if callers pass them as arguments.

If this client is ever used with sensitive command arguments, consider either redacting known‑sensitive options before logging or guarding these logs behind a more verbose level / config flag. Otherwise this is fine as debug‑level trace.

Also applies to: 246-247

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1)

469-694: Identity temp-file behavior and logging are well specified; consider one extra case

The identity-related tests (string vs file, lazy loading, temp-file creation/cleanup, creation error via ExceptionGroup, and cleanup warning logging) map closely to the implementation in _run_ssh_local and give strong coverage of tricky paths.

One optional enhancement would be a small unit test that exercises SSHCommandRunOptions(capture_output=False) to assert that:

  • subprocess.run is invoked with capture_output=False, and
  • the resulting SSHCommandRunResult.stdout/.stderr are whatever you expect in that mode (likely None).

This would lock in the intended semantics for the interactive/streaming case on the programmatic side. Otherwise, the existing tests look very solid.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4525d8b and d56492c.

📒 Files selected for processing (2)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (32 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
  • SSHCommandRunOptions (33-37)
  • SSHCommandRunResult (17-29)
  • run (80-113)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (42-52)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter/jumpstarter/common/exceptions.py (1)
  • FileNotFoundError (66-69)
⏰ 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: e2e
  • GitHub Check: build
  • GitHub Check: pytest-matrix (macos-15, 3.11)
  • 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.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
🔇 Additional comments (4)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (1)

115-158: Identity temp file handling and cleanup look correct and well-covered by tests

The _run_ssh_local identity handling (creation with NamedTemporaryFile, chmod 600, and unconditional cleanup in finally) is solid and the logging on both creation and cleanup failure is appropriate. The associated tests in driver_test.py validate both the happy path and error/warning cases, including permission errors and cleanup failures.

No changes needed here.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (3)

8-76: Tests correctly adopt SSHCommandRunOptions/SSHCommandRunResult

The updated import and the first test (test_ssh_command_with_default_username) now construct SSHCommandRunOptions(direct=False) and assert both the type and fields of SSHCommandRunResult. This matches the new client API and will catch regressions in how stdout/stderr/return_code are wired.

Looks good.


87-233: Programmatic result/error handling is well covered

The tests for:

  • no default username,
  • explicit -l override,
  • custom port and TcpPortforwardAdapter,
  • direct connection via tcp.address(),
  • and FileNotFoundErrorreturn_code == 127 behavior

all exercise the new result object (SSHCommandRunResult) thoroughly, including stdout/stderr plumbing and command argument construction.

No issues here; this suite gives good confidence in the new return type behavior.


245-380: Option/command separation and username injection tests nicely constrain behavior

The tests around:

  • multiple SSH options,
  • unknown options treated as part of the command,
  • “all arguments are command” cases,
  • and the subtle -l in command vs SSH login flag scenario

do a solid job of pinning down _separate_ssh_options_and_command_args and the username injection logic.

Given the refactor, these tests are especially valuable; no changes needed.

if result != 0:
click.get_current_context().exit(result)
return result
self.logger.debug("SSH exit code: %s", result.return_code)
Copy link
Member

Choose a reason for hiding this comment

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

In the case of the CLI, we should pass the new option structure, and use direct i/o, i.e. not capturing stdout/stderr, and printing through click.

Otherwise plain "j ssh" (which drops you into a shell) won't work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, sorry I missed it at first!

I tested it now, and it seems to work fine.

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.

left another comment, please try the "j ssh" command

@mangelajo
Copy link
Member

Thanks for the quick updates!

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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d56492c and 97d2bca.

📒 Files selected for processing (2)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (32 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
  • SSHCommandRunOptions (33-37)
  • SSHCommandRunResult (17-29)
  • run (88-121)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (42-52)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
⏰ 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: pytest-matrix (macos-15, 3.11)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.11)
  • GitHub Check: e2e
  • GitHub Check: pytest-matrix (macos-15, 3.12)
  • GitHub Check: pytest-matrix (macos-15, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.13)
  • GitHub Check: pytest-matrix (ubuntu-24.04, 3.12)
  • GitHub Check: build
  • GitHub Check: Redirect rules - jumpstarter-docs
  • GitHub Check: Header rules - jumpstarter-docs
  • GitHub Check: Pages changed - jumpstarter-docs
🔇 Additional comments (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)

32-38: LGTM! Good extensibility design.

The options dataclass follows the suggestion from previous reviews and provides a clean, extensible API for future enhancements without breaking changes.


57-76: LGTM! Interactive shell handling is correct.

The capture_output=bool(args) logic (line 62) elegantly solves the interactive shell concern raised in the PR comments. When no arguments are provided (interactive shell), capture_output=False allows direct terminal interaction. When arguments are provided, output is captured and echoed to the console.


88-122: LGTM! Fallback logic preserves capture settings correctly.

The method signature update is clean, and the fallback path (lines 108-112) correctly preserves capture_output and capture_as_text when falling back from direct connection to port forwarding.


257-271: LGTM! Consistent error handling.

Converting FileNotFoundError to a SSHCommandRunResult with exit code 127 maintains API consistency—callers always receive a result object rather than handling exceptions for missing SSH binaries.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (1)

8-694: LGTM! Comprehensive test coverage for the new API.

All tests have been properly updated to use SSHCommandRunOptions and SSHCommandRunResult. The test suite covers:

  • Default and overridden username scenarios
  • Port forwarding and direct connection modes
  • Identity file handling and cleanup
  • Error scenarios (SSH not found, invalid options)
  • Edge cases (multiple SSH options, unknown options, command -l flag disambiguation)

Mock configurations correctly provide stdout and stderr, and assertions validate the structured result objects.

# When no args are provided, an interactive shell is implied.
# In this case, we must not capture stdout/stderr so the shell
# can interact with the terminal.
capture_output=bool(args),
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
capture_output=bool(args),
capture_output=False,

The idea is that in either case it should behave like a regular ssh process just wrapped by us, tuneling stdin/stdout/stderr normally without waiting for the run to finish.

Copy link
Member

Choose a reason for hiding this comment

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

i.e. if it's a long running process... an rsync... download, whatever we want it to behave as a regular ssh user@host command

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah makes sense, let me change it.

Copy link
Member

Choose a reason for hiding this comment

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

thanks :) 👍

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-driver-ssh/jumpstarter_driver_ssh/client.py (2)

16-37: Result typing is inconsistent when capture_as_text=False (stdout/stderr may be bytes)

If a caller sets SSHCommandRunOptions.capture_as_text=False, subprocess.run(..., text=False) will populate CompletedProcess.stdout/stderr as bytes, but SSHCommandRunResult.stdout/stderr are annotated as str and from_completed_process passes them through unchanged. That breaks the type contract and could surprise callers doing string operations.

Consider either:

  • Restricting usage for now by always keeping capture_as_text=True and/or making capture_as_text internal, or
  • Widening SSHCommandRunResult.stdout/stderr to accept str | bytes (and documenting the relationship to capture_as_text), or
  • Decoding bytes to text with an explicit encoding before storing them.

48-75: CLI options preserve interactive j ssh behavior while still surfacing structured results

Using SSHCommandRunOptions(direct=direct, capture_output=False) in the CLI ensures SSH runs with normal stdin/stdout/stderr streaming, so interactive shells and long‑running commands behave like plain ssh. The SSHCommandRunResult still carries the exit code, and the stderr echoing covers synthetic error cases such as the FileNotFoundError path.

Functionally this looks good and addresses the concern about breaking interactive j ssh behavior. If you keep the echo blocks only for the error path, a short comment near them clarifying that CLI currently doesn’t capture stdout/stderr could help future readers.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 28433ef and 9cbdd0d.

📒 Files selected for processing (2)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (8 hunks)
  • packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (32 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)
  • SSHCommandRunOptions (33-37)
  • SSHCommandRunResult (17-29)
  • run (87-120)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (4)
packages/jumpstarter/jumpstarter/client/base.py (1)
  • call (42-52)
packages/jumpstarter/jumpstarter/client/core.py (1)
  • DriverMethodNotImplemented (39-42)
packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py (1)
  • TcpPortforwardAdapter (20-32)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py (1)
  • client (28-29)
🔇 Additional comments (5)
packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py (3)

87-121: run() options threading and direct→port‑forward fallback look correct

The new run(self, options: SSHCommandRunOptions, args) keeps the public surface clear and threads options cleanly through both the direct and port‑forward paths. On direct failures you preserve capture_output/capture_as_text when falling back to SSH port forwarding, which avoids surprising callers.

The recursive fallback (self.run(SSHCommandRunOptions(direct=False, ...), args)) will terminate because the second call always goes through the non‑direct branch. This flow looks sound.


122-165: Identity temp‑file lifecycle and SSH command construction/logging are robust

The _run_ssh_local changes properly:

  • Create a temporary identity file with strict permissions,
  • Log only the file path (not key contents),
  • Ensure cleanup in a finally block, logging a warning but not failing the SSH run if os.unlink raises.

Combined with the debug logging of parsed SSH options, command args, and the final SSH argv, this should make diagnosing SSH issues much easier without obvious security regressions. The associated tests around creation errors and cleanup warnings in driver_test.py cover these paths well.

Also applies to: 236-237, 253-253


256-270: _execute_ssh_command() correctly wraps subprocess and surfaces a structured “command not found” error

Mapping subprocess.run into SSHCommandRunResult and turning FileNotFoundError into a deterministic return_code=127 with a descriptive stderr string is a nice improvement over raising. This integrates cleanly with both the CLI (which prints stderr in this case) and programmatic callers that can branch on result.return_code.

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py (2)

8-586: Tests now align cleanly with the new run() API and result type

Updating tests to import SSHCommandRunOptions/SSHCommandRunResult, call client.run(options, args), and assert on result.return_code/stdout/stderr across username injection, port forwarding, direct connections, and identity configurations keeps them in lock‑step with the new client API. The assertions on the constructed SSH argv are still thorough and make it easy to catch regressions in option parsing.


588-694: Good coverage of identity temp‑file error and cleanup‑warning paths

The tests around identity temp‑file creation failure and cleanup failure (test_ssh_identity_temp_file_creation_error, test_ssh_identity_temp_file_cleanup_error) do a nice job verifying that:

  • Creation errors propagate as expected (wrapped in ExceptionGroup),
  • Cleanup failures only generate a warning via the client logger and do not break successful SSH runs.

This closely matches the new logging and cleanup behavior in _run_ssh_local.

Add SSHCommandExecResult to wrap the subprocess SSH command
return and use it as return type for SSHWrapperClient.run,
so that the output of the command can be inspected after
the call.

Signed-off-by: Albert Esteve <aesteve@redhat.com>
Add SSHCommandRunOptions class and modify the run
method signature to use it. This will make it easier
to change the options before having to change the
signature in the future.

Signed-off-by: Albert Esteve <aesteve@redhat.com>
Change logging formatting to use %-formatting
so that formatting is deferred until the log
is printed [1].

[1]
https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/logging-fstring-interpolation.html

Signed-off-by: Albert Esteve <aesteve@redhat.com>
@mangelajo mangelajo self-requested a review November 20, 2025 13:07
@mangelajo mangelajo enabled auto-merge November 20, 2025 13:11
@mangelajo mangelajo merged commit dc175d4 into jumpstarter-dev:main Nov 20, 2025
18 checks passed
@mangelajo
Copy link
Member

Thank you @aesteve-rh !!! :)

@jumpstarter-backport-bot
Copy link

Successfully created backport PR for release-0.7:

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants