Skip to content

Commit 2c05e9e

Browse files
committed
libvirt run: Add --ssh-wait flag for test integration
Add a new `--ssh-wait` flag to `bcvk libvirt run` that waits for SSH to become available without entering an interactive shell. This is designed for use in test suites that need to verify a VM is fully booted and SSH-ready before proceeding with additional operations. The `--ssh` flag behavior now also starts using this code, as does some of the integration tests. Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent d870f45 commit 2c05e9e

8 files changed

Lines changed: 189 additions & 144 deletions

File tree

crates/integration-tests/src/tests/libvirt_port_forward.rs

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ fn test_libvirt_port_forward_connectivity() -> Result<()> {
272272
&format!("{}:8080", host_port),
273273
"--filesystem",
274274
"ext4",
275+
"--ssh-wait",
275276
&test_image,
276277
])
277278
.expect("Failed to run libvirt run with port forwarding");
@@ -289,12 +290,8 @@ fn test_libvirt_port_forward_connectivity() -> Result<()> {
289290

290291
println!("Successfully created domain: {}", domain_name);
291292

292-
// Wait for VM to boot and SSH to become available
293-
println!("Waiting for VM to boot and SSH to become available...");
294-
if let Err(e) = wait_for_ssh_available(&domain_name, 180) {
295-
cleanup_domain(&domain_name);
296-
panic!("Failed to establish SSH connection: {}", e);
297-
}
293+
// SSH is already available due to --ssh-wait flag
294+
println!("✓ SSH is ready (via --ssh-wait)");
298295

299296
// Start a simple HTTP server on port 8080 inside the VM using Python
300297
println!("Starting HTTP server on port 8080 inside VM...");
@@ -445,56 +442,6 @@ fn cleanup_domain(domain_name: &str) {
445442
}
446443
}
447444

448-
/// Wait for SSH to become available on a domain with a timeout
449-
fn wait_for_ssh_available(
450-
domain_name: &str,
451-
timeout_secs: u64,
452-
) -> Result<(), Box<dyn std::error::Error>> {
453-
let start_time = std::time::Instant::now();
454-
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
455-
456-
println!(
457-
"Waiting for SSH to become available on domain: {}",
458-
domain_name
459-
);
460-
461-
loop {
462-
// Check if we've exceeded the timeout before attempting SSH
463-
if start_time.elapsed() >= timeout_duration {
464-
return Err(format!("Timeout waiting for SSH after {} seconds", timeout_secs).into());
465-
}
466-
467-
// Try a simple SSH command to test connectivity with a short timeout (5 seconds)
468-
// This prevents each SSH attempt from hanging for the default 30 seconds
469-
let ssh_test = run_bcvk(&[
470-
"libvirt",
471-
"ssh",
472-
"--timeout",
473-
"5",
474-
domain_name,
475-
"--",
476-
"echo",
477-
"ssh-ready",
478-
]);
479-
480-
match ssh_test {
481-
Ok(output) if output.success() => {
482-
println!("✓ SSH is now available");
483-
return Ok(());
484-
}
485-
Ok(_) => {
486-
// SSH command failed, but that's expected while VM is booting
487-
}
488-
Err(e) => {
489-
println!("SSH test error (expected while booting): {}", e);
490-
}
491-
}
492-
493-
// Wait 2 seconds before next attempt (since we already waited 5 seconds for SSH timeout)
494-
std::thread::sleep(std::time::Duration::from_secs(2));
495-
}
496-
}
497-
498445
/// Find an available port on the host
499446
fn find_available_port() -> Result<u16> {
500447
use std::net::TcpListener;

crates/integration-tests/src/tests/libvirt_verb.rs

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -486,46 +486,6 @@ fn check_libvirt_supports_readonly_virtiofs() -> Result<bool> {
486486
Ok(supports_readonly)
487487
}
488488

489-
/// Wait for SSH to become available on a domain with a timeout
490-
fn wait_for_ssh_available(
491-
domain_name: &str,
492-
timeout_secs: u64,
493-
) -> Result<(), Box<dyn std::error::Error>> {
494-
let start_time = std::time::Instant::now();
495-
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
496-
497-
println!(
498-
"Waiting for SSH to become available on domain: {}",
499-
domain_name
500-
);
501-
502-
loop {
503-
// Try a simple SSH command to test connectivity
504-
let ssh_test = run_bcvk(&["libvirt", "ssh", domain_name, "--", "echo", "ssh-ready"]);
505-
506-
match ssh_test {
507-
Ok(output) if output.success() => {
508-
println!("✓ SSH is now available");
509-
return Ok(());
510-
}
511-
Ok(_) => {
512-
// SSH command failed, but that's expected while VM is booting
513-
}
514-
Err(e) => {
515-
println!("SSH test error (expected while booting): {}", e);
516-
}
517-
}
518-
519-
// Check if we've exceeded the timeout
520-
if start_time.elapsed() >= timeout_duration {
521-
return Err(format!("Timeout waiting for SSH after {} seconds", timeout_secs).into());
522-
}
523-
524-
// Wait 5 seconds before next attempt
525-
std::thread::sleep(std::time::Duration::from_secs(5));
526-
}
527-
}
528-
529489
/// Test VM startup and shutdown with libvirt run
530490
fn test_libvirt_run_vm_lifecycle() -> Result<()> {
531491
let bck = get_bck_command()?;
@@ -664,6 +624,7 @@ fn test_libvirt_run_bind_storage_ro() -> Result<()> {
664624
"--bind-storage-ro",
665625
"--filesystem",
666626
"ext4",
627+
"--ssh-wait",
667628
&test_image,
668629
])
669630
.expect("Failed to run libvirt run with --bind-storage-ro");
@@ -730,13 +691,10 @@ fn test_libvirt_run_bind_storage_ro() -> Result<()> {
730691
println!("✓ Container storage mount is configured as read-only");
731692
println!("✓ hoststorage tag is present in filesystem configuration");
732693

733-
// Wait for VM to boot and SSH to become available
734-
if let Err(e) = wait_for_ssh_available(&domain_name, 180) {
735-
cleanup_domain(&domain_name);
736-
panic!("Failed to establish SSH connection: {}", e);
737-
}
694+
// SSH is already available due to --ssh-wait flag
695+
println!("✓ SSH is ready (via --ssh-wait)");
738696

739-
// Wait for VM to boot and automatic mount to complete
697+
// Wait for automatic mount to complete
740698
println!("Waiting for VM to boot and automatic mount to complete...");
741699
std::thread::sleep(std::time::Duration::from_secs(10));
742700

@@ -1482,3 +1440,5 @@ fn test_libvirt_run_bind_mounts() -> Result<()> {
14821440
Ok(())
14831441
}
14841442
integration_test!(test_libvirt_run_bind_mounts);
1443+
1444+
/// Test --ssh-wait flag waits for SSH without entering shell

crates/kit/src/libvirt/run.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ pub struct LibvirtRunOpts {
253253
#[clap(long)]
254254
pub ssh: bool,
255255

256+
/// Wait for SSH to become available and verify connectivity (for testing)
257+
#[clap(long)]
258+
pub ssh_wait: bool,
259+
256260
/// Mount host container storage (RO) at /run/host-container-storage
257261
#[clap(long = "bind-storage-ro")]
258262
pub bind_storage_ro: bool,
@@ -319,6 +323,57 @@ impl LibvirtRunOpts {
319323
}
320324
}
321325

326+
/// Wait for SSH to become available on a libvirt domain
327+
///
328+
/// Polls SSH connectivity by attempting simple commands until successful or timeout.
329+
fn wait_for_ssh_ready(
330+
global_opts: &crate::libvirt::LibvirtOptions,
331+
domain_name: &str,
332+
timeout_secs: u64,
333+
) -> Result<()> {
334+
use std::time::Duration;
335+
336+
debug!(
337+
"Waiting for SSH to become available on domain '{}' (timeout: {}s)",
338+
domain_name, timeout_secs
339+
);
340+
341+
// Create progress bar
342+
let pb = crate::boot_progress::create_boot_progress_bar();
343+
pb.set_message("Waiting for SSH to become available...");
344+
345+
// Clone values for closure
346+
let global_opts_clone = global_opts.clone();
347+
let domain_name_clone = domain_name.to_string();
348+
349+
// Use shared polling function with libvirt-specific test
350+
let (_elapsed, pb) = crate::utils::wait_for_readiness(
351+
pb,
352+
"Waiting for SSH",
353+
|| {
354+
// Create a test SSH connection with short timeout
355+
let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts {
356+
domain_name: domain_name_clone.clone(),
357+
user: "root".to_string(),
358+
command: vec!["true".to_string()], // Simple command to test connectivity
359+
strict_host_keys: false,
360+
timeout: 5, // Short timeout for each attempt
361+
log_level: "ERROR".to_string(),
362+
extra_options: vec![],
363+
suppress_output: true, // Suppress error messages during connectivity testing
364+
};
365+
366+
// Try to connect
367+
crate::libvirt::ssh::run_ssh_impl(&global_opts_clone, ssh_opts).map(|_| true)
368+
},
369+
Duration::from_secs(timeout_secs),
370+
Duration::from_secs(2), // Poll every 2 seconds
371+
)?;
372+
373+
pb.finish_and_clear();
374+
Ok(())
375+
}
376+
322377
/// Execute the libvirt run command
323378
pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtRunOpts) -> Result<()> {
324379
use crate::images;
@@ -445,12 +500,21 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtRunOpts) -
445500
}
446501
}
447502

448-
if opts.ssh {
503+
if opts.ssh_wait {
504+
// Wait for SSH to be ready and verify connectivity
505+
wait_for_ssh_ready(global_opts, &vm_name, 180)?;
506+
println!("Ready; use bcvk libvirt ssh to connect");
507+
Ok(())
508+
} else if opts.ssh {
509+
// Wait for SSH then enter interactive shell
510+
wait_for_ssh_ready(global_opts, &vm_name, 180)?;
511+
449512
// Use the libvirt SSH functionality directly
450513
let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts {
451514
domain_name: vm_name,
452515
user: "root".to_string(),
453516
command: vec![],
517+
suppress_output: false,
454518
strict_host_keys: false,
455519
timeout: 30,
456520
log_level: "ERROR".to_string(),

crates/kit/src/libvirt/ssh.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ pub struct LibvirtSshOpts {
4646
/// Extra SSH options in key=value format
4747
#[clap(long)]
4848
pub extra_options: Vec<String>,
49+
50+
/// Suppress stdout/stderr output (for connectivity testing)
51+
#[clap(skip)]
52+
pub suppress_output: bool,
4953
}
5054

5155
/// SSH configuration extracted from domain metadata
@@ -315,15 +319,17 @@ impl LibvirtSshOpts {
315319
.output()
316320
.map_err(|e| eyre!("Failed to execute SSH command: {}", e))?;
317321

318-
if !output.stdout.is_empty() {
319-
// Forward stdout to parent process
320-
print!("{}", String::from_utf8_lossy(&output.stdout));
321-
debug!("SSH stdout: {}", String::from_utf8_lossy(&output.stdout));
322-
}
323-
if !output.stderr.is_empty() {
324-
// Forward stderr to parent process
325-
eprint!("{}", String::from_utf8_lossy(&output.stderr));
326-
debug!("SSH stderr: {}", String::from_utf8_lossy(&output.stderr));
322+
if !self.suppress_output {
323+
if !output.stdout.is_empty() {
324+
// Forward stdout to parent process
325+
print!("{}", String::from_utf8_lossy(&output.stdout));
326+
debug!("SSH stdout: {}", String::from_utf8_lossy(&output.stdout));
327+
}
328+
if !output.stderr.is_empty() {
329+
// Forward stderr to parent process
330+
eprint!("{}", String::from_utf8_lossy(&output.stderr));
331+
debug!("SSH stderr: {}", String::from_utf8_lossy(&output.stderr));
332+
}
327333
}
328334

329335
if !output.status.success() {

crates/kit/src/libvirt/start.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtStartOpts)
4545
timeout: 30,
4646
log_level: "ERROR".to_string(),
4747
extra_options: vec![],
48+
suppress_output: false,
4849
};
4950
return crate::libvirt::ssh::run(global_opts, ssh_opts);
5051
}
@@ -81,6 +82,7 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtStartOpts)
8182
timeout: 30,
8283
log_level: "ERROR".to_string(),
8384
extra_options: vec![],
85+
suppress_output: false,
8486
};
8587
crate::libvirt::ssh::run(global_opts, ssh_opts)
8688
} else {

crates/kit/src/run_ephemeral_ssh.rs

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ use color_eyre::Result;
33
use indicatif::ProgressBar;
44
use std::os::unix::process::CommandExt;
55
use std::process::{Command, Stdio};
6-
use std::thread;
7-
use std::time::{Duration, Instant};
6+
use std::time::Duration;
87
use tracing::debug;
98

109
use crate::run_ephemeral::{run_detached, RunEphemeralOpts};
@@ -116,38 +115,32 @@ pub fn wait_for_ssh_ready(
116115
let timeout = timeout.unwrap_or(SSH_TIMEOUT);
117116
let (_, progress) = wait_for_vm_ssh(container_name, Some(timeout), progress)?;
118117

119-
debug!("Polling SSH connectivity...",);
120-
let start_time = Instant::now();
118+
debug!("Polling SSH connectivity...");
121119

122120
// Use SSH options optimized for connectivity testing
123121
let ssh_options = crate::ssh::SshConnectionOptions::for_connectivity_test();
124-
125-
let mut attempt = 0;
126-
while start_time.elapsed() < timeout {
127-
attempt += 1;
128-
progress.set_message(format!("Polling for SSH readiness (attempt {attempt})"));
129-
130-
// Try to connect via SSH and run a simple command using the centralized SSH function
131-
let status = crate::ssh::connect(
132-
container_name,
133-
vec!["true".to_string()], // Just run 'true' to test connectivity
134-
&ssh_options,
135-
);
136-
137-
if let Ok(exit_status) = status {
138-
if exit_status.success() {
139-
debug!("SSH connection successful, VM is ready");
140-
return Ok((start_time.elapsed(), progress));
122+
let container_name = container_name.to_string();
123+
124+
// Use shared polling function with container-specific test
125+
crate::utils::wait_for_readiness(
126+
progress,
127+
"Waiting for SSH",
128+
|| {
129+
// Try to connect via SSH and run a simple command
130+
let status = crate::ssh::connect(
131+
&container_name,
132+
vec!["true".to_string()], // Just run 'true' to test connectivity
133+
&ssh_options,
134+
);
135+
136+
match status {
137+
Ok(exit_status) if exit_status.success() => Ok(true),
138+
_ => Ok(false),
141139
}
142-
}
143-
144-
thread::sleep(Duration::from_secs(1));
145-
}
146-
147-
Err(color_eyre::eyre::eyre!(
148-
"Timeout waiting for SSH connectivity after {}s",
149-
timeout.as_secs()
150-
))
140+
},
141+
timeout,
142+
Duration::from_secs(1), // Poll every 1 second
143+
)
151144
}
152145

153146
/// Run an ephemeral pod and immediately SSH into it, with lifecycle binding

0 commit comments

Comments
 (0)