Skip to content

Commit 970f008

Browse files
committed
feat: Add KubeVirt common-instancetypes support
Add support for KubeVirt common-instancetypes to provide standardized VM sizing. Basically it's annoying to have to specify cpus and memory separately always since they're often *related*, and that's the whole idea of the generic instance type. We only expose the U1 (Universal) series instancetype definitions from kubevirt/common-instancetypes because we're not a true cloud. Both `bcvk ephemeral run` and `bcvk libvirt run` now accept an `--itype` flag that overrides `--vcpus` and `--memory` settings, making it easier to create consistently-sized VMs across different environments. For libvirt VMs, the instance type is stored in domain metadata as `bootc:instance-type` for reference. Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Colin Walters <[email protected]>
1 parent 6b3aa05 commit 970f008

File tree

16 files changed

+456
-18
lines changed

16 files changed

+456
-18
lines changed

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,97 @@ fn test_libvirt_ssh_integration() -> Result<()> {
363363
Ok(())
364364
}
365365

366+
#[distributed_slice(INTEGRATION_TESTS)]
367+
static TEST_LIBVIRT_RUN_WITH_INSTANCETYPE: IntegrationTest = IntegrationTest::new(
368+
"test_libvirt_run_with_instancetype",
369+
test_libvirt_run_with_instancetype,
370+
);
371+
372+
/// Test libvirt run with instancetype
373+
fn test_libvirt_run_with_instancetype() -> Result<()> {
374+
let test_image = get_test_image();
375+
376+
// Generate unique domain name for this test
377+
let domain_name = format!(
378+
"test-itype-{}",
379+
std::time::SystemTime::now()
380+
.duration_since(std::time::UNIX_EPOCH)
381+
.unwrap()
382+
.as_secs()
383+
);
384+
385+
println!(
386+
"Testing libvirt run with instancetype for domain: {}",
387+
domain_name
388+
);
389+
390+
// Cleanup any existing domain with this name
391+
cleanup_domain(&domain_name);
392+
393+
// Create domain with instancetype
394+
println!("Creating libvirt domain with instancetype u1.small...");
395+
let create_output = run_bcvk(&[
396+
"libvirt",
397+
"run",
398+
"--name",
399+
&domain_name,
400+
"--label",
401+
LIBVIRT_INTEGRATION_TEST_LABEL,
402+
"--itype",
403+
"u1.small",
404+
"--filesystem",
405+
"ext4",
406+
&test_image,
407+
])
408+
.expect("Failed to run libvirt run");
409+
410+
println!("Create stdout: {}", create_output.stdout);
411+
println!("Create stderr: {}", create_output.stderr);
412+
413+
if !create_output.success() {
414+
cleanup_domain(&domain_name);
415+
panic!(
416+
"Failed to create domain with instancetype: {}",
417+
create_output.stderr
418+
);
419+
}
420+
421+
println!("Successfully created domain: {}", domain_name);
422+
423+
// Inspect the domain to verify instancetype was set
424+
let inspect_output = run_bcvk(&["libvirt", "inspect", "--format", "xml", &domain_name])
425+
.expect("Failed to run libvirt inspect");
426+
427+
let inspect_stdout = inspect_output.stdout;
428+
println!("Inspect output: {}", inspect_stdout);
429+
430+
// Parse XML to verify memory and vcpus match u1.small (1 vcpu, 2048 MB)
431+
let dom = parse_xml_dom(&inspect_stdout).expect("Failed to parse domain XML");
432+
433+
// Check vCPUs (should be 1 for u1.small)
434+
let vcpu_node = dom.find("vcpu").expect("vcpu element not found");
435+
let vcpus: u32 = vcpu_node.text.parse().expect("Failed to parse vcpu count");
436+
assert_eq!(vcpus, 1, "u1.small should have 1 vCPU, got {}", vcpus);
437+
println!("✓ vCPUs correctly set to: {}", vcpus);
438+
439+
// Check memory (should be 2048 MB = 2097152 KB for u1.small)
440+
let memory_node = dom.find("memory").expect("memory element not found");
441+
let memory_kb: u64 = memory_node.text.parse().expect("Failed to parse memory");
442+
let memory_mb = memory_kb / 1024;
443+
assert_eq!(
444+
memory_mb, 2048,
445+
"u1.small should have 2048 MB, got {} MB",
446+
memory_mb
447+
);
448+
println!("✓ Memory correctly set to: {} MB", memory_mb);
449+
450+
// Cleanup domain
451+
cleanup_domain(&domain_name);
452+
453+
println!("✓ libvirt run with instancetype test passed");
454+
Ok(())
455+
}
456+
366457
/// Helper function to cleanup domain
367458
fn cleanup_domain(domain_name: &str) {
368459
println!("Cleaning up domain: {}", domain_name);

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,107 @@ fn test_run_ephemeral_container_ssh_access() -> Result<()> {
228228
assert!(ssh_output.stdout.contains("SSH_TEST_SUCCESS"));
229229
Ok(())
230230
}
231+
232+
#[distributed_slice(INTEGRATION_TESTS)]
233+
static TEST_RUN_EPHEMERAL_WITH_INSTANCETYPE: IntegrationTest = IntegrationTest::new(
234+
"run_ephemeral_with_instancetype",
235+
test_run_ephemeral_with_instancetype,
236+
);
237+
238+
fn test_run_ephemeral_with_instancetype() -> Result<()> {
239+
// Test u1.nano: 1 vCPU, 512 MiB memory
240+
// Calculate physical memory from /sys/firmware/memmap (System RAM regions)
241+
let script = "/bin/sh -c 'echo CPUs:$(grep -c ^processor /proc/cpuinfo); total=0; for dir in /sys/firmware/memmap/*; do type=$(cat \"$dir/type\" 2>/dev/null); if [ \"$type\" = \"System RAM\" ]; then start=$(cat \"$dir/start\"); end=$(cat \"$dir/end\"); start_dec=$((start)); end_dec=$((end)); size=$((end_dec - start_dec + 1)); total=$((total + size)); fi; done; total_kb=$((total / 1024)); echo PhysicalMemKB:$total_kb'";
242+
243+
let output = run_bcvk(&[
244+
"ephemeral",
245+
"run",
246+
"--rm",
247+
"--label",
248+
INTEGRATION_TEST_LABEL,
249+
"--itype",
250+
"u1.nano",
251+
"--execute",
252+
script,
253+
&get_test_image(),
254+
])?;
255+
256+
output.assert_success("ephemeral run with instance type u1.nano");
257+
258+
// Verify vCPUs (should be 1)
259+
assert!(
260+
output.stdout.contains("CPUs:1"),
261+
"Expected 1 vCPU for u1.nano, output: {}",
262+
output.stdout
263+
);
264+
265+
// Verify physical memory (should be exactly 512 MiB = 524288 kB)
266+
let mem_line = output
267+
.stdout
268+
.lines()
269+
.find(|line| line.contains("PhysicalMemKB:"))
270+
.expect("PhysicalMemKB line not found in output");
271+
272+
let mem_kb: u32 = mem_line
273+
.split(':')
274+
.nth(1)
275+
.expect("Could not parse PhysicalMemKB")
276+
.trim()
277+
.parse()
278+
.expect("Could not parse PhysicalMemKB as number");
279+
280+
// Physical memory should be close to 512 MiB = 524288 kB
281+
// QEMU reserves small memory regions (BIOS, VGA, ACPI, etc.) so actual may be slightly less
282+
// Allow 1% tolerance to account for hypervisor overhead
283+
let expected_kb = 512 * 1024;
284+
let tolerance_kb = expected_kb / 100; // 1% tolerance
285+
let diff = if mem_kb > expected_kb {
286+
mem_kb - expected_kb
287+
} else {
288+
expected_kb - mem_kb
289+
};
290+
291+
assert!(
292+
diff <= tolerance_kb,
293+
"Expected physical memory ~{} kB for u1.nano, got {} kB (diff: {} kB, max allowed: {} kB [1%])",
294+
expected_kb, mem_kb, diff, tolerance_kb
295+
);
296+
297+
Ok(())
298+
}
299+
300+
#[distributed_slice(INTEGRATION_TESTS)]
301+
static TEST_RUN_EPHEMERAL_INSTANCETYPE_INVALID: IntegrationTest = IntegrationTest::new(
302+
"run_ephemeral_instancetype_invalid",
303+
test_run_ephemeral_instancetype_invalid,
304+
);
305+
306+
fn test_run_ephemeral_instancetype_invalid() -> Result<()> {
307+
let output = run_bcvk(&[
308+
"ephemeral",
309+
"run",
310+
"--rm",
311+
"--label",
312+
INTEGRATION_TEST_LABEL,
313+
"--itype",
314+
"invalid.type",
315+
"--karg",
316+
"systemd.unit=poweroff.target",
317+
&get_test_image(),
318+
])?;
319+
320+
// Should fail with invalid instance type
321+
assert!(
322+
!output.success(),
323+
"Expected failure with invalid instance type, but succeeded"
324+
);
325+
326+
// Error message should mention the invalid type
327+
assert!(
328+
output.stderr.contains("invalid.type") || output.stderr.contains("Unknown instance type"),
329+
"Error message should mention invalid instance type: {}",
330+
output.stderr
331+
);
332+
333+
Ok(())
334+
}

crates/kit/src/instancetypes.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//! KubeVirt common-instancetypes support
2+
//!
3+
//! This module vendors the KubeVirt common-instancetypes definitions,
4+
//! specifically the U series (Universal/General Purpose) instance types.
5+
//! These provide standardized VM sizing with predefined vCPU and memory
6+
//! configurations.
7+
//!
8+
//! Instance types follow the format: u1.{size}
9+
//! Examples: u1.nano, u1.micro, u1.small, u1.medium, u1.large, etc.
10+
//!
11+
//! Source: https://github.com/kubevirt/common-instancetypes
12+
13+
/// Instance type variants with associated vCPU and memory specifications
14+
///
15+
/// Source: https://github.com/kubevirt/common-instancetypes/blob/main/instancetypes/u/1/sizes.yaml
16+
#[derive(
17+
Debug,
18+
Clone,
19+
Copy,
20+
PartialEq,
21+
Eq,
22+
serde::Serialize,
23+
serde::Deserialize,
24+
strum::Display,
25+
strum::EnumString,
26+
strum::EnumIter,
27+
)]
28+
#[non_exhaustive]
29+
pub enum InstanceType {
30+
/// u1.nano - 1 vCPU, 512 MiB memory
31+
#[strum(serialize = "u1.nano")]
32+
U1Nano,
33+
/// u1.micro - 1 vCPU, 1 GiB memory
34+
#[strum(serialize = "u1.micro")]
35+
U1Micro,
36+
/// u1.small - 1 vCPU, 2 GiB memory
37+
#[strum(serialize = "u1.small")]
38+
U1Small,
39+
/// u1.medium - 1 vCPU, 4 GiB memory
40+
#[strum(serialize = "u1.medium")]
41+
U1Medium,
42+
/// u1.2xmedium - 2 vCPU, 4 GiB memory
43+
#[strum(serialize = "u1.2xmedium")]
44+
U1TwoXMedium,
45+
/// u1.large - 2 vCPU, 8 GiB memory
46+
#[strum(serialize = "u1.large")]
47+
U1Large,
48+
/// u1.xlarge - 4 vCPU, 16 GiB memory
49+
#[strum(serialize = "u1.xlarge")]
50+
U1XLarge,
51+
/// u1.2xlarge - 8 vCPU, 32 GiB memory
52+
#[strum(serialize = "u1.2xlarge")]
53+
U1TwoXLarge,
54+
/// u1.4xlarge - 16 vCPU, 64 GiB memory
55+
#[strum(serialize = "u1.4xlarge")]
56+
U1FourXLarge,
57+
/// u1.8xlarge - 32 vCPU, 128 GiB memory
58+
#[strum(serialize = "u1.8xlarge")]
59+
U1EightXLarge,
60+
}
61+
62+
impl InstanceType {
63+
/// Get the number of vCPUs for this instance type
64+
pub const fn vcpus(self) -> u32 {
65+
match self {
66+
Self::U1Nano => 1,
67+
Self::U1Micro => 1,
68+
Self::U1Small => 1,
69+
Self::U1Medium => 1,
70+
Self::U1TwoXMedium => 2,
71+
Self::U1Large => 2,
72+
Self::U1XLarge => 4,
73+
Self::U1TwoXLarge => 8,
74+
Self::U1FourXLarge => 16,
75+
Self::U1EightXLarge => 32,
76+
}
77+
}
78+
79+
/// Get the memory in megabytes for this instance type
80+
pub const fn memory_mb(self) -> u32 {
81+
match self {
82+
Self::U1Nano => 512,
83+
Self::U1Micro => 1024,
84+
Self::U1Small => 2048,
85+
Self::U1Medium => 4096,
86+
Self::U1TwoXMedium => 4096,
87+
Self::U1Large => 8192,
88+
Self::U1XLarge => 16384,
89+
Self::U1TwoXLarge => 32768,
90+
Self::U1FourXLarge => 65536,
91+
Self::U1EightXLarge => 131072,
92+
}
93+
}
94+
}
95+
96+
#[cfg(test)]
97+
mod tests {
98+
use super::*;
99+
use std::str::FromStr;
100+
use strum::IntoEnumIterator;
101+
102+
#[test]
103+
fn test_properties() {
104+
for variant in InstanceType::iter() {
105+
let (expected_vcpus, expected_memory_mb) = match variant {
106+
InstanceType::U1Nano => (1, 512),
107+
InstanceType::U1Micro => (1, 1024),
108+
InstanceType::U1Small => (1, 2048),
109+
InstanceType::U1Medium => (1, 4096),
110+
InstanceType::U1TwoXMedium => (2, 4096),
111+
InstanceType::U1Large => (2, 8192),
112+
InstanceType::U1XLarge => (4, 16384),
113+
InstanceType::U1TwoXLarge => (8, 32768),
114+
InstanceType::U1FourXLarge => (16, 65536),
115+
InstanceType::U1EightXLarge => (32, 131072),
116+
};
117+
assert_eq!(
118+
variant.vcpus(),
119+
expected_vcpus,
120+
"Mismatch in vcpus for {:?}",
121+
variant
122+
);
123+
assert_eq!(
124+
variant.memory_mb(),
125+
expected_memory_mb,
126+
"Mismatch in memory_mb for {:?}",
127+
variant
128+
);
129+
}
130+
}
131+
132+
#[test]
133+
fn test_parse_invalid_instancetype() {
134+
let result = InstanceType::from_str("invalid");
135+
assert!(result.is_err());
136+
}
137+
138+
#[test]
139+
fn test_roundtrip() {
140+
for variant in InstanceType::iter() {
141+
let s = variant.to_string();
142+
let parsed = InstanceType::from_str(&s).unwrap();
143+
assert_eq!(parsed, variant);
144+
}
145+
}
146+
}

crates/kit/src/libvirt/base_disks_cli.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ fn run_list(connect_uri: Option<&str>, opts: ListOpts) -> Result<()> {
116116
"YAML format is not supported for base-disks list command"
117117
))
118118
}
119+
OutputFormat::Xml => {
120+
return Err(color_eyre::eyre::eyre!(
121+
"XML format is not supported for base-disks list command"
122+
))
123+
}
119124
}
120125

121126
Ok(())

crates/kit/src/libvirt/inspect.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtInspectOpt
5959
.with_context(|| "Failed to serialize VM as JSON")?
6060
);
6161
}
62+
OutputFormat::Xml => {
63+
// Output raw domain XML using virsh dumpxml
64+
let mut cmd = global_opts.virsh_command();
65+
cmd.args(["dumpxml", &opts.name]);
66+
let output = cmd
67+
.output()
68+
.with_context(|| format!("Failed to run virsh dumpxml for {}", opts.name))?;
69+
70+
if !output.status.success() {
71+
return Err(color_eyre::eyre::eyre!(
72+
"Failed to get domain XML: {}",
73+
String::from_utf8_lossy(&output.stderr)
74+
));
75+
}
76+
77+
print!("{}", String::from_utf8_lossy(&output.stdout));
78+
}
6279
OutputFormat::Table => {
6380
return Err(color_eyre::eyre::eyre!(
6481
"Table format is not supported for inspect command"

0 commit comments

Comments
 (0)