Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/integration-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub(crate) use integration_tests::{

mod tests {
pub mod libvirt_base_disks;
pub mod libvirt_ignition;
pub mod libvirt_port_forward;
pub mod libvirt_upload_disk;
pub mod libvirt_verb;
Expand Down
172 changes: 172 additions & 0 deletions crates/integration-tests/src/tests/libvirt_ignition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Integration tests for Ignition config injection in libvirt VMs

use integration_tests::integration_test;
use itest::TestResult;
use scopeguard::defer;
use tempfile::TempDir;
use xshell::cmd;

use std::fs;

use camino::Utf8Path;

use crate::{get_bck_command, shell, LIBVIRT_INTEGRATION_TEST_LABEL};

/// Generate a random alphanumeric suffix for VM names to avoid collisions
fn random_suffix() -> String {
use rand::{distr::Alphanumeric, Rng};
rand::rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect()
}

/// Fedora CoreOS image that supports Ignition
const FCOS_IMAGE: &str = "quay.io/fedora/fedora-coreos:stable";

/// Test that Ignition config injection mechanism works for libvirt
///
/// This test verifies that the Ignition config injection mechanism is working
/// by checking that the VM can be created with --ignition flag and that the
/// config file is properly stored.
fn test_libvirt_ignition_works() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let label = LIBVIRT_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)?;

// Generate a unique VM name to avoid conflicts
let vm_name = format!("test-ignition-{}", random_suffix());

// Create VM with Ignition config
// We use --ssh-wait to wait for the VM to boot and verify SSH connectivity
// FCOS requires --filesystem to be specified
let output = cmd!(
sh,
"{bck} libvirt run --name {vm_name} --label {label} --ignition {config_path} --filesystem xfs --ssh-wait --memory 2G --cpus 2 {FCOS_IMAGE}"
)
.ignore_status()
.output()?;

// Cleanup: remove the VM
defer! {
let _ = cmd!(sh, "{bck} libvirt rm {vm_name} --force").run();
}

// Check that the command succeeded
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
panic!(
"Failed to create VM with Ignition config.\nStdout: {}\nStderr: {}",
stdout, stderr
);
}

// Verify the VM was created
let vm_list = cmd!(sh, "{bck} libvirt list").read()?;
assert!(
vm_list.contains(&vm_name),
"VM should be listed after creation"
);

println!("Ignition config injection test passed");
Ok(())
}
integration_test!(test_libvirt_ignition_works);

/// Test that Ignition config validation rejects nonexistent files
fn test_libvirt_ignition_invalid_path() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let label = LIBVIRT_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 vm_name = format!("test-ignition-invalid-{}", random_suffix());

let output = cmd!(
sh,
"{bck} libvirt run --name {vm_name} --label {label} --ignition {nonexistent_path} {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
);

println!("Ignition invalid path test passed");
Ok(())
}
integration_test!(test_libvirt_ignition_invalid_path);

/// Test that Ignition is rejected for images that don't support it
fn test_libvirt_ignition_unsupported_image() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let label = LIBVIRT_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 vm_name = format!("test-ignition-unsupported-{}", random_suffix());

let output = cmd!(
sh,
"{bck} libvirt run --name {vm_name} --label {label} --ignition {config_path} {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
);

println!("Ignition unsupported image test passed");
Ok(())
}
integration_test!(test_libvirt_ignition_unsupported_image);
49 changes: 46 additions & 3 deletions crates/kit/src/libvirt/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub struct DomainBuilder {
nvram_template: Option<String>, // Custom NVRAM template with enrolled keys
nvram_format: Option<String>, // Format of NVRAM template (raw, qcow2)
firmware_log: Option<FirmwareLogOutput>, // OVMF debug log output via isa-debugcon
fw_cfg_entries: Vec<(String, String)>, // fw_cfg entries (name, file_path)
ignition_disk_path: Option<String>, // Path to Ignition config for virtio-blk injection
}

impl Default for DomainBuilder {
Expand Down Expand Up @@ -86,6 +88,8 @@ impl DomainBuilder {
nvram_template: None,
nvram_format: None,
firmware_log: None,
fw_cfg_entries: Vec::new(),
ignition_disk_path: None,
}
}

Expand Down Expand Up @@ -204,6 +208,21 @@ impl DomainBuilder {
self
}

/// Add a fw_cfg entry for passing config files to the guest
///
/// This is used for Ignition config injection on x86_64/aarch64.
/// The entry will be converted to a QEMU commandline argument in the domain XML.
pub fn add_fw_cfg(mut self, name: String, file_path: String) -> Self {
self.fw_cfg_entries.push((name, file_path));
self
}

/// Set Ignition config disk path for virtio-blk injection (s390x/ppc64le)
pub fn with_ignition_disk(mut self, disk_path: String) -> Self {
self.ignition_disk_path = Some(disk_path);
self
}

/// Build the domain XML
pub fn build_xml(self) -> Result<String> {
let name = self.name.ok_or_else(|| eyre!("Domain name is required"))?;
Expand All @@ -221,7 +240,7 @@ impl DomainBuilder {
let mut writer = XmlWriter::new();

// Root domain element
let domain_attrs = if self.qemu_args.is_empty() {
let domain_attrs = if self.qemu_args.is_empty() && self.fw_cfg_entries.is_empty() {
vec![("type", "kvm")]
} else {
vec![
Expand Down Expand Up @@ -379,6 +398,17 @@ impl DomainBuilder {
writer.end_element("disk")?;
}

// Ignition config disk (virtio-blk with serial="ignition" for s390x/ppc64le)
if let Some(ref ignition_disk) = self.ignition_disk_path {
writer.start_element("disk", &[("type", "file"), ("device", "disk")])?;
writer.write_empty_element("driver", &[("name", "qemu"), ("type", "raw")])?;
writer.write_empty_element("source", &[("file", ignition_disk)])?;
writer.write_empty_element("target", &[("dev", "vdb"), ("bus", "virtio")])?;
writer.write_text_element("serial", "ignition")?;
writer.write_empty_element("readonly", &[])?;
writer.end_element("disk")?;
}

// Network
let network_config = self.network.as_deref().unwrap_or("default");
match network_config {
Expand Down Expand Up @@ -483,9 +513,22 @@ impl DomainBuilder {

writer.end_element("devices")?;

// QEMU commandline section (if we have QEMU args)
if !self.qemu_args.is_empty() {
// QEMU commandline section (if we have QEMU args or fw_cfg entries)
if !self.qemu_args.is_empty() || !self.fw_cfg_entries.is_empty() {
writer.start_element("qemu:commandline", &[])?;

// Add fw_cfg entries first
// Format: -fw_cfg name=<name>,file=<path>
// Verified working: config accessible at /sys/firmware/qemu_fw_cfg/by_name/<name>/raw
for (name, file_path) in &self.fw_cfg_entries {
writer.write_empty_element("qemu:arg", &[("value", "-fw_cfg")])?;
writer.write_empty_element(
"qemu:arg",
&[("value", &format!("name={},file={}", name, file_path))],
)?;
}

// Then add other QEMU args
for arg in &self.qemu_args {
writer.write_empty_element("qemu:arg", &[("value", arg)])?;
}
Expand Down
21 changes: 21 additions & 0 deletions crates/kit/src/libvirt/rm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use clap::Parser;
use color_eyre::Result;
use tracing::debug;

/// Check if a domain is persistent (vs transient)
///
Expand Down Expand Up @@ -108,6 +109,26 @@ fn remove_vm_impl(
}
}

// Remove Ignition config file if it exists (stored in metadata)
// Parse domain XML to get the ignition persistent path
if let Ok(xml_output) = global_opts
.virsh_command()
.args(&["dumpxml", vm_name])
.output()
{
if let Ok(xml_str) = String::from_utf8(xml_output.stdout) {
if let Ok(dom) = crate::xml_utils::parse_xml_dom(&xml_str) {
if let Some(ignition_path_node) = dom.find("bootc:ignition-persistent-path") {
let ignition_path = ignition_path_node.text_content().trim();
if !ignition_path.is_empty() && std::path::Path::new(ignition_path).exists() {
debug!("Removing Ignition config file: {}", ignition_path);
let _ = std::fs::remove_file(ignition_path); // Don't fail if this fails
}
}
}
}
}

// Remove libvirt domain with nvram and storage
let output = global_opts
.virsh_command()
Expand Down
Loading
Loading