Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ filter = '''
test(/to_?disk/)
| test(/^test_to_base_disk_/)
| test(/^test_libvirt_run/)
| test(/^test_libvirt_(comprehensive|ssh_integration|port_forward_(xml|connectivity))/)
| test(/^test_libvirt_(comprehensive|ssh_integration|ssh_autostart|port_forward_(xml|connectivity))/)
| test(/^test_base_disk_creation/)
| test(=test_varlink_ephemeral_run_ps_and_ssh)
'''
Expand Down
45 changes: 45 additions & 0 deletions crates/integration-tests/src/tests/libvirt_verb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,51 @@ fn test_libvirt_ssh_integration() -> TestResult {
}
integration_test!(test_libvirt_ssh_integration);

/// Test that `bcvk libvirt ssh` starts a shut off domain
fn test_libvirt_ssh_autostart() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let test_image = get_test_image();
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
let domain_name = format!("test-ssh-autostart-{}", random_suffix());

cleanup_domain(&domain_name);
defer! {
cleanup_domain(&domain_name);
}

cmd!(
sh,
"{bck} libvirt run --name {domain_name} --label {label} --filesystem ext4 {test_image}"
)
.run()?;

cmd!(sh, "{bck} libvirt stop --force {domain_name}").run()?;
let state = cmd!(sh, "env LC_ALL=C virsh domstate {domain_name}").read()?;
assert_eq!(state.trim(), "shut off");

let output = cmd!(sh, "{bck} libvirt ssh {domain_name} -- echo hello")
.ignore_status()
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"ssh to a shut off domain should start it and succeed: {stderr}"
);
// The start notice must not end up in the remote command's output
assert_eq!(stdout.trim(), "hello");
assert!(
stderr.contains("starting it"),
"should say that the domain is being started: {stderr}"
);

let state = cmd!(sh, "env LC_ALL=C virsh domstate {domain_name}").read()?;
assert_eq!(state.trim(), "running");
Ok(())
}
integration_test!(test_libvirt_ssh_autostart);

/// Comprehensive workflow test: creates a VM and tests multiple features
/// This consolidates several smaller tests to reduce expensive disk image creation
fn test_libvirt_comprehensive_workflow() -> TestResult {
Expand Down
6 changes: 3 additions & 3 deletions crates/kit/src/libvirt/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ use crate::libvirt::domain::VirtiofsFilesystem;
use crate::utils::parse_memory_to_mb;
use crate::xml_utils;

/// SSH wait timeout in seconds
const SSH_WAIT_TIMEOUT_SECONDS: u64 = 180;
/// SSH wait timeout in seconds, for a domain that is booting
pub(crate) const SSH_WAIT_TIMEOUT_SECONDS: u64 = 180;

/// Transport type for updating from host container storage
const UPDATE_FROM_HOST_TRANSPORT: &str = "containers-storage";
Expand Down Expand Up @@ -374,7 +374,7 @@ impl LibvirtRunOpts {
///
/// Uses the same `wait_for_readiness` polling loop as the ephemeral path
/// and `run_ssh_impl`, just with a longer timeout for initial VM boot.
fn wait_for_ssh_ready(
pub(crate) fn wait_for_ssh_ready(
global_opts: &crate::libvirt::LibvirtOptions,
domain_name: &str,
timeout_secs: u64,
Expand Down
47 changes: 44 additions & 3 deletions crates/kit/src/libvirt/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const SSH_RETRY_TIMEOUT_SECS: u64 = 60; // Total time to retry SSH connections
const SSH_POLL_DELAY_SECS: u64 = 1; // Delay between SSH attempts
const SSH_SERVER_ALIVE_INTERVAL: u32 = 60; // Server alive interval in seconds

/// `virsh domstate` output for a running domain
const DOMAIN_STATE_RUNNING: &str = "running";
/// `virsh domstate` output for a domain that is defined but not running
const DOMAIN_STATE_SHUT_OFF: &str = "shut off";

/// Configuration options for SSH connection to libvirt domain
#[derive(Debug, Parser)]
pub struct LibvirtSshOpts {
Expand Down Expand Up @@ -278,7 +283,7 @@ impl LibvirtSshOpts {
return Err(eyre!("Domain '{}' not found", self.domain_name));
}
let state = self.get_domain_state(global_opts)?;
if state != "running" {
if state != DOMAIN_STATE_RUNNING {
return Err(eyre!(
"Domain '{}' is not running (current state: {}). Start it first with: virsh start {}",
self.domain_name,
Expand All @@ -289,6 +294,36 @@ impl LibvirtSshOpts {
Ok(())
}

/// Verify the domain exists, starting it if it is shut off.
///
/// Returns `true` if the domain was started, in which case it
/// will still be booting.
fn ensure_domain_running(&self, global_opts: &crate::libvirt::LibvirtOptions) -> Result<bool> {
if !self.check_domain_exists(global_opts)? {
return Err(eyre!("Domain '{}' not found", self.domain_name));
}
match self.get_domain_state(global_opts)?.as_str() {
DOMAIN_STATE_RUNNING => Ok(false),
DOMAIN_STATE_SHUT_OFF => {
// Use stderr so that the output of a remote command stays clean
if !self.suppress_output {
eprintln!("Domain '{}' is shut off; starting it", self.domain_name);
}
super::run::run_virsh_cmd(
global_opts.connect.as_deref(),
&["start", &self.domain_name],
&format!("Failed to start domain '{}'", self.domain_name),
)?;
Ok(true)
}
state => Err(eyre!(
"Domain '{}' is not running (current state: {}); only shut off domains are started automatically",
self.domain_name,
state
)),
}
}

/// Create temp key file and parse extra SSH options — shared setup for
/// both the retry path and single-attempt tests.
pub(crate) fn prepare_ssh_session(
Expand Down Expand Up @@ -374,7 +409,13 @@ pub fn run_ssh_impl(
) -> Result<()> {
debug!("Connecting to libvirt domain: {}", opts.domain_name);

opts.verify_domain_running(global_opts)?;
let started = opts.ensure_domain_running(global_opts)?;
// A domain we just started has to boot first
let ssh_timeout = if started {
super::run::SSH_WAIT_TIMEOUT_SECONDS
} else {
SSH_RETRY_TIMEOUT_SECS
};

let ssh_config = opts.extract_ssh_config(global_opts)?;

Expand Down Expand Up @@ -405,7 +446,7 @@ pub fn run_ssh_impl(
Err(_) => Ok(false),
}
},
Duration::from_secs(SSH_RETRY_TIMEOUT_SECS),
Duration::from_secs(ssh_timeout),
Duration::from_secs(SSH_POLL_DELAY_SECS),
)
.map_err(|_| {
Expand Down
8 changes: 8 additions & 0 deletions crates/kit/src/libvirt/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtStartOpts)
println!("VM '{}' started successfully", opts.name);

if opts.ssh {
// The guest has to boot first, so wait for SSH as long as
// `libvirt run --ssh` does rather than the shorter retry of `libvirt ssh`
crate::libvirt::run::wait_for_ssh_ready(
global_opts,
&opts.name,
crate::libvirt::run::SSH_WAIT_TIMEOUT_SECONDS,
)?;

// Use the libvirt SSH functionality directly
let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts {
domain_name: opts.name,
Expand Down
9 changes: 9 additions & 0 deletions docs/src/man/bcvk-libvirt-ssh.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ bcvk-libvirt-ssh - SSH to libvirt domain with embedded SSH key

SSH to libvirt domain with embedded SSH key

If the domain is shut off, it is started first, and the connection is
made once SSH is reachable. Domains in other states (e.g. paused) are
not changed and result in an error.

# OPTIONS

<!-- BEGIN GENERATED OPTIONS -->
Expand Down Expand Up @@ -57,6 +61,11 @@ SSH into a running libvirt VM:

bcvk libvirt ssh my-server

SSH into a stopped VM, starting it first:

bcvk libvirt stop my-server
bcvk libvirt ssh my-server

Execute a command on the VM:

bcvk libvirt ssh my-server 'systemctl status'
Expand Down
Loading