diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 55d355c..8d93793 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -251,6 +251,10 @@ fn main() { tests::libvirt_verb::test_libvirt_list_json_output(); Ok(()) }), + Trial::test("libvirt_list_json_ssh_metadata", || { + tests::libvirt_verb::test_libvirt_list_json_ssh_metadata(); + Ok(()) + }), Trial::test("libvirt_run_resource_options", || { tests::libvirt_verb::test_libvirt_run_resource_options(); Ok(()) diff --git a/crates/integration-tests/src/tests/libvirt_verb.rs b/crates/integration-tests/src/tests/libvirt_verb.rs index cf164ff..16ad440 100644 --- a/crates/integration-tests/src/tests/libvirt_verb.rs +++ b/crates/integration-tests/src/tests/libvirt_verb.rs @@ -79,6 +79,140 @@ pub fn test_libvirt_list_json_output() { println!("libvirt list JSON output tested"); } +/// Test libvirt list JSON output includes SSH metadata +pub fn test_libvirt_list_json_ssh_metadata() { + let test_image = get_test_image(); + + // Generate unique domain name for this test + let domain_name = format!( + "test-json-ssh-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + + println!( + "Testing libvirt list JSON output with SSH metadata for domain: {}", + domain_name + ); + + // Cleanup any existing domain with this name + cleanup_domain(&domain_name); + + // Create domain with SSH key generation (default behavior) + println!("Creating libvirt domain with SSH key..."); + let create_output = run_bcvk(&[ + "libvirt", + "run", + "--name", + &domain_name, + "--label", + LIBVIRT_INTEGRATION_TEST_LABEL, + "--filesystem", + "ext4", + &test_image, + ]) + .expect("Failed to run libvirt run"); + + 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 SSH: {}", create_output.stderr); + } + + println!("Successfully created domain: {}", domain_name); + + // List domains with JSON format + println!("Listing domains with JSON format..."); + let bck = get_bck_command().unwrap(); + let list_output = Command::new(&bck) + .args(["libvirt", "list", "--format", "json", "-a"]) + .output() + .expect("Failed to run libvirt list --format json"); + + let list_stdout = String::from_utf8_lossy(&list_output.stdout); + println!("List JSON output: {}", list_stdout); + + // Cleanup domain before assertions + cleanup_domain(&domain_name); + + // Check that the command succeeded + if !list_output.status.success() { + let stderr = String::from_utf8_lossy(&list_output.stderr); + panic!("libvirt list --format json failed: {}", stderr); + } + + // Parse JSON output + let domains: Vec = + serde_json::from_str(&list_stdout).expect("Failed to parse JSON output from libvirt list"); + + // Find our test domain in the output + let test_domain = domains + .iter() + .find(|d| d["name"].as_str() == Some(&domain_name)) + .expect(&format!( + "Test domain '{}' not found in JSON output", + domain_name + )); + + println!("Found test domain in JSON output: {:?}", test_domain); + + // Verify SSH port is present and is a number + let ssh_port = test_domain["ssh_port"] + .as_u64() + .expect("ssh_port should be present and be a number"); + assert!( + ssh_port > 0 && ssh_port < 65536, + "ssh_port should be a valid port number, got: {}", + ssh_port + ); + println!("✓ ssh_port is present and valid: {}", ssh_port); + + // Verify has_ssh_key is true + let has_ssh_key = test_domain["has_ssh_key"] + .as_bool() + .expect("has_ssh_key should be present and be a boolean"); + assert!( + has_ssh_key, + "has_ssh_key should be true for domain created with SSH key" + ); + println!("✓ has_ssh_key is true"); + + // Verify ssh_private_key is present and looks like a valid SSH key + let ssh_private_key = test_domain["ssh_private_key"] + .as_str() + .expect("ssh_private_key should be present and be a string"); + assert!( + !ssh_private_key.is_empty(), + "ssh_private_key should not be empty" + ); + assert!( + ssh_private_key.contains("-----BEGIN") && ssh_private_key.contains("PRIVATE KEY-----"), + "ssh_private_key should be a valid SSH private key format, got: {}", + &ssh_private_key[..std::cmp::min(100, ssh_private_key.len())] + ); + assert!( + ssh_private_key.contains("-----END") && ssh_private_key.contains("PRIVATE KEY-----"), + "ssh_private_key should have proper end marker" + ); + + // Verify the key has proper newlines (not escaped \n) + assert!( + ssh_private_key.lines().count() > 1, + "ssh_private_key should have multiple lines, not escaped newlines" + ); + + println!( + "✓ ssh_private_key is present and valid (has {} lines)", + ssh_private_key.lines().count() + ); + + println!("✓ libvirt list JSON SSH metadata test passed"); +} + /// Test domain resource configuration options pub fn test_libvirt_run_resource_options() { let bck = get_bck_command().unwrap(); diff --git a/crates/kit/src/domain_list.rs b/crates/kit/src/domain_list.rs index 68b562f..97fb1c1 100644 --- a/crates/kit/src/domain_list.rs +++ b/crates/kit/src/domain_list.rs @@ -4,6 +4,7 @@ //! using libvirt as the source of truth instead of the VmRegistry cache. use crate::xml_utils; +use base64::Engine; use color_eyre::{eyre::Context, Result}; use serde::{Deserialize, Serialize}; use std::process::Command; @@ -28,6 +29,12 @@ pub struct PodmanBootcDomain { pub disk_path: Option, /// User-defined labels for organizing domains pub labels: Vec, + /// SSH port for connecting to the domain + pub ssh_port: Option, + /// Whether SSH credentials are available in metadata + pub has_ssh_key: bool, + /// SSH private key (available only when outputting JSON) + pub ssh_private_key: Option, } impl PodmanBootcDomain { @@ -204,6 +211,15 @@ impl DomainLister { // Extract disk path from first disk device let disk_path = extract_disk_path(&dom); + // Extract SSH port + let ssh_port = dom + .find_with_namespace("ssh-port") + .and_then(|node| node.text_content().parse::().ok()); + + // Extract SSH private key (either base64 or legacy format) + let ssh_private_key = extract_ssh_private_key(dom); + let has_ssh_key = ssh_private_key.is_some(); + Ok(Some(PodmanBootcDomainMetadata { source_image, created, @@ -211,6 +227,9 @@ impl DomainLister { vcpus, disk_path, labels, + ssh_port, + has_ssh_key, + ssh_private_key, })) } @@ -243,6 +262,9 @@ impl DomainLister { .as_ref() .map(|m| m.labels.clone()) .unwrap_or_default(), + ssh_port: metadata.as_ref().and_then(|m| m.ssh_port), + has_ssh_key: metadata.as_ref().map(|m| m.has_ssh_key).unwrap_or(false), + ssh_private_key: metadata.as_ref().and_then(|m| m.ssh_private_key.clone()), }) } @@ -305,6 +327,9 @@ struct PodmanBootcDomainMetadata { vcpus: Option, disk_path: Option, labels: Vec, + ssh_port: Option, + has_ssh_key: bool, + ssh_private_key: Option, } /// Extract disk path from domain XML using DOM parser @@ -336,6 +361,24 @@ fn find_disk_with_file_type(node: &xml_utils::XmlNode) -> Option<&xml_utils::Xml None } +/// Extract SSH private key from domain XML, handling both base64 and legacy formats +fn extract_ssh_private_key(dom: &xml_utils::XmlNode) -> Option { + if let Some(encoded_key_node) = dom.find_with_namespace("ssh-private-key-base64") { + let encoded_key = encoded_key_node.text_content(); + // Strip whitespace/newlines from base64 before decoding + let encoded_key_clean: String = + encoded_key.chars().filter(|c| !c.is_whitespace()).collect(); + // Decode base64 encoded private key + base64::engine::general_purpose::STANDARD + .decode(encoded_key_clean.as_bytes()) + .ok() + .and_then(|decoded_bytes| String::from_utf8(decoded_bytes).ok()) + } else { + dom.find_with_namespace("ssh-private-key") + .map(|node| node.text_content().to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -406,6 +449,9 @@ mod tests { vcpus: None, disk_path: None, labels: vec![], + ssh_port: None, + has_ssh_key: false, + ssh_private_key: None, }; assert!(domain.is_running()); @@ -421,6 +467,9 @@ mod tests { vcpus: None, disk_path: None, labels: vec![], + ssh_port: None, + has_ssh_key: false, + ssh_private_key: None, }; assert!(!stopped_domain.is_running()); diff --git a/crates/kit/src/libvirt/list.rs b/crates/kit/src/libvirt/list.rs index fc2f0ac..30033c5 100644 --- a/crates/kit/src/libvirt/list.rs +++ b/crates/kit/src/libvirt/list.rs @@ -12,6 +12,9 @@ use super::OutputFormat; /// Options for listing libvirt domains #[derive(Debug, Parser)] pub struct LibvirtListOpts { + /// Domain name to query (returns only this domain) + pub domain_name: Option, + /// Output format #[clap(long, value_enum, default_value_t = OutputFormat::Table)] pub format: OutputFormat, @@ -37,7 +40,19 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts) None => DomainLister::new(), }; - let mut domains = if opts.all { + let mut domains = if let Some(ref domain_name) = opts.domain_name { + // Query specific domain by name + match lister.get_domain_info(domain_name) { + Ok(domain) => vec![domain], + Err(e) => { + return Err(color_eyre::eyre::eyre!( + "Failed to get domain '{}': {}", + domain_name, + e + )); + } + } + } else if opts.all { lister .list_bootc_domains() .with_context(|| "Failed to list bootc domains from libvirt")? @@ -69,7 +84,7 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts) let mut table = Table::new(); table.load_preset(UTF8_FULL); - table.set_header(vec!["NAME", "IMAGE", "STATUS", "MEMORY"]); + table.set_header(vec!["NAME", "IMAGE", "STATUS", "MEMORY", "SSH"]); for domain in &domains { let image = match &domain.image { @@ -86,7 +101,18 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts) Some(mem) => format!("{}MB", mem), None => "unknown".to_string(), }; - table.add_row(vec![&domain.name, &image, &domain.status_string(), &memory]); + let ssh = match domain.ssh_port { + Some(port) if domain.has_ssh_key => format!(":{}", port), + Some(port) => format!(":{}*", port), + None => "-".to_string(), + }; + table.add_row(vec![ + &domain.name, + &image, + &domain.status_string(), + &memory, + &ssh, + ]); } println!("{}", table); @@ -97,11 +123,20 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts) ); } OutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&domains) - .with_context(|| "Failed to serialize domains as JSON")? - ); + // If querying a specific domain, return object directly instead of array + if opts.domain_name.is_some() && !domains.is_empty() { + println!( + "{}", + serde_json::to_string_pretty(&domains[0]) + .with_context(|| "Failed to serialize domain as JSON")? + ); + } else { + println!( + "{}", + serde_json::to_string_pretty(&domains) + .with_context(|| "Failed to serialize domains as JSON")? + ); + } } OutputFormat::Yaml => { return Err(color_eyre::eyre::eyre!( diff --git a/docs/src/man/bcvk-libvirt-list.md b/docs/src/man/bcvk-libvirt-list.md index bb55ba7..4db5ad5 100644 --- a/docs/src/man/bcvk-libvirt-list.md +++ b/docs/src/man/bcvk-libvirt-list.md @@ -4,15 +4,27 @@ bcvk-libvirt-list - List available bootc volumes with metadata # SYNOPSIS -**bcvk libvirt list** [*OPTIONS*] +**bcvk libvirt list** [*DOMAIN_NAME*] [*OPTIONS*] # DESCRIPTION -List available bootc volumes with metadata +List available bootc domains with metadata. When a domain name is provided, returns information about that specific domain only. + +When using `--format=json` with a specific domain name, the output is a single JSON object (not an array), making it easy to extract SSH credentials and connection information using tools like `jq`. + +# OPTIONS + +**DOMAIN_NAME** + + Optional domain name to query. When specified, returns information about only this domain. # OPTIONS +**DOMAIN_NAME** + + Domain name to query (returns only this domain) + **--format**=*FORMAT* Output format @@ -48,10 +60,39 @@ Show VM status in your workflow: # Check what VMs are running bcvk libvirt list - + # Start a specific VM if needed bcvk libvirt start my-server +Query a specific domain: + + bcvk libvirt list my-domain + +## Working with SSH credentials via JSON output + +Connect via SSH using extracted credentials: + + # Query once, save to file, then extract credentials + DOMAIN_NAME="mydomain" + + # Query domain info once and save to file + bcvk libvirt list $DOMAIN_NAME --format=json > /tmp/domain-info.json + + # Extract SSH private key + jq -r '.ssh_private_key' /tmp/domain-info.json > /tmp/key.pem + chmod 600 /tmp/key.pem + + # Extract SSH port + SSH_PORT=$(jq -r '.ssh_port' /tmp/domain-info.json) + + # Connect via SSH + ssh -o IdentitiesOnly=yes -i /tmp/key.pem -p $SSH_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@127.0.0.1 + + # Cleanup + rm /tmp/domain-info.json /tmp/key.pem + +This is useful for automation scripts or when you need direct SSH access without using `bcvk libvirt ssh`. + # SEE ALSO **bcvk**(8)