Skip to content

Commit c6becee

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 c6becee

File tree

6 files changed

+423
-3
lines changed

6 files changed

+423
-3
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: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
let output = cmd!(
57+
sh,
58+
"{bck} libvirt run --name {vm_name} --label {label} --ignition {config_path} --ssh-wait --memory 2G --cpus 2 {FCOS_IMAGE}"
59+
)
60+
.ignore_status()
61+
.output()?;
62+
63+
// Cleanup: remove the VM
64+
defer! {
65+
let _ = cmd!(sh, "{bck} libvirt rm {vm_name} --force").run();
66+
}
67+
68+
// Check that the command succeeded
69+
if !output.status.success() {
70+
let stderr = String::from_utf8_lossy(&output.stderr);
71+
let stdout = String::from_utf8_lossy(&output.stdout);
72+
panic!(
73+
"Failed to create VM with Ignition config.\nStdout: {}\nStderr: {}",
74+
stdout, stderr
75+
);
76+
}
77+
78+
// Verify the VM was created
79+
let vm_list = cmd!(sh, "{bck} libvirt list").read()?;
80+
assert!(
81+
vm_list.contains(&vm_name),
82+
"VM should be listed after creation"
83+
);
84+
85+
println!("Ignition config injection test passed");
86+
Ok(())
87+
}
88+
integration_test!(test_libvirt_ignition_works);
89+
90+
/// Test that Ignition config validation rejects nonexistent files
91+
fn test_libvirt_ignition_invalid_path() -> TestResult {
92+
let sh = shell()?;
93+
let bck = get_bck_command()?;
94+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
95+
96+
// Pull FCOS image first
97+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
98+
99+
let temp = TempDir::new()?;
100+
let nonexistent_path = Utf8Path::from_path(temp.path())
101+
.expect("temp dir is not utf8")
102+
.join("nonexistent-config.ign");
103+
104+
let vm_name = format!("test-ignition-invalid-{}", random_suffix());
105+
106+
let output = cmd!(
107+
sh,
108+
"{bck} libvirt run --name {vm_name} --label {label} --ignition {nonexistent_path} {FCOS_IMAGE}"
109+
)
110+
.ignore_status()
111+
.output()?;
112+
113+
assert!(
114+
!output.status.success(),
115+
"Should fail with nonexistent Ignition config file"
116+
);
117+
118+
let stderr = String::from_utf8_lossy(&output.stderr);
119+
assert!(
120+
stderr.contains("not found"),
121+
"Error should mention missing file: {}",
122+
stderr
123+
);
124+
125+
println!("Ignition invalid path test passed");
126+
Ok(())
127+
}
128+
integration_test!(test_libvirt_ignition_invalid_path);
129+
130+
/// Test that Ignition is rejected for images that don't support it
131+
fn test_libvirt_ignition_unsupported_image() -> TestResult {
132+
let sh = shell()?;
133+
let bck = get_bck_command()?;
134+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
135+
136+
// Use standard bootc image that doesn't have Ignition support
137+
let image = "quay.io/centos-bootc/centos-bootc:stream10";
138+
139+
let temp_dir = TempDir::new()?;
140+
let config_path = Utf8Path::from_path(temp_dir.path())
141+
.expect("temp dir is not utf8")
142+
.join("config.ign");
143+
144+
let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#;
145+
fs::write(&config_path, ignition_config)?;
146+
147+
let vm_name = format!("test-ignition-unsupported-{}", random_suffix());
148+
149+
let output = cmd!(
150+
sh,
151+
"{bck} libvirt run --name {vm_name} --label {label} --ignition {config_path} {image}"
152+
)
153+
.ignore_status()
154+
.output()?;
155+
156+
assert!(
157+
!output.status.success(),
158+
"Should fail when using --ignition with non-Ignition image"
159+
);
160+
161+
let stderr = String::from_utf8_lossy(&output.stderr);
162+
assert!(
163+
stderr.contains("does not support Ignition"),
164+
"Error should mention missing Ignition support: {}",
165+
stderr
166+
);
167+
168+
println!("Ignition unsupported image test passed");
169+
Ok(())
170+
}
171+
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)