Skip to content

Commit e34cd8d

Browse files
committed
libvirt: Support extracting SSH connection data
I'm looking at using bcvk to create libvirt VMs that are targets for tmt, like bootc-dev/bootc@54f8562#diff-dad7b84674fccc140b0fb2f17af742872a2f5cda5ddd36af501f39b404baf0cbR70 was doing. Signed-off-by: Colin Walters <walters@verbum.org>
1 parent de51faf commit e34cd8d

5 files changed

Lines changed: 274 additions & 11 deletions

File tree

crates/integration-tests/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ fn main() {
251251
tests::libvirt_verb::test_libvirt_list_json_output();
252252
Ok(())
253253
}),
254+
Trial::test("libvirt_list_json_ssh_metadata", || {
255+
tests::libvirt_verb::test_libvirt_list_json_ssh_metadata();
256+
Ok(())
257+
}),
254258
Trial::test("libvirt_run_resource_options", || {
255259
tests::libvirt_verb::test_libvirt_run_resource_options();
256260
Ok(())

crates/integration-tests/src/tests/libvirt_verb.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,140 @@ pub fn test_libvirt_list_json_output() {
7979
println!("libvirt list JSON output tested");
8080
}
8181

82+
/// Test libvirt list JSON output includes SSH metadata
83+
pub fn test_libvirt_list_json_ssh_metadata() {
84+
let test_image = get_test_image();
85+
86+
// Generate unique domain name for this test
87+
let domain_name = format!(
88+
"test-json-ssh-{}",
89+
std::time::SystemTime::now()
90+
.duration_since(std::time::UNIX_EPOCH)
91+
.unwrap()
92+
.as_secs()
93+
);
94+
95+
println!(
96+
"Testing libvirt list JSON output with SSH metadata for domain: {}",
97+
domain_name
98+
);
99+
100+
// Cleanup any existing domain with this name
101+
cleanup_domain(&domain_name);
102+
103+
// Create domain with SSH key generation (default behavior)
104+
println!("Creating libvirt domain with SSH key...");
105+
let create_output = run_bcvk(&[
106+
"libvirt",
107+
"run",
108+
"--name",
109+
&domain_name,
110+
"--label",
111+
LIBVIRT_INTEGRATION_TEST_LABEL,
112+
"--filesystem",
113+
"ext4",
114+
&test_image,
115+
])
116+
.expect("Failed to run libvirt run");
117+
118+
println!("Create stdout: {}", create_output.stdout);
119+
println!("Create stderr: {}", create_output.stderr);
120+
121+
if !create_output.success() {
122+
cleanup_domain(&domain_name);
123+
panic!("Failed to create domain with SSH: {}", create_output.stderr);
124+
}
125+
126+
println!("Successfully created domain: {}", domain_name);
127+
128+
// List domains with JSON format
129+
println!("Listing domains with JSON format...");
130+
let bck = get_bck_command().unwrap();
131+
let list_output = Command::new(&bck)
132+
.args(["libvirt", "list", "--format", "json", "-a"])
133+
.output()
134+
.expect("Failed to run libvirt list --format json");
135+
136+
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
137+
println!("List JSON output: {}", list_stdout);
138+
139+
// Cleanup domain before assertions
140+
cleanup_domain(&domain_name);
141+
142+
// Check that the command succeeded
143+
if !list_output.status.success() {
144+
let stderr = String::from_utf8_lossy(&list_output.stderr);
145+
panic!("libvirt list --format json failed: {}", stderr);
146+
}
147+
148+
// Parse JSON output
149+
let domains: Vec<serde_json::Value> =
150+
serde_json::from_str(&list_stdout).expect("Failed to parse JSON output from libvirt list");
151+
152+
// Find our test domain in the output
153+
let test_domain = domains
154+
.iter()
155+
.find(|d| d["name"].as_str() == Some(&domain_name))
156+
.expect(&format!(
157+
"Test domain '{}' not found in JSON output",
158+
domain_name
159+
));
160+
161+
println!("Found test domain in JSON output: {:?}", test_domain);
162+
163+
// Verify SSH port is present and is a number
164+
let ssh_port = test_domain["ssh_port"]
165+
.as_u64()
166+
.expect("ssh_port should be present and be a number");
167+
assert!(
168+
ssh_port > 0 && ssh_port < 65536,
169+
"ssh_port should be a valid port number, got: {}",
170+
ssh_port
171+
);
172+
println!("✓ ssh_port is present and valid: {}", ssh_port);
173+
174+
// Verify has_ssh_key is true
175+
let has_ssh_key = test_domain["has_ssh_key"]
176+
.as_bool()
177+
.expect("has_ssh_key should be present and be a boolean");
178+
assert!(
179+
has_ssh_key,
180+
"has_ssh_key should be true for domain created with SSH key"
181+
);
182+
println!("✓ has_ssh_key is true");
183+
184+
// Verify ssh_private_key is present and looks like a valid SSH key
185+
let ssh_private_key = test_domain["ssh_private_key"]
186+
.as_str()
187+
.expect("ssh_private_key should be present and be a string");
188+
assert!(
189+
!ssh_private_key.is_empty(),
190+
"ssh_private_key should not be empty"
191+
);
192+
assert!(
193+
ssh_private_key.contains("-----BEGIN") && ssh_private_key.contains("PRIVATE KEY-----"),
194+
"ssh_private_key should be a valid SSH private key format, got: {}",
195+
&ssh_private_key[..std::cmp::min(100, ssh_private_key.len())]
196+
);
197+
assert!(
198+
ssh_private_key.contains("-----END") && ssh_private_key.contains("PRIVATE KEY-----"),
199+
"ssh_private_key should have proper end marker"
200+
);
201+
202+
// Verify the key has proper newlines (not escaped \n)
203+
assert!(
204+
ssh_private_key.lines().count() > 1,
205+
"ssh_private_key should have multiple lines, not escaped newlines"
206+
);
207+
208+
println!(
209+
"✓ ssh_private_key is present and valid (has {} lines)",
210+
ssh_private_key.lines().count()
211+
);
212+
213+
println!("✓ libvirt list JSON SSH metadata test passed");
214+
}
215+
82216
/// Test domain resource configuration options
83217
pub fn test_libvirt_run_resource_options() {
84218
let bck = get_bck_command().unwrap();

crates/kit/src/domain_list.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! using libvirt as the source of truth instead of the VmRegistry cache.
55
66
use crate::xml_utils;
7+
use base64::Engine;
78
use color_eyre::{eyre::Context, Result};
89
use serde::{Deserialize, Serialize};
910
use std::process::Command;
@@ -28,6 +29,12 @@ pub struct PodmanBootcDomain {
2829
pub disk_path: Option<String>,
2930
/// User-defined labels for organizing domains
3031
pub labels: Vec<String>,
32+
/// SSH port for connecting to the domain
33+
pub ssh_port: Option<u16>,
34+
/// Whether SSH credentials are available in metadata
35+
pub has_ssh_key: bool,
36+
/// SSH private key (available only when outputting JSON)
37+
pub ssh_private_key: Option<String>,
3138
}
3239

3340
impl PodmanBootcDomain {
@@ -204,13 +211,25 @@ impl DomainLister {
204211
// Extract disk path from first disk device
205212
let disk_path = extract_disk_path(&dom);
206213

214+
// Extract SSH port
215+
let ssh_port = dom
216+
.find_with_namespace("ssh-port")
217+
.and_then(|node| node.text_content().parse::<u16>().ok());
218+
219+
// Extract SSH private key (either base64 or legacy format)
220+
let ssh_private_key = extract_ssh_private_key(dom);
221+
let has_ssh_key = ssh_private_key.is_some();
222+
207223
Ok(Some(PodmanBootcDomainMetadata {
208224
source_image,
209225
created,
210226
memory_mb,
211227
vcpus,
212228
disk_path,
213229
labels,
230+
ssh_port,
231+
has_ssh_key,
232+
ssh_private_key,
214233
}))
215234
}
216235

@@ -243,6 +262,9 @@ impl DomainLister {
243262
.as_ref()
244263
.map(|m| m.labels.clone())
245264
.unwrap_or_default(),
265+
ssh_port: metadata.as_ref().and_then(|m| m.ssh_port),
266+
has_ssh_key: metadata.as_ref().map(|m| m.has_ssh_key).unwrap_or(false),
267+
ssh_private_key: metadata.as_ref().and_then(|m| m.ssh_private_key.clone()),
246268
})
247269
}
248270

@@ -305,6 +327,9 @@ struct PodmanBootcDomainMetadata {
305327
vcpus: Option<u32>,
306328
disk_path: Option<String>,
307329
labels: Vec<String>,
330+
ssh_port: Option<u16>,
331+
has_ssh_key: bool,
332+
ssh_private_key: Option<String>,
308333
}
309334

310335
/// 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
336361
None
337362
}
338363

364+
/// Extract SSH private key from domain XML, handling both base64 and legacy formats
365+
fn extract_ssh_private_key(dom: &xml_utils::XmlNode) -> Option<String> {
366+
if let Some(encoded_key_node) = dom.find_with_namespace("ssh-private-key-base64") {
367+
let encoded_key = encoded_key_node.text_content();
368+
// Strip whitespace/newlines from base64 before decoding
369+
let encoded_key_clean: String =
370+
encoded_key.chars().filter(|c| !c.is_whitespace()).collect();
371+
// Decode base64 encoded private key
372+
base64::engine::general_purpose::STANDARD
373+
.decode(encoded_key_clean.as_bytes())
374+
.ok()
375+
.and_then(|decoded_bytes| String::from_utf8(decoded_bytes).ok())
376+
} else {
377+
dom.find_with_namespace("ssh-private-key")
378+
.map(|node| node.text_content().to_string())
379+
}
380+
}
381+
339382
#[cfg(test)]
340383
mod tests {
341384
use super::*;
@@ -406,6 +449,9 @@ mod tests {
406449
vcpus: None,
407450
disk_path: None,
408451
labels: vec![],
452+
ssh_port: None,
453+
has_ssh_key: false,
454+
ssh_private_key: None,
409455
};
410456

411457
assert!(domain.is_running());
@@ -421,6 +467,9 @@ mod tests {
421467
vcpus: None,
422468
disk_path: None,
423469
labels: vec![],
470+
ssh_port: None,
471+
has_ssh_key: false,
472+
ssh_private_key: None,
424473
};
425474

426475
assert!(!stopped_domain.is_running());

crates/kit/src/libvirt/list.rs

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ use super::OutputFormat;
1212
/// Options for listing libvirt domains
1313
#[derive(Debug, Parser)]
1414
pub struct LibvirtListOpts {
15+
/// Domain name to query (returns only this domain)
16+
pub domain_name: Option<String>,
17+
1518
/// Output format
1619
#[clap(long, value_enum, default_value_t = OutputFormat::Table)]
1720
pub format: OutputFormat,
@@ -37,7 +40,19 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts)
3740
None => DomainLister::new(),
3841
};
3942

40-
let mut domains = if opts.all {
43+
let mut domains = if let Some(ref domain_name) = opts.domain_name {
44+
// Query specific domain by name
45+
match lister.get_domain_info(domain_name) {
46+
Ok(domain) => vec![domain],
47+
Err(e) => {
48+
return Err(color_eyre::eyre::eyre!(
49+
"Failed to get domain '{}': {}",
50+
domain_name,
51+
e
52+
));
53+
}
54+
}
55+
} else if opts.all {
4156
lister
4257
.list_bootc_domains()
4358
.with_context(|| "Failed to list bootc domains from libvirt")?
@@ -69,7 +84,7 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts)
6984

7085
let mut table = Table::new();
7186
table.load_preset(UTF8_FULL);
72-
table.set_header(vec!["NAME", "IMAGE", "STATUS", "MEMORY"]);
87+
table.set_header(vec!["NAME", "IMAGE", "STATUS", "MEMORY", "SSH"]);
7388

7489
for domain in &domains {
7590
let image = match &domain.image {
@@ -86,7 +101,18 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts)
86101
Some(mem) => format!("{}MB", mem),
87102
None => "unknown".to_string(),
88103
};
89-
table.add_row(vec![&domain.name, &image, &domain.status_string(), &memory]);
104+
let ssh = match domain.ssh_port {
105+
Some(port) if domain.has_ssh_key => format!(":{}", port),
106+
Some(port) => format!(":{}*", port),
107+
None => "-".to_string(),
108+
};
109+
table.add_row(vec![
110+
&domain.name,
111+
&image,
112+
&domain.status_string(),
113+
&memory,
114+
&ssh,
115+
]);
90116
}
91117

92118
println!("{}", table);
@@ -97,11 +123,20 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtListOpts)
97123
);
98124
}
99125
OutputFormat::Json => {
100-
println!(
101-
"{}",
102-
serde_json::to_string_pretty(&domains)
103-
.with_context(|| "Failed to serialize domains as JSON")?
104-
);
126+
// If querying a specific domain, return object directly instead of array
127+
if opts.domain_name.is_some() && !domains.is_empty() {
128+
println!(
129+
"{}",
130+
serde_json::to_string_pretty(&domains[0])
131+
.with_context(|| "Failed to serialize domain as JSON")?
132+
);
133+
} else {
134+
println!(
135+
"{}",
136+
serde_json::to_string_pretty(&domains)
137+
.with_context(|| "Failed to serialize domains as JSON")?
138+
);
139+
}
105140
}
106141
OutputFormat::Yaml => {
107142
return Err(color_eyre::eyre::eyre!(

0 commit comments

Comments
 (0)