Skip to content

Commit e74f5fd

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 - Test coverage for fw_cfg functionality The config is successfully passed to the guest (verified via /sys/firmware/qemu_fw_cfg/). 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. Closes: #228 Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: gursewak1997 <gursmangat@gmail.com>
1 parent 5c42e91 commit e74f5fd

File tree

5 files changed

+241
-2
lines changed

5 files changed

+241
-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/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
}

crates/kit/src/run_ephemeral.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ use crate::{
120120
systemd, utils, CONTAINER_STATEDIR,
121121
};
122122

123+
/// fw_cfg name for Ignition configuration (per FCOS documentation)
124+
const IGNITION_FW_CFG_NAME: &str = "opt/com.coreos/config";
125+
126+
/// virtio-blk serial name for Ignition configuration (per FCOS documentation)
127+
const IGNITION_SERIAL_NAME: &str = "ignition";
128+
129+
/// Mount path for Ignition config inside the container
130+
const IGNITION_CONFIG_MOUNT_PATH: &str = "/run/ignition-config.json";
131+
123132
/// Common container lifecycle options for podman commands.
124133
#[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)]
125134
pub struct CommonPodmanOptions {
@@ -287,6 +296,12 @@ pub struct RunEphemeralOpts {
287296
#[clap(long = "karg", help = "Additional kernel command line arguments")]
288297
pub kernel_args: Vec<String>,
289298

299+
#[clap(
300+
long = "ignition",
301+
help = "Path to Ignition config file (JSON format) to inject via fw_cfg"
302+
)]
303+
pub ignition_config: Option<String>,
304+
290305
/// Host DNS servers (read on host, configured via podman --dns flags)
291306
/// Not a CLI option - populated automatically from host's /etc/resolv.conf
292307
#[clap(skip)]
@@ -401,6 +416,27 @@ fn prepare_run_command_with_temp(
401416
) -> Result<(std::process::Command, tempfile::TempDir)> {
402417
debug!("Running QEMU inside hybrid container for {}", opts.image);
403418

419+
// Check Ignition support early (before launching container) if --ignition is specified
420+
if opts.ignition_config.is_some() {
421+
let has_ignition = check_ignition_support(&opts.image)?;
422+
if !has_ignition {
423+
return Err(eyre!(
424+
"Image does not support Ignition.\n\
425+
\n\
426+
To use Ignition with bootc images, build a custom image with Ignition included.\n\
427+
See: https://docs.fedoraproject.org/en-US/bootc/initramfs/\n\
428+
\n\
429+
Alternatively, use an image that includes Ignition by default:\n\
430+
- quay.io/fedora/fedora-coreos\n\
431+
\n\
432+
Images are detected as Ignition-capable if they have:\n\
433+
- Label 'coreos.ignition=1' (recommended), or\n\
434+
- Label 'com.coreos.osname' (CoreOS-based images)"
435+
));
436+
}
437+
debug!("Image {} supports Ignition", opts.image);
438+
}
439+
404440
let script = include_str!("../scripts/entrypoint.sh");
405441

406442
let td = tempfile::tempdir()?;
@@ -581,6 +617,35 @@ fn prepare_run_command_with_temp(
581617
cmd.args(["-v", &format!("{}:/run/systemd-units:ro", units_dir)]);
582618
}
583619

620+
// Mount Ignition config file if specified
621+
if let Some(ref ignition_path) = opts.ignition_config {
622+
// Convert to absolute path if needed
623+
let path = Utf8Path::new(ignition_path);
624+
let ignition_abs = if path.is_absolute() {
625+
path.to_owned()
626+
} else {
627+
let current_dir = Utf8PathBuf::try_from(std::env::current_dir()?)
628+
.context("Current directory path is not valid UTF-8")?;
629+
current_dir.join(path)
630+
};
631+
632+
// Validate file exists and is readable
633+
if !ignition_abs.exists() {
634+
return Err(eyre!("Ignition config file not found: {}", ignition_abs));
635+
}
636+
if !ignition_abs.is_file() {
637+
return Err(eyre!(
638+
"Ignition config path is not a regular file: {}",
639+
ignition_abs
640+
));
641+
}
642+
643+
cmd.args([
644+
"-v",
645+
&format!("{}:{}:ro", ignition_abs, IGNITION_CONFIG_MOUNT_PATH),
646+
]);
647+
}
648+
584649
// Read host DNS servers and configure them via podman --dns flags
585650
// This fixes DNS resolution issues when QEMU runs inside containers.
586651
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
@@ -834,6 +899,64 @@ fn check_required_container_binaries() -> Result<()> {
834899
Ok(())
835900
}
836901

902+
/// Check if the container image has Ignition support
903+
///
904+
/// Checks for labels indicating Ignition support:
905+
/// - 'coreos.ignition' (future convention, not yet widely used)
906+
/// - 'com.coreos.osname' (heuristic: CoreOS-based images likely have Ignition)
907+
///
908+
/// Returns true if the image is likely to support Ignition.
909+
fn check_ignition_support(image: &str) -> Result<bool> {
910+
use std::collections::HashMap;
911+
use std::process::Stdio;
912+
913+
// Fetch all labels with a single podman inspect call
914+
let output = Command::new("podman")
915+
.args(["image", "inspect", "--format", "{{json .Labels}}", image])
916+
.stdout(Stdio::piped())
917+
.stderr(Stdio::piped())
918+
.output()
919+
.context("Failed to inspect image for labels")?;
920+
921+
if !output.status.success() {
922+
let stderr = String::from_utf8_lossy(&output.stderr);
923+
return Err(eyre!(
924+
"Failed to inspect image {} for labels: {}",
925+
image,
926+
stderr.trim()
927+
));
928+
}
929+
930+
// Parse the JSON output
931+
let labels: HashMap<String, String> = serde_json::from_slice(&output.stdout)
932+
.context("Failed to parse image labels as JSON")?;
933+
934+
// Check for coreos.ignition label (future convention)
935+
if let Some(ignition_value) = labels.get("coreos.ignition") {
936+
if matches!(
937+
ignition_value.to_lowercase().as_str(),
938+
"1" | "true" | "yes" | "enabled"
939+
) {
940+
debug!("Image {} has coreos.ignition=1 label", image);
941+
return Ok(true);
942+
}
943+
}
944+
945+
// Fallback: check for com.coreos.osname (CoreOS-based images)
946+
if let Some(osname_value) = labels.get("com.coreos.osname") {
947+
if !osname_value.is_empty() {
948+
debug!(
949+
"Image {} has com.coreos.osname={}, assuming Ignition support",
950+
image, osname_value
951+
);
952+
return Ok(true);
953+
}
954+
}
955+
956+
debug!("Image {} does not appear to support Ignition", image);
957+
Ok(false)
958+
}
959+
837960
/// VM execution inside container: extracts kernel/initramfs, starts virtiofsd processes,
838961
/// generates systemd mount units, sets up command execution, launches QEMU.
839962
pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> {
@@ -1265,6 +1388,45 @@ StandardOutput=file:/dev/virtio-ports/executestatus
12651388
kernel_cmdline.extend(opts.kernel_args.clone());
12661389
qemu_config.set_kernel_cmdline(kernel_cmdline);
12671390

1391+
// Add Ignition config if specified
1392+
// Different architectures require different methods (per FCOS docs):
1393+
// - x86_64/aarch64: fw_cfg
1394+
// - s390x/ppc64le: virtio-blk with serial "ignition"
1395+
if opts.ignition_config.is_some() {
1396+
let ignition_path = Utf8Path::new(IGNITION_CONFIG_MOUNT_PATH);
1397+
if !ignition_path.exists() {
1398+
return Err(eyre!(
1399+
"Ignition config not found at expected location: {}\n\
1400+
This is an internal error - the config should have been mounted by podman.",
1401+
ignition_path
1402+
));
1403+
}
1404+
1405+
let arch = std::env::consts::ARCH;
1406+
match arch {
1407+
"x86_64" | "aarch64" => {
1408+
debug!("Adding Ignition config via fw_cfg: {}", ignition_path);
1409+
qemu_config.add_fw_cfg(IGNITION_FW_CFG_NAME.to_string(), ignition_path.to_owned());
1410+
}
1411+
"s390x" | "powerpc64" => {
1412+
debug!("Adding Ignition config via virtio-blk: {}", ignition_path);
1413+
qemu_config.add_virtio_blk_device_with_format_ro(
1414+
ignition_path.to_string(),
1415+
IGNITION_SERIAL_NAME.to_string(),
1416+
crate::to_disk::Format::Raw,
1417+
true, // readonly as required by FCOS
1418+
);
1419+
}
1420+
_ => {
1421+
return Err(eyre!(
1422+
"Ignition config injection not supported on architecture: {}\n\
1423+
Supported architectures: x86_64, aarch64, s390x, powerpc64",
1424+
arch
1425+
));
1426+
}
1427+
}
1428+
}
1429+
12681430
// TODO allocate unlinked unnamed file and pass via fd
12691431
let mut tmp_swapfile = None;
12701432
if let Some(size) = opts.add_swap {
@@ -1366,6 +1528,7 @@ Options=
13661528
disk_file,
13671529
serial,
13681530
format: format.into(),
1531+
readonly: false,
13691532
});
13701533
}
13711534
}

crates/kit/src/to_disk.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ pub fn run(opts: ToDiskOpts) -> Result<RunOutcome> {
525525
opts.additional.format.as_str()
526526
)], // Attach target disk
527527
kernel_args: Default::default(),
528+
ignition_config: None,
528529
debug_entrypoint: None,
529530
};
530531

crates/kit/src/varlink_ipc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ impl BcvkService {
275275
add_swap: opts.add_swap,
276276
mount_disk_files: opts.mount_disk_files.unwrap_or_default(),
277277
kernel_args: opts.kargs.unwrap_or_default(),
278+
ignition_config: None,
278279
host_dns_servers: None,
279280
};
280281

0 commit comments

Comments
 (0)