diff --git a/containers/anaconda-bootc/Dockerfile b/containers/anaconda-bootc/Dockerfile new file mode 100644 index 0000000..e25d4df --- /dev/null +++ b/containers/anaconda-bootc/Dockerfile @@ -0,0 +1,24 @@ +# Anaconda installer container for bcvk +# +# This container provides anaconda for installing bootc container images +# using kickstart files. It's designed to run as an ephemeral VM that +# boots into anaconda.target. +# +# Build: podman build -t localhost/anaconda-bootc:latest . + +FROM quay.io/fedora/fedora-bootc:42 + +# Install anaconda and required packages +COPY packages.txt /tmp/packages.txt +RUN dnf install -y $(grep -v '^#' /tmp/packages.txt | tr '\n' ' ') && \ + dnf clean all && \ + rm /tmp/packages.txt + +# Install bcvk setup service that runs before anaconda +COPY bcvk-anaconda-setup.service /usr/lib/systemd/system/bcvk-anaconda-setup.service +COPY bcvk-anaconda-setup.sh /usr/libexec/bcvk-anaconda-setup.sh +RUN chmod +x /usr/libexec/bcvk-anaconda-setup.sh + +# Set anaconda.target as default and enable our setup service +RUN systemctl set-default anaconda.target && \ + systemctl enable bcvk-anaconda-setup.service diff --git a/containers/anaconda-bootc/README.md b/containers/anaconda-bootc/README.md new file mode 100644 index 0000000..b78188f --- /dev/null +++ b/containers/anaconda-bootc/README.md @@ -0,0 +1,97 @@ +# Anaconda Installer Container for bcvk + +This container provides the anaconda installer for installing bootc container +images using kickstart files. It boots into `anaconda.target` and uses the +upstream anaconda systemd services. + +## Overview + +The container is based on `quay.io/fedora/fedora-bootc:42` with anaconda-tui +installed. It boots directly into `anaconda.target` and uses the upstream +`anaconda-direct.service` with a bcvk setup service that runs beforehand. + +## Building + +```bash +cd containers/anaconda-bootc +podman build -t localhost/anaconda-bootc:latest . +``` + +## How It Works + +1. bcvk creates a target disk and generates a kickstart file +2. bcvk starts the VM with: + - Host container storage mounted read-only via virtiofs + - Kickstart file mounted via virtiofs at `/run/virtiofs-mnt-kickstart/` + - Target disk attached via virtio-blk + - Kernel args: `inst.notmux inst.ks=file:///run/virtiofs-mnt-kickstart/anaconda.ks` +3. The VM boots into `anaconda.target` +4. `bcvk-anaconda-setup.service` runs first to: + - Mount virtiofs shares for container storage and kickstart + - Configure `/etc/containers/storage.conf` with additionalImageStores +5. Upstream `anaconda-direct.service` runs anaconda (triggered by `inst.notmux`) +6. Kickstart `poweroff` directive powers off the VM after anaconda completes + +## Integration with Upstream Anaconda + +This container leverages upstream anaconda systemd infrastructure: + +| Component | Source | Purpose | +|-----------|--------|---------| +| `anaconda.target` | Upstream | Default boot target for installation | +| `anaconda-direct.service` | Upstream | Runs anaconda without tmux | +| `bcvk-anaconda-setup.service` | bcvk | Sets up virtiofs mounts before anaconda (conditional on `bcvk.anaconda` kernel arg) | + +## Kickstart Requirements + +The user provides a kickstart with partitioning and locale settings. +bcvk injects: +- `ostreecontainer --transport=containers-storage --url=` +- `%post` script to repoint bootc origin to the registry (unless `--no-repoint`) + +**Important**: The target disk is available at `/dev/disk/by-id/virtio-output`. +bcvk also attaches a swap disk, so use `ignoredisk` to target the correct disk. + +Example kickstart for BIOS boot: +```kickstart +text +lang en_US.UTF-8 +keyboard us +timezone UTC --utc +network --bootproto=dhcp --activate + +# Target only the output disk +ignoredisk --only-use=/dev/disk/by-id/virtio-output + +zerombr +clearpart --all --initlabel + +# Create required boot partitions (biosboot + /boot) +reqpart --add-boot +part / --fstype=xfs --grow + +rootpw --lock +poweroff +``` + +## Installed Packages + +See `packages.txt` for the full list. Key packages: +- **anaconda-tui**: Text-mode anaconda installer +- **pykickstart**: Kickstart file processing +- **Disk tools**: parted, gdisk, lvm2, cryptsetup +- **Filesystem tools**: e2fsprogs, xfsprogs, btrfs-progs +- **Container tools**: skopeo (bootc is in base image) + +## Debugging + +If installation fails, check the VM console output or journal: +```bash +# Run with console output +bcvk anaconda install --console ... + +# Inside VM, check logs +journalctl -u bcvk-anaconda-setup +journalctl -u anaconda-direct +cat /tmp/anaconda.log +``` diff --git a/containers/anaconda-bootc/bcvk-anaconda-setup.service b/containers/anaconda-bootc/bcvk-anaconda-setup.service new file mode 100644 index 0000000..b0fb779 --- /dev/null +++ b/containers/anaconda-bootc/bcvk-anaconda-setup.service @@ -0,0 +1,18 @@ +[Unit] +Description=bcvk container storage setup for anaconda +# Run before anaconda starts, after basic system is up and target disk is available +Before=anaconda-direct.service anaconda.service +After=basic.target dev-disk-by\x2did-virtio\x2doutput.device +Requires=dev-disk-by\x2did-virtio\x2doutput.device +# Only run in bcvk anaconda environment (bcvk passes this kernel arg) +ConditionKernelCommandLine=bcvk.anaconda + +[Service] +Type=oneshot +ExecStart=/usr/libexec/bcvk-anaconda-setup.sh +RemainAfterExit=yes +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=anaconda.target diff --git a/containers/anaconda-bootc/bcvk-anaconda-setup.sh b/containers/anaconda-bootc/bcvk-anaconda-setup.sh new file mode 100644 index 0000000..cd8464d --- /dev/null +++ b/containers/anaconda-bootc/bcvk-anaconda-setup.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# bcvk anaconda setup script +# +# This script runs before anaconda to set up: +# - virtiofs mounts for host container storage and kickstart +# - container storage configuration for additionalImageStores +# +# Anaconda itself is run by the upstream anaconda-direct.service +set -euo pipefail + +echo "bcvk: Setting up container storage for anaconda..." + +# Mount host container storage via virtiofs +AIS=/run/virtiofs-mnt-hoststorage +if ! mountpoint -q "${AIS}" 2>/dev/null; then + mkdir -p "${AIS}" + mount -t virtiofs mount_hoststorage "${AIS}" || { + echo "bcvk: ERROR: Failed to mount host container storage" + exit 1 + } +fi + +# Mount kickstart directory via virtiofs +KS_DIR=/run/virtiofs-mnt-kickstart +if ! mountpoint -q "${KS_DIR}" 2>/dev/null; then + mkdir -p "${KS_DIR}" + mount -t virtiofs mount_kickstart "${KS_DIR}" || { + echo "bcvk: ERROR: Failed to mount kickstart directory" + exit 1 + } +fi + +# Configure containers to use host storage as additional image store +mkdir -p /etc/containers +cat > /etc/containers/storage.conf << 'EOF' +[storage] +driver = "overlay" +[storage.options] +additionalimagestores = ["/run/virtiofs-mnt-hoststorage"] +EOF + +# Verify kickstart exists +if [ ! -f "${KS_DIR}/anaconda.ks" ]; then + echo "bcvk: ERROR: Kickstart not found at ${KS_DIR}/anaconda.ks" + exit 1 +fi + +# Copy kickstart to where anaconda expects it +# Anaconda looks for /run/install/ks.cfg when inst.ks is specified +mkdir -p /run/install +cp "${KS_DIR}/anaconda.ks" /run/install/ks.cfg +echo "bcvk: Installed kickstart to /run/install/ks.cfg" + +echo "bcvk: Setup complete. Kickstart: ${KS_DIR}/anaconda.ks" diff --git a/containers/anaconda-bootc/packages.txt b/containers/anaconda-bootc/packages.txt new file mode 100644 index 0000000..c436ded --- /dev/null +++ b/containers/anaconda-bootc/packages.txt @@ -0,0 +1,19 @@ +# Anaconda installer packages +anaconda-tui +python3-kickstart +pykickstart + +# Disk tools +parted +gdisk +lvm2 +cryptsetup + +# Filesystem tools +e2fsprogs +xfsprogs +btrfs-progs +dosfstools + +# Container tools (bootc is in base image) +skopeo diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 93bc7d5..4b2ba01 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -15,8 +15,10 @@ pub(crate) use integration_tests::{ }; mod tests { + pub mod anaconda_install; pub mod libvirt_base_disks; pub mod libvirt_port_forward; + pub mod libvirt_run_anaconda; pub mod libvirt_upload_disk; pub mod libvirt_verb; pub mod mount_feature; diff --git a/crates/integration-tests/src/tests/anaconda_install.rs b/crates/integration-tests/src/tests/anaconda_install.rs new file mode 100644 index 0000000..3df07e1 --- /dev/null +++ b/crates/integration-tests/src/tests/anaconda_install.rs @@ -0,0 +1,191 @@ +//! Integration tests for anaconda install command +//! +//! These tests verify the anaconda installation workflow using a custom +//! anaconda container image that runs anaconda via systemd. +//! +//! **PREREQUISITES:** +//! - The anaconda-bootc container must be built first: +//! `podman build -t localhost/anaconda-bootc:latest containers/anaconda-bootc/` +//! - A bootc image must be available in local container storage +//! +//! **NOTE:** These tests are skipped if the anaconda container is not available. + +use camino::Utf8PathBuf; +use color_eyre::Result; +use integration_tests::integration_test; +use tempfile::TempDir; +use xshell::cmd; + +use crate::{get_bck_command, get_test_image, shell}; + +const ANACONDA_IMAGE: &str = "localhost/anaconda-bootc:latest"; + +/// Check if the anaconda container image is available +fn anaconda_image_available() -> bool { + let sh = match shell() { + Ok(sh) => sh, + Err(_) => return false, + }; + cmd!(sh, "podman image exists {ANACONDA_IMAGE}") + .quiet() + .run() + .is_ok() +} + +/// Create a kickstart file for BIOS boot testing +/// +/// This kickstart: +/// - Targets specifically the virtio-output disk (ignoring the swap disk) +/// - Uses reqpart to create required boot partitions (biosboot + /boot) +fn create_test_kickstart(dir: &std::path::Path) -> std::io::Result { + let ks_path = dir.join("test.ks"); + let ks_content = r#"# Test kickstart for bcvk anaconda integration tests (BIOS boot) +text +lang en_US.UTF-8 +keyboard us +timezone UTC --utc +network --bootproto=dhcp --activate + +# Target only the output disk, ignore the swap disk +ignoredisk --only-use=/dev/disk/by-id/virtio-output + +zerombr +clearpart --all --initlabel + +# Let anaconda create required boot partitions (biosboot + /boot for BIOS+GPT) +reqpart --add-boot + +# Root partition +part / --fstype=xfs --grow + +rootpw --lock + +poweroff +"#; + std::fs::write(&ks_path, ks_content)?; + Ok(ks_path) +} + +/// Test anaconda installation to a disk image +/// +/// This test requires the anaconda-bootc container to be pre-built. +fn test_anaconda_install() -> Result<()> { + if !anaconda_image_available() { + eprintln!( + "Skipping test_anaconda_install: {} not available", + ANACONDA_IMAGE + ); + eprintln!( + "Build it with: podman build -t {} containers/anaconda-bootc/", + ANACONDA_IMAGE + ); + return Ok(()); + } + + let sh = shell()?; + let bck = get_bck_command()?; + let image = get_test_image(); + + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let disk_path = Utf8PathBuf::try_from(temp_dir.path().join("anaconda-test.img")) + .expect("temp path is not UTF-8"); + let ks_path = create_test_kickstart(temp_dir.path()).expect("Failed to create kickstart"); + let ks_path_str = ks_path.to_string_lossy().into_owned(); + + // Run anaconda install (--no-repoint since we're just testing installation) + cmd!( + sh, + "{bck} anaconda install --kickstart {ks_path_str} --disk-size 10G --no-repoint {image} {disk_path}" + ) + .run()?; + + // Check that the disk was created + let metadata = std::fs::metadata(&disk_path).expect("Failed to get disk metadata"); + assert!( + metadata.len() > 0, + "test_anaconda_install: Disk image is empty" + ); + + // Mount the disk in an ephemeral VM and verify the installation + // This is more robust than parsing partition tables or MBR bytes + // + // Write the verification script to a directory (--bind requires a directory) + let verify_dir = temp_dir.path().join("verify"); + std::fs::create_dir(&verify_dir)?; + let verify_script_path = verify_dir.join("verify.sh"); + let verify_script = r#"#!/bin/bash +set -euo pipefail + +# Find the root partition (the largest partition, typically part3) +# Use TYPE=part to filter out the whole disk device +ROOT_PART=$(lsblk -nlo NAME,SIZE,TYPE /dev/disk/by-id/virtio-testdisk | awk '$3=="part"' | sort -k2 -h | tail -1 | awk '{print $1}') +ROOT_DEV="/dev/${ROOT_PART}" + +echo "Mounting root partition: ${ROOT_DEV}" +mkdir -p /mnt/testdisk +mount "${ROOT_DEV}" /mnt/testdisk + +# Mount the boot partition if it exists (typically vda2) +# The root's /boot is usually a separate partition in anaconda installs +BOOT_PART=$(lsblk -nlo NAME,SIZE,TYPE /dev/disk/by-id/virtio-testdisk | awk '$3=="part"' | sort -k2 -h | sed -n '2p' | awk '{print $1}') +if [ -n "$BOOT_PART" ] && [ -d /mnt/testdisk/boot ]; then + echo "Mounting boot partition: /dev/${BOOT_PART}" + mount "/dev/${BOOT_PART}" /mnt/testdisk/boot || true +fi + +# Verify ostree deployment exists +if [ ! -d /mnt/testdisk/ostree/deploy ]; then + echo "FAIL: No ostree deployment found" + exit 1 +fi +echo "OK: ostree deployment exists" + +# Verify deployment directory exists +DEPLOY_DIR=$(ls -d /mnt/testdisk/ostree/deploy/*/deploy/*/ 2>/dev/null | head -1) +if [ -z "$DEPLOY_DIR" ]; then + echo "FAIL: No deployment directory found" + exit 1 +fi +echo "OK: deployment directory found" + +# Check for boot loader entries +if ! ls /mnt/testdisk/boot/loader/entries/*.conf >/dev/null 2>&1; then + if ! ls /mnt/testdisk/boot/loader.*/entries/*.conf >/dev/null 2>&1; then + echo "FAIL: No boot loader entries found" + ls -la /mnt/testdisk/boot/ || true + exit 1 + fi +fi +echo "OK: boot loader entries found" + +# Verify /usr/bin exists in the deployment (basic sanity check) +if [ ! -d "${DEPLOY_DIR}/usr/bin" ]; then + echo "FAIL: deployment /usr/bin not found" + exit 1 +fi +echo "OK: deployment looks valid" + +umount /mnt/testdisk/boot 2>/dev/null || true +umount /mnt/testdisk +echo "PASS: anaconda installation verified successfully" +"#; + std::fs::write(&verify_script_path, verify_script)?; + + // Bind mount the script directory and execute via bash + let verify_dir_str = verify_dir.to_string_lossy().into_owned(); + let execute_cmd = "bash /run/virtiofs-mnt-verify/verify.sh"; + let output = cmd!( + sh, + "{bck} ephemeral run --mount-disk-file {disk_path}:testdisk --bind {verify_dir_str}:verify --execute {execute_cmd} {image}" + ) + .read()?; + + assert!( + output.contains("PASS: anaconda installation verified successfully"), + "test_anaconda_install: Disk verification failed. Output:\n{}", + output + ); + + Ok(()) +} +integration_test!(test_anaconda_install); diff --git a/crates/integration-tests/src/tests/libvirt_run_anaconda.rs b/crates/integration-tests/src/tests/libvirt_run_anaconda.rs new file mode 100644 index 0000000..1cf8af6 --- /dev/null +++ b/crates/integration-tests/src/tests/libvirt_run_anaconda.rs @@ -0,0 +1,348 @@ +//! Integration tests for `bcvk libvirt run-anaconda` command +//! +//! These tests verify the anaconda-based libvirt VM creation workflow: +//! - Creating VMs using anaconda with kickstart files +//! - SSH connectivity after VM creation +//! - All the same lifecycle management as `bcvk libvirt run` +//! +//! **PREREQUISITES:** +//! - The anaconda-bootc container must be built first: +//! `podman build -t localhost/anaconda-bootc:latest containers/anaconda-bootc/` +//! - A bootc image must be available in local container storage +//! +//! **NOTE:** These tests are skipped if the anaconda container is not available. + +use color_eyre::Result; +use integration_tests::integration_test; +use scopeguard::defer; +use xshell::cmd; + +use crate::{get_bck_command, get_test_image, shell, LIBVIRT_INTEGRATION_TEST_LABEL}; + +const ANACONDA_IMAGE: &str = "localhost/anaconda-bootc:latest"; + +/// Check if the anaconda container image is available +fn anaconda_image_available() -> bool { + let sh = match shell() { + Ok(sh) => sh, + Err(_) => return false, + }; + cmd!(sh, "podman image exists {ANACONDA_IMAGE}") + .quiet() + .run() + .is_ok() +} + +/// 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() +} + +/// Create a kickstart file for testing +fn create_test_kickstart(dir: &std::path::Path) -> std::io::Result { + let ks_path = dir.join("test.ks"); + let ks_content = r#"# Test kickstart for bcvk libvirt run-anaconda integration tests +text +lang en_US.UTF-8 +keyboard us +timezone UTC --utc +network --bootproto=dhcp --activate + +# Target only the output disk, ignore the swap disk +ignoredisk --only-use=/dev/disk/by-id/virtio-output + +zerombr +clearpart --all --initlabel + +# Let anaconda create required boot partitions +reqpart --add-boot + +# Root partition +part / --fstype=xfs --grow + +rootpw --lock + +poweroff +"#; + std::fs::write(&ks_path, ks_content)?; + Ok(ks_path) +} + +/// Helper function to cleanup domain +fn cleanup_domain(domain_name: &str) { + println!("Cleaning up domain: {}", domain_name); + + let sh = match shell() { + Ok(sh) => sh, + Err(_) => return, + }; + + // Stop domain if running + let _ = cmd!(sh, "virsh destroy {domain_name}") + .ignore_status() + .quiet() + .run(); + + // Use bcvk libvirt rm for proper cleanup + let bck = match get_bck_command() { + Ok(cmd) => cmd, + Err(_) => return, + }; + + match cmd!(sh, "{bck} libvirt rm {domain_name} --force --stop") + .ignore_status() + .output() + { + Ok(output) if output.status.success() => { + println!("Successfully cleaned up domain: {}", domain_name); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("Cleanup warning (may be expected): {}", stderr); + } + Err(_) => {} + } +} + +/// Test basic `bcvk libvirt run-anaconda` functionality +/// +/// This test: +/// 1. Creates a VM using anaconda with a kickstart file +/// 2. Waits for SSH to be available +/// 3. Verifies the VM is running +/// 4. Cleans up +fn test_libvirt_run_anaconda_basic() -> Result<()> { + if !anaconda_image_available() { + eprintln!( + "Skipping test_libvirt_run_anaconda_basic: {} not available", + ANACONDA_IMAGE + ); + eprintln!( + "Build it with: podman build -t {} containers/anaconda-bootc/", + ANACONDA_IMAGE + ); + return Ok(()); + } + + let sh = shell()?; + let bck = get_bck_command()?; + let test_image = get_test_image(); + let label = LIBVIRT_INTEGRATION_TEST_LABEL; + + // Generate unique domain name for this test + let domain_name = format!("test-run-anaconda-{}", random_suffix()); + + println!( + "Testing bcvk libvirt run-anaconda with domain: {}", + domain_name + ); + + // Create temporary kickstart file + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let ks_path = create_test_kickstart(temp_dir.path()).expect("Failed to create kickstart"); + let ks_path_str = ks_path.to_string_lossy().into_owned(); + + // Cleanup any existing domain with this name + cleanup_domain(&domain_name); + + // Set up cleanup guard that will run on scope exit + defer! { + cleanup_domain(&domain_name); + } + + // Create domain with anaconda, wait for SSH + // Use BIOS firmware because the kickstart uses reqpart --add-boot which creates + // BIOS boot partitions when anaconda runs in the ephemeral QEMU VM + println!("Creating libvirt domain via anaconda..."); + cmd!( + sh, + "{bck} libvirt run-anaconda --name {domain_name} --label {label} --kickstart {ks_path_str} --firmware bios --ssh-wait {test_image}" + ) + .run()?; + + println!("Successfully created domain: {}", domain_name); + + // Verify domain is running + println!("Verifying domain is running..."); + let dominfo = cmd!(sh, "virsh dominfo {domain_name}").read()?; + assert!( + dominfo.contains("running") || dominfo.contains("idle"), + "Domain should be running. dominfo: {}", + dominfo + ); + println!("Domain is running"); + + // Verify we can SSH into the VM + println!("Testing SSH connectivity..."); + let hostname_output = cmd!(sh, "{bck} libvirt ssh {domain_name} -- hostname").read()?; + assert!( + !hostname_output.is_empty(), + "Should be able to get hostname via SSH" + ); + println!( + "SSH connectivity verified, hostname: {}", + hostname_output.trim() + ); + + // Verify domain metadata contains anaconda install method + println!("Checking domain metadata..."); + let domain_xml = cmd!(sh, "virsh dumpxml {domain_name}").read()?; + assert!( + domain_xml.contains("bootc:install-method") && domain_xml.contains("anaconda"), + "Domain XML should contain anaconda install-method metadata" + ); + println!("Domain metadata correctly shows anaconda install method"); + + println!("libvirt run-anaconda basic test passed"); + Ok(()) +} +integration_test!(test_libvirt_run_anaconda_basic); + +/// Test `bcvk libvirt run-anaconda --replace` functionality +fn test_libvirt_run_anaconda_replace() -> Result<()> { + if !anaconda_image_available() { + eprintln!( + "Skipping test_libvirt_run_anaconda_replace: {} not available", + ANACONDA_IMAGE + ); + return Ok(()); + } + + let sh = shell()?; + let bck = get_bck_command()?; + let test_image = get_test_image(); + let label = LIBVIRT_INTEGRATION_TEST_LABEL; + + let domain_name = format!("test-anaconda-replace-{}", random_suffix()); + + println!( + "Testing bcvk libvirt run-anaconda --replace with domain: {}", + domain_name + ); + + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let ks_path = create_test_kickstart(temp_dir.path()).expect("Failed to create kickstart"); + let ks_path_str = ks_path.to_string_lossy().into_owned(); + + cleanup_domain(&domain_name); + + defer! { + cleanup_domain(&domain_name); + } + + // Create initial domain + // Use BIOS firmware because the kickstart creates BIOS boot partitions + println!("Creating initial domain..."); + cmd!( + sh, + "{bck} libvirt run-anaconda --name {domain_name} --label {label} --kickstart {ks_path_str} --firmware bios {test_image}" + ) + .run()?; + println!("Initial domain created"); + + // Replace the domain + println!("Replacing domain with --replace..."); + cmd!( + sh, + "{bck} libvirt run-anaconda --name {domain_name} --label {label} --kickstart {ks_path_str} --firmware bios --replace {test_image}" + ) + .run()?; + println!("Domain replaced successfully"); + + // Verify replaced domain is running + let dominfo = cmd!(sh, "virsh dominfo {domain_name}").read()?; + assert!( + dominfo.contains("running") || dominfo.contains("idle"), + "Replaced domain should be running" + ); + println!("Replaced domain is running"); + + println!("libvirt run-anaconda --replace test passed"); + Ok(()) +} +integration_test!(test_libvirt_run_anaconda_replace); + +/// Test `bcvk libvirt run-anaconda --transient` functionality +fn test_libvirt_run_anaconda_transient() -> Result<()> { + if !anaconda_image_available() { + eprintln!( + "Skipping test_libvirt_run_anaconda_transient: {} not available", + ANACONDA_IMAGE + ); + return Ok(()); + } + + let sh = shell()?; + let bck = get_bck_command()?; + let test_image = get_test_image(); + let label = LIBVIRT_INTEGRATION_TEST_LABEL; + + let domain_name = format!("test-anaconda-transient-{}", random_suffix()); + + println!( + "Testing bcvk libvirt run-anaconda --transient with domain: {}", + domain_name + ); + + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let ks_path = create_test_kickstart(temp_dir.path()).expect("Failed to create kickstart"); + let ks_path_str = ks_path.to_string_lossy().into_owned(); + + cleanup_domain(&domain_name); + + defer! { + cleanup_domain(&domain_name); + } + + // Create transient domain + // Use BIOS firmware because the kickstart creates BIOS boot partitions + println!("Creating transient domain..."); + cmd!( + sh, + "{bck} libvirt run-anaconda --name {domain_name} --label {label} --kickstart {ks_path_str} --firmware bios --transient {test_image}" + ) + .run()?; + println!("Transient domain created"); + + // Verify domain is transient + let dominfo = cmd!(sh, "virsh dominfo {domain_name}").read()?; + assert!( + dominfo.contains("Persistent:") && dominfo.contains("no"), + "Domain should be transient. dominfo: {}", + dominfo + ); + println!("Domain is correctly marked as transient"); + + // Stop the domain (should disappear since it's transient) + println!("Stopping transient domain..."); + cmd!(sh, "virsh destroy {domain_name}").run()?; + + // Poll for domain disappearance + let start_time = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + let mut domain_disappeared = false; + + while start_time.elapsed() < timeout { + let domain_list = cmd!(sh, "virsh list --all --name").ignore_status().read()?; + if !domain_list.contains(&domain_name) { + domain_disappeared = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + + assert!( + domain_disappeared, + "Transient domain should disappear after shutdown" + ); + println!("Transient domain correctly disappeared after shutdown"); + + println!("libvirt run-anaconda --transient test passed"); + Ok(()) +} +integration_test!(test_libvirt_run_anaconda_transient); diff --git a/crates/kit/src/anaconda/install.rs b/crates/kit/src/anaconda/install.rs new file mode 100644 index 0000000..3252507 --- /dev/null +++ b/crates/kit/src/anaconda/install.rs @@ -0,0 +1,523 @@ +//! Anaconda-based bootc installation using kickstart +//! +//! This module implements bootc container installation using anaconda as the +//! installation engine. It uses the ephemeral VM infrastructure to run anaconda +//! in an isolated environment with access to block devices and container storage. +//! +//! Unlike `bcvk to-disk` which runs `bootc install` directly, this approach: +//! - Uses anaconda for partitioning and system configuration via kickstart +//! - Runs anaconda via systemd in the VM (auto-starts on boot, powers off when done) +//! - Uses the `ostreecontainer` kickstart verb with `--transport=containers-storage` +//! +//! ## Example kickstart +//! +//! The user must provide a kickstart file with partitioning, locale, and other +//! system configuration. The `ostreecontainer` directive is **injected automatically**. +//! +//! ```kickstart +//! text +//! lang en_US.UTF-8 +//! keyboard us +//! timezone UTC --utc +//! network --bootproto=dhcp --activate +//! +//! zerombr +//! clearpart --all --initlabel +//! autopart --type=plain --fstype=xfs +//! bootloader --location=mbr +//! rootpw --lock +//! +//! poweroff +//! ``` +//! +//! bcvk will inject: +//! - `ostreecontainer --transport=containers-storage --url=` +//! - `%post` script to repoint the installed system to the registry image + +use camino::Utf8PathBuf; +use clap::Parser; +use color_eyre::eyre::{eyre, Context}; +use color_eyre::Result; +use indoc::formatdoc; +use tracing::{debug, info, warn}; + +use crate::images; +use crate::install_options::InstallOptions; +use crate::run_ephemeral::{CommonVmOpts, RunEphemeralOpts}; +use crate::to_disk::Format; +use crate::utils::DiskSize; + +const DEFAULT_ANACONDA_IMAGE: &str = "localhost/anaconda-bootc:latest"; +const KICKSTART_FILENAME: &str = "anaconda.ks"; +const KICKSTART_MOUNT_NAME: &str = "kickstart"; +/// Path where kickstart is mounted inside the VM (via virtiofs) +const KICKSTART_MOUNT_PATH: &str = "/run/virtiofs-mnt-kickstart"; + +/// Minimum disk size for anaconda installations. +/// +/// Anaconda requires space for the installed system plus working space for +/// package installation, SELinux relabeling, etc. 4GB is a conservative +/// minimum that works for most base bootc images. +const MIN_DISK_SIZE: u64 = 4 * 1024 * 1024 * 1024; + +#[derive(Debug, Parser)] +pub struct AnacondaInstallOpts { + /// Bootc container image to install (from host container storage) + pub image: String, + + /// Target disk image file path + pub target_disk: Utf8PathBuf, + + /// Kickstart file with partitioning and system configuration + /// + /// Must contain partitioning (e.g., autopart), locale settings (lang, + /// keyboard, timezone), and other system configuration. The `ostreecontainer` + /// directive, and `%post` registry repointing are injected automatically. + #[clap(long, short = 'k')] + pub kickstart: std::path::PathBuf, + + /// Target image reference for the installed system + /// + /// After installation, the system's bootc origin is repointed to this + /// registry image so that `bootc upgrade` pulls updates from the registry + /// rather than expecting containers-storage. Defaults to the image argument. + #[clap(long)] + pub target_imgref: Option, + + /// Skip injecting the %post script that repoints to target-imgref + /// + /// Use this if you want to handle bootc origin configuration yourself + /// in your kickstart file. + #[clap(long)] + pub no_repoint: bool, + + /// Anaconda container image to use as the installer + #[clap(long, default_value = DEFAULT_ANACONDA_IMAGE)] + pub anaconda_image: String, + + /// Disk size to create (e.g. 10G, 5120M) + #[clap(long)] + pub disk_size: Option, + + /// Output disk image format + #[clap(long, default_value_t = Format::Raw)] + pub format: Format, + + #[clap(flatten)] + pub install: InstallOptions, + + #[clap(flatten)] + pub common: CommonVmOpts, +} + +impl AnacondaInstallOpts { + fn calculate_disk_size(&self) -> Result { + if let Some(size) = self.disk_size { + return Ok(size.as_bytes()); + } + let image_size = images::get_image_size(&self.image)?; + Ok(std::cmp::max(image_size * 2, MIN_DISK_SIZE)) + } + + /// Get the target image reference for repointing after installation + fn get_target_imgref(&self) -> &str { + self.target_imgref.as_deref().unwrap_or(&self.image) + } + + /// Validate that an image reference doesn't contain characters that could + /// inject kickstart or shell syntax. + fn validate_image_ref(name: &str, field: &str) -> Result<()> { + if name.contains('\n') || name.contains('%') { + return Err(eyre!( + "{} contains invalid characters (newlines or '%' not allowed)", + field + )); + } + Ok(()) + } + + /// Generate the final kickstart by reading user kickstart and injecting + /// bcvk-specific directives. + fn generate_kickstart(&self) -> Result { + let user_kickstart = std::fs::read_to_string(&self.kickstart) + .with_context(|| format!("Failed to read kickstart: {}", self.kickstart.display()))?; + + // Validate that user kickstart doesn't contain ostreecontainer directive + // (we inject that ourselves). Ignore comments. + for line in user_kickstart.lines() { + let trimmed = line.trim(); + // Skip comments + if trimmed.starts_with('#') { + continue; + } + if trimmed.starts_with("ostreecontainer") { + return Err(eyre!( + "Kickstart must not contain 'ostreecontainer' directive; \ + bcvk injects this automatically with the correct transport" + )); + } + } + + // Validate both image and target_imgref don't contain injection characters + Self::validate_image_ref(&self.image, "Image name")?; + if let Some(ref target) = self.target_imgref { + Self::validate_image_ref(target, "Target image reference (--target-imgref)")?; + } + + // Build the %post script for repointing to registry + let post_section = if self.no_repoint { + String::new() + } else { + let target = self.get_target_imgref(); + // Shell-quote the target to prevent command injection + let quoted_target = shlex::try_quote(target) + .map_err(|e| eyre!("Target image reference contains invalid characters: {}", e))?; + formatdoc! {r#" + + %post --erroronfail + set -euo pipefail + # Repoint bootc origin to registry so `bootc upgrade` works + bootc switch --mutate-in-place --transport registry {quoted_target} + %end + "#, + quoted_target = quoted_target, + } + }; + + // Inject ostreecontainer directive before any %pre/%post sections + let mut result = String::new(); + let mut ostreecontainer_added = false; + + for line in user_kickstart.lines() { + let trimmed = line.trim(); + + // Detect section boundaries - insert ostreecontainer before first section + if trimmed.starts_with('%') && !trimmed.starts_with("%%") && !ostreecontainer_added { + result.push_str(&format!( + "ostreecontainer --transport=containers-storage --url={}\n", + self.image + )); + ostreecontainer_added = true; + } + + result.push_str(line); + result.push('\n'); + } + + // If no sections exist, add at the end + if !ostreecontainer_added { + result.push_str(&format!( + "ostreecontainer --transport=containers-storage --url={}\n", + self.image + )); + } + + // Always add our %post at the end (after user's sections) + result.push_str(&post_section); + + Ok(result) + } + + fn write_kickstart_to_tempdir(&self) -> Result<(tempfile::TempDir, Utf8PathBuf)> { + let content = self.generate_kickstart()?; + let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let path = temp_dir.path().join(KICKSTART_FILENAME); + + std::fs::write(&path, &content).context("Failed to write kickstart file")?; + + let path: Utf8PathBuf = path.try_into().context("Temp path is not valid UTF-8")?; + debug!("Wrote kickstart to: {}", path); + debug!("Kickstart content:\n{}", content); + Ok((temp_dir, path)) + } +} + +pub fn install(_global_opts: &super::AnacondaOptions, opts: AnacondaInstallOpts) -> Result<()> { + info!( + "Installing {} via anaconda ({})", + opts.image, opts.anaconda_image + ); + if !opts.no_repoint { + info!( + "Target imgref for bootc origin: {}", + opts.get_target_imgref() + ); + } + + let disk_size = opts.calculate_disk_size()?; + let (kickstart_tempdir, _) = opts.write_kickstart_to_tempdir()?; + let kickstart_dir: Utf8PathBuf = kickstart_tempdir + .path() + .to_path_buf() + .try_into() + .context("Temp directory path is not valid UTF-8")?; + + info!("Creating target disk: {}", opts.target_disk); + match opts.format { + Format::Raw => { + // Create sparse file - only allocates space as data is written + let file = std::fs::File::create(&opts.target_disk) + .with_context(|| format!("Creating {}", opts.target_disk))?; + file.set_len(disk_size)?; + } + Format::Qcow2 => { + // Use qemu-img to create qcow2 format + debug!("Creating qcow2 with size {} bytes", disk_size); + let size_arg = disk_size.to_string(); + let output = std::process::Command::new("qemu-img") + .args([ + "create", + "-f", + "qcow2", + opts.target_disk.as_str(), + &size_arg, + ]) + .output() + .with_context(|| { + format!("Failed to run qemu-img create for {}", opts.target_disk) + })?; + + if !output.status.success() { + return Err(eyre!( + "qemu-img create failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + } + + // Build ephemeral VM options + // The anaconda-install.service in the container will auto-start and poweroff when done + let ephemeral_opts = RunEphemeralOpts { + host_dns_servers: None, + image: opts.anaconda_image.clone(), + common: opts.common.clone(), + podman: crate::run_ephemeral::CommonPodmanOptions { + rm: true, + detach: false, // Wait for completion + tty: false, + ..Default::default() + }, + add_swap: Some(format!("{disk_size}")), + bind_mounts: Vec::new(), + ro_bind_mounts: vec![format!("{}:{}", kickstart_dir, KICKSTART_MOUNT_NAME)], + systemd_units_dir: None, + bind_storage_ro: true, + mount_disk_files: vec![format!( + "{}:output:{}", + opts.target_disk, + opts.format.as_str() + )], + kernel_args: vec![ + // Use anaconda's direct mode (no tmux) + "inst.notmux".to_string(), + // Point to our virtiofs-mounted kickstart + format!("inst.ks=file://{}/anaconda.ks", KICKSTART_MOUNT_PATH), + // Marker for bcvk-anaconda-setup.service to activate + "bcvk.anaconda".to_string(), + ], + debug_entrypoint: None, + }; + + info!("Starting anaconda VM (will poweroff when complete)..."); + + // Run the ephemeral VM - it will poweroff when anaconda completes + // Use run_sync to spawn as subprocess and wait, rather than exec which replaces the process + let result = crate::run_ephemeral::run_sync(ephemeral_opts); + + // Clean up temp directory + drop(kickstart_tempdir); + + match result { + Ok(()) => { + println!("\nInstallation completed successfully!"); + println!("Output disk: {}", opts.target_disk); + Ok(()) + } + Err(e) => { + if let Err(cleanup_err) = std::fs::remove_file(&opts.target_disk) { + warn!( + "Failed to clean up disk image {}: {}", + opts.target_disk, cleanup_err + ); + } + Err(e) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Helper to create a test opts struct with a kickstart file + fn create_test_opts(kickstart_content: &str) -> (TempDir, AnacondaInstallOpts) { + let temp_dir = TempDir::new().unwrap(); + let ks_path = temp_dir.path().join("test.ks"); + std::fs::write(&ks_path, kickstart_content).unwrap(); + + let opts = AnacondaInstallOpts { + image: "quay.io/fedora/fedora-bootc:42".to_string(), + target_disk: "/tmp/test.img".into(), + kickstart: ks_path, + target_imgref: None, + no_repoint: false, + anaconda_image: DEFAULT_ANACONDA_IMAGE.to_string(), + disk_size: None, + format: Format::Raw, + install: InstallOptions::default(), + common: CommonVmOpts::default(), + }; + + (temp_dir, opts) + } + + #[test] + fn test_generate_kickstart_basic() { + let ks = "text\nlang en_US.UTF-8\npoweroff\n"; + let (_dir, opts) = create_test_opts(ks); + + let result = opts.generate_kickstart().unwrap(); + + // Should contain the original content + assert!(result.contains("text")); + assert!(result.contains("lang en_US.UTF-8")); + assert!(result.contains("poweroff")); + + // Should inject ostreecontainer + assert!(result.contains("ostreecontainer --transport=containers-storage")); + assert!(result.contains("--url=quay.io/fedora/fedora-bootc:42")); + + // Should inject %post for repointing + assert!(result.contains("%post --erroronfail")); + assert!(result.contains("bootc switch --mutate-in-place --transport registry")); + } + + #[test] + fn test_generate_kickstart_no_repoint() { + let ks = "text\npoweroff\n"; + let (_dir, mut opts) = create_test_opts(ks); + opts.no_repoint = true; + + let result = opts.generate_kickstart().unwrap(); + + // Should NOT inject %post + assert!(!result.contains("%post")); + assert!(!result.contains("bootc switch")); + + // Should still inject ostreecontainer + assert!(result.contains("ostreecontainer")); + } + + #[test] + fn test_generate_kickstart_with_target_imgref() { + let ks = "text\npoweroff\n"; + let (_dir, mut opts) = create_test_opts(ks); + opts.target_imgref = Some("registry.example.com/myapp:prod".to_string()); + + let result = opts.generate_kickstart().unwrap(); + + // Should use target_imgref in %post, not the source image + assert!(result.contains("registry.example.com/myapp:prod")); + // The ostreecontainer should still use the source image + assert!(result.contains("--url=quay.io/fedora/fedora-bootc:42")); + } + + #[test] + fn test_generate_kickstart_ostreecontainer_in_comment_allowed() { + // Comments mentioning ostreecontainer should be allowed + let ks = "# Note: don't use ostreecontainer here\ntext\npoweroff\n"; + let (_dir, opts) = create_test_opts(ks); + + let result = opts.generate_kickstart(); + assert!(result.is_ok(), "Should allow ostreecontainer in comments"); + } + + #[test] + fn test_generate_kickstart_rejects_ostreecontainer_directive() { + let ks = "text\nostreecontainer --url=foo\npoweroff\n"; + let (_dir, opts) = create_test_opts(ks); + + let result = opts.generate_kickstart(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("ostreecontainer")); + } + + #[test] + fn test_generate_kickstart_rejects_newline_in_image() { + let ks = "text\npoweroff\n"; + let (_dir, mut opts) = create_test_opts(ks); + opts.image = "foo\nbar".to_string(); + + let result = opts.generate_kickstart(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("newlines")); + } + + #[test] + fn test_generate_kickstart_rejects_percent_in_image() { + let ks = "text\npoweroff\n"; + let (_dir, mut opts) = create_test_opts(ks); + opts.image = "foo%bar".to_string(); + + let result = opts.generate_kickstart(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("%")); + } + + #[test] + fn test_generate_kickstart_rejects_newline_in_target_imgref() { + let ks = "text\npoweroff\n"; + let (_dir, mut opts) = create_test_opts(ks); + opts.target_imgref = Some("foo\nbar".to_string()); + + let result = opts.generate_kickstart(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("target-imgref")); + } + + #[test] + fn test_generate_kickstart_with_existing_post_section() { + let ks = "text\n%post\necho hello\n%end\npoweroff\n"; + let (_dir, opts) = create_test_opts(ks); + + let result = opts.generate_kickstart().unwrap(); + + // Should preserve user's %post + assert!(result.contains("echo hello")); + + // Should add ostreecontainer BEFORE user's %post + let ostree_pos = result.find("ostreecontainer").unwrap(); + let user_post_pos = result.find("echo hello").unwrap(); + assert!( + ostree_pos < user_post_pos, + "ostreecontainer should be before user's %post" + ); + + // Should add our %post at the end + let our_post = result.rfind("bootc switch").unwrap(); + assert!( + our_post > user_post_pos, + "our %post should be after user's %post" + ); + } + + #[test] + fn test_generate_kickstart_shell_quoting() { + let ks = "text\npoweroff\n"; + let (_dir, mut opts) = create_test_opts(ks); + // Image with spaces (unusual but valid in some contexts) + opts.target_imgref = Some("registry.example.com/my app:v1".to_string()); + + let result = opts.generate_kickstart().unwrap(); + + // Should be properly quoted for shell + assert!( + result.contains("'registry.example.com/my app:v1'") + || result.contains("\"registry.example.com/my app:v1\""), + "Image ref with spaces should be quoted: {}", + result + ); + } +} diff --git a/crates/kit/src/anaconda/mod.rs b/crates/kit/src/anaconda/mod.rs new file mode 100644 index 0000000..1c398db --- /dev/null +++ b/crates/kit/src/anaconda/mod.rs @@ -0,0 +1,18 @@ +//! Anaconda integration for bcvk +//! +//! This module provides integration with the Anaconda installer to enable +//! bootc container installation using anaconda's capabilities for hardware +//! detection, partitioning, and system configuration via kickstart files. + +use clap::Subcommand; + +pub mod install; + +#[derive(Debug, Subcommand)] +pub enum AnacondaSubcommands { + /// Install a bootc container using anaconda + Install(install::AnacondaInstallOpts), +} + +#[derive(Debug, Clone, Default)] +pub struct AnacondaOptions {} diff --git a/crates/kit/src/libvirt/base_disks.rs b/crates/kit/src/libvirt/base_disks.rs index 6aafd7d..7725359 100644 --- a/crates/kit/src/libvirt/base_disks.rs +++ b/crates/kit/src/libvirt/base_disks.rs @@ -6,6 +6,7 @@ use crate::cache_metadata::DiskImageMetadata; use crate::install_options::InstallOptions; +use crate::utils::DiskSize; use camino::{Utf8Path, Utf8PathBuf}; use color_eyre::eyre::{eyre, Context}; use color_eyre::Result; @@ -109,8 +110,9 @@ fn create_base_disk( additional: ToDiskAdditionalOpts { disk_size: install_options .root_size - .clone() - .or(Some(super::LIBVIRT_DEFAULT_DISK_SIZE.to_string())), + .as_ref() + .and_then(|s| s.parse::().ok()) + .or_else(|| super::LIBVIRT_DEFAULT_DISK_SIZE.parse::().ok()), format: Format::Qcow2, // Use qcow2 for CoW cloning common: CommonVmOpts { memory: crate::common_opts::MemoryOpts { diff --git a/crates/kit/src/libvirt/mod.rs b/crates/kit/src/libvirt/mod.rs index 7e1bc17..9d47d0c 100644 --- a/crates/kit/src/libvirt/mod.rs +++ b/crates/kit/src/libvirt/mod.rs @@ -34,6 +34,7 @@ pub mod print_firmware; pub mod rm; pub mod rm_all; pub mod run; +pub mod run_anaconda; pub mod secureboot; pub mod ssh; pub mod start; @@ -185,6 +186,10 @@ pub enum LibvirtSubcommands { /// Run a bootable container as a persistent VM Run(run::LibvirtRunOpts), + /// Run a bootable container as a persistent VM, installed via anaconda + #[clap(name = "run-anaconda")] + RunAnaconda(run_anaconda::LibvirtRunAnacondaOpts), + /// SSH to libvirt domain with embedded SSH key Ssh(ssh::LibvirtSshOpts), diff --git a/crates/kit/src/libvirt/run.rs b/crates/kit/src/libvirt/run.rs index a466c60..bec13be 100644 --- a/crates/kit/src/libvirt/run.rs +++ b/crates/kit/src/libvirt/run.rs @@ -21,7 +21,20 @@ use crate::utils::parse_memory_to_mb; use crate::xml_utils; /// SSH wait timeout in seconds -const SSH_WAIT_TIMEOUT_SECONDS: u64 = 180; +pub(super) const SSH_WAIT_TIMEOUT_SECONDS: u64 = 180; + +/// Validate that labels don't contain commas (shared helper) +pub(super) fn validate_labels(labels: &[String]) -> Result<()> { + for label in labels { + if label.contains(',') { + return Err(eyre!( + "Label '{}' contains comma which is not allowed", + label + )); + } + } + Ok(()) +} /// Transport type for updating from host container storage const UPDATE_FROM_HOST_TRANSPORT: &str = "containers-storage"; @@ -309,15 +322,7 @@ pub struct LibvirtRunOpts { impl LibvirtRunOpts { /// Validate that labels don't contain commas fn validate_labels(&self) -> Result<()> { - for label in &self.label { - if label.contains(',') { - return Err(eyre::eyre!( - "Label '{}' contains comma which is not allowed", - label - )); - } - } - Ok(()) + validate_labels(&self.label) } /// Get resolved memory in MB, using instancetype if specified @@ -342,7 +347,7 @@ impl LibvirtRunOpts { /// Wait for SSH to become available on a libvirt domain /// /// Polls SSH connectivity by attempting simple commands until successful or timeout. -fn wait_for_ssh_ready( +pub(super) fn wait_for_ssh_ready( global_opts: &crate::libvirt::LibvirtOptions, domain_name: &str, timeout_secs: u64, @@ -704,7 +709,7 @@ pub fn get_libvirt_storage_pool_path(connect_uri: Option<&str>) -> Result String { +pub(super) fn generate_unique_vm_name(image: &str, existing_domains: &[String]) -> String { // Extract image name from full image path let base_name = if let Some(last_slash) = image.rfind('/') { &image[last_slash + 1..] @@ -769,7 +774,7 @@ pub fn list_storage_pool_volumes(connect_uri: Option<&str>) -> Result u16 { +pub(super) fn find_available_ssh_port() -> u16 { use rand::Rng; // Try random ports in the range 2222-3000 to avoid conflicts in concurrent scenarios @@ -839,7 +844,7 @@ fn parse_volume_mount(volume_str: &str) -> Result<(String, String)> { /// and creates systemd mount unit SMBIOS credentials for automatic mounting. /// /// Takes ownership of the domain builder and returns it. -fn process_bind_mounts( +pub(super) fn process_bind_mounts( bind_mounts: &[BindMount], tag_prefix: &str, readonly: bool, @@ -1019,7 +1024,7 @@ mod tests { } /// Create a libvirt domain directly from a disk image file -fn create_libvirt_domain_from_disk( +pub(super) fn create_libvirt_domain_from_disk( domain_name: &str, disk_path: &Utf8Path, image_digest: &str, diff --git a/crates/kit/src/libvirt/run_anaconda.rs b/crates/kit/src/libvirt/run_anaconda.rs new file mode 100644 index 0000000..a5e9dcd --- /dev/null +++ b/crates/kit/src/libvirt/run_anaconda.rs @@ -0,0 +1,533 @@ +//! libvirt run-anaconda command - run a bootable container as a VM installed via anaconda +//! +//! This module provides functionality for creating and managing libvirt-based VMs +//! from bootc container images using anaconda for installation. Unlike `libvirt run` +//! which uses `bootc install to-disk`, this command uses anaconda with kickstart files +//! for more flexible partitioning and system configuration. +//! +//! This module shares most of its implementation with `libvirt run`, only differing +//! in the base disk creation phase which uses anaconda instead of `bootc install to-disk`. + +use camino::Utf8PathBuf; +use clap::Parser; +use color_eyre::eyre::{eyre, Context}; +use color_eyre::Result; +use tracing::{debug, info}; + +use super::run::{BindMount, FirmwareType, LibvirtRunOpts, PortMapping}; +use crate::common_opts::MemoryOpts; +use crate::install_options::InstallOptions; + +/// Default anaconda installer image +const DEFAULT_ANACONDA_IMAGE: &str = "localhost/anaconda-bootc:latest"; + +/// Options for creating and running a bootable container VM via anaconda +/// +/// This struct mirrors `LibvirtRunOpts` but adds anaconda-specific options +/// (kickstart, target_imgref, anaconda_image). The VM lifecycle (SSH, networking, +/// bind mounts, etc.) is identical to `libvirt run`. +#[derive(Debug, Parser)] +pub struct LibvirtRunAnacondaOpts { + /// Container image to run as a bootable VM + pub image: String, + + /// Kickstart file with partitioning and system configuration + /// + /// Must contain partitioning (e.g., autopart), locale settings (lang, + /// keyboard, timezone), and other system configuration. The `ostreecontainer` + /// directive, and `%post` registry repointing are injected automatically. + #[clap(long, short = 'k')] + pub kickstart: std::path::PathBuf, + + /// Name for the VM (auto-generated if not specified) + #[clap(long)] + pub name: Option, + + /// Replace existing VM with same name (stop and remove if exists) + #[clap(long, short = 'R')] + pub replace: bool, + + /// Target image reference for the installed system + /// + /// After installation, the system's bootc origin is repointed to this + /// registry image so that `bootc upgrade` pulls updates from the registry + /// rather than expecting containers-storage. Defaults to the image argument. + #[clap(long)] + pub target_imgref: Option, + + /// Skip injecting the %post script that repoints to target-imgref + /// + /// Use this if you want to handle bootc origin configuration yourself + /// in your kickstart file. + #[clap(long)] + pub no_repoint: bool, + + /// Anaconda container image to use as the installer + #[clap(long, default_value = DEFAULT_ANACONDA_IMAGE)] + pub anaconda_image: String, + + #[clap( + long, + help = "Instance type (e.g., u1.nano, u1.small, u1.medium). Overrides cpus/memory if specified." + )] + pub itype: Option, + + #[clap(flatten)] + pub memory: MemoryOpts, + + /// Number of virtual CPUs for the VM (overridden by --itype if specified) + #[clap(long, default_value = "2")] + pub cpus: u32, + + /// Disk size for the VM (e.g. 20G, 10240M, or plain number for bytes) + #[clap(long, default_value = "20G")] + pub disk_size: String, + + /// Installation options (filesystem, root-size, etc.) + #[clap(flatten)] + pub install: InstallOptions, + + /// Port mapping from host to VM (format: host_port:guest_port, e.g., 8080:80) + #[clap(long = "port", short = 'p', action = clap::ArgAction::Append)] + pub port_mappings: Vec, + + /// Volume mount from host to VM (raw virtiofs tag, for manual mounting) + #[clap(long = "volume", short = 'v', action = clap::ArgAction::Append)] + pub raw_volumes: Vec, + + /// Bind mount from host to VM (format: host_path:guest_path) + #[clap(long = "bind", action = clap::ArgAction::Append)] + pub bind_mounts: Vec, + + /// Bind mount from host to VM as read-only (format: host_path:guest_path) + #[clap(long = "bind-ro", action = clap::ArgAction::Append)] + pub bind_mounts_ro: Vec, + + /// Network mode for the VM + #[clap(long, default_value = "user")] + pub network: String, + + /// Keep the VM running in background after creation + #[clap(long)] + pub detach: bool, + + /// Automatically SSH into the VM after creation + #[clap(long)] + pub ssh: bool, + + /// Wait for SSH to become available and verify connectivity (for testing) + #[clap(long, conflicts_with = "ssh")] + pub ssh_wait: bool, + + /// Mount host container storage (RO) at /run/host-container-storage + #[clap(long = "bind-storage-ro")] + pub bind_storage_ro: bool, + + /// Firmware type for the VM (defaults to uefi-secure) + #[clap(long, default_value = "uefi-secure")] + pub firmware: FirmwareType, + + /// Disable TPM 2.0 support (enabled by default) + #[clap(long)] + pub disable_tpm: bool, + + /// Directory containing secure boot keys (required for uefi-secure) + #[clap(long)] + pub secure_boot_keys: Option, + + /// User-defined labels for organizing VMs (comma not allowed in labels) + #[clap(long)] + pub label: Vec, + + /// Create a transient VM that disappears on shutdown/reboot + #[clap(long)] + pub transient: bool, +} + +impl LibvirtRunAnacondaOpts { + /// Validate that labels don't contain commas + fn validate_labels(&self) -> Result<()> { + super::run::validate_labels(&self.label) + } + + /// Convert to LibvirtRunOpts for domain creation (reuses all the domain creation logic) + fn to_libvirt_run_opts(&self) -> LibvirtRunOpts { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("bootc:install-method".to_string(), "anaconda".to_string()); + + LibvirtRunOpts { + image: self.image.clone(), + name: self.name.clone(), + replace: self.replace, + itype: self.itype, + memory: self.memory.clone(), + cpus: self.cpus, + disk_size: self.disk_size.clone(), + install: self.install.clone(), + port_mappings: self.port_mappings.clone(), + raw_volumes: self.raw_volumes.clone(), + bind_mounts: self.bind_mounts.clone(), + bind_mounts_ro: self.bind_mounts_ro.clone(), + network: self.network.clone(), + detach: self.detach, + ssh: self.ssh, + ssh_wait: self.ssh_wait, + bind_storage_ro: self.bind_storage_ro, + update_from_host: false, // anaconda doesn't use this + firmware: self.firmware, + disable_tpm: self.disable_tpm, + secure_boot_keys: self.secure_boot_keys.clone(), + label: self.label.clone(), + transient: self.transient, + metadata, + extra_smbios_credentials: Vec::new(), + } + } +} + +/// Execute the libvirt run-anaconda command +pub fn run( + global_opts: &crate::libvirt::LibvirtOptions, + opts: LibvirtRunAnacondaOpts, +) -> Result<()> { + use crate::domain_list::DomainLister; + use crate::images; + + // Validate labels don't contain commas + opts.validate_labels()?; + + let connect_uri = global_opts.connect.as_deref(); + let lister = match global_opts.connect.as_ref() { + Some(uri) => DomainLister::with_connection(uri.clone()), + None => DomainLister::new(), + }; + let existing_domains = lister + .list_all_domains() + .with_context(|| "Failed to list existing domains")?; + + // Generate or validate VM name (reuse shared function) + let vm_name = match &opts.name { + Some(name) => { + if existing_domains.contains(name) { + if opts.replace { + println!("Replacing existing VM '{}'...", name); + crate::libvirt::rm::remove_vm_forced(global_opts, name, true) + .with_context(|| format!("Failed to remove existing VM '{}'", name))?; + } else { + return Err(eyre!( + "VM '{}' already exists. Use --replace to replace it.", + name + )); + } + } + name.clone() + } + None => super::run::generate_unique_vm_name(&opts.image, &existing_domains), + }; + + println!( + "Creating libvirt domain '{}' via anaconda (install source: {})", + vm_name, opts.image + ); + + // Get the image digest for caching + let inspect = images::inspect(&opts.image)?; + let image_digest = inspect.digest.to_string(); + debug!("Image digest: {}", image_digest); + + // Phase 1: Find or create a base disk using anaconda + let base_disk_path = find_or_create_anaconda_base_disk( + &opts.image, + &image_digest, + &opts.kickstart, + opts.target_imgref.as_deref(), + opts.no_repoint, + &opts.anaconda_image, + &opts.install, + connect_uri, + ) + .with_context(|| "Failed to find or create anaconda base disk")?; + + println!("Using base disk image: {}", base_disk_path); + + // Phase 2: Clone the base disk to create a VM-specific disk (reuse shared function) + let disk_path = if opts.transient { + println!("Transient mode: using base disk directly with overlay"); + base_disk_path + } else { + let cloned_disk = + crate::libvirt::base_disks::clone_from_base(&base_disk_path, &vm_name, connect_uri) + .with_context(|| "Failed to clone VM disk from base")?; + println!("Created VM disk: {}", cloned_disk); + cloned_disk + }; + + // Phase 3: Create libvirt domain using shared domain creation logic + println!("Creating libvirt domain..."); + + // Convert to LibvirtRunOpts and use the shared domain creation function + let mut run_opts = opts.to_libvirt_run_opts(); + run_opts.name = Some(vm_name.clone()); + + super::run::create_libvirt_domain_from_disk( + &vm_name, + &disk_path, + &image_digest, + &run_opts, + global_opts, + ) + .with_context(|| "Failed to create libvirt domain")?; + + // Print success info + let resolved_memory = run_opts.resolved_memory_mb()?; + let resolved_cpus = run_opts.resolved_cpus()?; + + println!("VM '{}' created successfully!", vm_name); + println!(" Image: {}", opts.image); + println!(" Install method: anaconda"); + println!(" Disk: {}", disk_path); + if let Some(ref itype) = opts.itype { + println!(" Instance Type: {}", itype); + } + println!(" Memory: {} MiB", resolved_memory); + println!(" CPUs: {}", resolved_cpus); + + // Display port forwarding information if any + if !opts.port_mappings.is_empty() { + println!("\nPort forwarding:"); + for mapping in opts.port_mappings.iter() { + println!( + " localhost:{} -> VM:{}", + mapping.host_port, mapping.guest_port + ); + } + } + + // Handle SSH options (reuse shared wait function) + if opts.ssh_wait { + super::run::wait_for_ssh_ready( + global_opts, + &vm_name, + super::run::SSH_WAIT_TIMEOUT_SECONDS, + )?; + println!("Ready; use bcvk libvirt ssh to connect"); + Ok(()) + } else if opts.ssh { + super::run::wait_for_ssh_ready( + global_opts, + &vm_name, + super::run::SSH_WAIT_TIMEOUT_SECONDS, + )?; + + let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts { + domain_name: vm_name, + user: "root".to_string(), + command: vec![], + suppress_output: false, + strict_host_keys: false, + timeout: 30, + log_level: "ERROR".to_string(), + extra_options: vec![], + }; + crate::libvirt::ssh::run(global_opts, ssh_opts) + } else { + println!("\nUse 'bcvk libvirt ssh {}' to connect", vm_name); + Ok(()) + } +} + +/// Find or create a base disk using anaconda installation +/// +/// This is the only part that differs from `libvirt run` - instead of using +/// `bootc install to-disk`, we use anaconda with a kickstart file. +fn find_or_create_anaconda_base_disk( + source_image: &str, + image_digest: &str, + kickstart: &std::path::Path, + target_imgref: Option<&str>, + no_repoint: bool, + anaconda_image: &str, + install_options: &InstallOptions, + connect_uri: Option<&str>, +) -> Result { + use sha2::{Digest, Sha256}; + + // Read kickstart content to include in cache hash + let kickstart_content = std::fs::read_to_string(kickstart) + .with_context(|| format!("Failed to read kickstart: {}", kickstart.display()))?; + + // Compute a cache hash that includes all inputs that affect the resulting disk: + // - image digest + // - kickstart content hash + // - repoint setting + // - install options (filesystem, root-size, composefs, bootloader, kargs) + let cache_hash = { + let mut hasher = Sha256::new(); + hasher.update(image_digest.as_bytes()); + hasher.update(b"|anaconda|"); + hasher.update(kickstart_content.as_bytes()); + hasher.update(format!("|repoint:{}|", !no_repoint).as_bytes()); + if let Some(fs) = &install_options.filesystem { + hasher.update(format!("fs:{}", fs).as_bytes()); + } + if let Some(size) = &install_options.root_size { + hasher.update(format!("|size:{}", size).as_bytes()); + } + for karg in &install_options.karg { + hasher.update(format!("|karg:{}", karg).as_bytes()); + } + if install_options.composefs_backend { + hasher.update(b"|composefs:true"); + } + if let Some(ref bl) = install_options.bootloader { + hasher.update(format!("|bootloader:{}", bl).as_bytes()); + } + format!("sha256:{:x}", hasher.finalize()) + }; + + let short_hash = cache_hash + .strip_prefix("sha256:") + .unwrap_or(&cache_hash) + .chars() + .take(16) + .collect::(); + + // Use different prefix to distinguish from to-disk base disks + let base_disk_name = format!("bootc-base-anaconda-{}.qcow2", short_hash); + + let pool_path = super::run::get_libvirt_storage_pool_path(connect_uri)?; + let base_disk_path = pool_path.join(&base_disk_name); + + // Check if base disk already exists + if base_disk_path.exists() { + debug!("Found existing anaconda base disk: {:?}", base_disk_path); + // For anaconda disks, we trust the hash-based naming since the kickstart + // content hash is included in the filename + return Ok(base_disk_path); + } + + // Base disk doesn't exist, create it + info!("Creating anaconda base disk: {:?}", base_disk_path); + create_anaconda_base_disk( + &base_disk_path, + source_image, + image_digest, + kickstart, + target_imgref, + no_repoint, + anaconda_image, + install_options, + connect_uri, + )?; + + Ok(base_disk_path) +} + +/// Create a new base disk using anaconda installation +fn create_anaconda_base_disk( + base_disk_path: &camino::Utf8Path, + source_image: &str, + image_digest: &str, + kickstart: &std::path::Path, + target_imgref: Option<&str>, + no_repoint: bool, + anaconda_image: &str, + install_options: &InstallOptions, + connect_uri: Option<&str>, +) -> Result<()> { + use crate::anaconda::install::AnacondaInstallOpts; + use crate::run_ephemeral::CommonVmOpts; + use crate::to_disk::Format; + use crate::utils::DiskSize; + + // Calculate disk size + let disk_size = install_options + .root_size + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| { + super::LIBVIRT_DEFAULT_DISK_SIZE + .parse::() + .expect("Default disk size should parse") + }); + + // Generate a unique temporary path. We can't use tempfile::NamedTempFile because + // anaconda::install() creates its own file at the target path using qemu-img, + // which would conflict with the tempfile handle. + let temp_disk_name = format!( + "{}.{}.tmp.qcow2", + base_disk_path.file_stem().unwrap(), + uuid::Uuid::new_v4().simple() + ); + let temp_disk_path = base_disk_path.parent().unwrap().join(&temp_disk_name); + + // Run the installation in a closure so we can clean up the temp file on any error + let result = (|| -> Result<()> { + // Build anaconda install options + let anaconda_opts = AnacondaInstallOpts { + image: source_image.to_string(), + target_disk: temp_disk_path.clone(), + kickstart: kickstart.to_path_buf(), + target_imgref: target_imgref.map(|s| s.to_string()), + no_repoint, + anaconda_image: anaconda_image.to_string(), + disk_size: Some(disk_size), + format: Format::Qcow2, + install: install_options.clone(), + common: CommonVmOpts { + memory: crate::common_opts::MemoryOpts { + memory: super::LIBVIRT_DEFAULT_MEMORY.to_string(), + }, + ..Default::default() + }, + }; + + // Run anaconda installation + info!("Running anaconda installation to create base disk..."); + crate::anaconda::install::install(&crate::anaconda::AnacondaOptions {}, anaconda_opts) + .with_context(|| "Anaconda installation failed")?; + + // Write cache metadata as xattrs + let metadata = crate::cache_metadata::DiskImageMetadata::from( + install_options, + image_digest, + source_image, + ); + let file = std::fs::File::open(&temp_disk_path) + .with_context(|| format!("Failed to open disk for metadata: {}", temp_disk_path))?; + metadata + .write_to_file(&file) + .with_context(|| "Failed to write cache metadata to disk")?; + drop(file); // Close file before rename + + // Atomically rename temp file to final location + std::fs::rename(&temp_disk_path, base_disk_path) + .with_context(|| format!("Failed to persist base disk to {:?}", base_disk_path))?; + + debug!( + "Successfully created anaconda base disk: {:?}", + base_disk_path + ); + Ok(()) + })(); + + // Clean up temp file on error + if result.is_err() && temp_disk_path.exists() { + let _ = std::fs::remove_file(&temp_disk_path); + } + + result?; + + // Refresh libvirt storage pool so the new disk is visible + let mut cmd = super::run::virsh_command(connect_uri)?; + cmd.args(["pool-refresh", "default"]); + if let Err(e) = cmd.output() { + debug!("Warning: Failed to refresh libvirt storage pool: {}", e); + } + + info!( + "Successfully created anaconda base disk: {:?}", + base_disk_path + ); + Ok(()) +} diff --git a/crates/kit/src/libvirt/upload.rs b/crates/kit/src/libvirt/upload.rs index e2960c6..5315cc4 100644 --- a/crates/kit/src/libvirt/upload.rs +++ b/crates/kit/src/libvirt/upload.rs @@ -4,9 +4,10 @@ //! to libvirt storage pools, maintaining container image metadata as libvirt annotations. use crate::common_opts::MemoryOpts; +use crate::images; use crate::install_options::InstallOptions; use crate::to_disk::{run as to_disk, ToDiskAdditionalOpts, ToDiskOpts}; -use crate::{images, utils}; +use crate::utils::DiskSize; use camino::Utf8PathBuf; use clap::Parser; use color_eyre::{eyre::eyre, Result}; @@ -30,7 +31,7 @@ pub struct LibvirtUploadOpts { /// Size of the disk image (e.g., '20G', '10240M'). If not specified, uses the actual size of the created disk. #[clap(long)] - pub disk_size: Option, + pub disk_size: Option, /// Installation options (filesystem, root-size, storage-path) #[clap(flatten)] @@ -189,14 +190,14 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtUploadOpts debug!("Container image digest: {}", image_digest); // Phase 2: Calculate disk size to use - let disk_size = if let Some(ref size_str) = opts.disk_size { + let disk_size = if let Some(size) = opts.disk_size { // Use explicit size if provided - utils::parse_size(size_str)? + size } else { // Use same logic as to_disk: 2x source image size with 4GB minimum let image_size = images::get_image_size(&opts.source_image)?; - std::cmp::max(image_size * 2, 4u64 * 1024 * 1024 * 1024) + DiskSize::from_bytes(std::cmp::max(image_size * 2, 4u64 * 1024 * 1024 * 1024)) }; // Phase 2: Create temporary disk path @@ -211,7 +212,7 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtUploadOpts target_disk: temp_disk_path.clone(), install: opts.install.clone(), additional: ToDiskAdditionalOpts { - disk_size: Some(disk_size.to_string()), + disk_size: Some(disk_size), common: crate::run_ephemeral::CommonVmOpts { memory: opts.memory.clone(), vcpus: opts.vcpus, @@ -226,7 +227,7 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtUploadOpts opts.upload_to_libvirt( global_opts, temp_disk_path.as_std_path(), - disk_size, + disk_size.as_bytes(), &image_digest, )?; diff --git a/crates/kit/src/libvirt_upload_disk.rs b/crates/kit/src/libvirt_upload_disk.rs index b2fda00..52101c0 100644 --- a/crates/kit/src/libvirt_upload_disk.rs +++ b/crates/kit/src/libvirt_upload_disk.rs @@ -4,10 +4,11 @@ //! to libvirt storage pools, maintaining container image metadata as libvirt annotations. use crate::common_opts::MemoryOpts; +use crate::images; use crate::install_options::InstallOptions; use crate::to_disk::{run as to_disk, ToDiskAdditionalOpts, ToDiskOpts}; +use crate::utils::DiskSize; use crate::xml_utils::{self, XmlWriter}; -use crate::{images, utils}; use camino::Utf8Path; use clap::Parser; use color_eyre::{eyre::eyre, Result}; @@ -31,7 +32,7 @@ pub struct LibvirtUploadDiskOpts { /// Size of the disk image (e.g., '20G', '10240M'). If not specified, uses the actual size of the created disk. #[clap(long)] - pub disk_size: Option, + pub disk_size: Option, /// Installation options (filesystem, root-size, storage-path) #[clap(flatten)] @@ -258,9 +259,9 @@ pub fn run(opts: LibvirtUploadDiskOpts) -> Result<()> { ); // Phase 1: Calculate disk size to use - let disk_size = if let Some(ref size_str) = opts.disk_size { + let disk_size = if let Some(size) = opts.disk_size { // Use explicit size if provided - utils::parse_size(size_str)? + size.as_bytes() } else { // Use same logic as to_disk: 2x source image size with 4GB minimum let image_size = images::get_image_size(&opts.source_image)?; @@ -282,7 +283,7 @@ pub fn run(opts: LibvirtUploadDiskOpts) -> Result<()> { target_disk: temp_disk.clone(), install: opts.install.clone(), additional: ToDiskAdditionalOpts { - disk_size: Some(disk_size.to_string()), + disk_size: Some(DiskSize::from_bytes(disk_size)), common: crate::run_ephemeral::CommonVmOpts { memory: opts.memory.clone(), vcpus: opts.vcpus, diff --git a/crates/kit/src/main.rs b/crates/kit/src/main.rs index 468acd7..a260cac 100644 --- a/crates/kit/src/main.rs +++ b/crates/kit/src/main.rs @@ -4,6 +4,7 @@ use cap_std_ext::cap_std::fs::Dir; use clap::{Parser, Subcommand}; use color_eyre::{eyre::Context as _, Report, Result}; +mod anaconda; mod arch; mod boot_progress; mod cache_metadata; @@ -115,6 +116,10 @@ enum Commands { /// Internal diagnostic and tooling commands for development #[clap(hide = true)] Internals(InternalsOpts), + + /// Install bootc containers using anaconda + #[clap(subcommand, hide = true)] + Anaconda(anaconda::AnacondaSubcommands), } /// Install and configure the tracing/logging system. @@ -169,6 +174,9 @@ fn main() -> Result<(), Report> { let options = libvirt::LibvirtOptions { connect }; match command { libvirt::LibvirtSubcommands::Run(opts) => libvirt::run::run(&options, opts)?, + libvirt::LibvirtSubcommands::RunAnaconda(opts) => { + libvirt::run_anaconda::run(&options, opts)? + } libvirt::LibvirtSubcommands::Ssh(opts) => libvirt::ssh::run(&options, opts)?, libvirt::LibvirtSubcommands::List(opts) => libvirt::list::run(&options, opts)?, libvirt::LibvirtSubcommands::ListVolumes(opts) => { @@ -227,6 +235,14 @@ fn main() -> Result<(), Report> { println!("{}", json); } }, + Commands::Anaconda(cmd) => { + let options = anaconda::AnacondaOptions::default(); + match cmd { + anaconda::AnacondaSubcommands::Install(opts) => { + anaconda::install::install(&options, opts)? + } + } + } } tracing::debug!("exiting"); // Ensure we don't block on any spawned tasks diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 1b45f02..ea4346b 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -385,6 +385,8 @@ pub fn run_detached(opts: RunEphemeralOpts) -> Result { } /// Launch privileged container with QEMU+KVM for ephemeral VM. +/// This function uses exec() to replace the current process with podman. +/// Use `run_sync()` if you need to wait for completion and continue execution. pub fn run(opts: RunEphemeralOpts) -> Result<()> { let (mut cmd, _temp_dir) = prepare_run_command_with_temp(opts)?; // Keep _temp_dir alive until exec replaces our process @@ -393,6 +395,29 @@ pub fn run(opts: RunEphemeralOpts) -> Result<()> { return Err(cmd.exec()).context("execve"); } +/// Launch privileged container with QEMU+KVM for ephemeral VM as a subprocess. +/// Waits for the container to complete and returns success/failure. +/// Use this when you need to run an ephemeral VM and continue execution afterwards. +pub fn run_sync(opts: RunEphemeralOpts) -> Result<()> { + let (mut cmd, _temp_dir) = prepare_run_command_with_temp(opts)?; + // Keep _temp_dir alive until the child process completes + + debug!("Running podman command (sync): {:?}", cmd); + + let status = cmd.status().context("Failed to execute podman command")?; + + if !status.success() { + let code = status.code().unwrap_or(-1); + return Err(eyre!( + "Ephemeral VM container exited with non-zero status: {}", + code + )); + } + + debug!("Ephemeral VM container completed successfully"); + Ok(()) +} + fn prepare_run_command_with_temp( opts: RunEphemeralOpts, ) -> Result<(std::process::Command, tempfile::TempDir)> { diff --git a/crates/kit/src/to_disk.rs b/crates/kit/src/to_disk.rs index 33949f7..607c138 100644 --- a/crates/kit/src/to_disk.rs +++ b/crates/kit/src/to_disk.rs @@ -79,6 +79,7 @@ use crate::cache_metadata::DiskImageMetadata; use crate::install_options::InstallOptions; use crate::run_ephemeral::{run_detached, CommonVmOpts, RunEphemeralOpts}; use crate::run_ephemeral_ssh::wait_for_ssh_ready; +use crate::utils::DiskSize; use crate::{images, ssh, utils}; use camino::Utf8PathBuf; use clap::{Parser, ValueEnum}; @@ -119,7 +120,7 @@ impl std::fmt::Display for Format { pub struct ToDiskAdditionalOpts { /// Disk size to create (e.g. 10G, 5120M, or plain number for bytes) #[clap(long)] - pub disk_size: Option, + pub disk_size: Option, /// Output disk image format #[clap(long, default_value_t = Format::Raw)] @@ -337,13 +338,11 @@ EOF /// Calculate the optimal target disk size based on the source image or explicit size /// - /// Returns explicit disk_size if provided (parsed from human-readable format), - /// otherwise 2x the image size with a 4GB minimum. + /// Returns explicit disk_size if provided, otherwise 2x the image size with a 4GB minimum. fn calculate_disk_size(&self) -> Result { - if let Some(ref size_str) = self.additional.disk_size { - let parsed = utils::parse_size(size_str)?; - debug!("Using explicit disk size: {} -> {} bytes", size_str, parsed); - return Ok(parsed); + if let Some(size) = self.additional.disk_size { + debug!("Using explicit disk size: {} bytes", size.as_bytes()); + return Ok(size.as_bytes()); } // Get the image size and multiply by 2 for installation space @@ -639,7 +638,7 @@ mod tests { ..Default::default() }, additional: ToDiskAdditionalOpts { - disk_size: Some("10G".to_string()), + disk_size: Some("10G".parse().unwrap()), ..Default::default() }, }; @@ -657,7 +656,7 @@ mod tests { ..Default::default() }, additional: ToDiskAdditionalOpts { - disk_size: Some("5120M".to_string()), + disk_size: Some("5120M".parse().unwrap()), ..Default::default() }, }; diff --git a/crates/kit/src/utils.rs b/crates/kit/src/utils.rs index ac06491..8b93777 100644 --- a/crates/kit/src/utils.rs +++ b/crates/kit/src/utils.rs @@ -167,6 +167,59 @@ pub(crate) fn validate_container_storage_path(path: &Utf8Path) -> Result<()> { Ok(()) } +/// A disk/file size in bytes, parsed from human-readable strings like "10G", "5120M", "1T" +/// +/// Implements `FromStr` for direct use with clap's `value_parser`. +/// +/// # Examples +/// +/// ```ignore +/// use bcvk::utils::DiskSize; +/// +/// let size: DiskSize = "10G".parse().unwrap(); +/// assert_eq!(size.as_bytes(), 10 * 1024 * 1024 * 1024); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiskSize(u64); + +impl DiskSize { + /// Create a new DiskSize from bytes + pub fn from_bytes(bytes: u64) -> Self { + Self(bytes) + } + + /// Get the size in bytes + pub fn as_bytes(&self) -> u64 { + self.0 + } +} + +impl std::str::FromStr for DiskSize { + type Err = color_eyre::eyre::Error; + + fn from_str(s: &str) -> Result { + parse_size(s).map(DiskSize) + } +} + +impl std::fmt::Display for DiskSize { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Display in human-readable form + let bytes = self.0; + if bytes >= 1024 * 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024 * 1024) == 0 { + write!(f, "{}T", bytes / (1024 * 1024 * 1024 * 1024)) + } else if bytes >= 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024) == 0 { + write!(f, "{}G", bytes / (1024 * 1024 * 1024)) + } else if bytes >= 1024 * 1024 && bytes % (1024 * 1024) == 0 { + write!(f, "{}M", bytes / (1024 * 1024)) + } else if bytes >= 1024 && bytes % 1024 == 0 { + write!(f, "{}K", bytes / 1024) + } else { + write!(f, "{}", bytes) + } + } +} + /// Parse size string (e.g., "10G", "5120M", "1T") to bytes pub(crate) fn parse_size(size_str: &str) -> Result { let size_str = size_str.trim().to_uppercase(); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 8a81e77..0cfdb03 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,10 +14,13 @@ - [ephemeral ssh](./man/bcvk-ephemeral-ssh.md) - [ephemeral run-ssh](./man/bcvk-ephemeral-run-ssh.md) - [to-disk](./man/bcvk-to-disk.md) + - [anaconda](./man/bcvk-anaconda.md) + - [anaconda install](./man/bcvk-anaconda-install.md) - [images](./man/bcvk-images.md) - [images list](./man/bcvk-images-list.md) - [libvirt](./man/bcvk-libvirt.md) - [libvirt run](./man/bcvk-libvirt-run.md) + - [libvirt run-anaconda](./man/bcvk-libvirt-run-anaconda.md) - [libvirt list](./man/bcvk-libvirt-list.md) - [libvirt ssh](./man/bcvk-libvirt-ssh.md) - [libvirt stop](./man/bcvk-libvirt-stop.md) diff --git a/docs/src/man/bcvk-anaconda-install.md b/docs/src/man/bcvk-anaconda-install.md new file mode 100644 index 0000000..6c8a10d --- /dev/null +++ b/docs/src/man/bcvk-anaconda-install.md @@ -0,0 +1,234 @@ +# NAME + +bcvk-anaconda-install - Install a bootc container to disk using anaconda + +# SYNOPSIS + +**bcvk anaconda install** \[*OPTIONS*\] **-k** *KICKSTART* *IMAGE* *TARGET_DISK* + +# DESCRIPTION + +Install a bootc container image to a disk image using anaconda as the +installation engine. The user provides a kickstart file with partitioning +and system configuration; bcvk automatically injects the **ostreecontainer** +directive to pull the image from host container storage. + +The installation runs inside an ephemeral VM. The host's container storage +is mounted read-only via virtiofs, allowing anaconda to access local images +without copying. When installation completes, the VM powers off and the +disk image is ready for use. + +## Kickstart Requirements + +Your kickstart file must include: + +- **Partitioning**: Use **autopart**, **part**, or other partitioning commands +- **Target disk**: Use `ignoredisk --only-use=/dev/disk/by-id/virtio-output` + (bcvk also attaches a swap disk that should be ignored) +- **Boot partitions**: Use `reqpart --add-boot` for BIOS/UEFI boot partitions +- **Poweroff**: Include `poweroff` so the VM exits when done + +Do **not** include an **ostreecontainer** directive; bcvk injects this +automatically with the correct transport. + +## Registry Repointing + +By default, bcvk injects a **%post** script that runs `bootc switch` to +repoint the installed system's origin to the registry. This ensures that +`bootc upgrade` pulls updates from the registry rather than expecting +containers-storage (which won't exist on the installed system). + +Use **--no-repoint** if you want to handle this yourself, or if the image +will only be used locally. + +Use **--target-imgref** to specify a different registry reference than the +source image (e.g., when installing from a local build but wanting updates +from a production registry). + +# OPTIONS + + +**IMAGE** + + Bootc container image to install (from host container storage) + + This argument is required. + +**TARGET_DISK** + + Target disk image file path + + This argument is required. + +**-k**, **--kickstart**=*KICKSTART* + + Kickstart file with partitioning and system configuration + +**--target-imgref**=*TARGET_IMGREF* + + Target image reference for the installed system + +**--no-repoint** + + Skip injecting the %post script that repoints to target-imgref + +**--anaconda-image**=*ANACONDA_IMAGE* + + Anaconda container image to use as the installer + + Default: localhost/anaconda-bootc:latest + +**--disk-size**=*DISK_SIZE* + + Disk size to create (e.g. 10G, 5120M) + +**--format**=*FORMAT* + + Output disk image format + + Possible values: + - raw + - qcow2 + + Default: raw + +**--filesystem**=*FILESYSTEM* + + Root filesystem type (e.g. ext4, xfs, btrfs) + +**--root-size**=*ROOT_SIZE* + + Root filesystem size (e.g., '10G', '5120M') + +**--storage-path**=*STORAGE_PATH* + + Path to host container storage (auto-detected if not specified) + +**--target-transport**=*TARGET_TRANSPORT* + + The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry` + +**--karg**=*KARG* + + Set a kernel argument + +**--composefs-backend** + + Default to composefs-native storage + +**--bootloader**=*BOOTLOADER* + + Which bootloader to use for composefs-native backend + +**--itype**=*ITYPE* + + Instance type (e.g., u1.nano, u1.small, u1.medium). Overrides vcpus/memory if specified. + +**--memory**=*MEMORY* + + Memory size (e.g. 4G, 2048M, or plain number for MB) + + Default: 4G + +**--vcpus**=*VCPUS* + + Number of vCPUs (overridden by --itype if specified) + +**--console** + + Enable console output to terminal for debugging + +**--debug** + + Enable debug mode (drop to shell instead of running QEMU) + +**--virtio-serial-out**=*NAME:FILE* + + Add virtio-serial device with output to file (format: name:/path/to/file) + +**--execute**=*EXECUTE* + + Execute command inside VM via systemd and capture output + +**-K**, **--ssh-keygen** + + Generate SSH keypair and inject via systemd credentials + + + +# EXAMPLES + +Basic installation with a minimal kickstart: + + cat > my.ks << 'EOF' + text + lang en_US.UTF-8 + keyboard us + timezone UTC --utc + network --bootproto=dhcp --activate + + ignoredisk --only-use=/dev/disk/by-id/virtio-output + zerombr + clearpart --all --initlabel + reqpart --add-boot + part / --fstype=xfs --grow + + rootpw --lock + poweroff + EOF + + bcvk anaconda install -k my.ks --disk-size 20G \ + quay.io/fedora/fedora-bootc:42 output.img + +Install a locally-built image, repointing to production registry: + + podman build -t localhost/myapp:dev . + bcvk anaconda install -k prod.ks --disk-size 50G \ + --target-imgref registry.example.com/myapp:latest \ + localhost/myapp:dev production.img + +Create a qcow2 disk image for use with libvirt: + + bcvk anaconda install -k server.ks --disk-size 100G --format qcow2 \ + quay.io/centos-bootc/centos-bootc:stream10 server.qcow2 + +Debug installation issues with console output: + + bcvk anaconda install -k my.ks --disk-size 20G --console \ + localhost/myimage:latest debug.img + +# KICKSTART EXAMPLE + +A complete kickstart for BIOS boot with LVM: + + text + lang en_US.UTF-8 + keyboard us + timezone America/New_York --utc + network --bootproto=dhcp --device=link --activate + + # Target the bcvk output disk + ignoredisk --only-use=/dev/disk/by-id/virtio-output + + zerombr + clearpart --all --initlabel + + # Create boot partitions (biosboot + /boot) + reqpart --add-boot + + # LVM layout + part pv.01 --grow + volgroup vg0 pv.01 + logvol / --vgname=vg0 --name=root --fstype=xfs --size=10240 + logvol /var --vgname=vg0 --name=var --fstype=xfs --size=5120 --grow + + rootpw --lock + poweroff + +# SEE ALSO + +**bcvk-anaconda**(8), **bcvk-to-disk**(8), **bcvk**(8), **bootc**(8) + +# VERSION + + diff --git a/docs/src/man/bcvk-anaconda.md b/docs/src/man/bcvk-anaconda.md new file mode 100644 index 0000000..32f5a17 --- /dev/null +++ b/docs/src/man/bcvk-anaconda.md @@ -0,0 +1,60 @@ +# NAME + +bcvk-anaconda - Install bootc containers using anaconda and kickstart + +# SYNOPSIS + +**bcvk anaconda** \<*subcommand*\> + +# DESCRIPTION + +The **bcvk anaconda** command installs bootc container images to disk using +anaconda as the installation engine. This provides an alternative to +**bcvk to-disk** that leverages anaconda's kickstart-based configuration +for partitioning and system setup. + +Anaconda runs inside an ephemeral VM with access to the host's container +storage via virtiofs. The target disk is attached as a virtio-blk device, +and anaconda installs the bootc image using the **ostreecontainer** kickstart +directive. + +## When to Use Anaconda + +Use **bcvk anaconda** when you need: + +- Custom partitioning layouts via kickstart +- Integration with existing kickstart-based workflows +- Anaconda-specific features (LVM, LUKS encryption, etc.) + +For simpler cases where you just need a bootable disk image, **bcvk to-disk** +is faster and requires less setup. + +## Prerequisites + +The anaconda-bootc container must be built before use: + + podman build -t localhost/anaconda-bootc:latest containers/anaconda-bootc/ + + + + +# SUBCOMMANDS + +bcvk-anaconda-install(8) + +: Install a bootc container to a disk image using anaconda + +# EXAMPLES + +Install a bootc image with a custom kickstart: + + bcvk anaconda install -k my-kickstart.ks --disk-size 20G \ + quay.io/fedora/fedora-bootc:42 output.img + +# SEE ALSO + +**bcvk**(8), **bcvk-anaconda-install**(8), **bcvk-to-disk**(8) + +# VERSION + + diff --git a/docs/src/man/bcvk-libvirt-run-anaconda.md b/docs/src/man/bcvk-libvirt-run-anaconda.md new file mode 100644 index 0000000..367ccb4 --- /dev/null +++ b/docs/src/man/bcvk-libvirt-run-anaconda.md @@ -0,0 +1,239 @@ +# NAME + +bcvk-libvirt-run-anaconda - Run a bootable container as a persistent VM, installed via anaconda + +# SYNOPSIS + +**bcvk libvirt run-anaconda** [*OPTIONS*] **--kickstart** *KICKSTART* *IMAGE* + +# DESCRIPTION + +Run a bootable container as a persistent VM using anaconda for installation. +This command is similar to `bcvk libvirt run`, but uses anaconda with kickstart +files instead of `bootc install to-disk` for the installation phase. + +This allows for more flexible partitioning schemes and system configuration +through kickstart files, while still providing the same VM lifecycle management +(SSH access, networking, bind mounts, etc.) as `bcvk libvirt run`. + +The `ostreecontainer` directive is injected automatically into the kickstart +file, so you only need to provide the partitioning and system configuration. + +# OPTIONS + + +**IMAGE** + + Container image to run as a bootable VM + + This argument is required. + +**-k**, **--kickstart**=*KICKSTART* + + Kickstart file with partitioning and system configuration + +**--name**=*NAME* + + Name for the VM (auto-generated if not specified) + +**-R**, **--replace** + + Replace existing VM with same name (stop and remove if exists) + +**--target-imgref**=*TARGET_IMGREF* + + Target image reference for the installed system + +**--no-repoint** + + Skip injecting the %post script that repoints to target-imgref + +**--anaconda-image**=*ANACONDA_IMAGE* + + Anaconda container image to use as the installer + + Default: localhost/anaconda-bootc:latest + +**--itype**=*ITYPE* + + Instance type (e.g., u1.nano, u1.small, u1.medium). Overrides cpus/memory if specified. + +**--memory**=*MEMORY* + + Memory size (e.g. 4G, 2048M, or plain number for MB) + + Default: 4G + +**--cpus**=*CPUS* + + Number of virtual CPUs for the VM (overridden by --itype if specified) + + Default: 2 + +**--disk-size**=*DISK_SIZE* + + Disk size for the VM (e.g. 20G, 10240M, or plain number for bytes) + + Default: 20G + +**--filesystem**=*FILESYSTEM* + + Root filesystem type (e.g. ext4, xfs, btrfs) + +**--root-size**=*ROOT_SIZE* + + Root filesystem size (e.g., '10G', '5120M') + +**--storage-path**=*STORAGE_PATH* + + Path to host container storage (auto-detected if not specified) + +**--target-transport**=*TARGET_TRANSPORT* + + The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry` + +**--karg**=*KARG* + + Set a kernel argument + +**--composefs-backend** + + Default to composefs-native storage + +**--bootloader**=*BOOTLOADER* + + Which bootloader to use for composefs-native backend + +**-p**, **--port**=*PORT_MAPPINGS* + + Port mapping from host to VM (format: host_port:guest_port, e.g., 8080:80) + +**-v**, **--volume**=*RAW_VOLUMES* + + Volume mount from host to VM (raw virtiofs tag, for manual mounting) + +**--bind**=*BIND_MOUNTS* + + Bind mount from host to VM (format: host_path:guest_path) + +**--bind-ro**=*BIND_MOUNTS_RO* + + Bind mount from host to VM as read-only (format: host_path:guest_path) + +**--network**=*NETWORK* + + Network mode for the VM + + Default: user + +**--detach** + + Keep the VM running in background after creation + +**--ssh** + + Automatically SSH into the VM after creation + +**--ssh-wait** + + Wait for SSH to become available and verify connectivity (for testing) + +**--bind-storage-ro** + + Mount host container storage (RO) at /run/host-container-storage + +**--firmware**=*FIRMWARE* + + Firmware type for the VM (defaults to uefi-secure) + + Possible values: + - uefi-secure + - uefi-insecure + - bios + + Default: uefi-secure + +**--disable-tpm** + + Disable TPM 2.0 support (enabled by default) + +**--secure-boot-keys**=*SECURE_BOOT_KEYS* + + Directory containing secure boot keys (required for uefi-secure) + +**--label**=*LABEL* + + User-defined labels for organizing VMs (comma not allowed in labels) + +**--transient** + + Create a transient VM that disappears on shutdown/reboot + + + +# KICKSTART FILE + +The kickstart file should contain partitioning and system configuration. +You do NOT need to include the `ostreecontainer` directive - bcvk injects +this automatically with the correct transport. + +Example minimal kickstart: + +``` +text +lang en_US.UTF-8 +keyboard us +timezone UTC --utc +network --bootproto=dhcp --activate + +zerombr +clearpart --all --initlabel +reqpart --add-boot +autopart --type=plain --fstype=xfs +bootloader --location=mbr +rootpw --lock + +poweroff +``` + +# EXAMPLES + +Create a VM using anaconda with a kickstart file: + + bcvk libvirt run-anaconda --name my-server \ + --kickstart my-config.ks \ + quay.io/fedora/fedora-bootc:42 + +Create a VM with custom resources and SSH access: + + bcvk libvirt run-anaconda --name webserver \ + --kickstart server.ks \ + --memory 8192 --cpus 8 --disk-size 50G \ + --ssh \ + quay.io/centos-bootc/centos-bootc:stream10 + +Create a VM with a custom target image reference: + + bcvk libvirt run-anaconda --name prod-server \ + --kickstart prod.ks \ + --target-imgref registry.example.com/myapp:prod \ + quay.io/fedora/fedora-bootc:42 + +Test anaconda installation workflow: + + # Build the anaconda-bootc container first + podman build -t localhost/anaconda-bootc:latest containers/anaconda-bootc/ + + # Create a VM with anaconda installation + bcvk libvirt run-anaconda --name test-vm \ + --kickstart test.ks \ + --ssh-wait \ + quay.io/fedora/fedora-bootc:42 + +# SEE ALSO + +**bcvk-libvirt-run**(8), **bcvk-anaconda-install**(8), **bcvk**(8) + +# VERSION + +v0.1.0 diff --git a/docs/src/man/bcvk-libvirt-run.md b/docs/src/man/bcvk-libvirt-run.md index 4778775..0c0abf9 100644 --- a/docs/src/man/bcvk-libvirt-run.md +++ b/docs/src/man/bcvk-libvirt-run.md @@ -73,6 +73,10 @@ Run a bootable container as a persistent VM Default to composefs-native storage +**--bootloader**=*BOOTLOADER* + + Which bootloader to use for composefs-native backend + **-p**, **--port**=*PORT_MAPPINGS* Port mapping from host to VM (format: host_port:guest_port, e.g., 8080:80) diff --git a/docs/src/man/bcvk-libvirt-ssh.md b/docs/src/man/bcvk-libvirt-ssh.md index cdcb6af..f5209d4 100644 --- a/docs/src/man/bcvk-libvirt-ssh.md +++ b/docs/src/man/bcvk-libvirt-ssh.md @@ -37,7 +37,7 @@ SSH to libvirt domain with embedded SSH key SSH connection timeout in seconds - Default: 30 + Default: 5 **--log-level**=*LOG_LEVEL* diff --git a/docs/src/man/bcvk-libvirt-upload.md b/docs/src/man/bcvk-libvirt-upload.md index 328721a..70fe007 100644 --- a/docs/src/man/bcvk-libvirt-upload.md +++ b/docs/src/man/bcvk-libvirt-upload.md @@ -57,6 +57,10 @@ Upload bootc disk images to libvirt with metadata annotations Default to composefs-native storage +**--bootloader**=*BOOTLOADER* + + Which bootloader to use for composefs-native backend + **--memory**=*MEMORY* Memory size (e.g. 4G, 2048M, or plain number for MB) diff --git a/docs/src/man/bcvk-libvirt.md b/docs/src/man/bcvk-libvirt.md index a6877d5..4efc599 100644 --- a/docs/src/man/bcvk-libvirt.md +++ b/docs/src/man/bcvk-libvirt.md @@ -30,6 +30,14 @@ libvirt virtualization infrastructure, enabling: # SUBCOMMANDS +bcvk-libvirt-run(8) + +: Run a bootable container as a persistent VM + +bcvk-libvirt-run-anaconda(8) + +: Run a bootable container as a persistent VM, installed via anaconda + bcvk-libvirt-upload(8) : Upload bootc disk images to libvirt storage pools @@ -42,6 +50,26 @@ bcvk-libvirt-list(8) : List bootc-related libvirt domains and storage +bcvk-libvirt-ssh(8) + +: SSH into a libvirt VM + +bcvk-libvirt-stop(8) + +: Stop a libvirt VM + +bcvk-libvirt-start(8) + +: Start a stopped libvirt VM + +bcvk-libvirt-rm(8) + +: Remove a libvirt VM + +bcvk-libvirt-inspect(8) + +: Show detailed information about a libvirt VM + bcvk-libvirt-help(8) : Print this message or the help of the given subcommand(s) diff --git a/docs/src/man/bcvk-to-disk.md b/docs/src/man/bcvk-to-disk.md index e73fd05..1978d15 100644 --- a/docs/src/man/bcvk-to-disk.md +++ b/docs/src/man/bcvk-to-disk.md @@ -59,6 +59,10 @@ The installation process: Default to composefs-native storage +**--bootloader**=*BOOTLOADER* + + Which bootloader to use for composefs-native backend + **--disk-size**=*DISK_SIZE* Disk size to create (e.g. 10G, 5120M, or plain number for bytes) diff --git a/docs/src/man/bcvk.md b/docs/src/man/bcvk.md index 4f5c6b7..b63da2c 100644 --- a/docs/src/man/bcvk.md +++ b/docs/src/man/bcvk.md @@ -69,6 +69,10 @@ bcvk-to-disk(8) : Install bootc images to persistent disk images +bcvk-anaconda(8) + +: Install bootc images using anaconda and kickstart files + bcvk-libvirt(8) : Manage stateful VMs via libvirt (persistent disk images)