Skip to content

Commit 86e645a

Browse files
committed
ephemeral: Add Ignition config injection support
Add support for injecting Ignition configuration files into ephemeral 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. Implementation: - Architecture-specific config delivery per FCOS docs: * x86_64/aarch64: fw_cfg at opt/com.coreos/config * s390x/ppc64le: virtio-blk with serial 'ignition', readonly - Runtime architecture detection for correct method - Image validation via labels (coreos.ignition or com.coreos.osname) - Added readonly support for virtio-blk devices - Added ignition.platform.id=qemu kernel argument when config specified - Path validation with existence, type, and readability checks - Brief error messages with man page reference Testing: - 5 comprehensive integration tests: * fw_cfg accessibility verification * Invalid path rejection * Directory rejection * Unsupported image detection * Kernel argument presence validation - All tests pass with FCOS image verification Documentation: - Comprehensive man page section with working Ignition v3.3.0 example - SSH key injection and file creation demo - Architecture-specific delivery notes - Links to upstream Ignition and bootc documentation - Important notes about ephemeral boot behavior The config is successfully passed to the guest (verified via /sys/firmware/qemu_fw_cfg/ and /proc/cmdline). Ignition will process it on first boot. For ephemeral testing with pre-built FCOS images, Ignition skips as expected (subsequent boot behavior). Useful for custom bootc images with Ignition (see bootc initramfs docs) and future to-disk workflows. Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: gursewak1997 <gursmangat@gmail.com>
1 parent 5c42e91 commit 86e645a

File tree

9 files changed

+520
-2
lines changed

9 files changed

+520
-2
lines changed

crates/bcvk-qemu/src/qemu.rs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ pub struct VirtioBlkDevice {
7878
pub serial: String,
7979
/// Disk image format.
8080
pub format: DiskFormat,
81+
/// Mount as read-only.
82+
pub readonly: bool,
8183
}
8284

8385
/// VM display and console configuration.
@@ -224,6 +226,9 @@ pub struct QemuConfig {
224226
pub systemd_notify: Option<File>,
225227

226228
vhost_fd: Option<File>,
229+
230+
/// fw_cfg entries for passing config files to the guest
231+
fw_cfg_entries: Vec<(String, Utf8PathBuf)>,
227232
}
228233

229234
impl QemuConfig {
@@ -365,11 +370,23 @@ impl QemuConfig {
365370
disk_file: String,
366371
serial: String,
367372
format: DiskFormat,
373+
) -> &mut Self {
374+
self.add_virtio_blk_device_ro(disk_file, serial, format, false)
375+
}
376+
377+
/// Add a virtio-blk device with specified format and readonly flag.
378+
pub fn add_virtio_blk_device_ro(
379+
&mut self,
380+
disk_file: String,
381+
serial: String,
382+
format: DiskFormat,
383+
readonly: bool,
368384
) -> &mut Self {
369385
self.virtio_blk_devices.push(VirtioBlkDevice {
370386
disk_file,
371387
serial,
372388
format,
389+
readonly,
373390
});
374391
self
375392
}
@@ -440,6 +457,13 @@ impl QemuConfig {
440457
};
441458
self
442459
}
460+
461+
/// Add a fw_cfg entry to pass a file to the guest.
462+
/// The file will be accessible in the guest via the fw_cfg interface.
463+
pub fn add_fw_cfg(&mut self, name: String, file_path: Utf8PathBuf) -> &mut Self {
464+
self.fw_cfg_entries.push((name, file_path));
465+
self
466+
}
443467
}
444468

445469
/// Allocate a unique VSOCK CID.
@@ -560,13 +584,19 @@ fn spawn(
560584
// Add virtio-blk block devices
561585
for (idx, blk_device) in config.virtio_blk_devices.iter().enumerate() {
562586
let drive_id = format!("drive{}", idx);
587+
let readonly_flag = if blk_device.readonly {
588+
",readonly=on"
589+
} else {
590+
""
591+
};
563592
cmd.args([
564593
"-drive",
565594
&format!(
566-
"file={},format={},if=none,id={}",
595+
"file={},format={},if=none,id={}{}",
567596
blk_device.disk_file,
568597
blk_device.format.as_str(),
569-
drive_id
598+
drive_id,
599+
readonly_flag
570600
),
571601
"-device",
572602
&format!(
@@ -723,6 +753,11 @@ fn spawn(
723753
cmd.args(["-smbios", &format!("type=11,value={}", credential)]);
724754
}
725755

756+
// Add fw_cfg entries
757+
for (name, file_path) in &config.fw_cfg_entries {
758+
cmd.args(["-fw_cfg", &format!("name={},file={}", name, file_path)]);
759+
}
760+
726761
// Configure stdio based on display mode
727762
match &config.display_mode {
728763
DisplayMode::Console => {
@@ -993,4 +1028,24 @@ mod tests {
9931028
assert_eq!(DiskFormat::Raw.as_str(), "raw");
9941029
assert_eq!(DiskFormat::Qcow2.as_str(), "qcow2");
9951030
}
1031+
1032+
#[test]
1033+
fn test_fw_cfg_entry() {
1034+
let mut config = QemuConfig::new_direct_boot(
1035+
1024,
1036+
1,
1037+
"/test/kernel".to_string(),
1038+
"/test/initramfs".to_string(),
1039+
"/test/socket".into(),
1040+
);
1041+
config.add_fw_cfg(
1042+
"opt/com.coreos/config".to_string(),
1043+
"/test/ignition.json".into(),
1044+
);
1045+
1046+
// Test that the fw_cfg entry is created correctly
1047+
assert_eq!(config.fw_cfg_entries.len(), 1);
1048+
assert_eq!(config.fw_cfg_entries[0].0, "opt/com.coreos/config");
1049+
assert_eq!(config.fw_cfg_entries[0].1.as_str(), "/test/ignition.json");
1050+
}
9961051
}

crates/integration-tests/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod tests {
2121
pub mod libvirt_verb;
2222
pub mod mount_feature;
2323
pub mod run_ephemeral;
24+
pub mod run_ephemeral_ignition;
2425
pub mod run_ephemeral_ssh;
2526
pub mod to_disk;
2627
pub mod varlink;
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
//! Integration tests for Ignition config injection
2+
3+
use color_eyre::Result;
4+
use integration_tests::integration_test;
5+
use xshell::cmd;
6+
7+
use std::fs;
8+
use tempfile::TempDir;
9+
10+
use camino::Utf8Path;
11+
12+
use crate::{get_bck_command, shell, INTEGRATION_TEST_LABEL};
13+
14+
/// Fedora CoreOS image that supports Ignition
15+
const FCOS_IMAGE: &str = "quay.io/fedora/fedora-coreos:stable";
16+
17+
/// Test that Ignition config is injected via fw_cfg and accessible in the guest
18+
///
19+
/// This test verifies:
20+
/// 1. Ignition config file is passed to QEMU via fw_cfg
21+
/// 2. The config is readable at /sys/firmware/qemu_fw_cfg/by_name/opt/com.coreos/config/raw
22+
fn test_run_ephemeral_ignition_fw_cfg_accessible() -> Result<()> {
23+
let sh = shell()?;
24+
let bck = get_bck_command()?;
25+
let label = INTEGRATION_TEST_LABEL;
26+
27+
// Pull FCOS image first
28+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
29+
30+
// Create a temporary Ignition config
31+
let temp_dir = TempDir::new()?;
32+
let config_path = Utf8Path::from_path(temp_dir.path())
33+
.expect("temp dir is not utf8")
34+
.join("config.ign");
35+
36+
// Minimal valid Ignition config (v3.3.0 for FCOS)
37+
let ignition_config = r#"{
38+
"ignition": {
39+
"version": "3.3.0"
40+
},
41+
"storage": {
42+
"files": [
43+
{
44+
"path": "/etc/ignition-test-marker",
45+
"contents": {
46+
"source": "data:,ignition-applied"
47+
},
48+
"mode": 420
49+
}
50+
]
51+
}
52+
}"#;
53+
fs::write(&config_path, ignition_config)?;
54+
55+
// Run ephemeral VM and check if fw_cfg is accessible
56+
// We just verify the config is present in fw_cfg, not that it gets applied
57+
// (FCOS won't apply it in ephemeral mode since it's treated as subsequent boot)
58+
let script = "/bin/sh -c 'cat /sys/firmware/qemu_fw_cfg/by_name/opt/com.coreos/config/raw 2>/dev/null && echo FW_CFG_FOUND || echo FW_CFG_NOT_FOUND'";
59+
60+
let stdout = cmd!(
61+
sh,
62+
"{bck} ephemeral run --rm --label {label} --ignition {config_path} --execute {script} {FCOS_IMAGE}"
63+
)
64+
.read()?;
65+
66+
assert!(
67+
stdout.contains("FW_CFG_FOUND"),
68+
"fw_cfg config should be readable"
69+
);
70+
assert!(
71+
stdout.contains("ignition"),
72+
"Ignition config JSON should be accessible via fw_cfg, got: {}",
73+
stdout
74+
);
75+
assert!(
76+
!stdout.contains("FW_CFG_NOT_FOUND"),
77+
"fw_cfg path should exist, got: {}",
78+
stdout
79+
);
80+
81+
Ok(())
82+
}
83+
integration_test!(test_run_ephemeral_ignition_fw_cfg_accessible);
84+
85+
/// Test that Ignition config validation rejects non-existent files
86+
fn test_run_ephemeral_ignition_invalid_path() -> Result<()> {
87+
let sh = shell()?;
88+
let bck = get_bck_command()?;
89+
let label = INTEGRATION_TEST_LABEL;
90+
91+
// Pull FCOS image first
92+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
93+
94+
let nonexistent_path = "/tmp/nonexistent-ignition-config-12345.ign";
95+
96+
let output = cmd!(
97+
sh,
98+
"{bck} ephemeral run --rm --label {label} --ignition {nonexistent_path} --karg systemd.unit=poweroff.target {FCOS_IMAGE}"
99+
)
100+
.ignore_status()
101+
.output()?;
102+
103+
assert!(
104+
!output.status.success(),
105+
"Should fail with non-existent Ignition config file"
106+
);
107+
108+
let stderr = String::from_utf8_lossy(&output.stderr);
109+
assert!(
110+
stderr.contains("not found") || stderr.contains("does not exist"),
111+
"Error should mention missing file: {}",
112+
stderr
113+
);
114+
115+
Ok(())
116+
}
117+
integration_test!(test_run_ephemeral_ignition_invalid_path);
118+
119+
/// Test that Ignition config validation rejects directories
120+
fn test_run_ephemeral_ignition_directory_rejected() -> Result<()> {
121+
let sh = shell()?;
122+
let bck = get_bck_command()?;
123+
let label = INTEGRATION_TEST_LABEL;
124+
125+
// Pull FCOS image first
126+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
127+
128+
let temp_dir = TempDir::new()?;
129+
let dir_path = Utf8Path::from_path(temp_dir.path()).expect("temp dir is not utf8");
130+
131+
let output = cmd!(
132+
sh,
133+
"{bck} ephemeral run --rm --label {label} --ignition {dir_path} --karg systemd.unit=poweroff.target {FCOS_IMAGE}"
134+
)
135+
.ignore_status()
136+
.output()?;
137+
138+
assert!(
139+
!output.status.success(),
140+
"Should fail when Ignition config path is a directory"
141+
);
142+
143+
let stderr = String::from_utf8_lossy(&output.stderr);
144+
assert!(
145+
stderr.contains("not a regular file") || stderr.contains("is a directory"),
146+
"Error should mention that path is not a file: {}",
147+
stderr
148+
);
149+
150+
Ok(())
151+
}
152+
integration_test!(test_run_ephemeral_ignition_directory_rejected);
153+
154+
/// Test that Ignition is rejected for images that don't support it
155+
fn test_run_ephemeral_ignition_unsupported_image() -> Result<()> {
156+
let sh = shell()?;
157+
let bck = get_bck_command()?;
158+
let label = INTEGRATION_TEST_LABEL;
159+
160+
// Use standard bootc image that doesn't have Ignition support
161+
let image = "quay.io/centos-bootc/centos-bootc:stream10";
162+
163+
let temp_dir = TempDir::new()?;
164+
let config_path = Utf8Path::from_path(temp_dir.path())
165+
.expect("temp dir is not utf8")
166+
.join("config.ign");
167+
168+
let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#;
169+
fs::write(&config_path, ignition_config)?;
170+
171+
let output = cmd!(
172+
sh,
173+
"{bck} ephemeral run --rm --label {label} --ignition {config_path} --karg systemd.unit=poweroff.target {image}"
174+
)
175+
.ignore_status()
176+
.output()?;
177+
178+
assert!(
179+
!output.status.success(),
180+
"Should fail when using --ignition with non-Ignition image"
181+
);
182+
183+
let stderr = String::from_utf8_lossy(&output.stderr);
184+
assert!(
185+
stderr.contains("does not support Ignition"),
186+
"Error should mention missing Ignition support: {}",
187+
stderr
188+
);
189+
190+
Ok(())
191+
}
192+
integration_test!(test_run_ephemeral_ignition_unsupported_image);
193+
194+
/// Test that ignition.platform.id=qemu kernel argument is set when using Ignition
195+
fn test_run_ephemeral_ignition_platform_id_karg() -> Result<()> {
196+
let sh = shell()?;
197+
let bck = get_bck_command()?;
198+
let label = INTEGRATION_TEST_LABEL;
199+
200+
// Pull FCOS image first
201+
cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?;
202+
203+
let temp_dir = TempDir::new()?;
204+
let config_path = Utf8Path::from_path(temp_dir.path())
205+
.expect("temp dir is not utf8")
206+
.join("config.ign");
207+
208+
let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#;
209+
fs::write(&config_path, ignition_config)?;
210+
211+
// Check /proc/cmdline for the Ignition platform ID kernel argument
212+
let script = "/bin/sh -c 'cat /proc/cmdline'";
213+
214+
let stdout = cmd!(
215+
sh,
216+
"{bck} ephemeral run --rm --label {label} --ignition {config_path} --execute {script} {FCOS_IMAGE}"
217+
)
218+
.read()?;
219+
220+
assert!(
221+
stdout.contains("ignition.platform.id=qemu"),
222+
"Kernel command line should contain ignition.platform.id=qemu, got: {}",
223+
stdout
224+
);
225+
226+
Ok(())
227+
}
228+
integration_test!(test_run_ephemeral_ignition_platform_id_karg);

crates/kit/src/qemu.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ pub trait QemuConfigExt {
3939
serial: String,
4040
format: F,
4141
) -> &mut Self;
42+
43+
/// Add a virtio-blk device with specified format and readonly flag using kit's Format type.
44+
fn add_virtio_blk_device_with_format_ro<F: Into<DiskFormat>>(
45+
&mut self,
46+
disk_file: String,
47+
serial: String,
48+
format: F,
49+
readonly: bool,
50+
) -> &mut Self;
4251
}
4352

4453
impl QemuConfigExt for QemuConfig {
@@ -50,4 +59,14 @@ impl QemuConfigExt for QemuConfig {
5059
) -> &mut Self {
5160
self.add_virtio_blk_device(disk_file, serial, format.into())
5261
}
62+
63+
fn add_virtio_blk_device_with_format_ro<F: Into<DiskFormat>>(
64+
&mut self,
65+
disk_file: String,
66+
serial: String,
67+
format: F,
68+
readonly: bool,
69+
) -> &mut Self {
70+
self.add_virtio_blk_device_ro(disk_file, serial, format.into(), readonly)
71+
}
5372
}

0 commit comments

Comments
 (0)