From fcc370da9b47e83a4995570fdd2db3b3f0ec9963 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Mon, 27 Oct 2025 15:35:47 -0400 Subject: [PATCH] tests: Add integration tests for port forwarding Add comprehensive integration tests for the libvirt port forwarding feature: 1. test_libvirt_port_forward_parsing: Validates CLI argument parsing for various valid port forwarding formats (--port 8080:80, -p 80:80, etc.) 2. test_libvirt_port_forward_invalid: Tests error handling for invalid port formats (missing port, invalid numbers, out of range, etc.) 3. test_libvirt_port_forward_xml: Verifies that port forwarding is correctly configured in the libvirt domain XML with QEMU hostfwd args 4. test_libvirt_port_forward_connectivity: End-to-end test that creates a VM with port forwarding, starts an HTTP server inside the VM, and verifies connectivity from the host through the forwarded port The tests follow the existing integration test patterns using the distributed slice mechanism for test registration and include proper cleanup of test VMs. Assisted-by: Claude Code Signed-off-by: ckyrouac --- crates/integration-tests/src/main.rs | 1 + .../src/tests/libvirt_port_forward.rs | 531 ++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 crates/integration-tests/src/tests/libvirt_port_forward.rs diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index dcf1080c2..1d27d4d2f 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -17,6 +17,7 @@ use linkme::distributed_slice; mod tests { pub mod libvirt_base_disks; + pub mod libvirt_port_forward; pub mod libvirt_upload_disk; pub mod libvirt_verb; pub mod mount_feature; diff --git a/crates/integration-tests/src/tests/libvirt_port_forward.rs b/crates/integration-tests/src/tests/libvirt_port_forward.rs new file mode 100644 index 000000000..a61bf8793 --- /dev/null +++ b/crates/integration-tests/src/tests/libvirt_port_forward.rs @@ -0,0 +1,531 @@ +//! Integration tests for libvirt port forwarding functionality +//! +//! These tests verify: +//! - Port forwarding argument parsing and validation +//! - QEMU netdev configuration with hostfwd +//! - Actual network connectivity through forwarded ports + +use color_eyre::Result; +use linkme::distributed_slice; +use std::process::Command; + +use crate::{ + get_bck_command, get_test_image, run_bcvk, IntegrationTest, INTEGRATION_TESTS, + LIBVIRT_INTEGRATION_TEST_LABEL, +}; + +#[distributed_slice(INTEGRATION_TESTS)] +static TEST_LIBVIRT_PORT_FORWARD_PARSING: IntegrationTest = IntegrationTest::new( + "test_libvirt_port_forward_parsing", + test_libvirt_port_forward_parsing, +); + +/// Test port forwarding argument parsing +fn test_libvirt_port_forward_parsing() -> Result<()> { + let bck = get_bck_command()?; + + // Test valid port forwarding formats + let valid_port_tests = vec![ + vec!["--port", "8080:80"], + vec!["--port", "80:80"], + vec!["--port", "3000:3000", "--port", "8080:80"], + vec!["-p", "9090:90"], + ]; + + for ports in valid_port_tests { + let mut args = vec!["libvirt", "run"]; + args.extend(ports.iter()); + args.push("--help"); // Just test parsing, don't actually run + + let output = Command::new(&bck) + .args(&args) + .output() + .expect("Failed to run libvirt run with port forwarding"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + assert!( + !stderr.contains("invalid") && !stderr.contains("parse"), + "Port forwarding options should be parsed correctly: {:?}, stderr: {}", + ports, + stderr + ); + } else { + assert!( + stdout.contains("Usage") || stdout.contains("USAGE"), + "Should show help output when using --help" + ); + } + } + + println!("✓ Port forwarding argument parsing validated"); + Ok(()) +} + +#[distributed_slice(INTEGRATION_TESTS)] +static TEST_LIBVIRT_PORT_FORWARD_INVALID: IntegrationTest = IntegrationTest::new( + "test_libvirt_port_forward_invalid", + test_libvirt_port_forward_invalid, +); + +/// Test port forwarding error handling for invalid formats +fn test_libvirt_port_forward_invalid() -> Result<()> { + let bck = get_bck_command()?; + let test_image = get_test_image(); + + // Generate unique domain name for this test + let domain_name = format!( + "test-invalid-port-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + + // Test invalid port forwarding formats (should fail) + let invalid_port_tests = vec![ + (vec!["--port", "8080"], "missing guest port"), + (vec!["--port", "abc:80"], "invalid host port"), + (vec!["--port", "8080:xyz"], "invalid guest port"), + (vec!["--port", "70000:80"], "port out of range"), + ]; + + for (ports, error_desc) in invalid_port_tests { + let mut args = vec![ + "libvirt", + "run", + "--name", + &domain_name, + "--transient", + "--filesystem", + "ext4", + ]; + args.extend(ports.iter()); + args.push(&test_image); + + let output = Command::new(&bck).args(&args).output().expect(&format!( + "Failed to run error case for port forwarding: {}", + error_desc + )); + + // Cleanup in case domain was partially created + cleanup_domain(&domain_name); + + assert!( + !output.status.success(), + "Should fail for invalid port format: {}", + error_desc + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("port") + || stderr.contains("invalid") + || stderr.contains("parse") + || stderr.contains("Invalid"), + "Should have error message about port format for case: {}, stderr: {}", + error_desc, + stderr + ); + } + + println!("✓ Port forwarding error handling validated"); + Ok(()) +} + +#[distributed_slice(INTEGRATION_TESTS)] +static TEST_LIBVIRT_PORT_FORWARD_XML: IntegrationTest = IntegrationTest::new( + "test_libvirt_port_forward_xml", + test_libvirt_port_forward_xml, +); + +/// Test that port forwarding is correctly configured in domain XML +fn test_libvirt_port_forward_xml() -> Result<()> { + let test_image = get_test_image(); + + // Generate unique domain name for this test + let domain_name = format!( + "test-port-xml-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + + println!( + "Testing port forwarding XML configuration with domain: {}", + domain_name + ); + + // Cleanup any existing domain with this name + cleanup_domain(&domain_name); + + // Create domain with port forwarding + println!("Creating libvirt domain with port forwarding..."); + let create_output = run_bcvk(&[ + "libvirt", + "run", + "--name", + &domain_name, + "--label", + LIBVIRT_INTEGRATION_TEST_LABEL, + "--port", + "8080:80", + "--port", + "9090:8080", + "--filesystem", + "ext4", + &test_image, + ]) + .expect("Failed to run libvirt run with port forwarding"); + + println!("Create stdout: {}", create_output.stdout); + println!("Create stderr: {}", create_output.stderr); + + if !create_output.success() { + cleanup_domain(&domain_name); + panic!( + "Failed to create domain with port forwarding: {}", + create_output.stderr + ); + } + + println!("Successfully created domain: {}", domain_name); + + // Verify port forwarding in output + assert!( + create_output.stdout.contains("Port forwarding:") + || create_output.stdout.contains("localhost:8080") + || create_output.stdout.contains("localhost:9090"), + "Output should mention port forwarding configuration" + ); + + // Check domain XML for QEMU args with port forwarding + println!("Checking domain XML for port forwarding configuration..."); + let dumpxml_output = Command::new("virsh") + .args(&["dumpxml", &domain_name]) + .output() + .expect("Failed to dump domain XML"); + + if !dumpxml_output.status.success() { + cleanup_domain(&domain_name); + let stderr = String::from_utf8_lossy(&dumpxml_output.stderr); + panic!("Failed to dump domain XML: {}", stderr); + } + + let domain_xml = String::from_utf8_lossy(&dumpxml_output.stdout); + + // Verify QEMU commandline arguments contain hostfwd + assert!( + domain_xml.contains("qemu:commandline") || domain_xml.contains("qemu:arg"), + "Domain XML should contain QEMU commandline namespace" + ); + + assert!( + domain_xml.contains("hostfwd"), + "Domain XML should contain hostfwd configuration for port forwarding" + ); + + // Verify specific port forwards are present + assert!( + domain_xml.contains("8080") && domain_xml.contains("80"), + "Domain XML should contain port forwarding 8080:80" + ); + + assert!( + domain_xml.contains("9090") && domain_xml.contains("8080"), + "Domain XML should contain port forwarding 9090:8080" + ); + + println!("✓ Domain XML contains expected port forwarding configuration"); + + // Cleanup domain + cleanup_domain(&domain_name); + + println!("✓ Port forwarding XML configuration test passed"); + Ok(()) +} + +#[distributed_slice(INTEGRATION_TESTS)] +static TEST_LIBVIRT_PORT_FORWARD_CONNECTIVITY: IntegrationTest = IntegrationTest::new( + "test_libvirt_port_forward_connectivity", + test_libvirt_port_forward_connectivity, +); + +/// Test actual network connectivity through forwarded ports +fn test_libvirt_port_forward_connectivity() -> Result<()> { + let test_image = get_test_image(); + + // Find an available port on the host + let host_port = find_available_port()?; + + // Generate unique domain name for this test + let domain_name = format!( + "test-port-conn-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + + println!( + "Testing port forwarding connectivity with domain: {} on port {}", + domain_name, host_port + ); + + // Cleanup any existing domain with this name + cleanup_domain(&domain_name); + + // Create domain with port forwarding (forward host_port to guest port 8080) + println!( + "Creating libvirt domain with port forwarding {}:8080...", + host_port + ); + let create_output = run_bcvk(&[ + "libvirt", + "run", + "--name", + &domain_name, + "--label", + LIBVIRT_INTEGRATION_TEST_LABEL, + "--port", + &format!("{}:8080", host_port), + "--filesystem", + "ext4", + &test_image, + ]) + .expect("Failed to run libvirt run with port forwarding"); + + println!("Create stdout: {}", create_output.stdout); + println!("Create stderr: {}", create_output.stderr); + + if !create_output.success() { + cleanup_domain(&domain_name); + panic!( + "Failed to create domain with port forwarding: {}", + create_output.stderr + ); + } + + println!("Successfully created domain: {}", domain_name); + + // Wait for VM to boot and SSH to become available + println!("Waiting for VM to boot and SSH to become available..."); + if let Err(e) = wait_for_ssh_available(&domain_name, 180) { + cleanup_domain(&domain_name); + panic!("Failed to establish SSH connection: {}", e); + } + + // Start a simple HTTP server on port 8080 inside the VM using Python + println!("Starting HTTP server on port 8080 inside VM..."); + + // Create a test file to serve + println!("Creating test file in VM..."); + let create_file = run_bcvk(&[ + "libvirt", + "ssh", + "--timeout", + "10", + &domain_name, + "--", + "sh", + "-c", + "echo 'port-forward-test-success' > /tmp/test.txt", + ]) + .expect("Failed to create test file in VM"); + + if !create_file.success() { + cleanup_domain(&domain_name); + panic!("Failed to create test file in VM: {}", create_file.stderr); + } + println!("✓ Test file created successfully"); + + // Start HTTP server in background + // Use a combination of techniques to ensure the process fully detaches: + // 1. Redirect stdin from /dev/null + // 2. Redirect stdout and stderr to a file + // 3. Put the command in a subshell and background it + // 4. Use 'exec' to replace the shell with the server, making it cleaner + println!("Starting background HTTP server..."); + let start_server = run_bcvk(&[ + "libvirt", + "ssh", + "--timeout", + "10", + &domain_name, + "--", + "bash", + "-c", + "(cd /tmp && exec python3 -m http.server 8080 > /tmp/http.log 2>&1 < /dev/null &) && sleep 0.1", + ]) + .expect("Failed to start HTTP server in VM"); + + if !start_server.success() { + cleanup_domain(&domain_name); + panic!("Failed to start HTTP server in VM: {}", start_server.stderr); + } + println!("✓ HTTP server command executed"); + + // Wait a bit for the server to start + println!("Waiting for HTTP server to start..."); + std::thread::sleep(std::time::Duration::from_secs(5)); + + // Test connectivity from host to forwarded port using curl + println!("Testing connectivity to forwarded port from host..."); + let mut retry_count = 0; + let max_retries = 5; + let mut connection_success = false; + + while retry_count < max_retries { + let curl_output = Command::new("curl") + .args(&[ + "-s", + "-m", + "5", // 5 second timeout + &format!("http://localhost:{}/test.txt", host_port), + ]) + .output(); + + match curl_output { + Ok(output) if output.status.success() => { + let response = String::from_utf8_lossy(&output.stdout); + println!("Received response: {}", response); + + if response.contains("port-forward-test-success") { + println!( + "✓ Successfully connected to forwarded port and received expected content" + ); + connection_success = true; + break; + } + } + Ok(output) => { + println!( + "Attempt {}/{}: Connection failed, retrying... stderr: {}", + retry_count + 1, + max_retries, + String::from_utf8_lossy(&output.stderr) + ); + } + Err(e) => { + println!( + "Attempt {}/{}: curl error: {}", + retry_count + 1, + max_retries, + e + ); + } + } + + retry_count += 1; + if retry_count < max_retries { + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + + // Cleanup domain before assertions + cleanup_domain(&domain_name); + + assert!( + connection_success, + "Failed to connect to forwarded port after {} attempts", + max_retries + ); + + println!("✓ Port forwarding connectivity test passed"); + Ok(()) +} + +/// Helper function to cleanup domain +fn cleanup_domain(domain_name: &str) { + println!("Cleaning up domain: {}", domain_name); + + // Stop domain if running + let _ = Command::new("virsh") + .args(&["destroy", domain_name]) + .output(); + + // Use bcvk libvirt rm for proper cleanup + let bck = match get_bck_command() { + Ok(cmd) => cmd, + Err(_) => return, + }; + let cleanup_output = Command::new(&bck) + .args(&["libvirt", "rm", domain_name, "--force", "--stop"]) + .output(); + + if let Ok(output) = cleanup_output { + if output.status.success() { + println!("Successfully cleaned up domain: {}", domain_name); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("Cleanup warning (may be expected): {}", stderr); + } + } +} + +/// Wait for SSH to become available on a domain with a timeout +fn wait_for_ssh_available( + domain_name: &str, + timeout_secs: u64, +) -> Result<(), Box> { + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout_secs); + + println!( + "Waiting for SSH to become available on domain: {}", + domain_name + ); + + loop { + // Check if we've exceeded the timeout before attempting SSH + if start_time.elapsed() >= timeout_duration { + return Err(format!("Timeout waiting for SSH after {} seconds", timeout_secs).into()); + } + + // Try a simple SSH command to test connectivity with a short timeout (5 seconds) + // This prevents each SSH attempt from hanging for the default 30 seconds + let ssh_test = run_bcvk(&[ + "libvirt", + "ssh", + "--timeout", + "5", + domain_name, + "--", + "echo", + "ssh-ready", + ]); + + match ssh_test { + Ok(output) if output.success() => { + println!("✓ SSH is now available"); + return Ok(()); + } + Ok(_) => { + // SSH command failed, but that's expected while VM is booting + } + Err(e) => { + println!("SSH test error (expected while booting): {}", e); + } + } + + // Wait 2 seconds before next attempt (since we already waited 5 seconds for SSH timeout) + std::thread::sleep(std::time::Duration::from_secs(2)); + } +} + +/// Find an available port on the host +fn find_available_port() -> Result { + use std::net::TcpListener; + + // Try to bind to port 0, which will allocate an available port + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + + // Drop the listener to free the port + drop(listener); + + Ok(port) +}