Skip to content
Merged
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
4 changes: 4 additions & 0 deletions crates/integration-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
134 changes: 134 additions & 0 deletions crates/integration-tests/src/tests/libvirt_verb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::Value> =
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();
Expand Down
49 changes: 49 additions & 0 deletions crates/kit/src/domain_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,12 @@ pub struct PodmanBootcDomain {
pub disk_path: Option<String>,
/// User-defined labels for organizing domains
pub labels: Vec<String>,
/// SSH port for connecting to the domain
pub ssh_port: Option<u16>,
/// 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<String>,
}

impl PodmanBootcDomain {
Expand Down Expand Up @@ -204,13 +211,25 @@ 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::<u16>().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,
memory_mb,
vcpus,
disk_path,
labels,
ssh_port,
has_ssh_key,
ssh_private_key,
}))
}

Expand Down Expand Up @@ -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()),
})
}

Expand Down Expand Up @@ -305,6 +327,9 @@ struct PodmanBootcDomainMetadata {
vcpus: Option<u32>,
disk_path: Option<String>,
labels: Vec<String>,
ssh_port: Option<u16>,
has_ssh_key: bool,
ssh_private_key: Option<String>,
}

/// Extract disk path from domain XML using DOM parser
Expand Down Expand Up @@ -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<String> {
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::*;
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
51 changes: 43 additions & 8 deletions crates/kit/src/libvirt/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Output format
#[clap(long, value_enum, default_value_t = OutputFormat::Table)]
pub format: OutputFormat,
Expand All @@ -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")?
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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!(
Expand Down
Loading