diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 3d64015a2..d861b78b8 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -21,6 +21,7 @@ mod tests { pub mod mount_feature; pub mod run_ephemeral; pub mod run_ephemeral_ignition; + pub mod run_ephemeral_scp; pub mod run_ephemeral_ssh; pub mod to_disk; pub mod varlink; diff --git a/crates/integration-tests/src/tests/libvirt_verb.rs b/crates/integration-tests/src/tests/libvirt_verb.rs index 50d6af80a..129728641 100644 --- a/crates/integration-tests/src/tests/libvirt_verb.rs +++ b/crates/integration-tests/src/tests/libvirt_verb.rs @@ -5,6 +5,7 @@ //! - `bcvk libvirt list` - List bootc domains //! - `bcvk libvirt list-volumes` - List available bootc volumes //! - `bcvk libvirt ssh` - SSH into domains +//! - `bcvk libvirt scp` - Copy files to/from domains //! - Domain lifecycle management (start/stop/rm/inspect) use integration_tests::integration_test; @@ -143,6 +144,50 @@ fn test_libvirt_ssh_integration() -> TestResult { } integration_test!(test_libvirt_ssh_integration); +/// Test SCP integration with domains (syntax only, no running VM needed) +fn test_libvirt_scp_integration() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + + // Test that SCP command fails gracefully with nonexistent domain + let output = cmd!( + sh, + "{bck} libvirt scp test-domain domain:/etc/hostname ./hostname" + ) + .ignore_status() + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + + // Will fail since no domain exists, but should not crash + if !output.status.success() { + assert!( + stderr.contains("domain") || stderr.contains("not found"), + "SCP integration should fail gracefully with domain error: {}", + stderr + ); + } + + // Test that SCP requires exactly one domain: prefix + let output = cmd!(sh, "{bck} libvirt scp test-domain /local/a /local/b") + .ignore_status() + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !output.status.success(), + "SCP with two local paths should fail" + ); + assert!( + stderr.contains("domain:"), + "Error should mention domain: prefix: {}", + stderr + ); + + println!("libvirt SCP integration tested"); + Ok(()) +} +integration_test!(test_libvirt_scp_integration); + /// 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 { @@ -1158,3 +1203,107 @@ fn test_libvirt_run_console_log() -> TestResult { Ok(()) } integration_test!(test_libvirt_run_console_log); + +/// End-to-end SCP test: creates a VM, copies files to and from it +fn test_libvirt_scp_end_to_end() -> TestResult { + use std::fs; + use tempfile::TempDir; + + 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-scp-{}", random_suffix()); + println!("Testing SCP end-to-end with domain: {}", domain_name); + + cleanup_domain(&domain_name); + defer! { + cleanup_domain(&domain_name); + } + + // Create domain with --ssh-wait so SSH is ready when run returns + println!("Creating domain with --ssh-wait..."); + cmd!( + sh, + "{bck} libvirt run --name {domain_name} --label {label} --filesystem ext4 --ssh-wait {test_image}" + ) + .run()?; + println!("✓ Domain created and SSH ready: {}", domain_name); + + let sh = shell()?; + + // --- Upload a file to the VM --- + let upload_dir = TempDir::new()?; + let upload_file = upload_dir.path().join("upload-test.txt"); + fs::write(&upload_file, "hello from host")?; + let upload_path = upload_file.to_str().expect("non-UTF-8 temp path"); + + println!("Uploading file to VM..."); + cmd!( + sh, + "{bck} libvirt scp {domain_name} {upload_path} domain:/tmp/upload-test.txt" + ) + .run()?; + println!("✓ File uploaded"); + + // Verify it arrived + let cat_stdout = cmd!( + sh, + "{bck} libvirt ssh {domain_name} -- cat /tmp/upload-test.txt" + ) + .read()?; + assert_eq!( + cat_stdout.trim(), + "hello from host", + "Uploaded file content mismatch" + ); + println!("✓ Uploaded file content verified"); + + // --- Download a file from the VM --- + // Use /etc/os-release rather than /etc/hostname: the latter may not exist + // in a freshly-installed bootc VM whose hostname is managed by systemd. + let download_dir = TempDir::new()?; + let download_path = download_dir.path().join("os-release"); + let download_str = download_path.to_str().expect("non-UTF-8 temp path"); + + println!("Downloading /etc/os-release from VM..."); + cmd!( + sh, + "{bck} libvirt scp {domain_name} domain:/etc/os-release {download_str}" + ) + .run()?; + + let downloaded = fs::read_to_string(&download_path)?; + assert!( + downloaded.contains("NAME="), + "Downloaded os-release should contain NAME= field" + ); + println!("✓ Downloaded /etc/os-release: {} bytes", downloaded.len()); + + // --- Recursive copy from the VM --- + let rec_dir = download_dir.path().join("etc-yum"); + let rec_str = rec_dir.to_str().expect("non-UTF-8 temp path"); + + println!("Recursive download of /etc/yum.repos.d/ from VM..."); + cmd!( + sh, + "{bck} libvirt scp {domain_name} -r domain:/etc/yum.repos.d {rec_str}" + ) + .run()?; + + assert!( + rec_dir.exists(), + "Recursive download directory should exist" + ); + let entries: Vec<_> = fs::read_dir(&rec_dir)?.collect(); + assert!( + !entries.is_empty(), + "Recursive download should have produced files" + ); + println!("✓ Recursive download got {} entries", entries.len()); + + println!("✓ SCP end-to-end test passed"); + Ok(()) +} +integration_test!(test_libvirt_scp_end_to_end); diff --git a/crates/integration-tests/src/tests/run_ephemeral_scp.rs b/crates/integration-tests/src/tests/run_ephemeral_scp.rs new file mode 100644 index 000000000..714764030 --- /dev/null +++ b/crates/integration-tests/src/tests/run_ephemeral_scp.rs @@ -0,0 +1,221 @@ +//! Integration tests for ephemeral scp command +//! +//! ⚠️ **CRITICAL INTEGRATION TEST POLICY** ⚠️ +//! +//! INTEGRATION TESTS MUST NEVER "warn and continue" ON FAILURES! +//! +//! If something is not working: +//! - Use `todo!("reason why this doesn't work yet")` +//! - Use `panic!("clear error message")` +//! - Use `assert!()` and `unwrap()` to fail hard +//! +//! NEVER use patterns like: +//! - "Note: test failed - likely due to..." +//! - "This is acceptable in CI/testing environments" +//! - Warning and continuing on failures + +use integration_tests::integration_test; +use itest::TestResult; +use scopeguard::defer; +use xshell::cmd; + +use std::fs; +use tempfile::TempDir; + +use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL}; + +/// Test that ephemeral SCP validates syntax correctly +fn test_ephemeral_scp_syntax() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + + // Test that SCP requires at least one DOMAIN: prefix + let output = cmd!(sh, "{bck} ephemeral scp test-container /local/a /local/b") + .ignore_status() + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !output.status.success(), + "SCP with two local paths should fail" + ); + assert!( + stderr.contains("DOMAIN:"), + "Error should mention DOMAIN: prefix: {}", + stderr + ); + + // Test that SCP with two remote paths fails + let output2 = cmd!( + sh, + "{bck} ephemeral scp test-container DOMAIN:/remote/a DOMAIN:/remote/b" + ) + .ignore_status() + .output()?; + let stderr2 = String::from_utf8_lossy(&output2.stderr); + + assert!( + !output2.status.success(), + "SCP with two remote paths should fail" + ); + assert!( + stderr2.contains("DOMAIN:"), + "Error should mention DOMAIN: prefix: {}", + stderr2 + ); + + println!("ephemeral SCP syntax tested"); + Ok(()) +} +integration_test!(test_ephemeral_scp_syntax); + +/// End-to-end SCP test: starts an ephemeral VM, copies files to and from it +fn test_ephemeral_scp_end_to_end() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + let image = get_test_image(); + let label = INTEGRATION_TEST_LABEL; + let container_name = format!("test-scp-{}", std::process::id()); + + println!("Starting ephemeral VM for SCP end-to-end test..."); + cmd!( + sh, + "{bck} ephemeral run --ssh-keygen --label {label} --detach --name {container_name} {image}" + ) + .run()?; + + // Ensure the container is cleaned up even if the test fails + defer! { + let sh = shell().unwrap(); + let _ = cmd!(sh, "podman rm -f {container_name}") + .ignore_status() + .quiet() + .run(); + } + + // Wait a little for SSH connectivity to be verified / ready + println!("Waiting for SSH access to become ready..."); + let _ = cmd!(sh, "{bck} ephemeral ssh {container_name} echo READY").read()?; + + // --- Upload a file to the VM --- + let upload_dir = TempDir::new()?; + let upload_file = upload_dir.path().join("upload-test.txt"); + fs::write(&upload_file, "hello from host to ephemeral VM")?; + let upload_path = upload_file.to_str().expect("non-UTF-8 temp path"); + + println!("Uploading file to ephemeral VM..."); + cmd!( + sh, + "{bck} ephemeral scp {container_name} {upload_path} DOMAIN:/tmp/upload-test.txt" + ) + .run()?; + println!("✓ File uploaded"); + + // Verify it arrived + let cat_stdout = cmd!( + sh, + "{bck} ephemeral ssh {container_name} -- cat /tmp/upload-test.txt" + ) + .read()?; + assert_eq!( + cat_stdout.trim(), + "hello from host to ephemeral VM", + "Uploaded file content mismatch" + ); + println!("✓ Uploaded file content verified"); + + // --- Download a file from the VM --- + let download_dir = TempDir::new()?; + let download_path = download_dir.path().join("os-release"); + let download_str = download_path.to_str().expect("non-UTF-8 temp path"); + + // Get the expected os-release content first, and normalize CRLF to LF + let expected_os_release = cmd!( + sh, + "{bck} ephemeral ssh {container_name} -- cat /etc/os-release" + ) + .read()? + .trim() + .replace("\r", ""); + + println!("Downloading /etc/os-release from ephemeral VM..."); + cmd!( + sh, + "{bck} ephemeral scp {container_name} DOMAIN:/etc/os-release {download_str}" + ) + .run()?; + + let downloaded = fs::read_to_string(&download_path)?; + assert_eq!( + downloaded.trim(), + expected_os_release, + "Downloaded os-release mismatch" + ); + println!( + "✓ Downloaded /etc/os-release verified: {}", + downloaded.trim() + ); + + // --- Recursive Copy to VM --- + let local_rec_dir = TempDir::new()?; + let file1 = local_rec_dir.path().join("file1.txt"); + let file2 = local_rec_dir.path().join("file2.txt"); + fs::write(&file1, "nested content 1")?; + fs::write(&file2, "nested content 2")?; + let local_rec_path = local_rec_dir.path().to_str().expect("non-UTF-8 path"); + + println!("Recursive upload of directory to ephemeral VM..."); + cmd!( + sh, + "{bck} ephemeral scp {container_name} -r {local_rec_path} DOMAIN:/tmp/rec_upload" + ) + .run()?; + + // Verify contents of the recursively uploaded directory + let verify_rec_1 = cmd!( + sh, + "{bck} ephemeral ssh {container_name} -- cat /tmp/rec_upload/file1.txt" + ) + .read()?; + assert_eq!(verify_rec_1.trim(), "nested content 1"); + + let verify_rec_2 = cmd!( + sh, + "{bck} ephemeral ssh {container_name} -- cat /tmp/rec_upload/file2.txt" + ) + .read()?; + assert_eq!(verify_rec_2.trim(), "nested content 2"); + println!("✓ Recursive upload verified successfully"); + + // --- Recursive Download from VM --- + let download_rec_dir = TempDir::new()?; + let download_rec_path = download_rec_dir.path().join("downloaded_rec"); + let download_rec_str = download_rec_path.to_str().expect("non-UTF-8 path"); + + println!("Recursive download of directory from ephemeral VM..."); + cmd!( + sh, + "{bck} ephemeral scp {container_name} -r DOMAIN:/tmp/rec_upload {download_rec_str}" + ) + .run()?; + + assert!( + download_rec_path.exists(), + "Downloaded recursive directory should exist" + ); + let downloaded_file1 = download_rec_path.join("file1.txt"); + let downloaded_file2 = download_rec_path.join("file2.txt"); + assert_eq!( + fs::read_to_string(downloaded_file1)?.trim(), + "nested content 1" + ); + assert_eq!( + fs::read_to_string(downloaded_file2)?.trim(), + "nested content 2" + ); + println!("✓ Recursive download verified successfully"); + + println!("✓ Ephemeral SCP end-to-end test passed"); + Ok(()) +} +integration_test!(test_ephemeral_scp_end_to_end); diff --git a/crates/kit/src/ephemeral.rs b/crates/kit/src/ephemeral.rs index b0721e4f2..57fe31004 100644 --- a/crates/kit/src/ephemeral.rs +++ b/crates/kit/src/ephemeral.rs @@ -39,6 +39,87 @@ pub struct SshOpts { pub args: Vec, } +/// Configuration options for SCP file transfer to/from an ephemeral VM +#[derive(clap::Parser, Debug)] +pub struct EphemeralScpOpts { + /// Name or ID of the container running the target VM + pub container_name: String, + + /// Source path (use DOMAIN: prefix for remote paths, e.g. `/local/file` or `DOMAIN:/remote/file`) + pub source: String, + + /// Destination path (use DOMAIN: prefix for remote paths, e.g. `/local/file` or `DOMAIN:/remote/file`) + pub destination: String, + + /// Copy directories recursively + #[clap(short, long)] + pub recursive: bool, + + /// Use strict host key checking + #[clap(long)] + pub strict_host_keys: bool, + + /// SSH connection timeout in seconds + #[clap(long, default_value = "5")] + pub timeout: u32, + + /// SSH log level + #[clap(long, default_value = "ERROR")] + pub log_level: String, + + /// Extra SSH options in key=value format + #[clap(long)] + pub extra_options: Vec, +} + +impl EphemeralScpOpts { + /// Parse extra options into key-value pairs + fn parse_extra_options(&self) -> Result> { + let mut parsed = Vec::new(); + for option in &self.extra_options { + if let Some((key, value)) = option.split_once('=') { + parsed.push((key.to_string(), value.to_string())); + } else { + return Err(eyre!( + "Invalid extra option format '{}'. Expected 'key=value'", + option + )); + } + } + Ok(parsed) + } + + /// Build a pre-configured `podman exec ... scp` command + fn build_podman_scp_command(&self, parsed_extra_options: &[(String, String)]) -> Command { + let mut scp_cmd = Command::new("podman"); + scp_cmd.args([ + "exec", + "--", + &self.container_name, + "scp", + "-i", + &crate::ssh::container_ssh_key_path(), + "-P", + &crate::ssh::CONTAINER_SSH_PORT.to_string(), + ]); + + if self.recursive { + scp_cmd.arg("-r"); + } + + let common_opts = crate::ssh::CommonSshOptions { + strict_host_keys: self.strict_host_keys, + connect_timeout: self.timeout, + server_alive_interval: crate::libvirt::ssh::SSH_SERVER_ALIVE_INTERVAL, + log_level: self.log_level.clone(), + extra_options: parsed_extra_options.to_vec(), + }; + common_opts.apply_to_command(&mut scp_cmd); + + scp_cmd + } +} + /// Container list entry for ephemeral VMs #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] @@ -140,6 +221,10 @@ pub enum EphemeralCommands { #[clap(name = "ssh")] Ssh(SshOpts), + /// Copy files to/from an ephemeral VM via SCP + #[clap(name = "scp")] + Scp(EphemeralScpOpts), + /// List ephemeral VM containers #[clap(name = "ps")] Ps { @@ -171,6 +256,28 @@ impl EphemeralCommands { ssh::connect_via_container(&opts.container_name, opts.args) } + EphemeralCommands::Scp(opts) => { + let source_is_remote = opts.source.starts_with("DOMAIN:"); + let dest_is_remote = opts.destination.starts_with("DOMAIN:"); + + if source_is_remote == dest_is_remote { + return Err(eyre!( + "Exactly one of source or destination must use the DOMAIN: prefix to reference the remote VM.\n\ + Examples:\n \ + bcvk ephemeral scp myvm DOMAIN:/etc/hostname ./hostname\n \ + bcvk ephemeral scp myvm ./file.txt DOMAIN:/tmp/file.txt" + )); + } + + let progress_bar = crate::boot_progress::create_boot_progress_bar(); + let (_, progress_bar) = run_ephemeral_ssh::wait_for_ssh_ready( + &opts.container_name, + None, + progress_bar, + )?; + progress_bar.finish_and_clear(); + run_ephemeral_scp(opts) + } EphemeralCommands::Ps { json } => { let containers = list_ephemeral_containers()?; @@ -335,3 +442,161 @@ fn remove_all_ephemeral_containers(force: bool) -> Result<()> { Ok(()) } + +/// RAII cleanup guard for temporary directory inside container +struct ContainerTempCleanup { + container_name: String, + temp_dir: String, +} + +impl Drop for ContainerTempCleanup { + fn drop(&mut self) { + tracing::debug!("Cleaning up ephemeral SCP temp dir: {}", self.temp_dir); + let _ = Command::new("podman") + .args([ + "exec", + "--", + &self.container_name, + "rm", + "-rf", + &self.temp_dir, + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } +} + +/// Execute the ephemeral SCP command +pub fn run_ephemeral_scp(opts: EphemeralScpOpts) -> Result<()> { + tracing::debug!( + "SCP file transfer for ephemeral container: {}", + opts.container_name + ); + + // Validate that exactly one of the source or destination starts with "DOMAIN:" + let source_is_remote = opts.source.starts_with("DOMAIN:"); + let dest_is_remote = opts.destination.starts_with("DOMAIN:"); + + if source_is_remote == dest_is_remote { + return Err(eyre!( + "Exactly one of source or destination must use the DOMAIN: prefix to reference the remote VM.\n\ + Examples:\n \ + bcvk ephemeral scp myvm DOMAIN:/etc/hostname ./hostname\n \ + bcvk ephemeral scp myvm ./file.txt DOMAIN:/tmp/file.txt" + )); + } + + let parsed_extra_options = opts.parse_extra_options()?; + + // Generate a unique temporary directory name inside the container + let temp_dir_name = format!("/tmp/bcvk-scp-{}", uuid::Uuid::new_v4()); + + // Make sure the parent directory exists inside the container + let mkdir_status = Command::new("podman") + .args([ + "exec", + "--", + &opts.container_name, + "mkdir", + "-p", + &temp_dir_name, + ]) + .status() + .map_err(|e| eyre!("Failed to execute podman exec mkdir: {}", e))?; + + if !mkdir_status.success() { + return Err(eyre!( + "Failed to create temporary directory inside container" + )); + } + + // Set up the RAII cleanup guard for the temporary directory + let _cleanup = ContainerTempCleanup { + container_name: opts.container_name.clone(), + temp_dir: temp_dir_name.clone(), + }; + + if dest_is_remote { + // Uploading + // Get the filename from the source path + let file_name = std::path::Path::new(&opts.source) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("transfer"); + + let temp_transfer_path = format!("{}/{}", temp_dir_name, file_name); + + // Run podman cp : to copy from the host into the container. + let cp_status = Command::new("podman") + .args([ + "cp", + &opts.source, + &format!("{}:{}", opts.container_name, temp_transfer_path), + ]) + .status() + .map_err(|e| eyre!("Failed to execute podman cp: {}", e))?; + + if !cp_status.success() { + return Err(eyre!("Failed to copy source file into container")); + } + + // Run podman exec scp -i -P ... + // to transfer from container temp directory to root@127.0.0.1: + let dest_path = opts.destination.strip_prefix("DOMAIN:").unwrap(); + let remote_dest = format!("root@127.0.0.1:{}", dest_path); + + let mut scp_cmd = opts.build_podman_scp_command(&parsed_extra_options); + scp_cmd.arg(&temp_transfer_path); + scp_cmd.arg(&remote_dest); + + let scp_status = scp_cmd + .status() + .map_err(|e| eyre!("Failed to execute scp inside container: {}", e))?; + + if !scp_status.success() { + return Err(eyre!("SCP upload inside container failed")); + } + } else { + // Downloading + // Get the filename from the remote source path + let remote_src_path = opts.source.strip_prefix("DOMAIN:").unwrap(); + let file_name = std::path::Path::new(remote_src_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("transfer"); + + let temp_transfer_path = format!("{}/{}", temp_dir_name, file_name); + let remote_source = format!("root@127.0.0.1:{}", remote_src_path); + + // Run podman exec scp -i -P ... + // to transfer from root@127.0.0.1: to temp_transfer_path + let mut scp_cmd = opts.build_podman_scp_command(&parsed_extra_options); + scp_cmd.arg(&remote_source); + scp_cmd.arg(&temp_transfer_path); + + let scp_status = scp_cmd + .status() + .map_err(|e| eyre!("Failed to execute scp inside container: {}", e))?; + + if !scp_status.success() { + return Err(eyre!("SCP download inside container failed")); + } + + // Run podman cp : to copy from the container to the host. + let cp_status = Command::new("podman") + .args([ + "cp", + &format!("{}:{}", opts.container_name, temp_transfer_path), + &opts.destination, + ]) + .status() + .map_err(|e| eyre!("Failed to execute podman cp: {}", e))?; + + if !cp_status.success() { + return Err(eyre!("Failed to copy source file from container to host")); + } + } + + Ok(()) +} diff --git a/crates/kit/src/libvirt/mod.rs b/crates/kit/src/libvirt/mod.rs index 49f8a30a4..6c2d66a7c 100644 --- a/crates/kit/src/libvirt/mod.rs +++ b/crates/kit/src/libvirt/mod.rs @@ -34,6 +34,7 @@ pub mod print_firmware; pub mod rm; pub mod rm_all; pub mod run; +pub mod scp; pub mod secureboot; pub mod ssh; pub mod start; @@ -188,6 +189,9 @@ pub enum LibvirtSubcommands { /// SSH to libvirt domain with embedded SSH key Ssh(ssh::LibvirtSshOpts), + /// Copy files to/from a libvirt domain via SCP + Scp(scp::LibvirtScpOpts), + /// List bootc domains with metadata List(list::LibvirtListOpts), diff --git a/crates/kit/src/libvirt/run.rs b/crates/kit/src/libvirt/run.rs index 2885827c2..d20c34830 100644 --- a/crates/kit/src/libvirt/run.rs +++ b/crates/kit/src/libvirt/run.rs @@ -827,7 +827,7 @@ fn find_available_ssh_port() -> u16 { // Try random ports in the range 2222-3000 to avoid conflicts in concurrent scenarios let mut rng = rand::rng(); - const PORT_RANGE_START: u16 = 2222; + const PORT_RANGE_START: u16 = crate::ssh::CONTAINER_SSH_PORT; const PORT_RANGE_END: u16 = 3000; // Try up to 100 random attempts diff --git a/crates/kit/src/libvirt/scp.rs b/crates/kit/src/libvirt/scp.rs new file mode 100644 index 000000000..8c16b4153 --- /dev/null +++ b/crates/kit/src/libvirt/scp.rs @@ -0,0 +1,236 @@ +//! SCP file transfer to/from libvirt domains with embedded SSH credentials +//! +//! This module provides functionality to copy files to/from libvirt domains +//! that were created with SSH key injection, automatically retrieving SSH +//! credentials from domain XML metadata. + +use clap::Parser; +use color_eyre::{eyre::eyre, Result}; +use std::process::Command; +use std::time::Instant; +use tracing::debug; + +// Reuse SSH constants and helpers from the ssh module +use super::ssh::{wait_for_ssh_ready, LibvirtSshOpts}; + +/// Configuration options for SCP file transfer to/from a libvirt domain +#[derive(Debug, Parser)] +pub struct LibvirtScpOpts { + /// Name of the libvirt domain to connect to + pub domain_name: String, + + /// Source path (use domain: prefix for remote paths, e.g. `/local/file` or `domain:/remote/file`) + pub source: String, + + /// Destination path (use domain: prefix for remote paths, e.g. `/local/file` or `domain:/remote/file`) + pub destination: String, + + /// SSH username to use for connection (defaults to 'root') + #[clap(long, default_value = "root")] + pub user: String, + + /// Copy directories recursively + #[clap(short, long)] + pub recursive: bool, + + /// Use strict host key checking + #[clap(long)] + pub strict_host_keys: bool, + + /// SSH connection timeout in seconds + #[clap(long, default_value = "5")] + pub timeout: u32, + + /// SSH log level + #[clap(long, default_value = "ERROR")] + pub log_level: String, + + /// Extra SSH options in key=value format + #[clap(long)] + pub extra_options: Vec, +} + +/// Resolve a user-facing path, replacing `domain:` with `user@host:`. +/// +/// Users write: +/// bcvk libvirt scp myvm domain:/etc/hostname ./hostname +/// +/// This function turns `domain:/etc/hostname` into `root@127.0.0.1:/etc/hostname` +/// (or whichever user was requested). +fn resolve_scp_path(raw: &str, user: &str) -> String { + if let Some(remote_path) = raw.strip_prefix("domain:") { + format!("{}@127.0.0.1:{}", user, remote_path) + } else { + raw.to_string() + } +} + +impl LibvirtScpOpts { + /// Parse extra options into key-value pairs + fn parse_extra_options(&self) -> Result> { + let mut parsed = Vec::new(); + for option in &self.extra_options { + if let Some((key, value)) = option.split_once('=') { + parsed.push((key.to_string(), value.to_string())); + } else { + return Err(eyre!( + "Invalid extra option format '{}'. Expected 'key=value'", + option + )); + } + } + Ok(parsed) + } + + /// Build an SCP command with the correct key, port, and options. + fn build_scp_command( + &self, + temp_key: &tempfile::NamedTempFile, + ssh_port: u16, + parsed_extra_options: &[(String, String)], + ) -> Command { + let mut cmd = Command::new("scp"); + + // Identity / port + cmd.arg("-i").arg(temp_key.path()); + cmd.arg("-P").arg(ssh_port.to_string()); + + // Recursive flag + if self.recursive { + cmd.arg("-r"); + } + + // Reuse the common SSH option plumbing via `-o` + let common_opts = crate::ssh::CommonSshOptions { + strict_host_keys: self.strict_host_keys, + connect_timeout: self.timeout, + server_alive_interval: super::ssh::SSH_SERVER_ALIVE_INTERVAL, + log_level: self.log_level.clone(), + extra_options: parsed_extra_options.to_vec(), + }; + common_opts.apply_to_command(&mut cmd); + + cmd + } +} + +/// Execute the libvirt SCP command +pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtScpOpts) -> Result<()> { + debug!("SCP file transfer for libvirt domain: {}", opts.domain_name); + + // Validate that exactly one side references the domain + let source_is_remote = opts.source.starts_with("domain:"); + let dest_is_remote = opts.destination.starts_with("domain:"); + + if source_is_remote == dest_is_remote { + return Err(eyre!( + "Exactly one of source or destination must use the domain: prefix to reference the remote VM.\n\ + Examples:\n \ + bcvk libvirt scp myvm domain:/etc/hostname ./hostname\n \ + bcvk libvirt scp myvm ./file.txt domain:/tmp/file.txt" + )); + } + + // Reuse the SSH infrastructure for domain checks and credential extraction + // by constructing a temporary LibvirtSshOpts (with no command). + let ssh_helper = LibvirtSshOpts { + domain_name: opts.domain_name.clone(), + user: opts.user.clone(), + command: vec![], + strict_host_keys: opts.strict_host_keys, + timeout: opts.timeout, + log_level: opts.log_level.clone(), + extra_options: opts.extra_options.clone(), + suppress_output: true, + }; + + // Check domain exists + if !ssh_helper.check_domain_exists(global_opts)? { + return Err(eyre!("Domain '{}' not found", opts.domain_name)); + } + + // Check domain is running + let state = ssh_helper.get_domain_state(global_opts)?; + if state != "running" { + return Err(eyre!( + "Domain '{}' is not running (current state: {}). Start it first with: virsh start {}", + opts.domain_name, + state, + opts.domain_name + )); + } + + // Extract SSH config (key, port) from domain metadata + let ssh_config = ssh_helper.extract_ssh_config(global_opts)?; + + // Create temp key file + let temp_key = ssh_helper.create_temp_ssh_key(&ssh_config)?; + + let parsed_extra_options = opts.parse_extra_options()?; + + // Wait for SSH to be ready (shared helper with ssh subcommand) + let common_opts = crate::ssh::CommonSshOptions { + strict_host_keys: opts.strict_host_keys, + connect_timeout: opts.timeout, + server_alive_interval: super::ssh::SSH_SERVER_ALIVE_INTERVAL, + log_level: opts.log_level.clone(), + extra_options: parsed_extra_options.clone(), + }; + wait_for_ssh_ready( + &ssh_config, + temp_key.path(), + &opts.user, + &common_opts, + &opts.domain_name, + )?; + let start_time = Instant::now(); + + // Build and exec the SCP command + let resolved_source = resolve_scp_path(&opts.source, &opts.user); + let resolved_dest = resolve_scp_path(&opts.destination, &opts.user); + + debug!( + "Running SCP: {} -> {} (port {})", + resolved_source, resolved_dest, ssh_config.ssh_port + ); + + let mut scp_cmd = opts.build_scp_command(&temp_key, ssh_config.ssh_port, &parsed_extra_options); + scp_cmd.arg(&resolved_source).arg(&resolved_dest); + + let status = scp_cmd + .status() + .map_err(|e| eyre!("Failed to execute scp command: {}", e))?; + + if !status.success() { + return Err(eyre!("SCP failed with exit code: {:?}", status.code())); + } + + debug!( + "SCP completed successfully in {:.1}s", + start_time.elapsed().as_secs_f64() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_scp_path_remote() { + assert_eq!( + resolve_scp_path("domain:/etc/hostname", "root"), + "root@127.0.0.1:/etc/hostname" + ); + assert_eq!( + resolve_scp_path("domain:/tmp/file.txt", "alice"), + "alice@127.0.0.1:/tmp/file.txt" + ); + } + + #[test] + fn test_resolve_scp_path_local() { + assert_eq!(resolve_scp_path("/local/path", "root"), "/local/path"); + assert_eq!(resolve_scp_path("./relative", "root"), "./relative"); + } +} diff --git a/crates/kit/src/libvirt/ssh.rs b/crates/kit/src/libvirt/ssh.rs index 3d4261544..7e9243ff1 100644 --- a/crates/kit/src/libvirt/ssh.rs +++ b/crates/kit/src/libvirt/ssh.rs @@ -20,9 +20,66 @@ use tempfile; use tracing::debug; // SSH retry configuration -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 +pub(crate) const SSH_RETRY_TIMEOUT_SECS: u64 = 60; // Total time to retry SSH connections +pub(crate) const SSH_POLL_DELAY_SECS: u64 = 1; // Delay between SSH attempts +pub(crate) const SSH_SERVER_ALIVE_INTERVAL: u32 = 60; // Server alive interval in seconds + +/// Wait for SSH to become ready, retrying for up to [`SSH_RETRY_TIMEOUT_SECS`] seconds. +/// +/// Runs `ssh -i -p user@127.0.0.1 -- true` in a loop, +/// sleeping [`SSH_POLL_DELAY_SECS`] between attempts and showing a progress bar. +pub(crate) fn wait_for_ssh_ready( + ssh_config: &DomainSshConfig, + temp_key_path: &std::path::Path, + user: &str, + common_opts: &crate::ssh::CommonSshOptions, + domain_name: &str, +) -> Result<()> { + let start_time = Instant::now(); + let timeout = Duration::from_secs(SSH_RETRY_TIMEOUT_SECS); + + let pb = crate::boot_progress::create_boot_progress_bar(); + pb.set_message("Waiting for SSH to be ready..."); + + loop { + let mut test_cmd = Command::new("ssh"); + test_cmd + .arg("-i") + .arg(temp_key_path) + .arg("-p") + .arg(ssh_config.ssh_port.to_string()); + common_opts.apply_to_command(&mut test_cmd); + test_cmd + .arg(format!("{}@127.0.0.1", user)) + .arg("--") + .arg("true"); + + let output = test_cmd.output().context("Failed to spawn SSH command")?; + + if output.status.success() { + debug!( + "SSH connectivity confirmed after {:.1}s", + start_time.elapsed().as_secs_f64() + ); + pb.finish_and_clear(); + return Ok(()); + } + + if start_time.elapsed() >= timeout { + pb.finish_and_clear(); + let stderr_str = String::from_utf8_lossy(&output.stderr); + eprint!("{}", stderr_str); + eprintln!( + "\nSSH connection failed after {:.1}s. To see VM console output, run: virsh console {}", + start_time.elapsed().as_secs_f64(), + domain_name + ); + return Err(eyre!("SSH connection failed after timeout")); + } + + std::thread::sleep(Duration::from_secs(SSH_POLL_DELAY_SECS)); + } +} /// Configuration options for SSH connection to libvirt domain #[derive(Debug, Parser)] @@ -60,15 +117,18 @@ pub struct LibvirtSshOpts { /// SSH configuration extracted from domain metadata #[derive(Debug)] -struct DomainSshConfig { - private_key_content: String, - ssh_port: u16, - is_generated: bool, +pub(crate) struct DomainSshConfig { + pub(crate) private_key_content: String, + pub(crate) ssh_port: u16, + pub(crate) is_generated: bool, } impl LibvirtSshOpts { /// Check if domain exists and is accessible - fn check_domain_exists(&self, global_opts: &crate::libvirt::LibvirtOptions) -> Result { + pub(crate) fn check_domain_exists( + &self, + global_opts: &crate::libvirt::LibvirtOptions, + ) -> Result { let output = global_opts .virsh_command() .args(&["dominfo", &self.domain_name]) @@ -78,7 +138,10 @@ impl LibvirtSshOpts { } /// Get domain state - fn get_domain_state(&self, global_opts: &crate::libvirt::LibvirtOptions) -> Result { + pub(crate) fn get_domain_state( + &self, + global_opts: &crate::libvirt::LibvirtOptions, + ) -> Result { let output = global_opts .virsh_command() .args(&["domstate", &self.domain_name]) @@ -93,7 +156,7 @@ impl LibvirtSshOpts { } /// Extract SSH configuration from domain XML metadata - fn extract_ssh_config( + pub(crate) fn extract_ssh_config( &self, global_opts: &crate::libvirt::LibvirtOptions, ) -> Result { @@ -205,7 +268,10 @@ impl LibvirtSshOpts { } /// Create temporary SSH private key file and return its path - fn create_temp_ssh_key(&self, ssh_config: &DomainSshConfig) -> Result { + pub(crate) fn create_temp_ssh_key( + &self, + ssh_config: &DomainSshConfig, + ) -> Result { debug!( "Creating temporary SSH key file with {} bytes", ssh_config.private_key_content.len() @@ -301,48 +367,24 @@ impl LibvirtSshOpts { } let start_time = Instant::now(); - let timeout = Duration::from_secs(SSH_RETRY_TIMEOUT_SECS); // First, do connectivity check with retries (for both interactive and command) debug!("Testing SSH connectivity before session"); - // Create progress bar for user feedback (only shown in terminals) - let pb = crate::boot_progress::create_boot_progress_bar(); - pb.set_message("Waiting for SSH to be ready..."); - - loop { - let mut test_cmd = - self.build_ssh_command(ssh_config, &temp_key, parsed_extra_options.clone()); - test_cmd.arg("--").arg("true"); // Simple test command - - let output = test_cmd.output().context("Failed to spawn SSH command")?; - - if output.status.success() { - debug!( - "SSH connectivity confirmed after {:.1}s", - start_time.elapsed().as_secs_f64() - ); - pb.finish_and_clear(); - break; - } - - // Check if we've exceeded timeout - if start_time.elapsed() >= timeout { - pb.finish_and_clear(); - if !self.suppress_output { - let stderr_str = String::from_utf8_lossy(&output.stderr); - eprint!("{}", stderr_str); - eprintln!( - "\nSSH connection failed after {:.1}s. To see VM console output, run: virsh console {}", - start_time.elapsed().as_secs_f64(), - self.domain_name - ); - } - return Err(eyre!("SSH connection failed after timeout")); - } - - std::thread::sleep(Duration::from_secs(SSH_POLL_DELAY_SECS)); - } + let common_opts = crate::ssh::CommonSshOptions { + strict_host_keys: self.strict_host_keys, + connect_timeout: self.timeout, + server_alive_interval: SSH_SERVER_ALIVE_INTERVAL, + log_level: self.log_level.clone(), + extra_options: parsed_extra_options.clone(), + }; + wait_for_ssh_ready( + ssh_config, + temp_key.path(), + &self.user, + &common_opts, + &self.domain_name, + )?; // SSH is ready - now do the actual operation (oneshot) if self.command.is_empty() { diff --git a/crates/kit/src/main.rs b/crates/kit/src/main.rs index de0a3107e..6899294d9 100644 --- a/crates/kit/src/main.rs +++ b/crates/kit/src/main.rs @@ -117,6 +117,9 @@ pub enum StubEphemeralCommands { /// Connect to running VMs via SSH #[clap(name = "ssh")] Ssh, + /// Copy files to/from an ephemeral VM via SCP + #[clap(name = "scp")] + Scp, /// List ephemeral VM containers #[clap(name = "ps")] Ps, @@ -317,6 +320,7 @@ fn main() -> Result<(), Report> { match command { libvirt::LibvirtSubcommands::Run(opts) => libvirt::run::run(&options, opts)?, libvirt::LibvirtSubcommands::Ssh(opts) => libvirt::ssh::run(&options, opts)?, + libvirt::LibvirtSubcommands::Scp(opts) => libvirt::scp::run(&options, opts)?, libvirt::LibvirtSubcommands::List(opts) => libvirt::list::run(&options, opts)?, libvirt::LibvirtSubcommands::ListVolumes(opts) => { libvirt::list_volumes::run(&options, opts)? diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 9e4856778..a6a25931d 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -1566,8 +1566,11 @@ Options= } if opts.common.ssh_keygen { - qemu_config.enable_ssh_access(None); // Use default port 2222 - debug!("Enabled SSH port forwarding: host port 2222 -> guest port 22"); + qemu_config.enable_ssh_access(Some(crate::ssh::CONTAINER_SSH_PORT)); + debug!( + "Enabled SSH port forwarding: host port {} -> guest port 22", + crate::ssh::CONTAINER_SSH_PORT + ); // We need to extract the public key from the SSH credential to inject it via SMBIOS // For now, the credential is already being passed via kernel cmdline diff --git a/crates/kit/src/ssh.rs b/crates/kit/src/ssh.rs index 2cdbbda7e..6c91ccf3d 100644 --- a/crates/kit/src/ssh.rs +++ b/crates/kit/src/ssh.rs @@ -9,6 +9,17 @@ use tracing::debug; use crate::CONTAINER_STATEDIR; +/// Standard SSH port used by VMs inside containers +pub const CONTAINER_SSH_PORT: u16 = 2222; + +/// Get the path to the SSH private key inside the container environment +pub fn container_ssh_key_path() -> String { + Utf8Path::new("/run/tmproot") + .join(CONTAINER_STATEDIR.trim_start_matches('/')) + .join("ssh") + .into_string() +} + /// Combine multiple command arguments into a properly escaped shell command string /// /// This is necessary because SSH protocol sends commands as strings, not argument arrays. @@ -129,10 +140,8 @@ fn build_podman_ssh_command( cmd.args(["exec", "--", container_name, "ssh"]); } - let keypath = Utf8Path::new("/run/tmproot") - .join(CONTAINER_STATEDIR.trim_start_matches('/')) - .join("ssh"); - cmd.args(["-i", keypath.as_str()]); + let keypath = container_ssh_key_path(); + cmd.args(["-i", &keypath]); options.common.apply_to_command(&mut cmd); cmd.args(["-o", "BatchMode=yes"]); @@ -142,7 +151,7 @@ fn build_podman_ssh_command( } cmd.arg("root@127.0.0.1"); - cmd.args(["-p", "2222"]); + cmd.args(["-p", &CONTAINER_SSH_PORT.to_string()]); let ssh_args = build_ssh_command(args)?; if !ssh_args.is_empty() { diff --git a/crates/kit/src/varlink_ipc.rs b/crates/kit/src/varlink_ipc.rs index b5828087f..f6fefaebe 100644 --- a/crates/kit/src/varlink_ipc.rs +++ b/crates/kit/src/varlink_ipc.rs @@ -317,7 +317,7 @@ impl BcvkService { key_path, user: "root".to_string(), host: "127.0.0.1".to_string(), - port: 2222, + port: crate::ssh::CONTAINER_SSH_PORT, }) } diff --git a/docs/src/man/bcvk-ephemeral-run-ssh.md b/docs/src/man/bcvk-ephemeral-run-ssh.md index 6bebe0280..bc266acee 100644 --- a/docs/src/man/bcvk-ephemeral-run-ssh.md +++ b/docs/src/man/bcvk-ephemeral-run-ssh.md @@ -82,6 +82,10 @@ For longer-running VMs where you need to reconnect multiple times, use Generate SSH keypair and inject via systemd credentials +**--virtiofsd**=*VIRTIOFSD_BINARY* + + Path to virtiofsd binary (overrides auto-detection) + **-t**, **--tty** Allocate a pseudo-TTY for container diff --git a/docs/src/man/bcvk-ephemeral-run.md b/docs/src/man/bcvk-ephemeral-run.md index 72ba5ae47..6c5e6d894 100644 --- a/docs/src/man/bcvk-ephemeral-run.md +++ b/docs/src/man/bcvk-ephemeral-run.md @@ -84,6 +84,10 @@ This design allows bcvk to provide VM-like isolation and boot behavior while lev Generate SSH keypair and inject via systemd credentials +**--virtiofsd**=*VIRTIOFSD_BINARY* + + Path to virtiofsd binary (overrides auto-detection) + **-t**, **--tty** Allocate a pseudo-TTY for container diff --git a/docs/src/man/bcvk-to-disk.md b/docs/src/man/bcvk-to-disk.md index e824acf26..4b09c36f3 100644 --- a/docs/src/man/bcvk-to-disk.md +++ b/docs/src/man/bcvk-to-disk.md @@ -115,6 +115,10 @@ The installation process: Generate SSH keypair and inject via systemd credentials +**--virtiofsd**=*VIRTIOFSD_BINARY* + + Path to virtiofsd binary (overrides auto-detection) + **--install-log**=*INSTALL_LOG* Configure logging for `bootc install` by setting the `RUST_LOG` environment variable