diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 7760a23..5c88e07 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -78,6 +78,8 @@ pub struct VirtioBlkDevice { pub serial: String, /// Disk image format. pub format: DiskFormat, + /// Mount as read-only. + pub readonly: bool, } /// VM display and console configuration. @@ -224,6 +226,9 @@ pub struct QemuConfig { pub systemd_notify: Option, vhost_fd: Option, + + /// fw_cfg entries for passing config files to the guest + fw_cfg_entries: Vec<(String, Utf8PathBuf)>, } impl QemuConfig { @@ -365,11 +370,23 @@ impl QemuConfig { disk_file: String, serial: String, format: DiskFormat, + ) -> &mut Self { + self.add_virtio_blk_device_ro(disk_file, serial, format, false) + } + + /// Add a virtio-blk device with specified format and readonly flag. + pub fn add_virtio_blk_device_ro( + &mut self, + disk_file: String, + serial: String, + format: DiskFormat, + readonly: bool, ) -> &mut Self { self.virtio_blk_devices.push(VirtioBlkDevice { disk_file, serial, format, + readonly, }); self } @@ -440,6 +457,13 @@ impl QemuConfig { }; self } + + /// Add a fw_cfg entry to pass a file to the guest. + /// The file will be accessible in the guest via the fw_cfg interface. + pub fn add_fw_cfg(&mut self, name: String, file_path: Utf8PathBuf) -> &mut Self { + self.fw_cfg_entries.push((name, file_path)); + self + } } /// Allocate a unique VSOCK CID. @@ -560,13 +584,19 @@ fn spawn( // Add virtio-blk block devices for (idx, blk_device) in config.virtio_blk_devices.iter().enumerate() { let drive_id = format!("drive{}", idx); + let readonly_flag = if blk_device.readonly { + ",readonly=on" + } else { + "" + }; cmd.args([ "-drive", &format!( - "file={},format={},if=none,id={}", + "file={},format={},if=none,id={}{}", blk_device.disk_file, blk_device.format.as_str(), - drive_id + drive_id, + readonly_flag ), "-device", &format!( @@ -723,6 +753,11 @@ fn spawn( cmd.args(["-smbios", &format!("type=11,value={}", credential)]); } + // Add fw_cfg entries + for (name, file_path) in &config.fw_cfg_entries { + cmd.args(["-fw_cfg", &format!("name={},file={}", name, file_path)]); + } + // Configure stdio based on display mode match &config.display_mode { DisplayMode::Console => { @@ -993,4 +1028,24 @@ mod tests { assert_eq!(DiskFormat::Raw.as_str(), "raw"); assert_eq!(DiskFormat::Qcow2.as_str(), "qcow2"); } + + #[test] + fn test_fw_cfg_entry() { + let mut config = QemuConfig::new_direct_boot( + 1024, + 1, + "/test/kernel".to_string(), + "/test/initramfs".to_string(), + "/test/socket".into(), + ); + config.add_fw_cfg( + "opt/com.coreos/config".to_string(), + "/test/ignition.json".into(), + ); + + // Test that the fw_cfg entry is created correctly + assert_eq!(config.fw_cfg_entries.len(), 1); + assert_eq!(config.fw_cfg_entries[0].0, "opt/com.coreos/config"); + assert_eq!(config.fw_cfg_entries[0].1.as_str(), "/test/ignition.json"); + } } diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 14186da..3fd7c36 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -21,6 +21,7 @@ mod tests { pub mod libvirt_verb; pub mod mount_feature; pub mod run_ephemeral; + pub mod run_ephemeral_ignition; pub mod run_ephemeral_ssh; pub mod to_disk; pub mod varlink; diff --git a/crates/integration-tests/src/tests/run_ephemeral_ignition.rs b/crates/integration-tests/src/tests/run_ephemeral_ignition.rs new file mode 100644 index 0000000..c7a50eb --- /dev/null +++ b/crates/integration-tests/src/tests/run_ephemeral_ignition.rs @@ -0,0 +1,138 @@ +//! Integration tests for Ignition config injection + +use color_eyre::Result; +use integration_tests::integration_test; +use xshell::cmd; + +use std::fs; +use tempfile::TempDir; + +use camino::Utf8Path; + +use crate::{get_bck_command, shell, INTEGRATION_TEST_LABEL}; + +/// Fedora CoreOS image that supports Ignition +const FCOS_IMAGE: &str = "quay.io/fedora/fedora-coreos:stable"; + +/// Test that Ignition config injection mechanism works +/// +/// This test verifies that the Ignition config injection mechanism is working +/// by checking that the ignition.platform.id=qemu kernel argument is set when +/// --ignition is specified. This works across all architectures. +/// +/// Note: We don't test actual Ignition application here because FCOS won't +/// apply Ignition configs in ephemeral mode (treats it as subsequent boot). +/// The config injection works correctly for custom bootc images with Ignition. +fn test_run_ephemeral_ignition_works() -> Result<()> { + let sh = shell()?; + let bck = get_bck_command()?; + let label = INTEGRATION_TEST_LABEL; + + // Pull FCOS image first + cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?; + + // Create a temporary Ignition config + let temp_dir = TempDir::new()?; + let config_path = Utf8Path::from_path(temp_dir.path()) + .expect("temp dir is not utf8") + .join("config.ign"); + + // Minimal valid Ignition config (v3.3.0 for FCOS) + let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#; + fs::write(&config_path, ignition_config)?; + + // Check that the platform.id kernel arg is present + let script = "/bin/sh -c 'grep -q ignition.platform.id=qemu /proc/cmdline && echo KARG_FOUND'"; + + let stdout = cmd!( + sh, + "{bck} ephemeral run --rm --label {label} --ignition {config_path} --execute {script} {FCOS_IMAGE}" + ) + .read()?; + + assert!( + stdout.contains("KARG_FOUND"), + "Kernel command line should contain ignition.platform.id=qemu, got: {}", + stdout + ); + + Ok(()) +} +integration_test!(test_run_ephemeral_ignition_works); + +/// Test that Ignition config validation rejects nonexistent files +fn test_run_ephemeral_ignition_invalid_path() -> Result<()> { + let sh = shell()?; + let bck = get_bck_command()?; + let label = INTEGRATION_TEST_LABEL; + + // Pull FCOS image first + cmd!(sh, "podman pull -q {FCOS_IMAGE}").run()?; + + let temp = TempDir::new()?; + let nonexistent_path = Utf8Path::from_path(temp.path()) + .expect("temp dir is not utf8") + .join("nonexistent-config.ign"); + + let output = cmd!( + sh, + "{bck} ephemeral run --rm --label {label} --ignition {nonexistent_path} --karg systemd.unit=poweroff.target {FCOS_IMAGE}" + ) + .ignore_status() + .output()?; + + assert!( + !output.status.success(), + "Should fail with nonexistent Ignition config file" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found"), + "Error should mention missing file: {}", + stderr + ); + + Ok(()) +} +integration_test!(test_run_ephemeral_ignition_invalid_path); + +/// Test that Ignition is rejected for images that don't support it +fn test_run_ephemeral_ignition_unsupported_image() -> Result<()> { + let sh = shell()?; + let bck = get_bck_command()?; + let label = INTEGRATION_TEST_LABEL; + + // Use standard bootc image that doesn't have Ignition support + let image = "quay.io/centos-bootc/centos-bootc:stream10"; + + let temp_dir = TempDir::new()?; + let config_path = Utf8Path::from_path(temp_dir.path()) + .expect("temp dir is not utf8") + .join("config.ign"); + + let ignition_config = r#"{"ignition": {"version": "3.3.0"}}"#; + fs::write(&config_path, ignition_config)?; + + let output = cmd!( + sh, + "{bck} ephemeral run --rm --label {label} --ignition {config_path} --karg systemd.unit=poweroff.target {image}" + ) + .ignore_status() + .output()?; + + assert!( + !output.status.success(), + "Should fail when using --ignition with non-Ignition image" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("does not support Ignition"), + "Error should mention missing Ignition support: {}", + stderr + ); + + Ok(()) +} +integration_test!(test_run_ephemeral_ignition_unsupported_image); diff --git a/crates/kit/src/qemu.rs b/crates/kit/src/qemu.rs index eb31bc4..a2b1386 100644 --- a/crates/kit/src/qemu.rs +++ b/crates/kit/src/qemu.rs @@ -39,6 +39,15 @@ pub trait QemuConfigExt { serial: String, format: F, ) -> &mut Self; + + /// Add a virtio-blk device with specified format and readonly flag using kit's Format type. + fn add_virtio_blk_device_with_format_ro>( + &mut self, + disk_file: String, + serial: String, + format: F, + readonly: bool, + ) -> &mut Self; } impl QemuConfigExt for QemuConfig { @@ -50,4 +59,14 @@ impl QemuConfigExt for QemuConfig { ) -> &mut Self { self.add_virtio_blk_device(disk_file, serial, format.into()) } + + fn add_virtio_blk_device_with_format_ro>( + &mut self, + disk_file: String, + serial: String, + format: F, + readonly: bool, + ) -> &mut Self { + self.add_virtio_blk_device_ro(disk_file, serial, format.into(), readonly) + } } diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 124276d..140bda2 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -120,6 +120,15 @@ use crate::{ systemd, utils, CONTAINER_STATEDIR, }; +/// fw_cfg name for Ignition configuration (per FCOS documentation) +const IGNITION_FW_CFG_NAME: &str = "opt/com.coreos/config"; + +/// virtio-blk serial name for Ignition configuration (per FCOS documentation) +const IGNITION_SERIAL_NAME: &str = "ignition"; + +/// Mount path for Ignition config inside the container +const IGNITION_CONFIG_MOUNT_PATH: &str = "/run/ignition-config.json"; + /// Common container lifecycle options for podman commands. #[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)] pub struct CommonPodmanOptions { @@ -287,6 +296,12 @@ pub struct RunEphemeralOpts { #[clap(long = "karg", help = "Additional kernel command line arguments")] pub kernel_args: Vec, + #[clap( + long = "ignition", + help = "Path to Ignition config file (JSON format) to inject via fw_cfg" + )] + pub ignition_config: Option, + /// Host DNS servers (read on host, configured via podman --dns flags) /// Not a CLI option - populated automatically from host's /etc/resolv.conf #[clap(skip)] @@ -401,6 +416,17 @@ fn prepare_run_command_with_temp( ) -> Result<(std::process::Command, tempfile::TempDir)> { debug!("Running QEMU inside hybrid container for {}", opts.image); + // Check Ignition support early (before launching container) if --ignition is specified + if opts.ignition_config.is_some() { + let has_ignition = check_ignition_support(&opts.image)?; + if !has_ignition { + return Err(eyre!( + "Image does not support Ignition. See man bcvk-ephemeral-run for details." + )); + } + debug!("Image {} supports Ignition", opts.image); + } + let script = include_str!("../scripts/entrypoint.sh"); let td = tempfile::tempdir()?; @@ -581,6 +607,30 @@ fn prepare_run_command_with_temp( cmd.args(["-v", &format!("{}:/run/systemd-units:ro", units_dir)]); } + // Mount Ignition config file if specified + if let Some(ref ignition_path) = opts.ignition_config { + // Convert to absolute path if needed + let path = Utf8Path::new(ignition_path); + let ignition_abs = if path.is_absolute() { + path.to_owned() + } else { + let current_dir = Utf8PathBuf::try_from(std::env::current_dir()?) + .context("Current directory path is not valid UTF-8")?; + current_dir.join(path) + }; + + // Just validate we can access the file here, we pass the path + // to podman as a bind mount which will reopen. + if !ignition_abs.try_exists()? { + return Err(eyre!("Ignition config file not found: {}", ignition_abs)); + } + + cmd.args([ + "-v", + &format!("{}:{}:ro", ignition_abs, IGNITION_CONFIG_MOUNT_PATH), + ]); + } + // Read host DNS servers and configure them via podman --dns flags // This fixes DNS resolution issues when QEMU runs inside containers. // QEMU's slirp reads /etc/resolv.conf from the container's network namespace, @@ -834,6 +884,62 @@ fn check_required_container_binaries() -> Result<()> { Ok(()) } +/// Check if the container image has Ignition support +/// +/// Checks for labels indicating Ignition support: +/// - 'coreos.ignition' (future convention, not yet widely used) +/// - 'com.coreos.osname' (heuristic: CoreOS-based images likely have Ignition) +/// +/// Returns true if the image is likely to support Ignition. +fn check_ignition_support(image: &str) -> Result { + use std::collections::HashMap; + use std::process::Stdio; + + // Fetch all labels with a single podman inspect call + let output = Command::new("podman") + .args(["image", "inspect", "--format", "{{json .Labels}}", image]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to inspect image for labels")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!( + "Failed to inspect image {} for labels: {}", + image, + stderr.trim() + )); + } + + // Parse the JSON output + let labels: HashMap = + serde_json::from_slice(&output.stdout).context("Failed to parse image labels as JSON")?; + + // Check for coreos.ignition label (could contain version info or just "1") + if let Some(ignition_value) = labels.get("coreos.ignition") { + if !ignition_value.is_empty() { + debug!( + "Image {} has coreos.ignition={} label", + image, ignition_value + ); + return Ok(true); + } + } + + // Fallback: check for com.coreos.osname (CoreOS-based images) + if let Some(osname_value) = labels.get("com.coreos.osname").filter(|v| !v.is_empty()) { + debug!( + "Image {} has com.coreos.osname={}, assuming Ignition support", + image, osname_value + ); + return Ok(true); + } + + debug!("Image {} does not appear to support Ignition", image); + Ok(false) +} + /// VM execution inside container: extracts kernel/initramfs, starts virtiofsd processes, /// generates systemd mount units, sets up command execution, launches QEMU. pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> { @@ -1262,9 +1368,54 @@ StandardOutput=file:/dev/virtio-ports/executestatus kernel_cmdline.push("ds=iid-datasource-none".to_string()); } + // Add Ignition platform kernel argument if Ignition config is specified + // This tells Ignition which platform it's running on and where to find the config + if opts.ignition_config.is_some() { + kernel_cmdline.push("ignition.platform.id=qemu".to_string()); + } + kernel_cmdline.extend(opts.kernel_args.clone()); qemu_config.set_kernel_cmdline(kernel_cmdline); + // Add Ignition config if specified + // Different architectures require different methods (per FCOS docs): + // - x86_64/aarch64: fw_cfg + // - s390x/ppc64le: virtio-blk with serial "ignition" + if opts.ignition_config.is_some() { + let ignition_path = Utf8Path::new(IGNITION_CONFIG_MOUNT_PATH); + if !ignition_path.exists() { + return Err(eyre!( + "Ignition config not found at expected location: {}\n\ + This is an internal error - the config should have been mounted by podman.", + ignition_path + )); + } + + let arch = std::env::consts::ARCH; + match arch { + "x86_64" | "aarch64" => { + debug!("Adding Ignition config via fw_cfg: {}", ignition_path); + qemu_config.add_fw_cfg(IGNITION_FW_CFG_NAME.to_string(), ignition_path.to_owned()); + } + "s390x" | "powerpc64" => { + debug!("Adding Ignition config via virtio-blk: {}", ignition_path); + qemu_config.add_virtio_blk_device_with_format_ro( + ignition_path.to_string(), + IGNITION_SERIAL_NAME.to_string(), + crate::to_disk::Format::Raw, + true, // readonly as required by FCOS + ); + } + _ => { + return Err(eyre!( + "Ignition config injection not supported on architecture: {}\n\ + Supported architectures: x86_64, aarch64, s390x, powerpc64", + arch + )); + } + } + } + // TODO allocate unlinked unnamed file and pass via fd let mut tmp_swapfile = None; if let Some(size) = opts.add_swap { @@ -1366,6 +1517,7 @@ Options= disk_file, serial, format: format.into(), + readonly: false, }); } } diff --git a/crates/kit/src/to_disk.rs b/crates/kit/src/to_disk.rs index c5a9cd7..24a3066 100644 --- a/crates/kit/src/to_disk.rs +++ b/crates/kit/src/to_disk.rs @@ -525,6 +525,7 @@ pub fn run(opts: ToDiskOpts) -> Result { opts.additional.format.as_str() )], // Attach target disk kernel_args: Default::default(), + ignition_config: None, debug_entrypoint: None, }; diff --git a/crates/kit/src/varlink_ipc.rs b/crates/kit/src/varlink_ipc.rs index d08c017..b582808 100644 --- a/crates/kit/src/varlink_ipc.rs +++ b/crates/kit/src/varlink_ipc.rs @@ -275,6 +275,7 @@ impl BcvkService { add_swap: opts.add_swap, mount_disk_files: opts.mount_disk_files.unwrap_or_default(), kernel_args: opts.kargs.unwrap_or_default(), + ignition_config: None, host_dns_servers: None, }; diff --git a/docs/src/man/bcvk-ephemeral-run-ssh.md b/docs/src/man/bcvk-ephemeral-run-ssh.md index 745e83c..6bebe02 100644 --- a/docs/src/man/bcvk-ephemeral-run-ssh.md +++ b/docs/src/man/bcvk-ephemeral-run-ssh.md @@ -146,6 +146,10 @@ For longer-running VMs where you need to reconnect multiple times, use Additional kernel command line arguments +**--ignition**=*IGNITION_CONFIG* + + Path to Ignition config file (JSON format) to inject via fw_cfg + # EXAMPLES diff --git a/docs/src/man/bcvk-ephemeral-run.md b/docs/src/man/bcvk-ephemeral-run.md index 71c02f9..72ba5ae 100644 --- a/docs/src/man/bcvk-ephemeral-run.md +++ b/docs/src/man/bcvk-ephemeral-run.md @@ -148,6 +148,10 @@ This design allows bcvk to provide VM-like isolation and boot behavior while lev Additional kernel command line arguments +**--ignition**=*IGNITION_CONFIG* + + Path to Ignition config file (JSON format) to inject via fw_cfg + # EXAMPLES @@ -258,6 +262,54 @@ Use predefined instance types for consistent resource allocation: bcvk ephemeral run -d --rm -K --itype u1.medium --name vm localhost/mybootc bcvk ephemeral run -d --rm -K --itype u1.large --name vm localhost/mybootc +## Ignition Configuration + +Inject [Ignition](https://coreos.github.io/ignition/) configuration files for first-boot provisioning on CoreOS-based images: + + # Create an Ignition config file (v3.3.0 format) + cat > config.ign <