From 4e4c573460768c80c32c1e15587516af6d322fc7 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 5 Feb 2026 22:05:29 +0000 Subject: [PATCH 1/2] utils: Add DiskSize type for parsing human-readable sizes Add a DiskSize newtype that wraps u64 bytes and implements FromStr, allowing clap to parse disk sizes directly from command line arguments. This eliminates the need for manual parsing at each call site. The type supports human-readable formats like '10G', '5120M', '1T' and provides both from_bytes() constructor and as_bytes() accessor. Update all disk_size Option fields to use Option: - to_disk::ToDiskAdditionalOpts - libvirt/upload::LibvirtUploadOpts - libvirt_upload_disk::LibvirtUploadDiskOpts - libvirt/base_disks (internal usage) Assisted-by: OpenCode (Claude sonnet-4-20250514) Signed-off-by: Colin Walters --- crates/kit/src/libvirt/base_disks.rs | 6 ++- crates/kit/src/libvirt/upload.rs | 15 ++++---- crates/kit/src/libvirt_upload_disk.rs | 11 +++--- crates/kit/src/to_disk.rs | 17 ++++----- crates/kit/src/utils.rs | 53 +++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/crates/kit/src/libvirt/base_disks.rs b/crates/kit/src/libvirt/base_disks.rs index 6aafd7d1..77253599 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/upload.rs b/crates/kit/src/libvirt/upload.rs index e2960c68..5315cc41 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 b2fda001..52101c03 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/to_disk.rs b/crates/kit/src/to_disk.rs index 33949f73..607c1389 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 ac064918..8b937773 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(); From 6810f4925e1bebc6b882833cb5bc42d38a0d5d5e Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 10 Feb 2026 21:40:27 +0000 Subject: [PATCH 2/2] anaconda: Add bootc installation via anaconda Implement bootc container installation using anaconda as the installation engine with kickstart processing. Uses the ephemeral VM infrastructure to run anaconda in an isolated environment with proper access to block devices and container storage. Key implementation details: - User must provide kickstart file with partitioning and locale settings - bcvk injects ostreecontainer directive with --transport=containers-storage - Inject %pre script to configure container storage with host overlay - Inject %post script running 'bootc switch --mutate-in-place' to repoint the installed system to the registry image (for bootc upgrade to work) - Handle SSH exit code 255 as success when VM powers off after installation - Share disk creation logic via qemu_img::create_disk() Options: - --kickstart (-k): Required kickstart file path - --target-imgref: Registry image for bootc origin (defaults to image arg) - --no-repoint: Skip %post repointing if user handles it themselves The anaconda installer container (localhost/anaconda-bootc) is based on fedora-bootc with anaconda-tui installed. Build instructions in containers/anaconda-bootc/. Assisted-by: OpenCode (claude-opus-4-5@20251101) Signed-off-by: Colin Walters --- containers/anaconda-bootc/Dockerfile | 24 + containers/anaconda-bootc/README.md | 97 ++++ .../bcvk-anaconda-setup.service | 18 + .../anaconda-bootc/bcvk-anaconda-setup.sh | 54 ++ containers/anaconda-bootc/packages.txt | 19 + crates/integration-tests/src/main.rs | 2 + .../src/tests/anaconda_install.rs | 191 +++++++ .../src/tests/libvirt_run_anaconda.rs | 348 ++++++++++++ crates/kit/src/anaconda/install.rs | 523 +++++++++++++++++ crates/kit/src/anaconda/mod.rs | 18 + crates/kit/src/libvirt/mod.rs | 5 + crates/kit/src/libvirt/run.rs | 35 +- crates/kit/src/libvirt/run_anaconda.rs | 533 ++++++++++++++++++ crates/kit/src/main.rs | 16 + crates/kit/src/run_ephemeral.rs | 25 + docs/src/SUMMARY.md | 3 + docs/src/man/bcvk-anaconda-install.md | 234 ++++++++ docs/src/man/bcvk-anaconda.md | 60 ++ docs/src/man/bcvk-libvirt-run-anaconda.md | 239 ++++++++ docs/src/man/bcvk-libvirt-run.md | 4 + docs/src/man/bcvk-libvirt-ssh.md | 2 +- docs/src/man/bcvk-libvirt-upload.md | 4 + docs/src/man/bcvk-libvirt.md | 28 + docs/src/man/bcvk-to-disk.md | 4 + docs/src/man/bcvk.md | 4 + 25 files changed, 2474 insertions(+), 16 deletions(-) create mode 100644 containers/anaconda-bootc/Dockerfile create mode 100644 containers/anaconda-bootc/README.md create mode 100644 containers/anaconda-bootc/bcvk-anaconda-setup.service create mode 100644 containers/anaconda-bootc/bcvk-anaconda-setup.sh create mode 100644 containers/anaconda-bootc/packages.txt create mode 100644 crates/integration-tests/src/tests/anaconda_install.rs create mode 100644 crates/integration-tests/src/tests/libvirt_run_anaconda.rs create mode 100644 crates/kit/src/anaconda/install.rs create mode 100644 crates/kit/src/anaconda/mod.rs create mode 100644 crates/kit/src/libvirt/run_anaconda.rs create mode 100644 docs/src/man/bcvk-anaconda-install.md create mode 100644 docs/src/man/bcvk-anaconda.md create mode 100644 docs/src/man/bcvk-libvirt-run-anaconda.md diff --git a/containers/anaconda-bootc/Dockerfile b/containers/anaconda-bootc/Dockerfile new file mode 100644 index 00000000..e25d4dfd --- /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 00000000..b78188ff --- /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 00000000..b0fb7799 --- /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 00000000..cd8464d9 --- /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 00000000..c436ded9 --- /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 93bc7d55..4b2ba017 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 00000000..3df07e1d --- /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 00000000..1cf8af68 --- /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 00000000..32525074 --- /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 00000000..1c398db9 --- /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/mod.rs b/crates/kit/src/libvirt/mod.rs index 7e1bc17f..9d47d0ca 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 a466c60a..bec13be5 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 00000000..a5e9dcde --- /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/main.rs b/crates/kit/src/main.rs index 468acd71..a260cac3 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 1b45f02a..ea4346b4 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/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 8a81e775..0cfdb032 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 00000000..6c8a10d2 --- /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 00000000..32f5a17a --- /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 00000000..367ccb4f --- /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 47787750..0c0abf90 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 cdcb6aff..f5209d4d 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 328721a5..70fe0077 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 a6877d52..4efc5990 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 e73fd050..1978d155 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 4f5c6b7b..b63da2ca 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)