Skip to content

Commit b5c86ee

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 83b8664 commit b5c86ee

8 files changed

Lines changed: 190 additions & 139 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
@@ -271,6 +271,7 @@ fn test_libvirt_port_forward_connectivity() -> Result<()> {
271271
&format!("{}:8080", host_port),
272272
"--filesystem",
273273
"ext4",
274+
"--ssh-wait",
274275
&test_image,
275276
])
276277
.expect("Failed to run libvirt run with port forwarding");
@@ -288,12 +289,8 @@ fn test_libvirt_port_forward_connectivity() -> Result<()> {
288289

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

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

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

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

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

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

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

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

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

crates/kit/src/libvirt/run.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ use crate::libvirt::domain::VirtiofsFilesystem;
1919
use crate::utils::parse_memory_to_mb;
2020
use crate::xml_utils;
2121

22+
/// SSH wait timeout in seconds
23+
const SSH_WAIT_TIMEOUT_SECONDS: u64 = 180;
24+
2225
/// Create a virsh command with optional connection URI
2326
pub(super) fn virsh_command(connect_uri: Option<&str>) -> Result<std::process::Command> {
2427
let mut cmd = std::process::Command::new("virsh");
@@ -253,6 +256,10 @@ pub struct LibvirtRunOpts {
253256
#[clap(long)]
254257
pub ssh: bool,
255258

259+
/// Wait for SSH to become available and verify connectivity (for testing)
260+
#[clap(long, conflicts_with = "ssh")]
261+
pub ssh_wait: bool,
262+
256263
/// Mount host container storage (RO) at /run/host-container-storage
257264
#[clap(long = "bind-storage-ro")]
258265
pub bind_storage_ro: bool,
@@ -319,6 +326,60 @@ impl LibvirtRunOpts {
319326
}
320327
}
321328

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

448-
if opts.ssh {
509+
if opts.ssh_wait {
510+
// Wait for SSH to be ready and verify connectivity
511+
wait_for_ssh_ready(global_opts, &vm_name, SSH_WAIT_TIMEOUT_SECONDS)?;
512+
println!("Ready; use bcvk libvirt ssh to connect");
513+
Ok(())
514+
} else if opts.ssh {
515+
// Wait for SSH then enter interactive shell
516+
wait_for_ssh_ready(global_opts, &vm_name, SSH_WAIT_TIMEOUT_SECONDS)?;
517+
449518
// Use the libvirt SSH functionality directly
450519
let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts {
451520
domain_name: vm_name,
452521
user: "root".to_string(),
453522
command: vec![],
523+
suppress_output: false,
454524
strict_host_keys: false,
455525
timeout: 30,
456526
log_level: "ERROR".to_string(),

crates/kit/src/libvirt/ssh.rs

Lines changed: 12 additions & 4 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
@@ -316,13 +320,17 @@ impl LibvirtSshOpts {
316320
.map_err(|e| eyre!("Failed to execute SSH command: {}", e))?;
317321

318322
if !output.stdout.is_empty() {
319-
// Forward stdout to parent process
320-
print!("{}", String::from_utf8_lossy(&output.stdout));
323+
if !self.suppress_output {
324+
// Forward stdout to parent process
325+
print!("{}", String::from_utf8_lossy(&output.stdout));
326+
}
321327
debug!("SSH stdout: {}", String::from_utf8_lossy(&output.stdout));
322328
}
323329
if !output.stderr.is_empty() {
324-
// Forward stderr to parent process
325-
eprint!("{}", String::from_utf8_lossy(&output.stderr));
330+
if !self.suppress_output {
331+
// Forward stderr to parent process
332+
eprint!("{}", String::from_utf8_lossy(&output.stderr));
333+
}
326334
debug!("SSH stderr: {}", String::from_utf8_lossy(&output.stderr));
327335
}
328336

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)