Skip to content

Commit abe3227

Browse files
committed
libvirt: Add Ignition config injection support
Add support for injecting Ignition configuration files into libvirt VMs via QEMU's fw_cfg mechanism (x86_64/aarch64) and virtio-blk (s390x/ppc64le). This enables first-boot provisioning for bootc-based systems that use Ignition, matching the ephemeral implementation. Signed-off-by: gursewak1997 <gursmangat@gmail.com>
1 parent f4dd05a commit abe3227

File tree

7 files changed

+427
-5
lines changed

7 files changed

+427
-5
lines changed

crates/integration-tests/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub(crate) use integration_tests::{
1313

1414
mod tests {
1515
pub mod libvirt_base_disks;
16+
pub mod libvirt_ignition;
1617
pub mod libvirt_port_forward;
1718
pub mod libvirt_upload_disk;
1819
pub mod libvirt_verb;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
//! Integration tests for Ignition config injection in libvirt VMs
2+
3+
use integration_tests::integration_test;
4+
use itest::TestResult;
5+
use scopeguard::defer;
6+
use tempfile::TempDir;
7+
use xshell::cmd;
8+
9+
use std::fs;
10+
11+
use camino::Utf8Path;
12+
13+
use crate::{get_bck_command, shell, LIBVIRT_INTEGRATION_TEST_LABEL};
14+
15+
/// Generate a random alphanumeric suffix for VM names to avoid collisions
16+
fn random_suffix() -> String {
17+
use rand::{distr::Alphanumeric, Rng};
18+
rand::rng()
19+
.sample_iter(&Alphanumeric)
20+
.take(8)
21+
.map(char::from)
22+
.collect()
23+
}
24+
25+
/// Fedora CoreOS image that supports Ignition
26+
const FCOS_IMAGE: &str = "quay.io/fedora/fedora-coreos:stable";
27+
28+
/// Test that Ignition config injection mechanism works for libvirt
29+
///
30+
/// This test verifies that the Ignition config injection mechanism is working
31+
/// by checking that the VM can be created with --ignition flag and that the
32+
/// config file is properly stored.
33+
fn test_libvirt_ignition_works() -> TestResult {
34+
let sh = shell()?;
35+
let bck = get_bck_command()?;
36+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
37+
38+
// Pull FCOS image first
39+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
40+
41+
// Create a temporary Ignition config
42+
let temp_dir = TempDir::new()?;
43+
let config_path = Utf8Path::from_path(temp_dir.path())
44+
.expect("temp dir is not utf8")
45+
.join("config.ign");
46+
47+
// Minimal valid Ignition config (v3.3.0 for FCOS)
48+
let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#;
49+
fs::write(&config_path, ignition_config)?;
50+
51+
// Generate a unique VM name to avoid conflicts
52+
let vm_name = format!("test-ignition-{}", random_suffix());
53+
54+
// Create VM with Ignition config
55+
// We use --ssh-wait to wait for the VM to boot and verify SSH connectivity
56+
// FCOS requires --filesystem to be specified
57+
let output = cmd!(
58+
sh,
59+
"{bck} libvirt run --name {vm_name} --label {label} --ignition {config_path} --filesystem xfs --ssh-wait --memory 2G --cpus 2 {FCOS_IMAGE}"
60+
)
61+
.ignore_status()
62+
.output()?;
63+
64+
// Cleanup: remove the VM
65+
defer! {
66+
let _ = cmd!(sh, "{bck} libvirt rm {vm_name} --force").run();
67+
}
68+
69+
// Check that the command succeeded
70+
if !output.status.success() {
71+
let stderr = String::from_utf8_lossy(&output.stderr);
72+
let stdout = String::from_utf8_lossy(&output.stdout);
73+
panic!(
74+
"Failed to create VM with Ignition config.\nStdout: {}\nStderr: {}",
75+
stdout, stderr
76+
);
77+
}
78+
79+
// Verify the VM was created
80+
let vm_list = cmd!(sh, "{bck} libvirt list").read()?;
81+
assert!(
82+
vm_list.contains(&vm_name),
83+
"VM should be listed after creation"
84+
);
85+
86+
println!("Ignition config injection test passed");
87+
Ok(())
88+
}
89+
integration_test!(test_libvirt_ignition_works);
90+
91+
/// Test that Ignition config validation rejects nonexistent files
92+
fn test_libvirt_ignition_invalid_path() -> TestResult {
93+
let sh = shell()?;
94+
let bck = get_bck_command()?;
95+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
96+
97+
// Pull FCOS image first
98+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
99+
100+
let temp = TempDir::new()?;
101+
let nonexistent_path = Utf8Path::from_path(temp.path())
102+
.expect("temp dir is not utf8")
103+
.join("nonexistent-config.ign");
104+
105+
let vm_name = format!("test-ignition-invalid-{}", random_suffix());
106+
107+
let output = cmd!(
108+
sh,
109+
"{bck} libvirt run --name {vm_name} --label {label} --ignition {nonexistent_path} {FCOS_IMAGE}"
110+
)
111+
.ignore_status()
112+
.output()?;
113+
114+
assert!(
115+
!output.status.success(),
116+
"Should fail with nonexistent Ignition config file"
117+
);
118+
119+
let stderr = String::from_utf8_lossy(&output.stderr);
120+
assert!(
121+
stderr.contains("not found"),
122+
"Error should mention missing file: {}",
123+
stderr
124+
);
125+
126+
println!("Ignition invalid path test passed");
127+
Ok(())
128+
}
129+
integration_test!(test_libvirt_ignition_invalid_path);
130+
131+
/// Test that Ignition is rejected for images that don't support it
132+
fn test_libvirt_ignition_unsupported_image() -> TestResult {
133+
let sh = shell()?;
134+
let bck = get_bck_command()?;
135+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
136+
137+
// Use standard bootc image that doesn't have Ignition support
138+
let image = "quay.io/centos-bootc/centos-bootc:stream10";
139+
140+
let temp_dir = TempDir::new()?;
141+
let config_path = Utf8Path::from_path(temp_dir.path())
142+
.expect("temp dir is not utf8")
143+
.join("config.ign");
144+
145+
let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#;
146+
fs::write(&config_path, ignition_config)?;
147+
148+
let vm_name = format!("test-ignition-unsupported-{}", random_suffix());
149+
150+
let output = cmd!(
151+
sh,
152+
"{bck} libvirt run --name {vm_name} --label {label} --ignition {config_path} {image}"
153+
)
154+
.ignore_status()
155+
.output()?;
156+
157+
assert!(
158+
!output.status.success(),
159+
"Should fail when using --ignition with non-Ignition image"
160+
);
161+
162+
let stderr = String::from_utf8_lossy(&output.stderr);
163+
assert!(
164+
stderr.contains("does not support Ignition"),
165+
"Error should mention missing Ignition support: {}",
166+
stderr
167+
);
168+
169+
println!("Ignition unsupported image test passed");
170+
Ok(())
171+
}
172+
integration_test!(test_libvirt_ignition_unsupported_image);

crates/kit/src/libvirt/domain.rs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pub struct DomainBuilder {
5555
nvram_template: Option<String>, // Custom NVRAM template with enrolled keys
5656
nvram_format: Option<String>, // Format of NVRAM template (raw, qcow2)
5757
firmware_log: Option<FirmwareLogOutput>, // OVMF debug log output via isa-debugcon
58+
fw_cfg_entries: Vec<(String, String)>, // fw_cfg entries (name, file_path)
59+
ignition_disk_path: Option<String>, // Path to Ignition config for virtio-blk injection
5860
}
5961

6062
impl Default for DomainBuilder {
@@ -86,6 +88,8 @@ impl DomainBuilder {
8688
nvram_template: None,
8789
nvram_format: None,
8890
firmware_log: None,
91+
fw_cfg_entries: Vec::new(),
92+
ignition_disk_path: None,
8993
}
9094
}
9195

@@ -204,6 +208,21 @@ impl DomainBuilder {
204208
self
205209
}
206210

211+
/// Add a fw_cfg entry for passing config files to the guest
212+
///
213+
/// This is used for Ignition config injection on x86_64/aarch64.
214+
/// The entry will be converted to a QEMU commandline argument in the domain XML.
215+
pub fn add_fw_cfg(mut self, name: String, file_path: String) -> Self {
216+
self.fw_cfg_entries.push((name, file_path));
217+
self
218+
}
219+
220+
/// Set Ignition config disk path for virtio-blk injection (s390x/ppc64le)
221+
pub fn with_ignition_disk(mut self, disk_path: String) -> Self {
222+
self.ignition_disk_path = Some(disk_path);
223+
self
224+
}
225+
207226
/// Build the domain XML
208227
pub fn build_xml(self) -> Result<String> {
209228
let name = self.name.ok_or_else(|| eyre!("Domain name is required"))?;
@@ -221,7 +240,7 @@ impl DomainBuilder {
221240
let mut writer = XmlWriter::new();
222241

223242
// Root domain element
224-
let domain_attrs = if self.qemu_args.is_empty() {
243+
let domain_attrs = if self.qemu_args.is_empty() && self.fw_cfg_entries.is_empty() {
225244
vec![("type", "kvm")]
226245
} else {
227246
vec![
@@ -379,6 +398,17 @@ impl DomainBuilder {
379398
writer.end_element("disk")?;
380399
}
381400

401+
// Ignition config disk (virtio-blk with serial="ignition" for s390x/ppc64le)
402+
if let Some(ref ignition_disk) = self.ignition_disk_path {
403+
writer.start_element("disk", &[("type", "file"), ("device", "disk")])?;
404+
writer.write_empty_element("driver", &[("name", "qemu"), ("type", "raw")])?;
405+
writer.write_empty_element("source", &[("file", ignition_disk)])?;
406+
writer.write_empty_element("target", &[("dev", "vdb"), ("bus", "virtio")])?;
407+
writer.write_text_element("serial", "ignition")?;
408+
writer.write_empty_element("readonly", &[])?;
409+
writer.end_element("disk")?;
410+
}
411+
382412
// Network
383413
let network_config = self.network.as_deref().unwrap_or("default");
384414
match network_config {
@@ -483,9 +513,22 @@ impl DomainBuilder {
483513

484514
writer.end_element("devices")?;
485515

486-
// QEMU commandline section (if we have QEMU args)
487-
if !self.qemu_args.is_empty() {
516+
// QEMU commandline section (if we have QEMU args or fw_cfg entries)
517+
if !self.qemu_args.is_empty() || !self.fw_cfg_entries.is_empty() {
488518
writer.start_element("qemu:commandline", &[])?;
519+
520+
// Add fw_cfg entries first
521+
// Format: -fw_cfg name=<name>,file=<path>
522+
// Verified working: config accessible at /sys/firmware/qemu_fw_cfg/by_name/<name>/raw
523+
for (name, file_path) in &self.fw_cfg_entries {
524+
writer.write_empty_element("qemu:arg", &[("value", "-fw_cfg")])?;
525+
writer.write_empty_element(
526+
"qemu:arg",
527+
&[("value", &format!("name={},file={}", name, file_path))],
528+
)?;
529+
}
530+
531+
// Then add other QEMU args
489532
for arg in &self.qemu_args {
490533
writer.write_empty_element("qemu:arg", &[("value", arg)])?;
491534
}

crates/kit/src/libvirt/rm.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
use clap::Parser;
77
use color_eyre::Result;
8+
use tracing::debug;
89

910
/// Check if a domain is persistent (vs transient)
1011
///
@@ -108,6 +109,26 @@ fn remove_vm_impl(
108109
}
109110
}
110111

112+
// Remove Ignition config file if it exists (stored in metadata)
113+
// Parse domain XML to get the ignition persistent path
114+
if let Ok(xml_output) = global_opts
115+
.virsh_command()
116+
.args(&["dumpxml", vm_name])
117+
.output()
118+
{
119+
if let Ok(xml_str) = String::from_utf8(xml_output.stdout) {
120+
if let Ok(dom) = crate::xml_utils::parse_xml_dom(&xml_str) {
121+
if let Some(ignition_path_node) = dom.find("bootc:ignition-persistent-path") {
122+
let ignition_path = ignition_path_node.text_content().trim();
123+
if !ignition_path.is_empty() && std::path::Path::new(ignition_path).exists() {
124+
debug!("Removing Ignition config file: {}", ignition_path);
125+
let _ = std::fs::remove_file(ignition_path); // Don't fail if this fails
126+
}
127+
}
128+
}
129+
}
130+
}
131+
111132
// Remove libvirt domain with nvram and storage
112133
let output = global_opts
113134
.virsh_command()

0 commit comments

Comments
 (0)