1313from 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 )
1751class 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