diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f3483d8a..04485ea59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,10 +304,45 @@ jobs: name: tmt-log-PR-${{ github.event.number }}-fedora-43-coreos-${{ env.ARCH }} path: /var/tmp/tmt + # Test the container export -> Anaconda liveimg install path. + # Builds localhost/bootc, exports as tarball, installs via Anaconda in QEMU, + # and verifies the resulting disk boots. + test-container-export: + needs: package + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + - name: Bootc Ubuntu Setup + uses: bootc-dev/actions/bootc-ubuntu-setup@main + with: + libvirt: true + + - name: Setup env + run: | + echo "BOOTC_base=quay.io/centos-bootc/centos-bootc:stream10" >> $GITHUB_ENV + + - name: Download package artifacts + uses: actions/download-artifact@v7 + with: + name: packages-centos-10 + path: target/packages/ + + - name: Build and run container export test + run: | + BOOTC_SKIP_PACKAGE=1 just test-container-export + + - name: Archive test logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: container-export-test-${{ env.ARCH }} + path: target/anaconda-test/*.log + # Sentinel job for required checks - configure this job name in repository settings required-checks: if: always() - needs: [cargo-deny, validate, package, test-integration, test-coreos] + needs: [cargo-deny, validate, package, test-integration, test-coreos, test-container-export] runs-on: ubuntu-latest steps: - run: exit 1 @@ -316,4 +351,5 @@ jobs: needs.validate.result != 'success' || needs.package.result != 'success' || needs.test-integration.result != 'success' || - needs.test-coreos.result != 'success' + needs.test-coreos.result != 'success' || + needs.test-container-export.result != 'success' diff --git a/Cargo.lock b/Cargo.lock index acb81221f..996727932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -131,6 +140,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.20.0" @@ -143,6 +167,23 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "bcvk-qemu" +version = "0.1.0" +source = "git+https://github.com/cgwalters/bcvk-fork?branch=iso-boot-mode#becffa055dc4bbea713c0c3eb688848472c5333e" +dependencies = [ + "camino", + "cap-std-ext 4.0.6", + "color-eyre", + "data-encoding", + "libc", + "nix 0.29.0", + "rustix", + "tokio", + "tracing", + "vsock", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -471,6 +512,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "cap-std-ext" +version = "4.0.6" +source = "git+https://github.com/coreos/cap-std-ext?rev=cfdb25d51ffc697e70aa0d8d3cefe9ec2133bd0a#cfdb25d51ffc697e70aa0d8d3cefe9ec2133bd0a" +dependencies = [ + "cap-primitives 3.4.5", + "cap-tempfile 3.4.5", + "libc", + "rustix", +] + [[package]] name = "cap-std-ext" version = "4.0.7" @@ -685,6 +737,33 @@ dependencies = [ "roff", ] +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -973,6 +1052,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "derive_builder" version = "0.20.2" @@ -1155,6 +1240,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1376,6 +1471,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.20.12" @@ -1553,6 +1654,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.13.0" @@ -1935,6 +2042,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oci-spec" version = "0.8.4" @@ -2470,6 +2586,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2894,20 +3016,26 @@ name = "tests-integration" version = "0.1.0" dependencies = [ "anyhow", + "bcvk-qemu", "bootc-kernel-cmdline", "camino", "cap-std-ext 5.0.0", "clap", + "data-encoding", "fn-error-context", + "indicatif 0.18.3", "indoc", "libtest-mimic", "oci-spec 0.9.0", + "rand 0.9.2", "rexpect", "rustix", "scopeguard", "serde", "serde_json", + "tar", "tempfile", + "tokio", "xshell", ] @@ -3125,6 +3253,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-journald" version = "0.3.2" @@ -3271,6 +3409,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsock" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8b4d00e672f147fc86a09738fadb1445bd1c0a40542378dfb82909deeee688" +dependencies = [ + "libc", + "nix 0.29.0", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Justfile b/Justfile index a33e9640f..77ca2bc11 100644 --- a/Justfile +++ b/Justfile @@ -114,7 +114,7 @@ test-tmt *ARGS: build [group('core')] test-container: build build-units podman run --rm --read-only localhost/bootc-units /usr/bin/bootc-units - podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} --env=BOOTC_boot_type={{boot_type}} {{base_img}} bootc-integration-tests container + podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} --env=BOOTC_boot_type={{boot_type}} --mount=type=image,source={{base_img}},target=/run/target {{base_img}} bootc-integration-tests container [group('core')] test-composefs bootloader filesystem boot_type seal_state: @@ -145,6 +145,28 @@ test-composefs bootloader filesystem boot_type seal_state: validate: podman build {{base_buildargs}} --target validate . +# Test container export via Anaconda liveimg install in a QEMU VM +[group('testing')] +test-container-export: build + #!/bin/bash + set -xeuo pipefail + iso=target/anaconda-test/boot.iso + if [ ! -f "$iso" ]; then + # Determine the ISO download URL from the base image's os-release + eval $(podman run --rm {{base_img}} bash -c '. /etc/os-release && echo "ID=$ID VERSION_ID=$VERSION_ID"') + case "${ID}-${VERSION_ID}" in + centos-10) + url="https://mirror.stream.centos.org/10-stream/BaseOS/x86_64/iso/CentOS-Stream-10-latest-x86_64-boot.iso" ;; + fedora-*) + url="https://download.fedoraproject.org/pub/fedora/linux/releases/${VERSION_ID}/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-${VERSION_ID}-1.1.iso" ;; + *) + echo "Unsupported OS: ${ID}-${VERSION_ID}" >&2; exit 1 ;; + esac + mkdir -p target/anaconda-test + curl -L --retry 3 --progress-bar -o "$iso" "$url" + fi + cargo run -p tests-integration -- anaconda-test --iso "$iso" {{base_img}} + # ============================================================================ # Testing variants and utilities # ============================================================================ diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index ddac010b0..d58365b56 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -419,6 +419,41 @@ pub(crate) enum ContainerOpts { #[clap(last = true)] args: Vec, }, + /// Export container filesystem as a tar archive. + /// + /// This command exports the container filesystem in a bootable format with proper + /// SELinux labeling. The output is written to stdout by default or to a specified file. + /// + /// Example: + /// bootc container export /target > output.tar + #[clap(hide = true)] + Export { + /// Format for export output + #[clap(long, default_value = "tar")] + format: ExportFormat, + + /// Output file (defaults to stdout) + #[clap(long, short = 'o')] + output: Option, + + /// Copy kernel and initramfs from /usr/lib/modules to /boot for legacy compatibility. + /// This is useful for installers that expect the kernel in /boot. + #[clap(long)] + kernel_in_boot: bool, + + /// Disable SELinux labeling in the exported archive. + #[clap(long)] + disable_selinux: bool, + + /// Path to the container filesystem root + target: Utf8PathBuf, + }, +} + +#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)] +pub(crate) enum ExportFormat { + /// Export as tar archive + Tar, } /// Subcommands which operate on images. @@ -1631,6 +1666,22 @@ async fn run_from_opt(opt: Opt) -> Result<()> { allow_missing_verity, args, } => crate::ukify::build_ukify(&rootfs, &kargs, &args, allow_missing_verity), + ContainerOpts::Export { + format, + target, + output, + kernel_in_boot, + disable_selinux, + } => { + crate::container_export::export( + &format, + &target, + output.as_deref(), + kernel_in_boot, + disable_selinux, + ) + .await + } }, Opt::Completion { shell } => { use clap_complete::aot::generate; diff --git a/crates/lib/src/container_export.rs b/crates/lib/src/container_export.rs new file mode 100644 index 000000000..59e2a98b8 --- /dev/null +++ b/crates/lib/src/container_export.rs @@ -0,0 +1,433 @@ +//! # Container Export Functionality +//! +//! This module implements the `bootc container export` command which exports +//! container filesystems as bootable tar archives with proper SELinux labeling +//! and legacy boot compatibility. + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std_ext::dirext::{CapStdExtDirExt, WalkConfiguration}; +use fn_error_context::context; +use ostree_ext::ostree; +use std::collections::HashMap; +use std::fs::File; +use std::io::{self, Write}; +use std::ops::ControlFlow; + +use crate::cli::ExportFormat; + +/// Options for container export. +#[derive(Debug, Default)] +struct ExportOptions { + /// Copy kernel and initramfs to /boot for legacy compatibility. + kernel_in_boot: bool, + /// Disable SELinux labeling. + disable_selinux: bool, +} + +/// Export a container filesystem to tar format with bootc-specific features. +#[context("Exporting container")] +pub(crate) async fn export( + format: &ExportFormat, + target_path: &Utf8Path, + output_path: Option<&Utf8Path>, + kernel_in_boot: bool, + disable_selinux: bool, +) -> Result<()> { + use cap_std_ext::cap_std; + use cap_std_ext::cap_std::fs::Dir; + + let options = ExportOptions { + kernel_in_boot, + disable_selinux, + }; + + let root_dir = Dir::open_ambient_dir(target_path, cap_std::ambient_authority()) + .with_context(|| format!("Failed to open directory: {}", target_path))?; + + match format { + ExportFormat::Tar => export_tar(&root_dir, output_path, &options).await, + } +} + +/// Export container filesystem as tar archive. +#[context("Exporting to tar")] +async fn export_tar( + root_dir: &cap_std_ext::cap_std::fs::Dir, + output_path: Option<&Utf8Path>, + options: &ExportOptions, +) -> Result<()> { + let output: Box = match output_path { + Some(path) => { + let file = File::create(path) + .with_context(|| format!("Failed to create output file: {}", path))?; + Box::new(file) + } + None => Box::new(io::stdout()), + }; + + let mut tar_builder = tar::Builder::new(output); + export_filesystem(&mut tar_builder, root_dir, options)?; + tar_builder.finish().context("Finalizing tar archive")?; + + Ok(()) +} + +fn export_filesystem( + tar_builder: &mut tar::Builder, + root_dir: &cap_std_ext::cap_std::fs::Dir, + options: &ExportOptions, +) -> Result<()> { + // Load SELinux policy from the image filesystem. + // We use the policy to compute labels rather than reading xattrs from the + // mounted filesystem, because OCI images don't usually include selinux xattrs, + // and the mounted runtime will have e.g. container_t + let sepolicy = if options.disable_selinux { + None + } else { + crate::lsm::new_sepolicy_at(root_dir)? + }; + + export_filesystem_walk(tar_builder, root_dir, sepolicy.as_ref())?; + + if options.kernel_in_boot { + handle_kernel_relocation(tar_builder, root_dir)?; + } + + Ok(()) +} + +/// Create a tar header from filesystem metadata. +fn tar_header_from_meta( + entry_type: tar::EntryType, + size: u64, + meta: &cap_std_ext::cap_std::fs::Metadata, +) -> tar::Header { + use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt}; + + let mut header = tar::Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_size(size); + header.set_mode(meta.permissions().mode() & !libc::S_IFMT); + header.set_uid(meta.uid() as u64); + header.set_gid(meta.gid() as u64); + header +} + +/// Create a tar header for a root-owned directory with mode 0755. +fn tar_header_dir_root() -> tar::Header { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_uid(0); + header.set_gid(0); + header +} + +/// Paths that should be skipped during export. +/// These are bootc/ostree-specific paths that shouldn't be in the exported tarball. +const SKIP_PATHS: &[&str] = &["sysroot/ostree"]; + +fn export_filesystem_walk( + tar_builder: &mut tar::Builder, + root_dir: &cap_std_ext::cap_std::fs::Dir, + sepolicy: Option<&ostree::SePolicy>, +) -> Result<()> { + use cap_std_ext::cap_primitives::fs::MetadataExt; + use std::path::Path; + + // Get the device number of the root directory - we should never see a different device + // since we use noxdev() which prevents crossing mount points + let root_meta = root_dir.dir_metadata()?; + let expected_dev = root_meta.dev(); + + // Track hardlinks: maps inode -> first path seen + // We only track inode since all files must be on the same device + let mut hardlinks: HashMap = HashMap::new(); + + // The target mount shouldn't have submounts, but just in case we use noxdev + let walk_config = WalkConfiguration::default() + .noxdev() + .path_base(Path::new("/")); + + root_dir.walk(&walk_config, |entry| -> std::io::Result> { + let path = entry.path; + + // Skip the root directory itself - it is meaningless in OCI right now + // https://github.com/containers/composefs-rs/pull/209 + // The root is represented as "/" which has one component + if path == Path::new("/") { + return Ok(ControlFlow::Continue(())); + } + + // Ensure the path is relative by default + let relative_path = path.strip_prefix("/").unwrap_or(path); + + // Skip empty paths (shouldn't happen but be safe) + if relative_path == Path::new("") { + return Ok(ControlFlow::Continue(())); + } + + // Skip paths that shouldn't be in the exported tarball + for skip_path in SKIP_PATHS { + if relative_path.starts_with(skip_path) { + // For directories, skip the entire subtree + if entry.file_type.is_dir() { + return Ok(ControlFlow::Break(())); + } + return Ok(ControlFlow::Continue(())); + } + } + + let file_type = entry.file_type; + if file_type.is_dir() { + add_directory_to_tar_from_walk(tar_builder, entry.dir, path, relative_path, sepolicy) + .map_err(std::io::Error::other)?; + } else if file_type.is_file() { + add_file_to_tar_from_walk( + tar_builder, + entry.dir, + entry.filename, + path, + relative_path, + sepolicy, + expected_dev, + &mut hardlinks, + ) + .map_err(std::io::Error::other)?; + } else if file_type.is_symlink() { + add_symlink_to_tar_from_walk( + tar_builder, + entry.dir, + entry.filename, + path, + relative_path, + sepolicy, + ) + .map_err(std::io::Error::other)?; + } else { + return Err(std::io::Error::other(format!( + "Unsupported file type: {}", + relative_path.display() + ))); + } + + Ok(ControlFlow::Continue(())) + })?; + + Ok(()) +} + +fn add_directory_to_tar_from_walk( + tar_builder: &mut tar::Builder, + dir: &cap_std_ext::cap_std::fs::Dir, + absolute_path: &std::path::Path, + relative_path: &std::path::Path, + sepolicy: Option<&ostree::SePolicy>, +) -> Result<()> { + use cap_std_ext::cap_primitives::fs::PermissionsExt; + + let metadata = dir.dir_metadata()?; + let mut header = tar_header_from_meta(tar::EntryType::Directory, 0, &metadata); + + if let Some(policy) = sepolicy { + let label = compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?; + add_selinux_pax_extension(tar_builder, &label)?; + } + + tar_builder + .append_data(&mut header, relative_path, &mut std::io::empty()) + .with_context(|| format!("Failed to add directory: {}", relative_path.display()))?; + + Ok(()) +} + +fn add_file_to_tar_from_walk( + tar_builder: &mut tar::Builder, + dir: &cap_std_ext::cap_std::fs::Dir, + filename: &std::ffi::OsStr, + absolute_path: &std::path::Path, + relative_path: &std::path::Path, + sepolicy: Option<&ostree::SePolicy>, + expected_dev: u64, + hardlinks: &mut HashMap, +) -> Result<()> { + use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt}; + use std::path::Path; + + let filename_path = Path::new(filename); + let metadata = dir.metadata(filename_path)?; + + // Skip files on different devices (e.g., bind mounts in containers like /etc/hosts). + // The noxdev() option prevents descending into directories on different devices, + // but individual files can still be bind-mounted from other filesystems. + let dev = metadata.dev(); + if dev != expected_dev { + tracing::debug!( + "Skipping file on different device: {} (expected dev {}, found {})", + relative_path.display(), + expected_dev, + dev + ); + return Ok(()); + } + + // Check for hardlinks: if nlink > 1, this file may have other links + let nlink = metadata.nlink(); + if nlink > 1 { + let ino = metadata.ino(); + if let Some(first_path) = hardlinks.get(&ino) { + // This is a hardlink to a file we've already written + let mut header = tar_header_from_meta(tar::EntryType::Link, 0, &metadata); + + if let Some(policy) = sepolicy { + let label = + compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?; + add_selinux_pax_extension(tar_builder, &label)?; + } + + tar_builder + .append_link(&mut header, relative_path, first_path) + .with_context(|| format!("Failed to add hardlink: {}", relative_path.display()))?; + return Ok(()); + } else { + // First time seeing this inode, record it + hardlinks.insert(ino, relative_path.to_path_buf()); + } + } + + // Regular file (or first occurrence of a hardlinked file) + let mut header = tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata); + + if let Some(policy) = sepolicy { + let label = compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?; + add_selinux_pax_extension(tar_builder, &label)?; + } + + let mut file = dir.open(filename_path)?; + tar_builder + .append_data(&mut header, relative_path, &mut file) + .with_context(|| format!("Failed to add file: {}", relative_path.display()))?; + + Ok(()) +} + +fn add_symlink_to_tar_from_walk( + tar_builder: &mut tar::Builder, + dir: &cap_std_ext::cap_std::fs::Dir, + filename: &std::ffi::OsStr, + absolute_path: &std::path::Path, + relative_path: &std::path::Path, + sepolicy: Option<&ostree::SePolicy>, +) -> Result<()> { + use cap_std_ext::cap_primitives::fs::PermissionsExt; + use std::path::Path; + + let filename_path = Path::new(filename); + let link_target = dir + .read_link_contents(filename_path) + .with_context(|| format!("Failed to read symlink: {:?}", filename))?; + let metadata = dir.symlink_metadata(filename_path)?; + let mut header = tar_header_from_meta(tar::EntryType::Symlink, 0, &metadata); + + if let Some(policy) = sepolicy { + // For symlinks, combine S_IFLNK with mode for proper label lookup + let symlink_mode = libc::S_IFLNK | (metadata.permissions().mode() & !libc::S_IFMT); + let label = compute_selinux_label(policy, absolute_path, symlink_mode)?; + add_selinux_pax_extension(tar_builder, &label)?; + } + + tar_builder + .append_link(&mut header, relative_path, &link_target) + .with_context(|| format!("Failed to add symlink: {}", relative_path.display()))?; + + Ok(()) +} + +/// Copy kernel and initramfs to /boot for legacy installers (e.g. Anaconda liveimg). +fn handle_kernel_relocation( + tar_builder: &mut tar::Builder, + root_dir: &cap_std_ext::cap_std::fs::Dir, +) -> Result<()> { + use crate::kernel::KernelType; + + let kernel_info = match crate::kernel::find_kernel(root_dir)? { + Some(kernel) => kernel, + None => return Ok(()), + }; + + append_dir_entry(tar_builder, "boot")?; + append_dir_entry(tar_builder, "boot/grub2")?; + + // UKIs don't need relocation - they're already in /boot/EFI/Linux + if kernel_info.kernel.unified { + return Ok(()); + } + + // Traditional vmlinuz kernels need to be copied to /boot + if let KernelType::Vmlinuz { path, initramfs } = &kernel_info.k_type { + let version = &kernel_info.kernel.version; + + // Copy vmlinuz + if root_dir.try_exists(path)? { + let metadata = root_dir.metadata(path)?; + let mut header = + tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata); + let mut file = root_dir.open(path)?; + let boot_path = format!("boot/vmlinuz-{}", version); + tar_builder + .append_data(&mut header, &boot_path, &mut file) + .with_context(|| format!("Failed to add kernel: {}", boot_path))?; + } + + // Copy initramfs + if root_dir.try_exists(initramfs)? { + let metadata = root_dir.metadata(initramfs)?; + let mut header = + tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata); + let mut file = root_dir.open(initramfs)?; + let boot_path = format!("boot/initramfs-{}.img", version); + tar_builder + .append_data(&mut header, &boot_path, &mut file) + .with_context(|| format!("Failed to add initramfs: {}", boot_path))?; + } + } + + Ok(()) +} + +fn append_dir_entry(tar_builder: &mut tar::Builder, path: &str) -> Result<()> { + let mut header = tar_header_dir_root(); + tar_builder + .append_data(&mut header, path, &mut std::io::empty()) + .with_context(|| format!("Failed to create {} directory", path))?; + Ok(()) +} + +fn compute_selinux_label( + policy: &ostree::SePolicy, + path: &std::path::Path, + mode: u32, +) -> Result { + use camino::Utf8Path; + + // Convert path to UTF-8 for policy lookup - non-UTF8 paths are not supported + let path_str = path + .to_str() + .ok_or_else(|| anyhow::anyhow!("Non-UTF8 path not supported: {:?}", path))?; + let utf8_path = Utf8Path::new(path_str); + + let label = crate::lsm::require_label(policy, utf8_path, mode)?; + Ok(label.to_string()) +} + +fn add_selinux_pax_extension( + tar_builder: &mut tar::Builder, + selinux_context: &str, +) -> Result<()> { + tar_builder + .append_pax_extensions([("SCHILY.xattr.security.selinux", selinux_context.as_bytes())]) + .context("Failed to add SELinux PAX extension")?; + Ok(()) +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index cd1b37052..a40bc58eb 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -69,6 +69,7 @@ mod bootloader; mod boundimage; pub mod cli; mod composefs_consts; +mod container_export; mod containerenv; pub(crate) mod deploy; mod discoverable_partition_specification; diff --git a/crates/tests-integration/Cargo.toml b/crates/tests-integration/Cargo.toml index 19e98b236..69d862ed7 100644 --- a/crates/tests-integration/Cargo.toml +++ b/crates/tests-integration/Cargo.toml @@ -26,10 +26,21 @@ xshell = { workspace = true } bootc-kernel-cmdline = { path = "../kernel_cmdline", version = "0.0.0" } # Crate-specific dependencies +# bcvk-qemu: QEMU/virtiofsd management from the bcvk project. +# This is a git dependency — not published to crates.io. +# When updating, also check the bcvk-qemu Cargo.toml for its own +# dependency versions (cap-std-ext, etc.) to avoid conflicts. +# TODO: Update rev after https://github.com/bootc-dev/bcvk/pull/217 merges +bcvk-qemu = { git = "https://github.com/cgwalters/bcvk-fork", branch = "iso-boot-mode" } +data-encoding = "2.9" +indicatif = { workspace = true } libtest-mimic = "0.8.0" oci-spec = "0.9.0" +rand = "0.9" rexpect = "0.6" scopeguard = "1.2.0" +tar = "0.4" +tokio = { workspace = true, features = ["rt", "macros"] } [lints] workspace = true diff --git a/crates/tests-integration/src/anaconda.rs b/crates/tests-integration/src/anaconda.rs new file mode 100644 index 000000000..cd46af976 --- /dev/null +++ b/crates/tests-integration/src/anaconda.rs @@ -0,0 +1,770 @@ +//! Anaconda installer testing via QEMU. +//! +//! This module tests the `bootc container export --format=tar` -> Anaconda +//! `liveimg` installation path. It: +//! +//! 1. Builds a derived container image with the ostree kernel-install layout +//! disabled (so standard kernel-install plugins work). +//! 2. Exports it to a tarball via `bootc container export`. +//! 3. Generates a kickstart that mounts the tarball via virtiofs and installs +//! via `liveimg`. +//! 4. Creates an automated ISO with the kickstart baked in. +//! 5. Boots the ISO in QEMU (managed by bcvk-qemu) and monitors logs. +//! +//! ## Container image requirements +//! +//! The input container image must be a bootc/rpm-ostree image. Before export, +//! the test builds a thin derived image that disables the ostree kernel-install +//! layout: +//! +//! - `/usr/lib/kernel/install.conf` — `layout=ostree` line removed +//! - `/usr/lib/kernel/install.conf.d/*-bootc-*.conf` — bootc drop-ins removed +//! - `/usr/lib/kernel/install.d/*-rpmostree.install` — rpm-ostree plugin removed + +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::process::Child; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use fn_error_context::context; +use xshell::{Shell, cmd}; + +/// Stage timeouts for installation monitoring. +const STAGE_TIMEOUT_ANACONDA_START: Duration = Duration::from_secs(180); +const STAGE_TIMEOUT_INSTALL: Duration = Duration::from_secs(900); +const STAGE_TIMEOUT_REBOOT: Duration = Duration::from_secs(60); + +/// Patterns that indicate installation progress. +const PATTERN_ANACONDA_STARTED: &str = "anaconda"; +const PATTERN_LIVEIMG_DOWNLOAD: &str = "liveimg"; +const PATTERN_INSTALL_COMPLETE: &str = "reboot: Restarting system"; + +/// Patterns that indicate errors. +const ERROR_PATTERNS: &[&str] = &[ + "Traceback (most recent call last)", + "FATAL:", + "Installation failed", + "error: Installation was stopped", + "kernel panic", + "Kernel panic", +]; + +/// Arguments for the anaconda test subcommand. +#[derive(Debug, clap::Args)] +pub(crate) struct AnacondaTestArgs { + /// Path to the Anaconda boot ISO. + #[arg(long)] + pub(crate) iso: Utf8PathBuf, + + /// Container image to install (must be in local container storage). + pub(crate) image: String, + + /// Output disk image path. + #[arg(long)] + pub(crate) disk: Option, + + /// Disk size in GB. + #[arg(long, default_value = "20")] + pub(crate) disk_size: u32, + + /// VM memory in MB. + #[arg(long, default_value = "10240")] + pub(crate) memory: u32, + + /// Number of vCPUs. + #[arg(long, default_value = "4")] + pub(crate) vcpus: u32, + + /// SSH port forwarding. + #[arg(long, default_value = "10022")] + pub(crate) ssh_port: u16, + + /// Keep VM running after installation (for debugging). + #[arg(long)] + pub(crate) keep_running: bool, + + /// Path to custom kickstart file. + #[arg(long)] + pub(crate) kickstart: Option, + + /// Root password for the installed system. + #[arg(long, default_value = "testcase")] + pub(crate) root_password: String, + + /// Skip creating automated ISO (use provided ISO directly). + #[arg(long)] + pub(crate) no_iso_modify: bool, + + /// Prepare ISO and kickstart only, don't run QEMU. + #[arg(long)] + pub(crate) dry_run: bool, +} + +/// The derived image tag used for the anaconda test. +const ANACONDA_TEST_IMAGE: &str = "localhost/bootc-anaconda-test"; + +/// Container image used to run `mkksiso`. +const MKKSISO_CONTAINER: &str = "quay.io/centos/centos:stream10"; + +/// Build a derived container image with ostree kernel-install layout disabled. +#[context("Building derived image for anaconda test")] +fn build_derived_image(sh: &Shell, base_image: &str) -> Result<()> { + let containerfile = format!( + r#"FROM {base_image} +RUN sed -i '/layout=ostree/d' /usr/lib/kernel/install.conf && \ + rm -vf /usr/lib/kernel/install.conf.d/*-bootc-*.conf \ + /usr/lib/kernel/install.d/*-rpmostree.install +"# + ); + + println!("Building derived image {ANACONDA_TEST_IMAGE}..."); + cmd!( + sh, + "podman build --network=none -t {ANACONDA_TEST_IMAGE} -f - ." + ) + .stdin(containerfile.as_bytes()) + .run() + .context("Building derived anaconda-test image")?; + + Ok(()) +} + +/// Export a container image to a tarball using `bootc container export`. +#[context("Exporting container to tarball")] +fn export_container_to_tarball(sh: &Shell, image: &str, output_path: &Utf8Path) -> Result<()> { + println!("Exporting container image to tarball..."); + println!(" Image: {image}"); + println!(" Output: {output_path}"); + + let output_dir = output_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid output path"))?; + let output_filename = output_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid output filename"))?; + + sh.create_dir(output_dir) + .context("Creating output directory")?; + + let abs_output_dir = std::fs::canonicalize(output_dir) + .context("Getting absolute path")? + .to_string_lossy() + .to_string(); + + let output_in_container = format!("/output/{output_filename}"); + cmd!( + sh, + "podman run --rm --privileged --network=none + -v {abs_output_dir}:/output:Z + {image} + bootc container export --format=tar --kernel-in-boot -o {output_in_container} /" + ) + .run() + .context("Running bootc container export")?; + + if !output_path.exists() { + anyhow::bail!("Tarball was not created at {output_path}"); + } + + let metadata = std::fs::metadata(output_path).context("Getting tarball metadata")?; + println!( + " Created tarball: {output_path} ({})", + indicatif::HumanBytes(metadata.len()) + ); + + Ok(()) +} + +/// Generate kickstart content for bootc liveimg installation. +/// +/// The tarball is shared into the guest via virtiofs and mounted at +/// `/mnt/tarball/` in a `%pre` script. +fn generate_kickstart_liveimg(root_password: &str) -> String { + format!( + r#"# Automated bootc installation kickstart (liveimg) +# Generated by bootc integration tests + +reboot + +# Install from tarball shared via virtiofs +liveimg --url=file:///mnt/tarball/rootfs.tar + +# Basic configuration +rootpw {root_password} + +# Mount the virtiofs share before Anaconda tries to fetch the tarball +%pre --log=/tmp/pre-mount.log +set -eux +mkdir -p /mnt/tarball +mount -t virtiofs tarball /mnt/tarball +ls -la /mnt/tarball/ +%end + +bootloader --timeout=1 +zerombr +clearpart --all --initlabel +# Use ext4 to avoid btrfs subvolume complexity +autopart --nohome --noswap --type=plain --fstype=ext4 + +lang en_US.UTF-8 +keyboard us +timezone America/New_York --utc + +# Set up bootloader entries for the installed system. +%post --log=/root/ks-post.log +set -eux + +KVER=$(ls /usr/lib/modules | head -1) +echo "Kernel version: $KVER" + +# Ensure machine-id exists (needed by kernel-install for BLS filenames) +if [ ! -s /etc/machine-id ]; then + systemd-machine-id-setup +fi + +kernel-install add "$KVER" "/usr/lib/modules/$KVER/vmlinuz" + +# Append console=ttyS0 to the generated BLS entry so serial output works +for entry in /boot/loader/entries/*.conf; do + if ! grep -q 'console=ttyS0' "$entry"; then + sed -i 's/^options .*/& console=ttyS0/' "$entry" + fi +done + +# Regenerate grub config to pick up BLS entries +grub2-mkconfig -o /boot/grub2/grub.cfg || true +if [ -d /boot/efi/EFI/fedora ]; then + grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg || true +fi + +echo "Bootloader setup complete" +cat /boot/loader/entries/*.conf +%end +"#, + root_password = root_password, + ) +} + +/// Create an automated ISO by injecting a kickstart file using `mkksiso`. +/// +/// Runs inside a container so the host only needs `podman`. +#[context("Preparing automated ISO")] +fn prepare_automated_iso( + sh: &Shell, + input_iso: &Utf8Path, + output_iso: &Utf8Path, + kickstart_path: &Utf8Path, +) -> Result<()> { + if output_iso.exists() { + std::fs::remove_file(output_iso).context("Removing existing output ISO")?; + } + + let abs_iso = + std::fs::canonicalize(input_iso).with_context(|| format!("Resolving {input_iso}"))?; + let abs_ks = std::fs::canonicalize(kickstart_path) + .with_context(|| format!("Resolving {kickstart_path}"))?; + let abs_outdir = std::fs::canonicalize( + output_iso + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid output ISO path"))?, + ) + .context("Resolving output directory")?; + let out_filename = output_iso + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid output ISO filename"))?; + + let abs_iso = abs_iso.to_string_lossy().into_owned(); + let abs_ks = abs_ks.to_string_lossy().into_owned(); + let abs_outdir = abs_outdir.to_string_lossy().into_owned(); + + let bash_cmd = format!( + "dnf install -y lorax xorriso && mkksiso --ks /work/ks.cfg --skip-mkefiboot \ + -c 'console=ttyS0 inst.sshd inst.nomediacheck' /work/input.iso /work/out/{out_filename}" + ); + cmd!( + sh, + "podman run --rm --network=host + -v {abs_iso}:/work/input.iso:ro + -v {abs_ks}:/work/ks.cfg:ro + -v {abs_outdir}:/work/out:Z + {MKKSISO_CONTAINER} + bash -c {bash_cmd}" + ) + .run() + .context("Running mkksiso in container")?; + + println!("Created automated ISO: {output_iso}"); + Ok(()) +} + +/// Run the Anaconda installation test. +#[context("Running Anaconda test")] +pub(crate) fn run_anaconda_test(args: &AnacondaTestArgs) -> Result<()> { + let sh = Shell::new()?; + + cmd!(sh, "which podman") + .ignore_stdout() + .run() + .context("podman is required")?; + + let workdir = Utf8Path::new("target/anaconda-test"); + sh.create_dir(workdir).context("Creating workdir")?; + + let disk_path = args + .disk + .clone() + .unwrap_or_else(|| workdir.join("disk.img")); + let tarball_path = workdir.join("rootfs.tar"); + let kickstart_path = workdir.join("kickstart.ks"); + let auto_iso_path = workdir.join("anaconda-auto.iso"); + let anaconda_log = workdir.join("anaconda-install.log"); + let program_log = workdir.join("anaconda-program.log"); + let serial_log = workdir.join("serial.log"); + + // Verify the base image exists + let image = &args.image; + cmd!(sh, "podman image exists {image}") + .run() + .with_context(|| format!("Image '{image}' not found in local container storage"))?; + println!("Verified image exists: {image}"); + + build_derived_image(&sh, image)?; + export_container_to_tarball(&sh, ANACONDA_TEST_IMAGE, &tarball_path)?; + + // Generate kickstart + let kickstart_content = if let Some(ref ks) = args.kickstart { + std::fs::read_to_string(ks).with_context(|| format!("Reading kickstart: {ks}"))? + } else { + generate_kickstart_liveimg(&args.root_password) + }; + std::fs::write(&kickstart_path, &kickstart_content).context("Writing kickstart")?; + println!("Kickstart written to: {kickstart_path}"); + + // Prepare the ISO + let boot_iso = if args.no_iso_modify { + args.iso.clone() + } else { + prepare_automated_iso(&sh, &args.iso, &auto_iso_path, &kickstart_path)?; + auto_iso_path.clone() + }; + + if args.dry_run { + println!("\nDry-run complete. Generated files:"); + println!(" Tarball: {tarball_path}"); + println!(" Kickstart: {kickstart_path}"); + if !args.no_iso_modify { + println!(" Automated ISO: {boot_iso}"); + } + println!("\nTo run the full test, remove --dry-run"); + return Ok(()); + } + + // Non-dry-run: check for qemu-img + cmd!(sh, "which qemu-img") + .ignore_stdout() + .run() + .context("qemu-img is required")?; + + // Create disk image + let disk_size = format!("{}G", args.disk_size); + cmd!(sh, "qemu-img create -f qcow2 {disk_path} {disk_size}") + .run() + .context("Creating disk image")?; + println!("Created disk: {disk_path} ({disk_size})"); + + // Resolve workdir to an absolute path; all log/socket paths are derived from it. + let abs_workdir = + Utf8PathBuf::try_from(std::fs::canonicalize(workdir).context("Canonicalizing workdir")?) + .context("Workdir path is not valid UTF-8")?; + + let abs_disk_path = if let Some(ref custom) = args.disk { + std::fs::canonicalize(custom) + .context("Getting absolute disk path")? + .to_string_lossy() + .into_owned() + } else { + abs_workdir.join("disk.img").into_string() + }; + + // Build the SMBIOS credentials for the program-log streaming unit. + // This injects a systemd unit into the installer that streams + // /tmp/program.log to the host via virtio-serial. + let program_log_unit = r#"[Unit] +Description=Stream Anaconda program.log to host via virtio +DefaultDependencies=no +After=dev-virtio\x2dports-org.fedoraproject.anaconda.program.0.device +ConditionKernelCommandLine=inst.stage2 + +[Service] +Type=simple +ExecStartPre=/bin/sh -c "for i in {1..300}; do [ -e /tmp/program.log ] && [ -e /dev/virtio-ports/org.fedoraproject.anaconda.program.0 ] && break; sleep 0.1; done" +ExecStart=/bin/sh -c "exec tail -f -n +0 /tmp/program.log > /dev/virtio-ports/org.fedoraproject.anaconda.program.0 2>/dev/null || true" +Restart=always +RestartSec=2"#; + + let program_log_dropin = r#"[Unit] +Wants=anaconda-program-log.service +After=anaconda-program-log.service"#; + + let unit_b64 = data_encoding::BASE64.encode(program_log_unit.as_bytes()); + let dropin_b64 = data_encoding::BASE64.encode(program_log_dropin.as_bytes()); + + let smbios_unit = format!( + "io.systemd.credential.binary:systemd.extra-unit.anaconda-program-log.service={unit_b64}", + ); + let smbios_dropin = format!( + "io.systemd.credential.binary:systemd.unit-dropin.sysinit.target~anaconda-program-log={dropin_b64}", + ); + + println!("\nStarting QEMU with Anaconda installation..."); + println!(" Disk: {disk_path}"); + println!(" ISO: {boot_iso}"); + println!(" Anaconda log: {anaconda_log}"); + println!(" Program log: {program_log}"); + println!(); + println!(" SSH access: ssh -p {} root@localhost", args.ssh_port); + println!(" Password: {}", args.root_password); + println!(); + println!(" Monitor progress:"); + println!(" tail -f {anaconda_log}"); + println!(" tail -f {program_log}"); + println!(); + + let socket_path = abs_workdir.join("virtiofs.sock"); + // Remove stale socket from a previous run + if socket_path.exists() { + std::fs::remove_file(&socket_path) + .with_context(|| format!("Removing stale socket {socket_path}"))?; + } + + let virtiofs_config = bcvk_qemu::VirtiofsConfig { + socket_path: socket_path.clone(), + shared_dir: abs_workdir.clone(), + debug: false, + readonly: true, + log_file: None, + }; + + // Build QemuConfig using bcvk-qemu + let abs_iso = std::fs::canonicalize(&boot_iso) + .context("Resolving ISO path")? + .to_string_lossy() + .into_owned(); + + let mut config = bcvk_qemu::QemuConfig::new_iso_boot(args.memory, args.vcpus, abs_iso); + config.serial_log = Some(abs_workdir.join("serial.log").into_string()); + config.no_reboot = args.keep_running; + + // Add disk + config.add_virtio_blk_device( + abs_disk_path, + "bootdisk".to_string(), + bcvk_qemu::DiskFormat::Qcow2, + ); + + // SSH port forwarding + config.enable_ssh_access(Some(args.ssh_port)); + + // Virtio-serial for Anaconda log channels + config.add_virtio_serial_out( + "org.fedoraproject.anaconda.log.0", + abs_workdir.join("anaconda-install.log").into_string(), + false, + ); + config.add_virtio_serial_out( + "org.fedoraproject.anaconda.program.0", + abs_workdir.join("anaconda-program.log").into_string(), + false, + ); + + // SMBIOS credentials for the program-log streaming unit + config.add_smbios_credential(smbios_unit); + config.add_smbios_credential(smbios_dropin); + + // Virtiofs for sharing the tarball into the guest + config.add_virtiofs(virtiofs_config, "tarball"); + + // Spawn QEMU + virtiofsd via bcvk-qemu + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Creating tokio runtime")?; + + let mut running = rt.block_on(async { + bcvk_qemu::RunningQemu::spawn(config) + .await + .map_err(|e| anyhow::anyhow!("{e:#}")) + })?; + + println!("QEMU started (PID: {})", running.qemu_process.id()); + + // Give QEMU a moment to start and check for immediate failures. + // Note: bcvk-qemu inherits stderr so QEMU errors appear on the terminal directly. + std::thread::sleep(Duration::from_millis(500)); + if let Ok(Some(status)) = running.qemu_process.try_wait() { + anyhow::bail!("QEMU failed to start (exit {status}); check stderr output above"); + } + + // Monitor logs for progress and errors + let result = monitor_installation( + &anaconda_log, + &program_log, + &serial_log, + &mut running.qemu_process, + ); + + // Clean up QEMU if still running + if let Ok(None) = running.qemu_process.try_wait() { + println!("Terminating QEMU..."); + let _ = running.qemu_process.kill(); + let _ = running.qemu_process.wait(); + } + + match result { + Ok(()) => { + println!("\nAnaconda installation completed successfully!"); + println!("Disk image: {disk_path}"); + Ok(()) + } + Err(e) => { + eprintln!("\n=== Installation failed ==="); + eprintln!("Error: {e}"); + eprintln!("\n--- Last 20 lines of anaconda log ---"); + print_last_lines(&anaconda_log, 20); + eprintln!("\n--- Last 20 lines of program log ---"); + print_last_lines(&program_log, 20); + eprintln!("\n--- Last 20 lines of serial log ---"); + print_last_lines(&serial_log, 20); + Err(e) + } + } +} + +/// Print the last N lines of a file. +fn print_last_lines(path: &Utf8Path, n: usize) { + if let Ok(content) = std::fs::read_to_string(path) { + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(n); + for line in &lines[start..] { + eprintln!("{line}"); + } + } else { + eprintln!("(file not found or unreadable)"); + } +} + +/// Installation stage for progress tracking. +#[derive(Debug, Clone, Copy, PartialEq)] +enum InstallStage { + Booting, + AnacondaStarting, + Installing, + Rebooting, +} + +impl std::fmt::Display for InstallStage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Booting => write!(f, "Booting"), + Self::AnacondaStarting => write!(f, "Starting Anaconda"), + Self::Installing => write!(f, "Installing (liveimg)"), + Self::Rebooting => write!(f, "Rebooting"), + } + } +} + +/// Monitor installation logs for progress and errors. +fn monitor_installation( + anaconda_log: &Utf8Path, + program_log: &Utf8Path, + serial_log: &Utf8Path, + qemu: &mut Child, +) -> Result<()> { + let start_time = Instant::now(); + let mut stage = InstallStage::Booting; + let mut stage_start = Instant::now(); + let mut last_activity = Instant::now(); + + let mut anaconda_pos: u64 = 0; + let mut program_pos: u64 = 0; + let mut serial_pos: u64 = 0; + + println!("Monitoring installation progress..."); + println!(" Stage: {stage}"); + + loop { + // Check if QEMU exited + if let Some(status) = qemu.try_wait().context("Checking QEMU status")? { + if stage == InstallStage::Rebooting || status.success() { + return Ok(()); + } + anyhow::bail!("QEMU exited unexpectedly with status: {status} at stage: {stage}"); + } + + let anaconda_new = read_new_content(anaconda_log, &mut anaconda_pos); + let program_new = read_new_content(program_log, &mut program_pos); + let serial_new = read_new_content(serial_log, &mut serial_pos); + + // Check for errors + for (log_name, content) in [ + ("anaconda", &anaconda_new), + ("program", &program_new), + ("serial", &serial_new), + ] { + for pattern in ERROR_PATTERNS { + if content.contains(pattern) { + anyhow::bail!( + "Error detected in {log_name} log: found '{pattern}'\nContext: {}", + extract_context(content, pattern) + ); + } + } + } + + // Update stage + let old_stage = stage; + if stage == InstallStage::Booting + && (anaconda_new.contains(PATTERN_ANACONDA_STARTED) || serial_new.contains("anaconda")) + { + stage = InstallStage::AnacondaStarting; + stage_start = Instant::now(); + } + if stage == InstallStage::AnacondaStarting + && (program_new.contains(PATTERN_LIVEIMG_DOWNLOAD) + || anaconda_new.to_lowercase().contains("liveimg") + || anaconda_new.contains("/mnt/tarball")) + { + stage = InstallStage::Installing; + stage_start = Instant::now(); + } + if stage == InstallStage::Installing + && (serial_new.contains(PATTERN_INSTALL_COMPLETE) + || serial_new.contains("reboot: Restarting")) + { + stage = InstallStage::Rebooting; + stage_start = Instant::now(); + } + if stage == InstallStage::Rebooting { + println!(" Installation completed, reboot initiated."); + return Ok(()); + } + + if stage != old_stage { + let elapsed = start_time.elapsed(); + println!(" Stage: {stage} ({}s elapsed)", elapsed.as_secs()); + last_activity = Instant::now(); + } + + if !anaconda_new.is_empty() || !program_new.is_empty() || !serial_new.is_empty() { + last_activity = Instant::now(); + } + + let stage_elapsed = stage_start.elapsed(); + let timeout = match stage { + InstallStage::Booting | InstallStage::AnacondaStarting => STAGE_TIMEOUT_ANACONDA_START, + InstallStage::Installing => STAGE_TIMEOUT_INSTALL, + InstallStage::Rebooting => STAGE_TIMEOUT_REBOOT, + }; + + if stage_elapsed > timeout { + anyhow::bail!( + "Timeout waiting for stage '{stage}' to complete ({}s elapsed, {}s timeout)", + stage_elapsed.as_secs(), + timeout.as_secs() + ); + } + + if last_activity.elapsed() > Duration::from_secs(120) { + anyhow::bail!( + "No activity for 120 seconds at stage '{stage}'. Installation may be stuck." + ); + } + + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// Read new content from a file since last position. +fn read_new_content(path: &Utf8Path, pos: &mut u64) -> String { + let Ok(mut file) = File::open(path) else { + return String::new(); + }; + + let Ok(metadata) = file.metadata() else { + return String::new(); + }; + + let file_len = metadata.len(); + if file_len <= *pos { + return String::new(); + } + + if file.seek(SeekFrom::Start(*pos)).is_err() { + return String::new(); + } + + let mut content = String::new(); + let reader = BufReader::new(&mut file); + for line in reader.lines().map_while(Result::ok) { + content.push_str(&line); + content.push('\n'); + } + + *pos = file_len; + content +} + +/// Extract context around a pattern match for error reporting. +fn extract_context(content: &str, pattern: &str) -> String { + let Some(idx) = content.find(pattern) else { + return String::new(); + }; + let mut start = idx.saturating_sub(100); + while start > 0 && !content.is_char_boundary(start) { + start -= 1; + } + let mut end = (idx + pattern.as_bytes().len() + 200).min(content.as_bytes().len()); + while end < content.as_bytes().len() && !content.is_char_boundary(end) { + end += 1; + } + format!("...{}...", &content[start..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_context_basic() { + let content = "aaa ERROR bbb"; + let ctx = extract_context(content, "ERROR"); + assert!(ctx.contains("ERROR")); + assert!(ctx.starts_with("...")); + assert!(ctx.ends_with("...")); + } + + #[test] + fn test_extract_context_not_found() { + assert_eq!(extract_context("hello world", "MISSING"), ""); + } + + #[test] + fn test_extract_context_multibyte() { + let prefix = "é".repeat(60); + let suffix = "日本語".repeat(80); + let content = format!("{prefix}PATTERN{suffix}"); + let ctx = extract_context(&content, "PATTERN"); + assert!(ctx.contains("PATTERN")); + } + + #[test] + fn test_extract_context_at_boundaries() { + let ctx = extract_context("PATTERN and more", "PATTERN"); + assert!(ctx.contains("PATTERN")); + + let ctx = extract_context("some text PATTERN", "PATTERN"); + assert!(ctx.contains("PATTERN")); + } +} diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 52671fbbd..e53786675 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -188,6 +188,93 @@ fn test_variant_base_crosscheck() -> Result<()> { Ok(()) } +/// Verify exported tar has correct size/mode/content vs source. +/// Checks all critical paths (kernel, boot) plus ~10% random sample. +pub(crate) fn test_container_export_tar() -> Result<()> { + use rand::{Rng, SeedableRng}; + use std::io::Read; + use std::os::unix::fs::MetadataExt; + + const TARGET: &str = "/run/target"; + const CRITICAL: &[&str] = &["usr/lib/modules/", "usr/lib/ostree-boot/", "boot/"]; + + anyhow::ensure!( + std::path::Path::new(TARGET).exists(), + "Test requires image mounted at {TARGET}" + ); + + let td = tempfile::tempdir()?; + let tar_path = td.path().join("export.tar"); + let tar_str = tar_path.to_str().unwrap(); + + let sh = Shell::new()?; + cmd!( + sh, + "bootc container export --format=tar -o {tar_str} {TARGET}" + ) + .run()?; + + // Collect tar entries: path -> (size, mode, first 4KB content) + let mut entries: Vec<(String, u64, u32, Vec)> = Vec::new(); + for entry in tar::Archive::new(fs::File::open(&tar_path)?).entries()? { + let mut entry = entry?; + let header = entry.header(); + if header.entry_type() != tar::EntryType::Regular { + continue; + } + let path = entry.path()?.to_string_lossy().into_owned(); + let size: u64 = header.size()?; + let mode = header.mode()?; + let sample_len = usize::try_from(size).unwrap_or(usize::MAX).min(4096); + let mut sample = vec![0u8; sample_len]; + entry.read_exact(&mut sample)?; + entries.push((path, size, mode, sample)); + } + assert!(entries.len() > 100, "too few files: {}", entries.len()); + + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let (mut verified, mut critical_count) = (0, 0); + + for (path, tar_size, tar_mode, tar_sample) in &entries { + let is_critical = CRITICAL.iter().any(|p| path.contains(p)); + if !is_critical && !rng.random_bool(0.1) { + continue; + } + + let src = std::path::Path::new(TARGET).join(path); + let Ok(meta) = src.symlink_metadata() else { + continue; + }; + if !meta.is_file() { + continue; + } + + assert_eq!(*tar_size, meta.len(), "{path}: size mismatch"); + assert_eq!( + tar_mode & 0o7777, + meta.mode() & 0o7777, + "{path}: mode mismatch" + ); + + let mut src_sample = vec![0u8; tar_sample.len()]; + fs::File::open(&src)?.read_exact(&mut src_sample)?; + assert_eq!(tar_sample, &src_sample, "{path}: content mismatch"); + + verified += 1; + if is_critical { + critical_count += 1; + } + } + + assert!(verified >= 50, "only verified {verified} files"); + assert!(critical_count >= 5, "only {critical_count} critical files"); + eprintln!( + "Verified {verified}/{} files ({critical_count} critical)", + entries.len() + ); + Ok(()) +} + /// Test that compute-composefs-digest works on a directory pub(crate) fn test_compute_composefs_digest() -> Result<()> { use std::os::unix::fs::PermissionsExt; @@ -254,6 +341,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { new_test("status", test_bootc_status), new_test("container inspect", test_bootc_container_inspect), new_test("system-reinstall --help", test_system_reinstall_help), + new_test("container export tar", test_container_export_tar), new_test("compute-composefs-digest", test_compute_composefs_digest), ]; diff --git a/crates/tests-integration/src/tests-integration.rs b/crates/tests-integration/src/tests-integration.rs index d412ae39d..d5c2e2dd6 100644 --- a/crates/tests-integration/src/tests-integration.rs +++ b/crates/tests-integration/src/tests-integration.rs @@ -4,6 +4,7 @@ use camino::Utf8PathBuf; use cap_std_ext::cap_std::{self, fs::Dir}; use clap::Parser; +mod anaconda; mod container; mod hostpriv; mod install; @@ -47,6 +48,8 @@ pub(crate) enum Opt { #[clap(long)] warn: bool, }, + /// Test bootc installation via Anaconda with a local container image + AnacondaTest(anaconda::AnacondaTestArgs), } fn main() { @@ -61,6 +64,7 @@ fn main() { let root = &Dir::open_ambient_dir(&rootfs, cap_std::ambient_authority()).unwrap(); selinux::verify_selinux_recurse(root, warn) } + Opt::AnacondaTest(args) => anaconda::run_anaconda_test(&args), }; if let Err(e) = r { eprintln!("error: {e:?}"); diff --git a/deny.toml b/deny.toml index 0db37433b..65b2a57dd 100644 --- a/deny.toml +++ b/deny.toml @@ -12,4 +12,4 @@ name = "ring" [sources] unknown-registry = "deny" unknown-git = "deny" -allow-git = ["https://github.com/composefs/composefs-rs"] +allow-git = ["https://github.com/composefs/composefs-rs", "https://github.com/bootc-dev/bcvk"] diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 5dd3d8a99..d4d046523 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -65,6 +65,7 @@ - [fsck](experimental-fsck.md) - [install reset](experimental-install-reset.md) - [--progress-fd](experimental-progress-fd.md) +- [container export](experimental-container-export.md) # More information diff --git a/docs/src/experimental-container-export.md b/docs/src/experimental-container-export.md new file mode 100644 index 000000000..aea795c17 --- /dev/null +++ b/docs/src/experimental-container-export.md @@ -0,0 +1,126 @@ +# container export + +Experimental features are subject to change or removal. Please +do provide feedback on them. + +## Overview + +The `bootc container export` command exports a container filesystem as a +tar archive suitable for unpacking onto a target system. The output includes +proper SELinux labeling (computed from the image's policy) and can optionally +relocate the kernel to `/boot` for compatibility with legacy installers like +Anaconda's `liveimg` command. + +This is hidden from `--help` output; run `bootc container export --help` +directly to see usage. + +## Usage + +``` +bootc container export [OPTIONS] TARGET +``` + +### Options + +- `--format ` - Export format (default: `tar`) +- `-o, --output ` - Output file (defaults to stdout) +- `--kernel-in-boot` - Copy kernel and initramfs from `/usr/lib/modules` to `/boot` for legacy compatibility +- `--disable-selinux` - Disable SELinux labeling in the exported archive + +### Examples + +Export a mounted container image to a tar file: + +``` +bootc container export /run/target -o /output/rootfs.tar +``` + +Export to stdout and pipe to another command: + +``` +bootc container export /run/target | tar -C /mnt -xf - +``` + +Export with kernel relocation for legacy installers: + +``` +bootc container export --kernel-in-boot /run/target -o rootfs.tar +``` + +Using podman to mount and export an image: + +``` +podman run --rm \ + --mount=type=image,source=quay.io/fedora/fedora-bootc:42,target=/run/target \ + quay.io/fedora/fedora-bootc:42 \ + bootc container export --kernel-in-boot -o /output/rootfs.tar /run/target +``` + +## Anaconda liveimg integration + +The tar export can be used with Anaconda's `liveimg` kickstart command to install +bootc-built images on systems without native bootc support in the installer. + +### Important considerations + +**This creates a traditional filesystem install, NOT a full bootc system.** +The installed system will: + +- Have the filesystem contents from the container image +- Boot with a standard GRUB setup +- NOT have ostree/bootc infrastructure for atomic updates + +For full bootc functionality, use `bootc install` or Anaconda's native `bootc` +kickstart command (available in Fedora 43+). + +### Container image requirements + +At the current time this is only tested with a workflow starting +`FROM quay.io/fedora/fedora-bootc` or equivalent. In theory, this workflow +would be compatible with an image starting with just `FROM fedora` then +`RUN dnf -y install kernel` etc., but that is not tested. + +For the first case right now, you must include as part of your container +build this logic or equivalent: + +```dockerfile +RUN sed -i '/layout=ostree/d' /usr/lib/kernel/install.conf && \ + rm -vf /usr/lib/kernel/install.conf.d/*-bootc-*.conf \ + /usr/lib/kernel/install.d/*-rpmostree.install +``` + +The sed command removes the `layout=ostree` line from `install.conf` while +preserving any other settings. The rm commands remove the bootc drop-in +and rpm-ostree plugin that would otherwise intercept `kernel-install` and +delegate to rpm-ostree (which doesn't work outside an ostree deployment). + +### Required kickstart configuration + +When using the exported tar with Anaconda's `liveimg`, several kickstart +options are required for a successful installation. + +#### Bootloader setup via kernel-install + +The `%post` script should use `kernel-install add` to set up the bootloader. +This creates BLS entries, copies the kernel, and generates an initramfs +via the standard plugin chain (50-dracut, 90-loaderentry, etc.): + +``` +%post --erroronfail +set -eux + +KVER=$(ls /usr/lib/modules | head -1) + +# Ensure machine-id exists (needed by kernel-install for BLS filenames) +if [ ! -s /etc/machine-id ]; then + systemd-machine-id-setup +fi + +# kernel-install creates the BLS entry, copies vmlinuz, and generates +# initramfs via the standard plugin chain (50-dracut, 90-loaderentry, etc.) +kernel-install add "$KVER" "/usr/lib/modules/$KVER/vmlinuz" + +# Regenerate grub config to pick up BLS entries +grub2-mkconfig -o /boot/grub2/grub.cfg +%end +```