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

Commit bd07a7b

Browse files
authored
Merge pull request #784 from jumpstarter-dev/backport-748-to-release-0.7
[Backport release-0.7] driver-ssh: SSHWrapperClient.run return stdout and stderr
2 parents c5d38de + cc55096 commit bd07a7b

2 files changed

Lines changed: 168 additions & 79 deletions

File tree

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py

Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,40 @@
1313
from jumpstarter.client.decorators import driver_click_command
1414

1515

16+
@dataclass
17+
class SSHCommandRunResult:
18+
"""Result of executing an SSH command"""
19+
return_code: int
20+
stdout: str | bytes
21+
stderr: str | bytes
22+
23+
@staticmethod
24+
def from_completed_process(result: subprocess.CompletedProcess) -> "SSHCommandRunResult":
25+
return SSHCommandRunResult(
26+
return_code=result.returncode,
27+
stdout=result.stdout or "",
28+
stderr=result.stderr or "",
29+
)
30+
31+
32+
@dataclass
33+
class SSHCommandRunOptions:
34+
"""
35+
Options for running an SSH command
36+
37+
Attributes:
38+
direct: If True, connect directly to the host's TCP address.
39+
If False, use SSH port forwarding.
40+
capture_output: If True, capture stdout and stderr.
41+
If False, they are inherited from the parent process.
42+
capture_as_text: If True and output is captured, decode stdout and
43+
stderr as text. Otherwise, they are captured as bytes.
44+
"""
45+
direct: bool = False
46+
capture_output: bool = True
47+
capture_as_text: bool = True
48+
49+
1650
@dataclass(kw_only=True)
1751
class SSHWrapperClient(CompositeClient):
1852
"""
@@ -30,11 +64,25 @@ def cli(self):
3064
@click.option("--direct", is_flag=True, help="Use direct TCP address")
3165
@click.argument("args", nargs=-1)
3266
def ssh(direct, args):
33-
result = self.run(direct, args)
34-
self.logger.debug(f"SSH result: {result}")
35-
if result != 0:
36-
click.get_current_context().exit(result)
37-
return result
67+
options = SSHCommandRunOptions(
68+
direct=direct,
69+
# For the CLI, we never capture output so that interactive shells
70+
# and long-running commands stream their output directly.
71+
capture_output=False,
72+
)
73+
74+
result = self.run(options, args)
75+
self.logger.debug("SSH exit code: %s", result.return_code)
76+
77+
if result.stdout:
78+
click.echo(result.stdout, nl=False)
79+
if result.stderr:
80+
click.echo(result.stderr, nl=False, err=True)
81+
82+
if result.return_code != 0:
83+
click.get_current_context().exit(result.return_code)
84+
85+
return result.return_code
3886

3987
return ssh
4088

@@ -46,14 +94,14 @@ def stream(self, method="connect"):
4694
async def stream_async(self, method):
4795
return await self.tcp.stream_async(method)
4896

49-
def run(self, direct, args):
97+
def run(self, options: SSHCommandRunOptions, args) -> SSHCommandRunResult:
5098
"""Run SSH command with the given parameters and arguments"""
5199
# Get SSH command and default username from driver
52100
ssh_command = self.call("get_ssh_command")
53101
default_username = self.call("get_default_username")
54102
ssh_identity = self.call("get_ssh_identity")
55103

56-
if direct:
104+
if options.direct:
57105
# Use direct TCP address
58106
try:
59107
address = self.tcp.address() # (format: "tcp://host:port")
@@ -62,23 +110,26 @@ def run(self, direct, args):
62110
port = parsed.port
63111
if not host or not port:
64112
raise ValueError(f"Invalid address format: {address}")
65-
self.logger.debug(f"Using direct TCP connection for SSH - host: {host}, port: {port}")
66-
return self._run_ssh_local(host, port, ssh_command, default_username, ssh_identity, args)
113+
self.logger.debug("Using direct TCP connection for SSH - host: %s, port: %s", host, port)
114+
return self._run_ssh_local(host, port, ssh_command, options, default_username, ssh_identity, args)
67115
except (DriverMethodNotImplemented, ValueError) as e:
68-
self.logger.error(f"Direct address connection failed ({e}), falling back to SSH port forwarding")
69-
return self.run(False, args)
116+
self.logger.error("Direct address connection failed (%s), falling back to SSH port forwarding", e)
117+
return self.run(SSHCommandRunOptions(
118+
direct=False,
119+
capture_output=options.capture_output,
120+
capture_as_text=options.capture_as_text,
121+
), args)
70122
else:
71123
# Use SSH port forwarding (default behavior)
72124
self.logger.debug("Using SSH port forwarding for SSH connection")
73125
with TcpPortforwardAdapter(
74126
client=self.tcp,
75127
) as addr:
76-
host = addr[0]
77-
port = addr[1]
78-
self.logger.debug(f"SSH port forward established - host: {host}, port: {port}")
79-
return self._run_ssh_local(host, port, ssh_command, default_username, ssh_identity, args)
128+
host, port = addr
129+
self.logger.debug("SSH port forward established - host: %s, port: %s", host, port)
130+
return self._run_ssh_local(host, port, ssh_command, options, default_username, ssh_identity, args)
80131

81-
def _run_ssh_local(self, host, port, ssh_command, default_username, ssh_identity, args):
132+
def _run_ssh_local(self, host, port, ssh_command, options, default_username, ssh_identity, args):
82133
"""Run SSH command with the given host, port, and arguments"""
83134
# Create temporary identity file if needed
84135
identity_file = None
@@ -91,9 +142,9 @@ def _run_ssh_local(self, host, port, ssh_command, default_username, ssh_identity
91142
# Set proper permissions (600) for SSH key
92143
os.chmod(temp_file.name, 0o600)
93144
identity_file = temp_file.name
94-
self.logger.debug(f"Created temporary identity file: {identity_file}")
145+
self.logger.debug("Created temporary identity file: %s", identity_file)
95146
except Exception as e:
96-
self.logger.error(f"Failed to create temporary identity file: {e}")
147+
self.logger.error("Failed to create temporary identity file: %s", e)
97148
if temp_file:
98149
try:
99150
os.unlink(temp_file.name)
@@ -112,15 +163,15 @@ def _run_ssh_local(self, host, port, ssh_command, default_username, ssh_identity
112163
ssh_args = self._build_final_ssh_command(ssh_args, ssh_options, host, command_args)
113164

114165
# Execute the command
115-
return self._execute_ssh_command(ssh_args)
166+
return self._execute_ssh_command(ssh_args, options)
116167
finally:
117168
# Clean up temporary identity file
118169
if identity_file:
119170
try:
120171
os.unlink(identity_file)
121-
self.logger.debug(f"Cleaned up temporary identity file: {identity_file}")
172+
self.logger.debug("Cleaned up temporary identity file: %s", identity_file)
122173
except Exception as e:
123-
self.logger.warning(f"Failed to clean up temporary identity file {identity_file}: {e}")
174+
self.logger.warning("Failed to clean up temporary identity file %s: %s", identity_file, str(e))
124175

125176
def _build_ssh_command_args(self, ssh_command, port, default_username, identity_file, args):
126177
"""Build initial SSH command arguments"""
@@ -192,8 +243,8 @@ def _separate_ssh_options_and_command_args(self, args):
192243
i += 1
193244

194245
# Debug output
195-
self.logger.debug(f"SSH options: {ssh_options}")
196-
self.logger.debug(f"Command args: {command_args}")
246+
self.logger.debug("SSH options: %s", ssh_options)
247+
self.logger.debug("Command args: %s", command_args)
197248
return ssh_options, command_args
198249

199250

@@ -209,16 +260,21 @@ def _build_final_ssh_command(self, ssh_args, ssh_options, host, command_args):
209260
# Add command arguments
210261
ssh_args.extend(command_args)
211262

212-
self.logger.debug(f"Running SSH command: {ssh_args}")
263+
self.logger.debug("Running SSH command: %s", ssh_args)
213264
return ssh_args
214265

215-
def _execute_ssh_command(self, ssh_args):
266+
def _execute_ssh_command(self, ssh_args, options: SSHCommandRunOptions) -> SSHCommandRunResult:
216267
"""Execute the SSH command and return the result"""
217268
try:
218-
result = subprocess.run(ssh_args)
219-
return result.returncode
269+
result = subprocess.run(ssh_args, capture_output=options.capture_output, text=options.capture_as_text)
270+
return SSHCommandRunResult.from_completed_process(result)
220271
except FileNotFoundError:
221272
self.logger.error(
222-
f"SSH command '{ssh_args[0]}' not found. Please ensure SSH is installed and available in PATH."
273+
"SSH command '%s' not found. Please ensure SSH is installed and available in PATH.",
274+
ssh_args[0],
275+
)
276+
return SSHCommandRunResult(
277+
return_code=127, # Standard exit code for "command not found"
278+
stdout="",
279+
stderr=f"SSH command '{ssh_args[0]}' not found",
223280
)
224-
return 127 # Standard exit code for "command not found"

0 commit comments

Comments
 (0)