@@ -15,7 +15,7 @@ use std::io::Write;
1515use std:: os:: unix:: fs:: PermissionsExt as _;
1616use std:: os:: unix:: process:: CommandExt ;
1717use std:: process:: Command ;
18- use std:: time:: { Duration , Instant } ;
18+ use std:: time:: Duration ;
1919use tempfile;
2020use tracing:: debug;
2121
@@ -60,7 +60,7 @@ pub struct LibvirtSshOpts {
6060
6161/// SSH configuration extracted from domain metadata
6262#[ derive( Debug ) ]
63- struct DomainSshConfig {
63+ pub ( crate ) struct DomainSshConfig {
6464 private_key_content : String ,
6565 ssh_port : u16 ,
6666 is_generated : bool ,
@@ -93,7 +93,7 @@ impl LibvirtSshOpts {
9393 }
9494
9595 /// Extract SSH configuration from domain XML metadata
96- fn extract_ssh_config (
96+ pub ( crate ) fn extract_ssh_config (
9797 & self ,
9898 global_opts : & crate :: libvirt:: LibvirtOptions ,
9999 ) -> Result < DomainSshConfig > {
@@ -243,7 +243,7 @@ impl LibvirtSshOpts {
243243 }
244244
245245 /// Build SSH command with configured options
246- fn build_ssh_command (
246+ pub ( crate ) fn build_ssh_command (
247247 & self ,
248248 ssh_config : & DomainSshConfig ,
249249 temp_key : & tempfile:: NamedTempFile ,
@@ -269,25 +269,34 @@ impl LibvirtSshOpts {
269269 ssh_cmd
270270 }
271271
272- /// Execute SSH connection to domain with retries
273- fn connect_ssh (
272+ /// Verify the domain exists and is running.
273+ pub ( crate ) fn verify_domain_running (
274274 & self ,
275- _global_opts : & crate :: libvirt:: LibvirtOptions ,
276- ssh_config : & DomainSshConfig ,
275+ global_opts : & crate :: libvirt:: LibvirtOptions ,
277276 ) -> Result < ( ) > {
278- debug ! (
279- "Connecting to domain '{}' via SSH on port {} (user: {})" ,
280- self . domain_name, ssh_config. ssh_port, self . user
281- ) ;
282-
283- if ssh_config. is_generated {
284- debug ! ( "Using ephemeral SSH key from domain metadata" ) ;
277+ if !self . check_domain_exists ( global_opts) ? {
278+ return Err ( eyre ! ( "Domain '{}' not found" , self . domain_name) ) ;
285279 }
280+ let state = self . get_domain_state ( global_opts) ?;
281+ if state != "running" {
282+ return Err ( eyre ! (
283+ "Domain '{}' is not running (current state: {}). Start it first with: virsh start {}" ,
284+ self . domain_name,
285+ state,
286+ self . domain_name
287+ ) ) ;
288+ }
289+ Ok ( ( ) )
290+ }
286291
287- // Create temporary SSH key file
292+ /// Create temp key file and parse extra SSH options — shared setup for
293+ /// both the retry path and single-attempt tests.
294+ pub ( crate ) fn prepare_ssh_session (
295+ & self ,
296+ ssh_config : & DomainSshConfig ,
297+ ) -> Result < ( tempfile:: NamedTempFile , Vec < ( String , String ) > ) > {
288298 let temp_key = self . create_temp_ssh_key ( ssh_config) ?;
289299
290- // Parse extra options
291300 let mut parsed_extra_options = Vec :: new ( ) ;
292301 for option in & self . extra_options {
293302 if let Some ( ( key, value) ) = option. split_once ( '=' ) {
@@ -299,65 +308,29 @@ impl LibvirtSshOpts {
299308 ) ) ;
300309 }
301310 }
311+ Ok ( ( temp_key, parsed_extra_options) )
312+ }
302313
303- let start_time = Instant :: now ( ) ;
304- let timeout = Duration :: from_secs ( SSH_RETRY_TIMEOUT_SECS ) ;
305-
306- // First, do connectivity check with retries (for both interactive and command)
307- debug ! ( "Testing SSH connectivity before session" ) ;
308-
309- // Create progress bar for user feedback (only shown in terminals)
310- let pb = crate :: boot_progress:: create_boot_progress_bar ( ) ;
311- pb. set_message ( "Waiting for SSH to be ready..." ) ;
312-
313- loop {
314- let mut test_cmd =
315- self . build_ssh_command ( ssh_config, & temp_key, parsed_extra_options. clone ( ) ) ;
316- test_cmd. arg ( "--" ) . arg ( "true" ) ; // Simple test command
317-
318- let output = test_cmd. output ( ) . context ( "Failed to spawn SSH command" ) ?;
319-
320- if output. status . success ( ) {
321- debug ! (
322- "SSH connectivity confirmed after {:.1}s" ,
323- start_time. elapsed( ) . as_secs_f64( )
324- ) ;
325- pb. finish_and_clear ( ) ;
326- break ;
327- }
328-
329- // Check if we've exceeded timeout
330- if start_time. elapsed ( ) >= timeout {
331- pb. finish_and_clear ( ) ;
332- if !self . suppress_output {
333- let stderr_str = String :: from_utf8_lossy ( & output. stderr ) ;
334- eprint ! ( "{}" , stderr_str) ;
335- eprintln ! (
336- "\n SSH connection failed after {:.1}s. To see VM console output, run: virsh console {}" ,
337- start_time. elapsed( ) . as_secs_f64( ) ,
338- self . domain_name
339- ) ;
340- }
341- return Err ( eyre ! ( "SSH connection failed after timeout" ) ) ;
342- }
343-
344- std:: thread:: sleep ( Duration :: from_secs ( SSH_POLL_DELAY_SECS ) ) ;
345- }
346-
347- // SSH is ready - now do the actual operation (oneshot)
314+ /// Execute the SSH session (interactive or command) after connectivity
315+ /// has already been confirmed by the caller.
316+ fn exec_ssh_session (
317+ & self ,
318+ ssh_config : & DomainSshConfig ,
319+ temp_key : & tempfile:: NamedTempFile ,
320+ parsed_extra_options : Vec < ( String , String ) > ,
321+ ) -> Result < ( ) > {
348322 if self . command . is_empty ( ) {
349- // Interactive: exec directly
350- debug ! ( "SSH ready, launching interactive session" ) ;
351- let mut ssh_cmd = self . build_ssh_command ( ssh_config, & temp_key, parsed_extra_options) ;
323+ // Interactive: exec directly (replaces current process)
324+ debug ! ( "Launching interactive SSH session" ) ;
325+ let mut ssh_cmd = self . build_ssh_command ( ssh_config, temp_key, parsed_extra_options) ;
352326 let error = ssh_cmd. exec ( ) ;
353327 return Err ( eyre ! ( "Failed to exec SSH command: {}" , error) ) ;
354328 }
355329
356- // Command execution: single attempt since we already confirmed connectivity
357- debug ! ( "SSH ready, executing command" ) ;
358- let mut ssh_cmd = self . build_ssh_command ( ssh_config, & temp_key, parsed_extra_options) ;
330+ // Command execution
331+ debug ! ( "Executing SSH command" ) ;
332+ let mut ssh_cmd = self . build_ssh_command ( ssh_config, temp_key, parsed_extra_options) ;
359333
360- // Add command
361334 ssh_cmd. arg ( "--" ) ;
362335 if self . command . len ( ) > 1 {
363336 let combined_command = crate :: ssh:: shell_escape_command ( & self . command )
@@ -367,7 +340,6 @@ impl LibvirtSshOpts {
367340 ssh_cmd. args ( & self . command ) ;
368341 }
369342
370- // Execute command
371343 let output = ssh_cmd
372344 . output ( )
373345 . map_err ( |e| eyre ! ( "Failed to execute SSH command: {}" , e) ) ?;
@@ -376,14 +348,9 @@ impl LibvirtSshOpts {
376348 if !output. stdout . is_empty ( ) && !self . suppress_output {
377349 print ! ( "{}" , String :: from_utf8_lossy( & output. stdout) ) ;
378350 }
379- debug ! (
380- "Command completed successfully after {:.1}s total" ,
381- start_time. elapsed( ) . as_secs_f64( )
382- ) ;
383351 return Ok ( ( ) ) ;
384352 }
385353
386- // Command failed
387354 if !self . suppress_output {
388355 let stderr_str = String :: from_utf8_lossy ( & output. stderr ) ;
389356 eprint ! ( "{}" , stderr_str) ;
@@ -400,36 +367,63 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtSshOpts) -
400367 run_ssh_impl ( global_opts, opts)
401368}
402369
403- /// SSH implementation
370+ /// SSH implementation — waits for connectivity then runs the session.
404371pub fn run_ssh_impl (
405372 global_opts : & crate :: libvirt:: LibvirtOptions ,
406373 opts : LibvirtSshOpts ,
407374) -> Result < ( ) > {
408375 debug ! ( "Connecting to libvirt domain: {}" , opts. domain_name) ;
409376
410- // Check if domain exists
411- if !opts. check_domain_exists ( global_opts) ? {
412- return Err ( eyre ! ( "Domain '{}' not found" , opts. domain_name) ) ;
413- }
377+ opts. verify_domain_running ( global_opts) ?;
378+
379+ let ssh_config = opts. extract_ssh_config ( global_opts) ?;
414380
415- // Check if domain is running
416- let state = opts. get_domain_state ( global_opts) ?;
417- if state != "running" {
418- return Err ( eyre ! (
419- "Domain '{}' is not running (current state: {}). Start it first with: virsh start {}" ,
420- opts. domain_name,
421- state,
422- opts. domain_name
423- ) ) ;
381+ if ssh_config. is_generated {
382+ debug ! ( "Using ephemeral SSH key from domain metadata" ) ;
424383 }
425384
426- // Extract SSH configuration from domain metadata
427- let ssh_config = opts. extract_ssh_config ( global_opts) ?;
385+ let ( temp_key, parsed_extra_options) = opts. prepare_ssh_session ( & ssh_config) ?;
428386
429- // Connect via SSH with retries
430- opts. connect_ssh ( global_opts, & ssh_config) ?;
387+ // Wait for SSH connectivity using the shared polling loop — same
388+ // pattern as the ephemeral path in run_ephemeral_ssh::wait_for_ssh_ready.
389+ let mut last_stderr = String :: new ( ) ;
390+ let pb = crate :: boot_progress:: create_boot_progress_bar ( ) ;
391+ let ( _elapsed, pb) = crate :: utils:: wait_for_readiness (
392+ pb,
393+ "Waiting for SSH" ,
394+ || {
395+ let mut test_cmd =
396+ opts. build_ssh_command ( & ssh_config, & temp_key, parsed_extra_options. clone ( ) ) ;
397+ test_cmd. arg ( "--" ) . arg ( "true" ) ;
398+
399+ match test_cmd. output ( ) {
400+ Ok ( output) if output. status . success ( ) => Ok ( true ) ,
401+ Ok ( output) => {
402+ last_stderr = String :: from_utf8_lossy ( & output. stderr ) . into_owned ( ) ;
403+ Ok ( false )
404+ }
405+ Err ( _) => Ok ( false ) ,
406+ }
407+ } ,
408+ Duration :: from_secs ( SSH_RETRY_TIMEOUT_SECS ) ,
409+ Duration :: from_secs ( SSH_POLL_DELAY_SECS ) ,
410+ )
411+ . map_err ( |_| {
412+ if !opts. suppress_output {
413+ if !last_stderr. is_empty ( ) {
414+ eprint ! ( "{}" , last_stderr) ;
415+ }
416+ eprintln ! (
417+ "\n SSH connection failed. To see VM console output, run: virsh console {}" ,
418+ opts. domain_name
419+ ) ;
420+ }
421+ eyre ! ( "SSH connection failed after timeout" )
422+ } ) ?;
423+ pb. finish_and_clear ( ) ;
431424
432- Ok ( ( ) )
425+ // Connectivity confirmed — run the actual session
426+ opts. exec_ssh_session ( & ssh_config, & temp_key, parsed_extra_options)
433427}
434428
435429#[ cfg( test) ]
0 commit comments