From 2e1b6d66e05f01661cf00b712d796e7db17f34c4 Mon Sep 17 00:00:00 2001 From: Mihai Petracovici Date: Fri, 18 Sep 2026 18:35:24 -0600 Subject: [PATCH] to-disk: Switch to a minimal boot target for to-disk installation Since we are using the user-provided image as the source for the ephemeral VM in the to-disk process, it is possible that there are services in that image that can break the installation (i.e. quadlets). Add a custom systemd target the only loads services essential to the to-disk installation process. An integration test is included to test for any regressions in the future. Booting into a default target will cause that test to fail. Closes: #351 Assisted-By: AI Signed-off-by: Mihai Petracovici --- .../fixtures/Dockerfile.quadlet | 24 +++++++ crates/integration-tests/src/tests/to_disk.rs | 64 ++++++++++++++++++- crates/kit/src/run_ephemeral.rs | 7 +- crates/kit/src/to_disk.rs | 24 +++++-- crates/kit/src/units/bcvk-to-disk.target | 14 ++++ 5 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 crates/integration-tests/fixtures/Dockerfile.quadlet create mode 100644 crates/kit/src/units/bcvk-to-disk.target diff --git a/crates/integration-tests/fixtures/Dockerfile.quadlet b/crates/integration-tests/fixtures/Dockerfile.quadlet new file mode 100644 index 000000000..c1c41335a --- /dev/null +++ b/crates/integration-tests/fixtures/Dockerfile.quadlet @@ -0,0 +1,24 @@ +# Test fixture: Bootc image with a quadlet that starts at boot +# +# This creates an image with an example service that starts at boot +# at the default.target that would normally break the bootc to-disk process. +# We use this test to confirm that we are booting into our custom target +# where only required services are started. +# +# Usage: +# podman build -f Dockerfile.quadlet -t localhost/bcvk-test-quadlet:latest \ +# --build-arg BASE_IMAGE=quay.io/centos-bootc/centos-bootc:stream10 . + +ARG BASE_IMAGE=quay.io/centos-bootc/centos-bootc:stream10 + +FROM ${BASE_IMAGE} + +COPY --chown=0:0 < TestResult { Ok(()) } parameterized_integration_test!(test_to_disk_for_image); + +/// Tag of the quadlet fixture image built by [`build_quadlet_image`] +/// +/// Fully qualified so skopeo inside the install VM resolves it from +/// containers-storage instead of trying docker.io/library/. +const QUADLET_IMAGE: &str = "localhost/bcvk-test-quadlet:latest"; + +/// Build the quadlet fixture image and verify it contains the quadlet unit +fn build_quadlet_image(sh: &Shell) -> TestResult { + let base_image = get_test_image(); + let fixture_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/Dockerfile.quadlet"); + let fixture_dir = fixture_path.parent().unwrap(); + + cmd!( + sh, + "podman build -f {fixture_path} -t {QUADLET_IMAGE} --build-arg BASE_IMAGE={base_image} {fixture_dir}" + ) + .run()?; + + // Verify the quadlet unit is present in the image + let verify_stdout = cmd!( + sh, + "podman run --rm {QUADLET_IMAGE} sh -c 'ls /etc/containers/systemd/*.container'" + ) + .read()?; + assert!( + verify_stdout.contains("sleep.container"), + "Quadlet fixture image should contain sleep.container: {}", + verify_stdout + ); + + Ok(()) +} + +/// Test that to-disk succeeds on an image containing a quadlet that starts at boot +/// +/// The to-disk install VM reuses the target image as its environment. If the VM +/// booted the full default.target, the quadlet's container would start and open +/// files under /var/lib/containers, making `rm -rf /var/lib/containers` in the +/// install script fail with EBUSY. The VM must instead boot to +/// bcvk-to-disk.target, which does not pull in default.target units. +fn test_to_disk_quadlet() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + let label = INTEGRATION_TEST_LABEL; + build_quadlet_image(&sh)?; + + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let disk_path = Utf8PathBuf::try_from(temp_dir.path().join("test-disk.img")) + .expect("temp path is not UTF-8"); + + let output = cmd!( + sh, + "{bck} to-disk --label {label} --filesystem ext4 {QUADLET_IMAGE} {disk_path}" + ) + .output()?; + + validate_disk_image(&disk_path, &output, "test_to_disk_quadlet")?; + Ok(()) +} +integration_test!(test_to_disk_quadlet); diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index aa7ad7657..798112d1a 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -1141,12 +1141,15 @@ fn inject_systemd_units() -> Result<()> { fs::create_dir_all(&format!("{}/default.target.wants", target_units))?; fs::create_dir_all(&format!("{}/remote-fs.target.wants", target_units))?; - // Copy all .service and .mount files + // Copy all .service, .mount and .target files for entry in fs::read_dir(source_units)? { let entry = entry?; let path = entry.path(); let extension = path.extension().map(|ext| ext.to_string_lossy()); - if matches!(extension.as_deref(), Some("service") | Some("mount")) { + if matches!( + extension.as_deref(), + Some("service") | Some("mount") | Some("target") + ) { let filename = path.file_name().unwrap().to_string_lossy(); let target_path = format!("{}/{}", target_units, filename); fs::copy(&path, &target_path)?; diff --git a/crates/kit/src/to_disk.rs b/crates/kit/src/to_disk.rs index 60a0eab66..c160b88ab 100644 --- a/crates/kit/src/to_disk.rs +++ b/crates/kit/src/to_disk.rs @@ -86,6 +86,7 @@ use color_eyre::eyre::{eyre, Context}; use color_eyre::Result; use indicatif::HumanDuration; use indoc::indoc; +use tempfile::TempDir; use tracing::debug; /// Supported disk image formats @@ -528,11 +529,26 @@ pub fn run(mut opts: ToDiskOpts) -> Result { let tty = std::io::stdout().is_terminal(); + // Boot the install VM to a minimal target instead of default.target. + // This is to prevent user-created services in the image (such as quadlets) + // from interfering with the to-disk install process. + // + // bcvk-to-disk.target starts only what the install needs: + // basic.target, network-online + sshd (the host drives + // the install over SSH), and remote-fs (virtiofs host-storage mounts). + let units_dir = TempDir::new()?; + let units_system_dir = units_dir.path().join("system"); + std::fs::create_dir_all(&units_system_dir)?; + std::fs::write( + units_system_dir.join("bcvk-to-disk.target"), + include_str!("units/bcvk-to-disk.target"), + )?; + // Configure VM for installation: // - Use source image as installer environment // - Mount host storage read-only for image access // - Attach target disk via virtio-blk - // - Disable networking (using local storage only) + // - Boot to bcvk-to-disk.target instead of default.target let ephemeral_opts = RunEphemeralOpts { host_dns_servers: None, image: opts.get_installer_image().to_string(), @@ -550,14 +566,14 @@ pub fn run(mut opts: ToDiskOpts) -> Result { add_swap: Some(format!("{disk_size}")), bind_mounts: Vec::new(), // No additional bind mounts needed ro_bind_mounts: Vec::new(), // No additional ro bind mounts needed - systemd_units_dir: None, // No custom systemd units - bind_storage_ro: true, // Mount host container storage read-only + systemd_units_dir: Some(units_dir.path().to_string_lossy().to_string()), + bind_storage_ro: true, // Mount host container storage read-only mount_disk_files: vec![format!( "{}:output:{}", opts.target_disk, opts.additional.format.as_str() )], // Attach target disk - kernel_args: Default::default(), + kernel_args: vec!["systemd.unit=bcvk-to-disk.target".to_string()], ignition_config: None, debug_entrypoint: None, }; diff --git a/crates/kit/src/units/bcvk-to-disk.target b/crates/kit/src/units/bcvk-to-disk.target new file mode 100644 index 000000000..41fef444c --- /dev/null +++ b/crates/kit/src/units/bcvk-to-disk.target @@ -0,0 +1,14 @@ +# Boot target for bcvk to-disk ephemeral install VMs. +# +# The to-disk flow reuses the target image as the installer environment. +# Booting the full default.target could start user services (e.g. quadlets) +# that can interfere with the installation, e.g. by opening files under +# /var/lib/containers. This target starts only what the install needs: +# - basic.target (pulls in sysinit.target: udevd, journald, ...) +# - network-online.target and sshd.service (the host drives the install over SSH) +# - remote-fs.target (virtiofs host-storage mounts are wired here via SMBIOS creds) +[Unit] +Description=bcvk to-disk install target +Requires=basic.target network-online.target sshd.service +Wants=remote-fs.target +After=basic.target network-online.target sshd.service remote-fs.target