cockpit-kata/qcrows-export

874 lines
35 KiB
Bash
Executable File

#!/usr/bin/env bash
# ============================================================================
# qcrows-export — Export a QCrows image to qcow2, ISO, or PXE format
#
# Converts a .qcrows archive into:
# - qcow2: QEMU disk image with rootfs installed, ready for VM boot
# - iso: Bootable ISO for live USB media (hybrid MBR/EFI)
# - pxe: PXE/TFTP directory for network boot via dnsmasq
#
# Usage:
# qcrows-export --format qcow2 image.qcrows -o disk.qcow2
# qcrows-export --format iso image.qcrows -o live.iso
# qcrows-export --format pxe image.qcrows -o /srv/tftp
#
# Prerequisites:
# qcow2: qemu-img, parted, mkfs.ext4, mount, tar
# iso: xorriso (or genisoimage), syslinux/mtools (for MBR boot),
# grub-mkrescue (for EFI boot)
# pxe: dnsmasq (for DHCP+TFTP), squashfs-tools (for rootfs packing)
# ============================================================================
set -euo pipefail
# ─── Version ────────────────────────────────────────────────────────────────
VERSION="0.2.0"
# ─── Colors ─────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${GREEN}[qcrows-export]${NC} $*"; }
warn() { echo -e "${YELLOW}[qcrows-export]${NC} WARNING: $*"; }
error() { echo -e "${RED}[qcrows-export]${NC} ERROR: $*" >&2; }
die() { error "$@"; exit 1; }
# ─── Defaults ───────────────────────────────────────────────────────────────
FORMAT=""
INPUT=""
OUTPUT=""
DISK_SIZE="" # calculated from rootfs size if empty
BOOT_LABEL="QCROWS"
ROOT_LABEL="ROOTFS"
EFI_PARTITION=false
COMPRESS_QCOW2=true
VERBOSE=false
PXE_SERVER_IP=""
PXE_DEFAULT=true
PXE_APPEND_CMDLINE=""
# ─── Temp directory ────────────────────────────────────────────────────────
TMPDIR=""
WORKDIR=""
cleanup() {
if [[ -n "$WORKDIR" && -d "$WORKDIR" ]]; then
mountpoint -q "${WORKDIR}/mnt/rootfs" 2>/dev/null && umount -l "${WORKDIR}/mnt/rootfs" 2>/dev/null || true
mountpoint -q "${WORKDIR}/mnt/efi" 2>/dev/null && umount -l "${WORKDIR}/mnt/efi" 2>/dev/null || true
losetup -D 2>/dev/null || true
fi
[[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
# ─── Helpers ────────────────────────────────────────────────────────────────
# Return the first existing file from candidate paths
candidate_file() {
local p
for p in "$@"; do
[[ -f "$p" ]] && { echo "$p"; return 0; }
done
return 1
}
# Return the first existing file from candidate paths (supports glob expansion)
first_existing_file() {
local candidate match
for candidate in "$@"; do
for match in $candidate; do
if [[ -f "$match" ]]; then
echo "$match"
return 0
fi
done
done
return 1
}
# Copy source to destination only if source exists
copy_if_exists() {
local src="$1" dest="$2"
[[ -f "$src" ]] && cp "$src" "$dest"
}
# Construct kernel command line from base params + optional boot-params.conf
build_cmdline() {
local base="$1"
local params_file="$2"
if [[ -n "$params_file" && -f "$params_file" ]]; then
local extra
extra=$(tr '\n' ' ' < "$params_file" | sed 's/ */ /g; s/^ *//; s/ *$//')
echo "${base} ${extra}"
else
echo "$base"
fi
}
# ─── Usage ──────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
qcrows-export v${VERSION} — Export QCrows image to qcow2 or ISO
USAGE:
qcrows-export --format FORMAT INPUT -o OUTPUT [OPTIONS]
REQUIRED:
--format FORMAT Export format: qcow2 | iso | pxe
INPUT Path to .qcrows archive
-o, --output PATH Output file (qcow2/iso) or TFTP directory (pxe)
QCOW2 OPTIONS:
--disk-size SIZE Disk size (e.g., 2G, 500M). Default: rootfs size + 256M
--no-compress Do not compress the qcow2 image
--efi Create an EFI-compatible partition layout
ISO OPTIONS:
--boot-label LABEL Boot partition label (default: QCROWS)
--root-label LABEL Root filesystem label (default: ROOTFS)
--efi Create EFI-bootable ISO (requires grub-mkrescue)
PXE OPTIONS:
--server-ip IP PXE/DHCP server IP (required for pxe format)
--no-default Do not set as default PXE entry
--append-cmdline S Extra kernel command-line parameters
GENERAL OPTIONS:
-v, --verbose Verbose output
-h, --help Show this help
--version Show version
PREREQUISITES:
qcow2: qemu-img, parted, mkfs.ext4, mount, tar
iso: xorriso or genisoimage, isolinux (for MBR), grub-mkrescue (for EFI)
pxe: dnsmasq, squashfs-tools (mksquashfs)
EXAMPLES:
# Export to qcow2 (sized from rootfs)
qcrows-export --format qcow2 alpine-kata.qcrows -o alpine-kata.qcow2
# Export to 2GB qcow2 with EFI support
qcrows-export --format qcow2 --disk-size 2G --efi alpine-kata.qcrows -o alpine-efi.qcow2
# Export to bootable ISO for USB
qcrows-export --format iso alpine-kata.qcrows -o alpine-live.iso
# Write ISO to USB
dd if=alpine-live.iso of=/dev/sdX bs=4M status=progress && sync
# Export to PXE/TFTP directory
qcrows-export --format pxe --server-ip 192.168.1.1 alpine-kata.qcrows -o /srv/tftp
EOF
}
# ─── Parse Arguments ────────────────────────────────────────────────────────
parse_args() {
local positional_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--format) FORMAT="$2"; shift 2 ;;
--output|-o) OUTPUT="$2"; shift 2 ;;
--disk-size) DISK_SIZE="$2"; shift 2 ;;
--no-compress) COMPRESS_QCOW2=false; shift ;;
--efi) EFI_PARTITION=true; shift ;;
--boot-label) BOOT_LABEL="$2"; shift 2 ;;
--root-label) ROOT_LABEL="$2"; shift 2 ;;
--verbose|-v) VERBOSE=true; shift ;;
--server-ip) PXE_SERVER_IP="$2"; shift 2 ;;
--no-default) PXE_DEFAULT=false; shift ;;
--append-cmdline) PXE_APPEND_CMDLINE="$2"; shift 2 ;;
--help|-h) usage; exit 0 ;;
--version) echo "qcrows-export v${VERSION}"; exit 0 ;;
-*) die "Unknown option: $1 (use --help)" ;;
*) positional_args+=("$1"); shift ;;
esac
done
INPUT="${positional_args[0]:-}"
[[ -z "$FORMAT" ]] && die "Format is required (--format qcow2|iso|pxe)"
[[ -z "$INPUT" ]] && die "Input .qcrows file is required"
[[ -z "$OUTPUT" ]] && die "Output path is required (--output or -o)"
[[ ! -f "$INPUT" ]] && die "Input file not found: $INPUT"
case "$FORMAT" in
qcow2|iso|pxe) ;;
*) die "Unsupported format: ${FORMAT} (use qcow2, iso, or pxe)" ;;
esac
[[ "$FORMAT" == "pxe" && -z "$PXE_SERVER_IP" ]] && die "PXE format requires --server-ip"
}
# ─── Prerequisite Checks ───────────────────────────────────────────────────
check_prereqs_qcow2() {
local -a missing=()
command -v qemu-img &>/dev/null || missing+=(qemu-img)
command -v parted &>/dev/null || missing+=(parted)
command -v mkfs.ext4 &>/dev/null || missing+=(mkfs.ext4)
command -v mount &>/dev/null || missing+=(mount)
command -v tar &>/dev/null || missing+=(tar)
[[ ${#missing[@]} -gt 0 ]] && die "Missing prerequisites for qcow2 export: ${missing[*]}. Install qemu-utils, parted, e2fsprogs."
}
check_prereqs_iso() {
local -a missing=()
# ISO tool detection — ordered preference
local -a iso_tools=(xorriso genisoimage mkisofs)
ISO_TOOL=""
for tool in "${iso_tools[@]}"; do
if command -v "$tool" &>/dev/null; then
ISO_TOOL="$tool"
break
fi
done
[[ -z "$ISO_TOOL" ]] && missing+=("xorriso or genisoimage")
# MBR boot requires isolinux
if [[ "$EFI_PARTITION" == "false" ]]; then
candidate_file /usr/lib/ISOLINUX/isolinux.bin /usr/share/syslinux/isolinux.bin &>/dev/null \
|| missing+=(isolinux/syslinux)
fi
# EFI boot requires grub-mkrescue
[[ "$EFI_PARTITION" == "true" ]] && { command -v grub-mkrescue &>/dev/null || missing+=(grub-mkrescue); }
[[ ${#missing[@]} -gt 0 ]] && die "Missing prerequisites for ISO export: ${missing[*]}. Install xorriso, syslinux, and/or grub2."
}
# ─── Extract QCrows Archive ────────────────────────────────────────────────
extract_qcrows() {
local archive="$1"
local dest="$2"
log "Extracting QCrows archive: $(basename "$archive")"
case "$archive" in
*.gz) tar xzf "$archive" -C "$dest" 2>/dev/null || tar xf "$archive" -C "$dest" 2>/dev/null ;;
*) tar xf "$archive" -C "$dest" 2>/dev/null ;;
esac
# Descend into subdirectory if the archive wraps everything in one
local -a top_entries
mapfile -t top_entries < <(find "$dest" -maxdepth 1 -type d ! -path "$dest")
if [[ ${#top_entries[@]} -eq 1 && -f "${top_entries[0]}/metadata.toml" ]]; then
mv "${top_entries[0]}"/* "$dest/" 2>/dev/null || true
rmdir "${top_entries[0]}" 2>/dev/null || true
fi
# Validate required components
[[ -f "${dest}/metadata.toml" ]] || die "metadata.toml not found in archive"
[[ -f "${dest}/hashes.sha256" ]] || die "hashes.sha256 not found in archive"
# Find rootfs
ROOTFS_FILE=""
local -a rootfs_candidates=()
mapfile -t rootfs_candidates < <(find "$dest" -maxdepth 1 -name 'rootfs*' -type f 2>/dev/null)
[[ ${#rootfs_candidates[@]} -gt 0 ]] || die "rootfs not found in archive"
ROOTFS_FILE="${rootfs_candidates[0]}"
log "Found rootfs: $(basename "$ROOTFS_FILE")"
# Find kernel
KERNEL_FILE="$(first_existing_file "${dest}/kernel/vmlinuz" "${dest}/kernel/vmlinux" || true)"
[[ -n "$KERNEL_FILE" ]] || die "kernel not found in archive (required for bootable export)"
log "Found kernel: $(basename "$KERNEL_FILE")"
# Find initrd
INITRD_FILE=""
local -a initrd_candidates=()
mapfile -t initrd_candidates < <(find "$dest" -maxdepth 1 \( -name 'initrd*' -o -name 'initramfs*' \) -type f 2>/dev/null)
if [[ ${#initrd_candidates[@]} -gt 0 ]]; then
INITRD_FILE="${initrd_candidates[0]}"
log "Found initrd: $(basename "$INITRD_FILE")"
else
warn "No initrd found — ISO boot may require initramfs"
fi
# Find boot-params
BOOT_PARAMS_FILE=""
[[ -f "${dest}/boot-params.conf" ]] && { BOOT_PARAMS_FILE="${dest}/boot-params.conf"; log "Found boot-params.conf"; }
# Read kernel version from metadata
KERNEL_VERSION=$(awk '/\[ kernel \]/{found=1} found && /^version/{print; exit}' "$dest/metadata.toml" | sed 's/.*= *//' | tr -d '"' || echo "unknown")
if [[ -z "$KERNEL_VERSION" || "$KERNEL_VERSION" == "unknown" ]]; then
KERNEL_VERSION=$(strings "$KERNEL_FILE" 2>/dev/null | grep -oP '^\d+\.\d+\.\d+' | head -1 || echo "unknown")
fi
log "Kernel version: ${KERNEL_VERSION}"
# Verify hashes
log "Verifying archive integrity..."
(cd "$dest" && sha256sum --quiet -c hashes.sha256 2>/dev/null) || warn "SHA-256 verification reported mismatches — proceed with caution"
}
# ─── Compute disk size from rootfs ─────────────────────────────────────────
compute_disk_size() {
if [[ -n "$DISK_SIZE" ]]; then
echo "$DISK_SIZE"
return
fi
local rootfs_bytes
rootfs_bytes=$(stat --format='%s' "$ROOTFS_FILE" 2>/dev/null || stat -f '%z' "$ROOTFS_FILE" 2>/dev/null || echo 0)
# Assume 2.5x compression ratio for uncompressed rootfs size estimate
local estimated_mb=$(( (rootfs_bytes * 5 / 2) / (1024 * 1024) ))
# Add 256MB headroom, minimum 512MB
local total_mb=$(( estimated_mb + 256 ))
[[ $total_mb -lt 512 ]] && total_mb=512
# Round up to next 128MB boundary
total_mb=$(( ((total_mb / 128) + 1) * 128 ))
echo "${total_mb}M"
}
# ─── Export to qcow2 ───────────────────────────────────────────────────────
export_qcow2() {
local archive="$1"
local output="$2"
check_prereqs_qcow2
WORKDIR="$(mktemp -d /tmp/qcrows-export-qcow2-XXXXXX)"
local extract_dir="${WORKDIR}/extracted"
mkdir -p "$extract_dir"
extract_qcrows "$archive" "$extract_dir"
local disk_size
disk_size=$(compute_disk_size)
log "Disk size: ${disk_size}"
# ── Step 1: Create raw disk image ─────────────────────────────────────
local raw_img="${WORKDIR}/disk.raw"
log "Creating raw disk image (${disk_size})..."
qemu-img create -f raw "$raw_img" "$disk_size"
# ── Step 2: Partition ─────────────────────────────────────────────────
log "Partitioning disk..."
local efi_part="" root_part=""
if [[ "$EFI_PARTITION" == "true" ]]; then
parted -s "$raw_img" mklabel gpt
parted -s "$raw_img" mkpart ESP fat32 1MiB 513MiB
parted -s "$raw_img" set 1 esp on
parted -s "$raw_img" mkpart root ext4 513MiB 100%
else
parted -s "$raw_img" mklabel msdos
parted -s "$raw_img" mkpart primary ext4 1MiB 100%
parted -s "$raw_img" set 1 boot on
fi
# ── Step 3: Set up loop device ────────────────────────────────────────
log "Setting up loop device..."
local loop_dev
loop_dev=$(losetup --find --show --partscan "$raw_img")
sleep 1
partprobe "$loop_dev" 2>/dev/null || true
sleep 1
local -a partitions=()
mapfile -t partitions < <(ls "${loop_dev}p"* 2>/dev/null || echo "")
if [[ "$EFI_PARTITION" == "true" ]]; then
efi_part="${partitions[0]}"
root_part="${partitions[1]}"
log "Formatting EFI partition..."
mkfs.vfat -F 32 -n "$BOOT_LABEL" "$efi_part" 2>/dev/null || die "Failed to format EFI partition"
else
root_part="${partitions[0]}"
fi
log "Formatting root partition..."
mkfs.ext4 -L "$ROOT_LABEL" -q "$root_part" || die "Failed to format root partition"
# ── Step 4: Mount and install rootfs ──────────────────────────────────
local mnt="${WORKDIR}/mnt"
mkdir -p "${mnt}/rootfs"
log "Mounting root partition..."
mount "$root_part" "${mnt}/rootfs"
log "Extracting rootfs onto disk..."
case "$ROOTFS_FILE" in
*.tar.gz|*.tgz) tar xzf "$ROOTFS_FILE" -C "${mnt}/rootfs" ;;
*.tar.xz|*.txz) tar xJf "$ROOTFS_FILE" -C "${mnt}/rootfs" ;;
*.tar.zst) tar --zstd -xf "$ROOTFS_FILE" -C "${mnt}/rootfs" ;;
*) tar xf "$ROOTFS_FILE" -C "${mnt}/rootfs" ;;
esac
# ── Step 5: Install kernel ───────────────────────────────────────────
log "Installing kernel into /boot..."
mkdir -p "${mnt}/rootfs/boot"
cp "$KERNEL_FILE" "${mnt}/rootfs/boot/vmlinuz-${KERNEL_VERSION}"
copy_if_exists "${extract_dir}/kernel/config" "${mnt}/rootfs/boot/config-${KERNEL_VERSION}"
[[ -n "$INITRD_FILE" ]] && cp "$INITRD_FILE" "${mnt}/rootfs/boot/initrd-${KERNEL_VERSION}"
# ── Step 6: Create fstab ─────────────────────────────────────────────
log "Generating /etc/fstab..."
local -a fstab_lines=("LABEL=${ROOT_LABEL} / ext4 defaults,noatime 0 1")
[[ "$EFI_PARTITION" == "true" ]] && fstab_lines+=("LABEL=${BOOT_LABEL} /boot/efi vfat defaults,noatime 0 2")
printf '%s\n' "# /etc/fstab — QCrows export disk layout" "${fstab_lines[@]}" > "${mnt}/rootfs/etc/fstab"
# ── Step 7: Install bootloader ───────────────────────────────────────
local cmdline
cmdline=$(build_cmdline "root=LABEL=${ROOT_LABEL} rw console=ttyS0 console=tty0" "$BOOT_PARAMS_FILE")
if [[ "$EFI_PARTITION" == "false" ]]; then
# MBR boot with extlinux
log "Installing extlinux bootloader..."
mkdir -p "${mnt}/rootfs/boot/extlinux"
local -a extlinux_lines=(
"# extlinux.conf — QCrows boot configuration"
"DEFAULT kata"
"TIMEOUT 30"
""
"LABEL kata"
" KERNEL /boot/vmlinuz-${KERNEL_VERSION}"
)
[[ -n "$INITRD_FILE" ]] && extlinux_lines+=(" INITRD /boot/initrd-${KERNEL_VERSION}")
extlinux_lines+=(" APPEND ${cmdline}")
printf '%s\n' "${extlinux_lines[@]}" > "${mnt}/rootfs/boot/extlinux/extlinux.conf"
# Install extlinux if available
command -v extlinux &>/dev/null && extlinux --install "${mnt}/rootfs/boot/extlinux" 2>/dev/null \
|| warn "extlinux install failed — install syslinux-extlinux"
# Install MBR boot code
local mbr_bin
mbr_bin=$(candidate_file /usr/lib/syslinux/mbr/mbr.bin /usr/share/syslinux/mbr.bin || true)
[[ -n "$mbr_bin" ]] && dd if="$mbr_bin" of="$raw_img" bs=440 count=1 conv=notrunc 2>/dev/null \
|| warn "MBR boot code install failed — install syslinux"
else
# EFI boot with GRUB
log "Setting up EFI bootloader..."
mkdir -p "${mnt}/efi"
mount "$efi_part" "${mnt}/efi"
mkdir -p "${mnt}/efi/EFI/BOOT"
cp "$KERNEL_FILE" "${mnt}/efi/EFI/BOOT/vmlinuz"
[[ -n "$INITRD_FILE" ]] && cp "$INITRD_FILE" "${mnt}/efi/EFI/BOOT/initrd"
# Build GRUB config
local -a grub_lines=(
"# grub.cfg — QCrows EFI boot configuration"
"set timeout=3"
"set default=0"
""
"menuentry \"Kata Container Image (${KERNEL_VERSION})\" {"
" linux /EFI/BOOT/vmlinuz ${cmdline}"
)
[[ -n "$INITRD_FILE" ]] && grub_lines+=(" initrd /EFI/BOOT/initrd")
grub_lines+=("}")
printf '%s\n' "${grub_lines[@]}" > "${mnt}/efi/EFI/BOOT/grub.cfg"
# Locate and install GRUB EFI binary
local grub_x64
grub_x64=$(candidate_file \
"/usr/lib/grub/x86_64-efi/grubx64.efi" \
"/usr/share/grub/x86_64-efi/grubx64.efi" \
"/boot/efi/EFI/fedora/grubx64.efi" \
"/boot/efi/EFI/debian/grubx64.efi" \
"/boot/efi/EFI/ubuntu/grubx64.efi" \
|| true)
if [[ -n "$grub_x64" ]]; then
cp "$grub_x64" "${mnt}/efi/EFI/BOOT/grubx64.efi"
log "Installed GRUB EFI bootloader"
else
warn "No GRUB EFI binary found — EFI boot requires a bootloader"
warn "Install grub2-efi-x64 or similar package"
fi
umount "${mnt}/efi"
fi
# ── Step 8: Unmount ──────────────────────────────────────────────────
log "Syncing and unmounting..."
sync
umount "${mnt}/rootfs"
# Detach loop device
losetup -d "$loop_dev" 2>/dev/null || true
# ── Step 9: Convert to qcow2 ────────────────────────────────────────
log "Converting to qcow2..."
local -a qcow2_opts=()
[[ "$COMPRESS_QCOW2" == "true" ]] && qcow2_opts+=(-c)
qemu-img convert -f raw -O qcow2 "${qcow2_opts[@]}" "$raw_img" "$output"
local final_size
final_size=$(du -sh "$output" | cut -f1)
# ── Summary ──────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ QCrows → qcow2 export complete ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════╝${NC}"
echo ""
echo -e " Output: ${CYAN}${output}${NC}"
echo -e " Size: ${CYAN}${final_size}${NC}"
echo -e " Disk size: ${CYAN}${disk_size}${NC}"
echo -e " Kernel: ${CYAN}${KERNEL_VERSION}${NC}"
echo -e " Boot: ${CYAN}$([ "$EFI_PARTITION" == "true" ] && echo "EFI/GPT" || echo "MBR/extlinux")${NC}"
echo ""
echo -e " Boot with QEMU:"
echo -e " ${CYAN}qemu-system-x86_64 -m 1G -smp 2 -drive file=${output},format=qcow2 -enable-kvm${NC}"
echo ""
}
# ─── Export to ISO ──────────────────────────────────────────────────────────
export_iso() {
local archive="$1"
local output="$2"
check_prereqs_iso
WORKDIR="$(mktemp -d /tmp/qcrows-export-iso-XXXXXX)"
local extract_dir="${WORKDIR}/extracted"
local iso_root="${WORKDIR}/iso"
mkdir -p "$extract_dir" "$iso_root"
extract_qcrows "$archive" "$extract_dir"
# ── Step 1: Build ISO directory structure ─────────────────────────────
log "Building ISO directory structure..."
mkdir -p "${iso_root}/boot/isolinux"
mkdir -p "${iso_root}/boot/grub"
mkdir -p "${iso_root}/live"
mkdir -p "${iso_root}/kata"
# ── Step 2: Copy kernel and initrd ───────────────────────────────────
log "Installing kernel for ISO boot..."
cp "$KERNEL_FILE" "${iso_root}/boot/vmlinuz"
cp "$KERNEL_FILE" "${iso_root}/live/vmlinuz"
if [[ -n "$INITRD_FILE" ]]; then
cp "$INITRD_FILE" "${iso_root}/boot/initrd"
cp "$INITRD_FILE" "${iso_root}/live/initrd"
fi
copy_if_exists "${extract_dir}/kernel/config" "${iso_root}/boot/"
# ── Step 3: Copy rootfs and Kata deployment bundle ───────────────────
log "Bundling rootfs for live boot..."
cp "$ROOTFS_FILE" "${iso_root}/live/rootfs.tar.gz"
# Place components in /kata for direct Kata deployment
cp "$ROOTFS_FILE" "${iso_root}/kata/"
[[ -n "$INITRD_FILE" ]] && cp "$INITRD_FILE" "${iso_root}/kata/"
cp "$KERNEL_FILE" "${iso_root}/kata/"
copy_if_exists "${extract_dir}/kernel/config" "${iso_root}/kata/"
# Copy QCrows metadata for in-ISO reference
cp "${extract_dir}/metadata.toml" "${iso_root}/kata/"
copy_if_exists "${extract_dir}/menu.toml" "${iso_root}/kata/"
copy_if_exists "${extract_dir}/build.toml" "${iso_root}/kata/"
copy_if_exists "${extract_dir}/boot-params.conf" "${iso_root}/kata/"
# ── Step 4: Build kernel command line ────────────────────────────────
local cmdline
cmdline=$(build_cmdline "boot=live components quiet" "$BOOT_PARAMS_FILE")
# ── Step 5: Create isolinux config (MBR boot) ────────────────────────
log "Creating isolinux configuration..."
local -a isolinux_lines=(
"# isolinux.cfg — QCrows live boot menu"
"DEFAULT kata"
"TIMEOUT 30"
"PROMPT 0"
""
"LABEL kata"
" KERNEL /boot/vmlinuz"
)
[[ -n "$INITRD_FILE" ]] && isolinux_lines+=(" INITRD /boot/initrd")
isolinux_lines+=(" APPEND ${cmdline}")
printf '%s\n' "${isolinux_lines[@]}" > "${iso_root}/boot/isolinux/isolinux.cfg"
# ── Step 6: Create GRUB config (EFI boot) ───────────────────────────
log "Creating GRUB configuration..."
local -a grub_lines=(
"# grub.cfg — QCrows EFI boot menu"
"set timeout=3"
"set default=0"
"set graphics=off"
""
"menuentry \"Kata Container Image (${KERNEL_VERSION})\" {"
" linux /boot/vmlinuz ${cmdline}"
)
[[ -n "$INITRD_FILE" ]] && grub_lines+=(" initrd /boot/initrd")
grub_lines+=("}")
# Secondary entry for Kata deployment mode
grub_lines+=(
""
"menuentry \"Kata Deployment Mode (load rootfs from /kata/)\" {"
" linux /boot/vmlinuz kata-deploy=live quiet"
)
[[ -n "$INITRD_FILE" ]] && grub_lines+=(" initrd /boot/initrd")
grub_lines+=("}")
printf '%s\n' "${grub_lines[@]}" > "${iso_root}/boot/grub/grub.cfg"
# ── Step 7: Create a README on the ISO ───────────────────────────────
cat > "${iso_root}/README.txt" <<README
QCrows Live Image — $(date -u +%Y-%m-%d)
=========================================
Kernel: ${KERNEL_VERSION}
Format: QCrows live ISO
This ISO contains a Kata Container image usable in two modes:
1. LIVE BOOT: Boot this ISO directly (via USB or virtual CD-ROM).
The kernel and initrd load with the rootfs at /live/rootfs.tar.gz
2. KATA DEPLOY: Copy files from the /kata/ directory on this ISO
to /usr/share/kata-containers/ on your host system:
- vmlinuz (guest kernel)
- rootfs.tar.gz (guest filesystem)
- initrd (initial ramdisk)
- config (kernel .config)
- metadata.toml (image manifest)
- menu.toml (Cockpit UI entry)
- boot-params.conf (kernel parameters)
Then update your configuration.toml to point to the new paths.
Verify: qcrows-verify <archive.qcrows>
Inspect: qcrows-inspect <archive.qcrows>
README
# ── Step 8: Build ISO ────────────────────────────────────────────────
log "Building ISO image..."
if [[ "$EFI_PARTITION" == "true" ]]; then
log "Creating EFI-bootable ISO with grub-mkrescue..."
grub-mkrescue -o "$output" "$iso_root" 2>/dev/null || die "grub-mkrescue failed"
else
# MBR-bootable ISO using isolinux + xorriso/genisoimage
log "Creating MBR-bootable ISO..."
local isolinux_bin
isolinux_bin=$(candidate_file \
/usr/lib/ISOLINUX/isolinux.bin \
/usr/share/syslinux/isolinux.bin \
/usr/lib/syslinux/isolinux.bin \
|| true)
[[ -z "$isolinux_bin" ]] && die "isolinux.bin not found — install syslinux or isolinux"
# Copy isolinux binaries
cp "$isolinux_bin" "${iso_root}/boot/isolinux/isolinux.bin"
# Copy syslinux modules
local syslinux_dir
syslinux_dir=$(dirname "$isolinux_bin")
find "$syslinux_dir" -name '*.c32' -exec cp {} "${iso_root}/boot/isolinux/" \; 2>/dev/null || true
case "$ISO_TOOL" in
xorriso)
local isohdpfx
isohdpfx=$(candidate_file "${syslinux_dir}/isohdpfx.bin" || true)
local -a xorriso_args=(
-as mkisofs
-o "$output"
-c boot/isolinux/boot.cat
-b boot/isolinux/isolinux.bin
-no-emul-boot
-boot-load-size 4
-boot-info-table
-V "${BOOT_LABEL}"
-J -R
)
[[ -n "$isohdpfx" ]] && xorriso_args=(-isohybrid-mbr "$isohdpfx" "${xorriso_args[@]}")
xorriso "${xorriso_args[@]}" "$iso_root" 2>/dev/null || die "xorriso ISO creation failed"
;;
genisoimage|mkisofs)
"${ISO_TOOL}" \
-o "$output" \
-c boot/isolinux/boot.cat \
-b boot/isolinux/isolinux.bin \
-no-emul-boot \
-boot-load-size 4 \
-boot-info-table \
-V "${BOOT_LABEL}" \
-J -R \
"$iso_root" 2>/dev/null || die "${ISO_TOOL} ISO creation failed"
# Make hybrid (dd-able to USB)
command -v isohybrid &>/dev/null && isohybrid "$output" 2>/dev/null \
|| warn "isohybrid failed — ISO may not be USB-bootable"
;;
esac
fi
local final_size
final_size=$(du -sh "$output" | cut -f1)
# ── Summary ──────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ QCrows → ISO export complete ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════╝${NC}"
echo ""
echo -e " Output: ${CYAN}${output}${NC}"
echo -e " Size: ${CYAN}${final_size}${NC}"
echo -e " Kernel: ${CYAN}${KERNEL_VERSION}${NC}"
echo -e " Boot mode: ${CYAN}$([ "$EFI_PARTITION" == "true" ] && echo "EFI/GRUB" || echo "MBR/isolinux")${NC}"
echo -e " Hybrid: ${CYAN}$([ "$EFI_PARTITION" == "false" ] && echo "yes (isohybrid)" || echo "EFI only")${NC}"
echo ""
echo -e " Write to USB:"
echo -e " ${CYAN}dd if=${output} of=/dev/sdX bs=4M status=progress && sync${NC}"
echo ""
echo -e " Boot with QEMU:"
echo -e " ${CYAN}qemu-system-x86_64 -m 1G -smp 2 -cdrom ${output} -enable-kvm${NC}"
echo ""
echo -e " Kata files on ISO:"
echo -e " ${CYAN}/kata/vmlinuz${NC} — Guest kernel"
echo -e " ${CYAN}/kata/rootfs.tar.gz${NC} — Guest rootfs"
echo -e " ${CYAN}/kata/initrd${NC} — Initial ramdisk"
echo -e " ${CYAN}/kata/metadata.toml${NC} — Image manifest"
echo -e " ${CYAN}/kata/menu.toml${NC} — Cockpit UI entry"
echo ""
}
# ─── PXE Export ─────────────────────────────────────────────────────────────
export_pxe() {
local archive="$1"
local tftp_root="$2"
log "Exporting QCrows image to PXE/TFTP directory"
# Prerequisite checks
local -a missing=()
command -v mksquashfs &>/dev/null || missing+=(mksquashfs)
command -v dnsmasq &>/dev/null || missing+=(dnsmasq)
[[ ${#missing[@]} -gt 0 ]] && warn "Missing tools: ${missing[*]} — PXE boot may not function until installed"
# Extract archive to temp directory
TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/qcrows-export-pxe.XXXXXX")
extract_qcrows "$archive" "$TMPDIR"
# Derive image slug from metadata
local image_name
image_name=$(awk '/^name/{print $3}' "${TMPDIR}/metadata.toml" | tr -d '"' | tr ' ' '-' || echo "kata-image")
[[ -z "$image_name" ]] && image_name="kata-image"
local pxe_dir="${tftp_root}/kata/${image_name}"
local pxe_cfg_dir="${tftp_root}/pxelinux.cfg"
log "Creating PXE directory structure: ${pxe_dir}"
mkdir -p "$pxe_dir" "$pxe_cfg_dir"
# Copy kernel to TFTP
log "Copying kernel to ${pxe_dir}/vmlinuz"
cp "$KERNEL_FILE" "${pxe_dir}/vmlinuz"
# Copy initrd if present
local initrd_dest=""
if [[ -n "$INITRD_FILE" ]]; then
log "Copying initrd to ${pxe_dir}/initrd.img"
cp "$INITRD_FILE" "${pxe_dir}/initrd.img"
initrd_dest="kata/${image_name}/initrd.img"
fi
# Pack rootfs as squashfs for efficient network boot
local rootfs_dest="${pxe_dir}/rootfs.squashfs"
if command -v mksquashfs &>/dev/null; then
log "Packing rootfs as squashfs to ${rootfs_dest}"
local rootfs_unpack_dir
rootfs_unpack_dir=$(mktemp -d "${TMPDIR}/rootfs-unpack.XXXXXX")
tar xf "$ROOTFS_FILE" -C "$rootfs_unpack_dir" 2>/dev/null || {
warn "Rootfs extraction failed — copying raw archive instead"
cp "$ROOTFS_FILE" "${pxe_dir}/rootfs.tar.gz"
rootfs_dest="${pxe_dir}/rootfs.tar.gz"
}
if [[ -d "$rootfs_unpack_dir" && ! -f "${pxe_dir}/rootfs.tar.gz" ]]; then
mksquashfs "$rootfs_unpack_dir" "$rootfs_dest" -noappend -comp zstd -Xcompression-level 3 2>/dev/null \
|| mksquashfs "$rootfs_unpack_dir" "$rootfs_dest" -noappend -comp gzip 2>/dev/null \
|| { warn "Squashfs creation failed — using raw rootfs"; cp "$ROOTFS_FILE" "${pxe_dir}/rootfs.tar.gz"; rootfs_dest="${pxe_dir}/rootfs.tar.gz"; }
rm -rf "$rootfs_unpack_dir"
fi
else
warn "mksquashfs not available — copying raw rootfs archive"
cp "$ROOTFS_FILE" "${pxe_dir}/rootfs.tar.gz"
rootfs_dest="${pxe_dir}/rootfs.tar.gz"
fi
# Build kernel command line
local base_cmdline="root=/dev/nfs nfsroot=${PXE_SERVER_IP}:${pxe_dir} ip=dhcp console=ttyS0,115200"
local cmdline
cmdline=$(build_cmdline "$base_cmdline" "$BOOT_PARAMS_FILE")
[[ -n "$PXE_APPEND_CMDLINE" ]] && cmdline="${cmdline} ${PXE_APPEND_CMDLINE}"
# Generate pxelinux configuration
local pxe_config_file="${pxe_cfg_dir}/${image_name}"
log "Writing PXE configuration to ${pxe_config_file}"
{
[[ "$PXE_DEFAULT" == "true" ]] && echo "DEFAULT ${image_name}"
echo "LABEL ${image_name}"
echo " MENU LABEL Kata ${image_name}"
echo " KERNEL kata/${image_name}/vmlinuz"
[[ -n "$initrd_dest" ]] && echo " INITRD ${initrd_dest}"
echo " APPEND ${cmdline}"
} > "$pxe_config_file"
# Update dnsmasq configuration
local dnsmasq_conf="/etc/dnsmasq.d/kata-pxe.conf"
if [[ -d "/etc/dnsmasq.d" ]]; then
log "Updating dnsmasq configuration at ${dnsmasq_conf}"
if [[ ! -f "$dnsmasq_conf" ]] || ! grep -q "pxe-service" "$dnsmasq_conf" 2>/dev/null; then
{
echo ""
echo "# Kata Containers PXE — managed by qcrows-export"
echo "dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,12h"
echo "dhcp-boot=pxelinux.0"
echo "pxe-service=x86PC,\"Kata QCrows Boot\",pxelinux"
echo "enable-tftp"
echo "tftp-root=${tftp_root}"
echo ""
} >> "$dnsmasq_conf"
fi
log "dnsmasq config updated — restart dnsmasq to activate"
else
warn "/etc/dnsmasq.d not found — configure dnsmasq manually for DHCP+TFTP"
fi
# ── Summary ──────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ QCrows → PXE export complete ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════╝${NC}"
echo ""
echo -e " TFTP Root: ${CYAN}${tftp_root}${NC}"
echo -e " Image Dir: ${CYAN}${pxe_dir}${NC}"
echo -e " Kernel: ${CYAN}${pxe_dir}/vmlinuz${NC}"
[[ -n "$initrd_dest" ]] && echo -e " Initrd: ${CYAN}${pxe_dir}/initrd.img${NC}"
echo -e " Rootfs: ${CYAN}${rootfs_dest}${NC}"
echo -e " PXE Config: ${CYAN}${pxe_config_file}${NC}"
echo -e " Server IP: ${CYAN}${PXE_SERVER_IP}${NC}"
echo -e " Default Entry: ${CYAN}$([ "$PXE_DEFAULT" == "true" ] && echo "yes" || echo "no")${NC}"
echo ""
echo -e " Next steps:"
echo -e " ${CYAN}1. Ensure pxelinux.0 exists in ${tftp_root}/${NC}"
echo -e " ${CYAN}2. Restart dnsmasq: systemctl restart dnsmasq${NC}"
echo -e " ${CYAN}3. Boot target machines via PXE network boot${NC}"
echo ""
}
# ─── Entry Point ────────────────────────────────────────────────────────────
parse_args "$@"
case "$FORMAT" in
qcow2) export_qcow2 "$INPUT" "$OUTPUT" ;;
iso) export_iso "$INPUT" "$OUTPUT" ;;
pxe) export_pxe "$INPUT" "$OUTPUT" ;;
*) die "Unhandled format: ${FORMAT}" ;;
esac