build-usb: whole webapp, hard sfdisk failure, ISO-derived boot params, image-file mode; tests/vm harness

- Copy the whole webapp/ tree (minus __pycache__) in build-usb.sh and
  build-proxmox-iso.sh, so config, services/, views/ and
  blancco_evidence_monthly.py reach the server.
- sfdisk --append failure now aborts; the CIDATA partition is the one
  starting exactly at the computed sector, and nothing that starts
  before it is ever formatted (the old fallback hit the ISO's EFI
  partition).
- Volume id and El Torito load sizes come from the ISO
  (xorriso -report_el_torito as_mkisofs); grub.cfg must contain
  autoinstall after patching.
- --image FILE SIZE|auto builds the same layout in a file without root
  (mkfs.vfat -C + mtools, sfdisk on the file). Plus --payload-root,
  --skip-enrollment/-bios/-drivers/-boot-tools, --yes.
- tests/vm: libvirt harness (pxetest-server, pxetest-client, isolated
  pxetest-imaging network) with fetch-iso/build-image/create/start/
  status/stop/destroy.
This commit is contained in:
cproudlock
2026-10-15 08:55:40 -04:00
parent 689eebf9d1
commit 45cf7be37a
12 changed files with 1084 additions and 219 deletions

View File

@@ -80,8 +80,19 @@ sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
# With WinPE images bundled (single USB, larger drive needed)
sudo ./scripts/build-usb.sh /dev/sdX /path/to/ubuntu-24.04.iso /path/to/winpe-images
# Same layout into a disk-image file, no root (dd it to a stick later, or
# boot it in a VM: see tests/vm/README.md). SIZE is e.g. 16G or "auto".
./scripts/build-usb.sh --image pxe-usb.img auto /path/to/ubuntu-24.04.iso
```
Options: `--payload-root DIR` takes offline-packages/, pip-wheels/,
boot-files/, boot-tools/, enrollment/, bios-staging/ and drivers-staging/
from another checkout; `--skip-enrollment`, `--skip-bios`,
`--skip-drivers`, `--skip-boot-tools` leave those out; `--yes` skips the
prompt. The ISO volume label and El Torito load sizes are read from the
ISO, so any 24.04.x point release works.
This creates a bootable USB with:
- **Partition 1:** Ubuntu Server installer
- **Partition 2:** CIDATA (autoinstall config, offline .debs, pip wheels, Ansible playbook, webapp, boot tools)

View File

@@ -225,9 +225,12 @@ echo " Copied playbook/"
# Flask webapp
if [ -d "$WEBAPP_DIR" ]; then
mkdir -p "$PXE_DATA/webapp"
cp "$WEBAPP_DIR/app.py" "$WEBAPP_DIR/requirements.txt" "$PXE_DATA/webapp/"
cp -r "$WEBAPP_DIR/templates" "$WEBAPP_DIR/static" "$PXE_DATA/webapp/"
# Whole tree: app.py imports config, services/ and views/, and the
# Blancco evidence timer runs blancco_evidence_monthly.py.
rm -rf "$PXE_DATA/webapp"
cp -r "$WEBAPP_DIR" "$PXE_DATA/webapp"
find "$PXE_DATA/webapp" -name __pycache__ -type d -prune -exec rm -rf {} +
find "$PXE_DATA/webapp" -name '*.py[co]' -delete
echo " Copied webapp/"
fi

View File

@@ -1,62 +1,159 @@
#!/bin/bash
#
# build-usb.sh — Build a bootable PXE-server installer USB
# build-usb.sh - Build a bootable PXE-server installer USB (or disk image)
#
# Creates a two-partition USB:
# Partition 1: Ubuntu Server 24.04 installer (ISO contents)
# Partition 2: CIDATA volume (autoinstall config, .debs, playbook)
# Creates a two-partition layout:
# Partition 1: Ubuntu Server 24.04 installer (ISO contents, autoinstall patched)
# Partition N: CIDATA volume (autoinstall config, .debs, playbook, webapp...)
#
# The target machine boots from this USB, Ubuntu auto-installs with
# cloud-init (user-data/meta-data from CIDATA), installs offline .debs,
# and on first boot runs the Ansible playbook to configure PXE services.
#
# Usage:
# sudo ./build-usb.sh /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso
# sudo ./build-usb.sh [options] /dev/sdX /path/to/ubuntu-24.04-live-server-amd64.iso [winpe-dir]
# ./build-usb.sh [options] --image out.img SIZE /path/to/ubuntu.iso [winpe-dir]
#
# WARNING: This will ERASE the target USB device.
# Image mode writes the same layout into a regular FILE and needs no root:
# the CIDATA filesystem is built with mkfs.vfat on an image file and filled
# with mtools (mcopy), then placed after the ISO with sfdisk on the file.
# SIZE is a byte count with an optional K/M/G suffix (1024-based), or
# "auto" to size the image to fit the payload.
#
# Options:
# --image FILE SIZE build a disk image file instead of writing a device
# --payload-root DIR take the gitignored payload dirs (offline-packages/,
# pip-wheels/, boot-files/, boot-tools/, enrollment/,
# bios-staging/, drivers-staging/) from DIR instead of
# the repo root (e.g. a main checkout next to a worktree)
# --skip-enrollment do not copy enrollment/
# --skip-bios do not copy bios-staging/
# --skip-drivers do not copy drivers-staging/
# --skip-boot-tools do not copy boot-tools/
# --yes do not ask for confirmation (device mode still asks
# YES for a non-removable device)
#
# WARNING: device mode ERASES the target USB device.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
AUTOINSTALL_DIR="$REPO_ROOT/autoinstall"
PLAYBOOK_DIR="$REPO_ROOT/playbook"
OFFLINE_PKG_DIR="$REPO_ROOT/offline-packages"
WEBAPP_DIR="$REPO_ROOT/webapp"
# --- Validate arguments ---
if [ $# -lt 2 ]; then
echo "Usage: sudo $0 /dev/sdX /path/to/ubuntu-24.04.iso [/path/to/winpe-images]"
usage() {
echo "Usage: sudo $0 [options] /dev/sdX /path/to/ubuntu-24.04.iso [/path/to/winpe-images]"
echo " $0 [options] --image out.img SIZE|auto /path/to/ubuntu-24.04.iso [/path/to/winpe-images]"
echo ""
echo " The optional third argument is the path to WinPE deployment content"
echo " Options: --payload-root DIR, --skip-enrollment, --skip-bios,"
echo " --skip-drivers, --skip-boot-tools, --yes"
echo ""
echo " The optional last argument is the path to WinPE deployment content"
echo " (e.g., the mounted Media Creator LITE USB). If provided, the images"
echo " will be bundled onto the CIDATA partition for automatic import."
echo ""
echo "Available removable devices:"
lsblk -d -o NAME,SIZE,TRAN,RM | grep -E '^\S+\s+\S+\s+(usb)\s+1'
exit 1
lsblk -d -o NAME,SIZE,TRAN,RM 2>/dev/null | grep -E '^\S+\s+\S+\s+(usb)\s+1' || true
}
# --- Parse arguments ---
IMAGE_MODE=0
IMAGE_FILE=""
IMAGE_SIZE_ARG=""
PAYLOAD_ROOT="$REPO_ROOT"
SKIP_ENROLLMENT=0
SKIP_BIOS=0
SKIP_DRIVERS=0
SKIP_BOOT_TOOLS=0
ASSUME_YES=0
POSITIONAL=()
while [ $# -gt 0 ]; do
case "$1" in
--image)
[ $# -ge 3 ] || { usage; exit 1; }
IMAGE_MODE=1; IMAGE_FILE="$2"; IMAGE_SIZE_ARG="$3"; shift 3 ;;
--payload-root)
[ $# -ge 2 ] || { usage; exit 1; }
PAYLOAD_ROOT="$(cd "$2" && pwd)"; shift 2 ;;
--skip-enrollment) SKIP_ENROLLMENT=1; shift ;;
--skip-bios) SKIP_BIOS=1; shift ;;
--skip-drivers) SKIP_DRIVERS=1; shift ;;
--skip-boot-tools) SKIP_BOOT_TOOLS=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; POSITIONAL+=("$@"); break ;;
-*) echo "ERROR: unknown option $1"; usage; exit 1 ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
if [ "$IMAGE_MODE" -eq 1 ]; then
if [ ${#POSITIONAL[@]} -lt 1 ]; then usage; exit 1; fi
USB_DEV=""
ISO_PATH="${POSITIONAL[0]}"
WINPE_SOURCE="${POSITIONAL[1]:-}"
else
if [ ${#POSITIONAL[@]} -lt 2 ]; then usage; exit 1; fi
USB_DEV="${POSITIONAL[0]}"
ISO_PATH="${POSITIONAL[1]}"
WINPE_SOURCE="${POSITIONAL[2]:-}"
fi
USB_DEV="$1"
ISO_PATH="$2"
WINPE_SOURCE="${3:-}"
OFFLINE_PKG_DIR="$PAYLOAD_ROOT/offline-packages"
PIP_WHEELS_DIR="$PAYLOAD_ROOT/pip-wheels"
BOOT_FILES_DIR="$PAYLOAD_ROOT/boot-files"
BOOT_TOOLS_DIR="$PAYLOAD_ROOT/boot-tools"
ENROLLMENT_DIR="$PAYLOAD_ROOT/enrollment"
BIOS_DIR="$PAYLOAD_ROOT/bios-staging"
DRIVERS_DIR="$PAYLOAD_ROOT/drivers-staging"
[ "$SKIP_ENROLLMENT" -eq 1 ] && ENROLLMENT_DIR=""
[ "$SKIP_BIOS" -eq 1 ] && BIOS_DIR=""
[ "$SKIP_DRIVERS" -eq 1 ] && DRIVERS_DIR=""
[ "$SKIP_BOOT_TOOLS" -eq 1 ] && BOOT_TOOLS_DIR=""
# Safety checks
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: Must run as root (sudo)."
exit 1
fi
if [ ! -b "$USB_DEV" ]; then
echo "ERROR: $USB_DEV is not a block device."
exit 1
fi
# FAT32 has a 4GB max file size; files larger than this are split into
# chunks that the playbook reassembles with `cat`.
FAT32_MAX=$((3500 * 1024 * 1024)) # 3500 MiB chunks, safely under 4GiB
SECTOR_SIZE=512
# --- Safety checks ---
if [ ! -f "$ISO_PATH" ]; then
echo "ERROR: ISO not found at $ISO_PATH"
exit 1
fi
ISO_PATH="$(cd "$(dirname "$ISO_PATH")" && pwd)/$(basename "$ISO_PATH")"
need_tools=(xorriso 7z sfdisk mkfs.vfat)
if [ "$IMAGE_MODE" -eq 1 ]; then
need_tools+=(mcopy mmd mdir truncate)
else
need_tools+=(partprobe)
fi
for t in "${need_tools[@]}"; do
if ! command -v "$t" >/dev/null 2>&1; then
echo "ERROR: required tool '$t' not found in PATH"
exit 1
fi
done
if [ "$IMAGE_MODE" -eq 1 ]; then
if [ -e "$IMAGE_FILE" ] && [ ! -f "$IMAGE_FILE" ]; then
echo "ERROR: $IMAGE_FILE exists and is not a regular file (image mode never writes devices)."
exit 1
fi
IMAGE_FILE="$(cd "$(dirname "$IMAGE_FILE")" && pwd)/$(basename "$IMAGE_FILE")"
else
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: Must run as root (sudo). (Use --image to build a file without root.)"
exit 1
fi
if [ ! -b "$USB_DEV" ]; then
echo "ERROR: $USB_DEV is not a block device."
exit 1
fi
# Verify it's a removable device (safety against wiping system disks)
REMOVABLE=$(lsblk -nd -o RM "$USB_DEV" 2>/dev/null || echo "0")
REMOVABLE=$(lsblk -nd -o RM "$USB_DEV" 2>/dev/null | tr -d '[:space:]' || echo "0")
if [ "$REMOVABLE" != "1" ]; then
echo "WARNING: $USB_DEV does not appear to be a removable device."
read -rp "Are you SURE you want to erase $USB_DEV? (type YES): " CONFIRM
@@ -65,60 +162,106 @@ if [ "$REMOVABLE" != "1" ]; then
exit 1
fi
fi
fi
# Verify required source files exist
if [ ! -f "$AUTOINSTALL_DIR/user-data" ]; then
echo "ERROR: user-data not found at $AUTOINSTALL_DIR/user-data"
exit 1
fi
if [ ! -f "$AUTOINSTALL_DIR/meta-data" ]; then
echo "ERROR: meta-data not found at $AUTOINSTALL_DIR/meta-data"
exit 1
fi
if [ ! -f "$PLAYBOOK_DIR/pxe_server_setup.yml" ]; then
echo "ERROR: pxe_server_setup.yml not found at $PLAYBOOK_DIR/"
for f in "$AUTOINSTALL_DIR/user-data" "$AUTOINSTALL_DIR/meta-data" "$PLAYBOOK_DIR/pxe_server_setup.yml"; do
if [ ! -f "$f" ]; then
echo "ERROR: $f not found"
exit 1
fi
done
echo "============================================"
echo "PXE Server USB Builder"
echo "============================================"
if [ "$IMAGE_MODE" -eq 1 ]; then
echo "Image file : $IMAGE_FILE (size: $IMAGE_SIZE_ARG)"
else
echo "USB Device : $USB_DEV"
fi
echo "ISO : $ISO_PATH"
echo "Source Dir : $REPO_ROOT"
echo "Payloads : $PAYLOAD_ROOT"
echo ""
if [ "$ASSUME_YES" -ne 1 ]; then
if [ "$IMAGE_MODE" -eq 1 ]; then
echo "This will overwrite $IMAGE_FILE."
else
echo "This will ERASE all data on $USB_DEV."
fi
read -rp "Continue? (y/N): " PROCEED
if [[ ! "$PROCEED" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
fi
# Work dir: next to the output image in image mode (multi-GB, keep it off
# a tmpfs /tmp), else the default temp dir.
if [ "$IMAGE_MODE" -eq 1 ]; then
WORK=$(mktemp -d "$(dirname "$IMAGE_FILE")/.build-usb.XXXXXX")
else
WORK=$(mktemp -d)
fi
MOUNT_POINT=""
cleanup() {
if [ -n "$MOUNT_POINT" ] && mountpoint -q "$MOUNT_POINT" 2>/dev/null; then
umount "$MOUNT_POINT" || true
fi
[ -n "$MOUNT_POINT" ] && rmdir "$MOUNT_POINT" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT
# --- Unmount any existing partitions ---
echo ""
if [ "$IMAGE_MODE" -eq 0 ]; then
echo "[1/6] Unmounting existing partitions on $USB_DEV..."
for part in "${USB_DEV}"*; do
umount "$part" 2>/dev/null || true
done
else
echo "[1/6] Image mode: nothing to unmount"
fi
# --- Rebuild ISO with 'autoinstall' kernel parameter ---
echo "[2/6] Rebuilding ISO with autoinstall kernel parameter..."
ISO_WORK=$(mktemp -d)
7z -y x "$ISO_PATH" -o"$ISO_WORK/iso" >/dev/null 2>&1
mv "$ISO_WORK/iso/[BOOT]" "$ISO_WORK/BOOT"
chmod -R u+w "$ISO_WORK/iso"
# Volume label and El Torito load sizes come from the source ISO, so a
# point release (24.04.3, 24.04.5, ...) does not need a script edit.
ELTORITO_REPORT=$(xorriso -indev "$ISO_PATH" -report_el_torito as_mkisofs 2>/dev/null)
ISO_VOLID=$(printf '%s\n' "$ELTORITO_REPORT" | sed -n "s/^-V '\(.*\)'\$/\1/p" | head -1)
# Load sizes in order: first follows -b (BIOS), last follows -e (EFI).
mapfile -t LOAD_SIZES < <(printf '%s\n' "$ELTORITO_REPORT" | sed -n 's/^-boot-load-size \([0-9][0-9]*\)$/\1/p')
if [ -z "$ISO_VOLID" ] || [ ${#LOAD_SIZES[@]} -lt 2 ]; then
echo "ERROR: could not read volume id / El Torito load sizes from $ISO_PATH"
echo " (xorriso -indev ISO -report_el_torito as_mkisofs)"
exit 1
fi
BIOS_LOAD_SIZE="${LOAD_SIZES[0]}"
EFI_LOAD_SIZE="${LOAD_SIZES[${#LOAD_SIZES[@]}-1]}"
echo " Volume id: $ISO_VOLID (boot-load-size BIOS=$BIOS_LOAD_SIZE EFI=$EFI_LOAD_SIZE)"
7z -y x "$ISO_PATH" -o"$WORK/iso" >/dev/null 2>&1
mv "$WORK/iso/[BOOT]" "$WORK/BOOT"
chmod -R u+w "$WORK/iso"
# Patch grub.cfg: add 'autoinstall' to kernel cmdline, reduce timeout
sed -i 's|linux\t/casper/vmlinuz ---|linux\t/casper/vmlinuz autoinstall ---|' "$ISO_WORK/iso/boot/grub/grub.cfg"
sed -i 's/^set timeout=30/set timeout=5/' "$ISO_WORK/iso/boot/grub/grub.cfg"
GRUB_CFG="$WORK/iso/boot/grub/grub.cfg"
sed -i 's|linux\t/casper/vmlinuz ---|linux\t/casper/vmlinuz autoinstall ---|' "$GRUB_CFG"
sed -i 's/^set timeout=30/set timeout=5/' "$GRUB_CFG"
if ! grep -q 'vmlinuz.*autoinstall' "$GRUB_CFG"; then
echo "ERROR: grub.cfg patch failed: no 'autoinstall' on a kernel line in boot/grub/grub.cfg"
echo " The ISO's grub.cfg format changed; update the sed in this script."
exit 1
fi
PATCHED_ISO="$ISO_WORK/patched.iso"
cd "$ISO_WORK/iso"
PATCHED_ISO="$WORK/patched.iso"
(
cd "$WORK/iso"
xorriso -as mkisofs -r \
-V 'Ubuntu-Server 24.04.3 LTS amd64' \
-V "$ISO_VOLID" \
-o "$PATCHED_ISO" \
--grub2-mbr ../BOOT/1-Boot-NoEmul.img \
--protective-msdos-label \
@@ -131,103 +274,238 @@ xorriso -as mkisofs -r \
-c '/boot.catalog' \
-b '/boot/grub/i386-pc/eltorito.img' \
-no-emul-boot \
-boot-load-size 4 \
-boot-load-size "$BIOS_LOAD_SIZE" \
-boot-info-table \
--grub2-boot-info \
-eltorito-alt-boot \
-e '--interval:appended_partition_2:::' \
-no-emul-boot \
-boot-load-size 10160 \
. 2>/dev/null
cd "$REPO_ROOT"
-boot-load-size "$EFI_LOAD_SIZE" \
. 2>"$WORK/xorriso.log"
) || { echo "ERROR: xorriso failed:"; tail -20 "$WORK/xorriso.log"; exit 1; }
rm -rf "$WORK/iso"
echo " ISO rebuilt with 'autoinstall' kernel param and 5s GRUB timeout"
echo " Writing patched ISO to $USB_DEV..."
dd if="$PATCHED_ISO" of="$USB_DEV" bs=4M status=progress oflag=sync
sync
ISO_SIZE=$(stat -c%s "$PATCHED_ISO")
rm -rf "$ISO_WORK"
# --- Find the end of the ISO to create CIDATA partition ---
echo "[3/6] Creating CIDATA partition after ISO data..."
# Get ISO size in bytes and calculate the start sector for the new partition
SECTOR_SIZE=512
# Start the CIDATA partition 1MB after the ISO ends (alignment)
START_SECTOR=$(( (ISO_SIZE / SECTOR_SIZE) + 2048 ))
# Use sfdisk to append a new partition
# --- Payload size (image mode sizing) ---
src_bytes() {
# Apparent size in bytes of the given paths (missing ones count 0)
local total=0 p n
for p in "$@"; do
[ -n "$p" ] && [ -e "$p" ] || continue
n=$(du -sbL "$p" 2>/dev/null | cut -f1)
total=$((total + ${n:-0}))
done
echo "$total"
}
parse_size() {
local s="$1" num unit
num="${s%[KkMmGgTt]}"
unit="${s#"$num"}"
[[ "$num" =~ ^[0-9]+$ ]] || { echo "ERROR: bad size '$s'" >&2; return 1; }
case "$unit" in
"") echo "$num" ;;
K|k) echo $((num * 1024)) ;;
M|m) echo $((num * 1024 * 1024)) ;;
G|g) echo $((num * 1024 * 1024 * 1024)) ;;
T|t) echo $((num * 1024 * 1024 * 1024 * 1024)) ;;
esac
}
if [ "$IMAGE_MODE" -eq 1 ]; then
PAYLOAD_BYTES=$(src_bytes "$AUTOINSTALL_DIR/user-data" "$AUTOINSTALL_DIR/meta-data" \
"$OFFLINE_PKG_DIR" "$PLAYBOOK_DIR" "$WEBAPP_DIR" "$PIP_WHEELS_DIR" \
"$BOOT_FILES_DIR" "$BOOT_TOOLS_DIR" "$ENROLLMENT_DIR" "$BIOS_DIR" \
"$DRIVERS_DIR" "$WINPE_SOURCE")
if [ "$IMAGE_SIZE_ARG" = "auto" ]; then
# payload + 10% + 256 MiB FAT/cluster slack, rounded up to 1 MiB
CIDATA_BYTES=$(( PAYLOAD_BYTES + PAYLOAD_BYTES / 10 + 256 * 1024 * 1024 ))
IMAGE_BYTES=$(( START_SECTOR * SECTOR_SIZE + CIDATA_BYTES + 2 * 1024 * 1024 ))
else
IMAGE_BYTES=$(parse_size "$IMAGE_SIZE_ARG")
fi
IMAGE_BYTES=$(( (IMAGE_BYTES + 1048575) / 1048576 * 1048576 ))
MIN_BYTES=$(( START_SECTOR * SECTOR_SIZE + PAYLOAD_BYTES + 64 * 1024 * 1024 ))
if [ "$IMAGE_BYTES" -lt "$MIN_BYTES" ]; then
echo "ERROR: image size $((IMAGE_BYTES / 1048576)) MiB too small; need at least $((MIN_BYTES / 1048576)) MiB"
echo " (ISO $((ISO_SIZE / 1048576)) MiB + payload $((PAYLOAD_BYTES / 1048576)) MiB). Use SIZE=auto."
exit 1
fi
echo " Payload: $((PAYLOAD_BYTES / 1048576)) MiB, image: $((IMAGE_BYTES / 1048576)) MiB"
fi
# --- Write the ISO ---
if [ "$IMAGE_MODE" -eq 1 ]; then
echo " Writing patched ISO to $IMAGE_FILE..."
rm -f "$IMAGE_FILE"
truncate -s "$IMAGE_BYTES" "$IMAGE_FILE"
dd if="$PATCHED_ISO" of="$IMAGE_FILE" bs=4M conv=notrunc,sparse status=none
TARGET="$IMAGE_FILE"
else
echo " Writing patched ISO to $USB_DEV..."
dd if="$PATCHED_ISO" of="$USB_DEV" bs=4M status=progress oflag=sync
sync
TARGET="$USB_DEV"
fi
rm -f "$PATCHED_ISO"
# --- Create the CIDATA partition after the ISO data ---
echo "[3/6] Creating CIDATA partition after ISO data..."
echo " ISO size: $((ISO_SIZE / 1024 / 1024)) MB"
echo " CIDATA partition starts at sector $START_SECTOR"
# Add a new partition using sfdisk --append
echo "${START_SECTOR},+,L" | sfdisk --append "$USB_DEV" --no-reread 2>/dev/null || true
# A failed append must stop the build: the old fallback ("last partition")
# then formatted the ISO's own EFI partition.
if ! echo "${START_SECTOR},+,L" | sfdisk --append "$TARGET" --no-reread >"$WORK/sfdisk.log" 2>&1; then
echo "ERROR: sfdisk --append failed on $TARGET:"
sed 's/^/ /' "$WORK/sfdisk.log"
exit 1
fi
# Find the partition that starts exactly at our start sector.
# sfdisk -d lines look like: "/dev/sdb3 : start= 6324224, size= ..., type=..."
read_new_part() {
sfdisk -d "$TARGET" 2>/dev/null | awk -v want="$START_SECTOR" '
/ : start=/ {
node = $1
line = $0
sub(/.*start=[ \t]*/, "", line); start = line; sub(/[^0-9].*/, "", start)
line = $0
sub(/.*size=[ \t]*/, "", line); size = line; sub(/[^0-9].*/, "", size)
if (start + 0 == want + 0) { print node, start, size; exit }
}'
}
NEW_PART=$(read_new_part)
if [ -z "$NEW_PART" ]; then
echo "ERROR: no partition starting at sector $START_SECTOR after sfdisk --append. Table:"
sfdisk -d "$TARGET" 2>&1 | sed 's/^/ /'
exit 1
fi
read -r PART_NODE PART_START PART_SECTORS <<<"$NEW_PART"
if [ "$IMAGE_MODE" -eq 0 ]; then
partprobe "$USB_DEV"
sleep 2
# Determine the new partition name (could be sdX3, sdX4, etc.)
CIDATA_PART=""
for part in "${USB_DEV}"[0-9]*; do
# Find the partition that starts at or after our start sector
PART_START=$(sfdisk -d "$USB_DEV" 2>/dev/null | grep "$part" | grep -o 'start=[[:space:]]*[0-9]*' | grep -o '[0-9]*')
if [ -n "$PART_START" ] && [ "$PART_START" -ge "$START_SECTOR" ]; then
CIDATA_PART="$part"
break
CIDATA_PART="$PART_NODE"
if [ ! -b "$CIDATA_PART" ]; then
echo "ERROR: $CIDATA_PART (from sfdisk) is not a block device after partprobe"
exit 1
fi
done
# Fallback: use the last partition
if [ -z "$CIDATA_PART" ]; then
CIDATA_PART=$(lsblk -ln -o NAME "$USB_DEV" | tail -1)
CIDATA_PART="/dev/$CIDATA_PART"
# Refuse to format anything that starts before the computed start sector
# (the ISO9660 data and its EFI partition live there).
SYS_START=$(cat "/sys/class/block/$(basename "$CIDATA_PART")/start" 2>/dev/null || echo 0)
if [ "$SYS_START" -lt "$START_SECTOR" ]; then
echo "ERROR: refusing to format $CIDATA_PART: it starts at sector $SYS_START, before $START_SECTOR"
exit 1
fi
echo " CIDATA partition: $CIDATA_PART"
else
if [ "$PART_START" -lt "$START_SECTOR" ]; then
echo "ERROR: refusing to format partition at sector $PART_START (< $START_SECTOR)"
exit 1
fi
echo " CIDATA partition: $PART_NODE (sectors $PART_START + $PART_SECTORS)"
fi
# --- Format CIDATA partition ---
if [ "$IMAGE_MODE" -eq 0 ]; then
echo "[4/6] Formatting $CIDATA_PART as FAT32 (label: CIDATA)..."
mkfs.vfat -F 32 -n CIDATA "$CIDATA_PART"
# --- Mount and copy files ---
echo "[5/6] Copying autoinstall config, packages, and playbook to CIDATA..."
MOUNT_POINT=$(mktemp -d)
mount "$CIDATA_PART" "$MOUNT_POINT"
else
echo "[4/6] Building FAT32 CIDATA filesystem image ($((PART_SECTORS / 2048)) MiB)..."
CIDATA_IMG="$WORK/cidata.img"
# mkfs.vfat -C takes the size in 1 KiB blocks
mkfs.vfat -F 32 -n CIDATA -C "$CIDATA_IMG" $((PART_SECTORS / 2)) >/dev/null
export MTOOLS_SKIP_CHECK=1
fi
# --- Target-neutral copy helpers ---
# Paths are relative to the CIDATA root ("" = root).
t_mkdir() {
local rel="$1" acc="" part
if [ "$IMAGE_MODE" -eq 0 ]; then
mkdir -p "$MOUNT_POINT/$rel"
return
fi
local IFS=/
for part in $rel; do
[ -n "$part" ] || continue
acc="$acc/$part"
mmd -i "$CIDATA_IMG" "::$acc" 2>/dev/null || true
done
}
# t_cp DEST_REL_DIR SRC... : copy files/dirs (recursively) into a CIDATA dir
t_cp() {
local dest="$1"; shift
if [ "$IMAGE_MODE" -eq 0 ]; then
cp -r "$@" "$MOUNT_POINT/$dest/"
else
mcopy -i "$CIDATA_IMG" -s -Q -D o "$@" "::/${dest:+$dest/}"
fi
}
# t_cp_split SRC DEST_REL_DIR : copy one file, splitting it if over FAT32_MAX
SPLIT_COUNT=0
t_cp_split() {
local f="$1" dest="$2" bn
bn="$(basename "$f")"
if [ "$(stat -L -c%s "$f")" -gt "$FAT32_MAX" ]; then
echo " Splitting $bn (>$((FAT32_MAX / 1024 / 1024))M) into chunks..."
if [ "$IMAGE_MODE" -eq 0 ]; then
split -b "$FAT32_MAX" -d -a 2 "$f" "$MOUNT_POINT/$dest/${bn}.part."
else
mkdir -p "$WORK/split"
split -b "$FAT32_MAX" -d -a 2 "$f" "$WORK/split/${bn}.part."
t_cp "$dest" "$WORK/split/"*
rm -rf "$WORK/split"
fi
SPLIT_COUNT=$((SPLIT_COUNT + 1))
else
t_cp "$dest" "$f"
fi
}
# --- Copy files ---
echo "[5/6] Copying autoinstall config, packages, and playbook to CIDATA..."
# Copy cloud-init files
cp "$AUTOINSTALL_DIR/user-data" "$MOUNT_POINT/"
cp "$AUTOINSTALL_DIR/meta-data" "$MOUNT_POINT/"
t_cp "" "$AUTOINSTALL_DIR/user-data" "$AUTOINSTALL_DIR/meta-data"
# Copy offline .deb packages into packages/ subdirectory
mkdir -p "$MOUNT_POINT/packages"
t_mkdir packages
DEB_COUNT=0
if [ -d "$OFFLINE_PKG_DIR" ]; then
for deb in "$OFFLINE_PKG_DIR"/*.deb; do
if [ -f "$deb" ]; then
cp "$deb" "$MOUNT_POINT/packages/"
DEB_COUNT=$((DEB_COUNT + 1))
fi
done
if [ -d "$OFFLINE_PKG_DIR" ] && compgen -G "$OFFLINE_PKG_DIR/*.deb" >/dev/null; then
t_cp packages "$OFFLINE_PKG_DIR"/*.deb
DEB_COUNT=$(find "$OFFLINE_PKG_DIR" -maxdepth 1 -name '*.deb' | wc -l)
else
echo " WARNING: no .deb packages in $OFFLINE_PKG_DIR (run download-packages.sh)"
fi
echo " Copied $DEB_COUNT .deb packages to packages/"
# Copy playbook directory
cp -r "$PLAYBOOK_DIR" "$MOUNT_POINT/playbook"
t_cp "" "$PLAYBOOK_DIR"
echo " Copied playbook/"
# Copy webapp
WEBAPP_DIR="$REPO_ROOT/webapp"
# Copy the whole webapp tree (app.py imports config, services/, views/;
# the Blancco evidence timer runs blancco_evidence_monthly.py), minus caches.
if [ -d "$WEBAPP_DIR" ]; then
mkdir -p "$MOUNT_POINT/webapp"
cp -r "$WEBAPP_DIR/app.py" "$WEBAPP_DIR/requirements.txt" "$MOUNT_POINT/webapp/"
cp -r "$WEBAPP_DIR/templates" "$WEBAPP_DIR/static" "$MOUNT_POINT/webapp/"
mkdir -p "$WORK/stage"
cp -r "$WEBAPP_DIR" "$WORK/stage/webapp"
find "$WORK/stage/webapp" -name __pycache__ -type d -prune -exec rm -rf {} +
find "$WORK/stage/webapp" -name '*.py[co]' -delete
t_cp "" "$WORK/stage/webapp"
rm -rf "$WORK/stage"
echo " Copied webapp/"
fi
# Copy pip wheels for offline Flask install
PIP_WHEELS_DIR="$REPO_ROOT/pip-wheels"
if [ ! -d "$PIP_WHEELS_DIR" ]; then
echo " pip-wheels/ not found — downloading now..."
echo " pip-wheels/ not found - downloading now..."
mkdir -p "$PIP_WHEELS_DIR"
if pip3 download -d "$PIP_WHEELS_DIR" flask lxml 2>/dev/null; then
echo " Downloaded pip wheels successfully."
@@ -239,100 +517,92 @@ if [ ! -d "$PIP_WHEELS_DIR" ]; then
fi
fi
if [ -d "$PIP_WHEELS_DIR" ]; then
cp -r "$PIP_WHEELS_DIR" "$MOUNT_POINT/pip-wheels"
t_cp "" "$PIP_WHEELS_DIR"
WHEEL_COUNT=$(find "$PIP_WHEELS_DIR" -name '*.whl' | wc -l)
echo " Copied pip-wheels/ ($WHEEL_COUNT wheels)"
fi
# Copy WinPE boot files (wimboot, boot.wim, BCD, ipxe.efi, etc.)
BOOT_FILES_DIR="$REPO_ROOT/boot-files"
# Copy WinPE boot files (wimboot, boot.wim, BCD, ipxe.efi, etc.) to the root
if [ -d "$BOOT_FILES_DIR" ]; then
BOOT_FILE_COUNT=0
for bf in "$BOOT_FILES_DIR"/*; do
if [ -f "$bf" ]; then
cp "$bf" "$MOUNT_POINT/"
t_cp_split "$bf" ""
BOOT_FILE_COUNT=$((BOOT_FILE_COUNT + 1))
fi
done
BOOT_FILES_SIZE=$(du -sh "$BOOT_FILES_DIR" | cut -f1)
echo " Copied $BOOT_FILE_COUNT boot files ($BOOT_FILES_SIZE) — wimboot, boot.wim, ipxe.efi, etc."
BOOT_FILES_SIZE=$(du -shL "$BOOT_FILES_DIR" | cut -f1)
echo " Copied $BOOT_FILE_COUNT boot files ($BOOT_FILES_SIZE) - wimboot, boot.wim, ipxe.efi, etc."
else
echo " WARNING: No boot-files/ found (copy WinPE boot files from Media Creator)"
fi
# Copy boot tools (Clonezilla, Blancco, Memtest) if prepared
BOOT_TOOLS_DIR="$REPO_ROOT/boot-tools"
if [ -d "$BOOT_TOOLS_DIR" ]; then
cp -r "$BOOT_TOOLS_DIR" "$MOUNT_POINT/boot-tools"
TOOLS_SIZE=$(du -sh "$MOUNT_POINT/boot-tools" | cut -f1)
if [ -n "$BOOT_TOOLS_DIR" ] && [ -d "$BOOT_TOOLS_DIR" ]; then
t_cp "" "$BOOT_TOOLS_DIR"
TOOLS_SIZE=$(du -shL "$BOOT_TOOLS_DIR" | cut -f1)
echo " Copied boot-tools/ ($TOOLS_SIZE)"
elif [ -z "$BOOT_TOOLS_DIR" ]; then
echo " Skipped boot-tools/ (--skip-boot-tools)"
else
echo " No boot-tools/ found (run prepare-boot-tools.sh first)"
fi
# Copy enrollment directory (PPKGs, run-enrollment.ps1) if present
# FAT32 has a 4GB max file size; files larger than that are split into chunks
# that the playbook reassembles with `cat`.
ENROLLMENT_DIR="$REPO_ROOT/enrollment"
FAT32_MAX=$((3500 * 1024 * 1024)) # 3500 MiB chunks, safely under 4GiB FAT32 limit
if [ -d "$ENROLLMENT_DIR" ]; then
mkdir -p "$MOUNT_POINT/enrollment"
# Copy enrollment directory (PPKGs, run-enrollment.ps1) if present.
# Files over the FAT32 limit are split; the playbook reassembles them.
if [ -n "$ENROLLMENT_DIR" ] && [ -d "$ENROLLMENT_DIR" ]; then
t_mkdir enrollment
SPLIT_COUNT=0
for f in "$ENROLLMENT_DIR"/*; do
[ -e "$f" ] || continue
bn="$(basename "$f")"
if [ -f "$f" ] && [ "$(stat -c%s "$f")" -gt "$FAT32_MAX" ]; then
echo " Splitting $bn (>$((FAT32_MAX / 1024 / 1024))M) into chunks..."
split -b "$FAT32_MAX" -d -a 2 "$f" "$MOUNT_POINT/enrollment/${bn}.part."
SPLIT_COUNT=$((SPLIT_COUNT + 1))
if [ -f "$f" ]; then
t_cp_split "$f" enrollment
else
cp -r "$f" "$MOUNT_POINT/enrollment/"
t_cp enrollment "$f"
fi
done
PPKG_COUNT=$(find "$ENROLLMENT_DIR" -maxdepth 1 -name '*.ppkg' 2>/dev/null | wc -l)
ENROLL_SIZE=$(du -sh "$MOUNT_POINT/enrollment" | cut -f1)
ENROLL_SIZE=$(du -shL "$ENROLLMENT_DIR" | cut -f1)
echo " Copied enrollment/ ($ENROLL_SIZE, $PPKG_COUNT PPKGs, $SPLIT_COUNT split)"
elif [ -z "$ENROLLMENT_DIR" ]; then
echo " Skipped enrollment/ (--skip-enrollment)"
else
echo " No enrollment/ directory found (PPKGs can be uploaded via webapp later)"
fi
# Copy BIOS update binaries if staged
BIOS_DIR="$REPO_ROOT/bios-staging"
if [ -d "$BIOS_DIR" ] && [ "$(ls -A "$BIOS_DIR" 2>/dev/null)" ]; then
if [ -n "$BIOS_DIR" ] && [ -d "$BIOS_DIR" ] && [ "$(ls -A "$BIOS_DIR" 2>/dev/null)" ]; then
echo " Copying BIOS update binaries from bios-staging/..."
mkdir -p "$MOUNT_POINT/bios"
cp -r "$BIOS_DIR"/* "$MOUNT_POINT/bios/" 2>/dev/null || true
BIOS_COUNT=$(find "$MOUNT_POINT/bios" -name '*.exe' 2>/dev/null | wc -l)
BIOS_SIZE=$(du -sh "$MOUNT_POINT/bios" | cut -f1)
t_mkdir bios
t_cp bios "$BIOS_DIR"/* || true
BIOS_COUNT=$(find "$BIOS_DIR" -name '*.exe' 2>/dev/null | wc -l)
BIOS_SIZE=$(du -shL "$BIOS_DIR" | cut -f1)
echo " Copied bios/ ($BIOS_SIZE, $BIOS_COUNT files)"
elif [ -z "$BIOS_DIR" ]; then
echo " Skipped bios-staging/ (--skip-bios)"
else
echo " No bios-staging/ found (BIOS updates can be pushed via download-drivers.py later)"
fi
# Copy Dell driver packs if staged
# Files larger than the FAT32 4GB limit are split into chunks; the playbook
# reassembles them on the server.
DRIVERS_DIR="$REPO_ROOT/drivers-staging"
if [ -d "$DRIVERS_DIR" ] && [ "$(ls -A "$DRIVERS_DIR" 2>/dev/null)" ]; then
# Copy Dell driver packs if staged (split over the FAT32 limit)
if [ -n "$DRIVERS_DIR" ] && [ -d "$DRIVERS_DIR" ] && [ "$(ls -A "$DRIVERS_DIR" 2>/dev/null)" ]; then
echo " Copying Dell driver packs from drivers-staging/..."
mkdir -p "$MOUNT_POINT/drivers"
DRV_SPLIT=0
# Mirror directory tree first (fast)
(cd "$DRIVERS_DIR" && find . -type d -exec mkdir -p "$MOUNT_POINT/drivers/{}" \;)
# Copy files <4GB directly, split files >=4GB into chunks
t_mkdir drivers
SPLIT_COUNT=0
# Mirror directory tree first
while IFS= read -r d; do
t_mkdir "drivers/${d#./}"
done < <(cd "$DRIVERS_DIR" && find . -mindepth 1 -type d)
while IFS= read -r f; do
rel="${f#$DRIVERS_DIR/}"
dest="$MOUNT_POINT/drivers/$rel"
if [ "$(stat -c%s "$f")" -gt "$FAT32_MAX" ]; then
echo " Splitting $rel..."
split -b "$FAT32_MAX" -d -a 2 "$f" "${dest}.part."
DRV_SPLIT=$((DRV_SPLIT + 1))
else
cp "$f" "$dest"
fi
rel="${f#"$DRIVERS_DIR"/}"
reldir="$(dirname "$rel")"
[ "$reldir" = "." ] && reldir="" || reldir="/$reldir"
t_cp_split "$f" "drivers$reldir"
done < <(find "$DRIVERS_DIR" -type f)
DRIVERS_SIZE=$(du -sh "$MOUNT_POINT/drivers" | cut -f1)
echo " Copied drivers/ ($DRIVERS_SIZE, $DRV_SPLIT split)"
DRIVERS_SIZE=$(du -shL "$DRIVERS_DIR" | cut -f1)
echo " Copied drivers/ ($DRIVERS_SIZE, $SPLIT_COUNT split)"
elif [ -z "$DRIVERS_DIR" ]; then
echo " Skipped drivers-staging/ (--skip-drivers)"
else
echo " No drivers-staging/ found (drivers can be downloaded later)"
fi
@@ -340,9 +610,9 @@ fi
# Optionally copy WinPE deployment images
if [ -n "$WINPE_SOURCE" ] && [ -d "$WINPE_SOURCE" ]; then
echo " Copying WinPE deployment content from $WINPE_SOURCE..."
mkdir -p "$MOUNT_POINT/images"
cp -r "$WINPE_SOURCE"/* "$MOUNT_POINT/images/" 2>/dev/null || true
IMG_SIZE=$(du -sh "$MOUNT_POINT/images" | cut -f1)
t_mkdir images
t_cp images "$WINPE_SOURCE"/* || true
IMG_SIZE=$(du -shL "$WINPE_SOURCE" | cut -f1)
echo " Copied WinPE images ($IMG_SIZE)"
elif [ -n "$WINPE_SOURCE" ]; then
echo " WARNING: WinPE source path not found: $WINPE_SOURCE (skipping)"
@@ -351,23 +621,44 @@ fi
# List what's on CIDATA
echo ""
echo " CIDATA contents:"
if [ "$IMAGE_MODE" -eq 0 ]; then
# shellcheck disable=SC2012 # human-readable listing only
ls -lh "$MOUNT_POINT/" | sed 's/^/ /'
else
mdir -i "$CIDATA_IMG" ::/ | sed 's/^/ /'
fi
# --- Cleanup ---
# --- Finish ---
echo ""
if [ "$IMAGE_MODE" -eq 0 ]; then
echo "[6/6] Syncing and unmounting..."
sync
umount "$MOUNT_POINT"
rmdir "$MOUNT_POINT"
MOUNT_POINT=""
else
echo "[6/6] Writing CIDATA filesystem into $IMAGE_FILE at sector $PART_START..."
dd if="$CIDATA_IMG" of="$IMAGE_FILE" bs=1M conv=notrunc,sparse \
oflag=seek_bytes seek=$((PART_START * SECTOR_SIZE)) status=none
rm -f "$CIDATA_IMG"
echo " Partition table:"
sfdisk -l "$IMAGE_FILE" 2>/dev/null | sed 's/^/ /'
fi
echo ""
echo "============================================"
echo "USB build complete!"
echo "============================================"
echo ""
if [ "$IMAGE_MODE" -eq 1 ]; then
echo "Image: $IMAGE_FILE"
echo " Write to a stick with: sudo dd if=$IMAGE_FILE of=/dev/sdX bs=4M conv=fsync status=progress"
echo " or attach it to a VM as a USB disk (see tests/vm/README.md)."
else
echo "Next steps:"
echo " 1. Insert USB into target machine"
echo " 2. Boot from USB (F12 / boot menu)"
echo " 3. Ubuntu will auto-install and configure the PXE server"
echo " 4. After reboot, move the NIC to the isolated PXE network"
fi
echo ""

60
tests/vm/README.md Normal file
View File

@@ -0,0 +1,60 @@
# pxetest VM harness
Proves a new PXE server can be built from this repo, in two libvirt VMs on
a laptop, without root and without touching a real network.
## Topology
| Object | What |
|-------------------|----------------------------------------------------------------------|
| `pxetest-imaging` | libvirt network, bridge `virbr-pxetest`. No `<forward>`, no `<ip>`: the host has no address on it and libvirt runs no DHCP there. Only VM taps are on the bridge. |
| `default` | libvirt NAT, 192.168.122.0/24. Stands in for the GE/corp network. |
| `pxetest-server` | 2 vCPU, 4 GiB, q35 + OVMF Secure Boot (MS keys), 60G thin qcow2 (`vda`, boot 1), the USB image as a USB disk (`sda`, boot 2). |
| `pxetest-client` | 2 vCPU, 2 GiB, OVMF Secure Boot (MS keys), 1G blank disk, network boot first (UEFI IPv4 PXE, virtio-net, OVMF driver, no option ROM). |
NIC MACs (fixed):
| VM | NIC | MAC | Network | Playbook var |
|--------|---------|---------------------|-------------------|-------------------|
| server | GE | `52:54:00:9e:10:01` | `default` NAT | `pxe_ge_mac` / corp_nic_mac |
| server | imaging | `52:54:00:9e:10:02` | `pxetest-imaging` | `pxe_imaging_mac` / pxe_nic_mac |
| client | - | `52:54:00:9e:10:03` | `pxetest-imaging` | - |
The disk boots before the USB on purpose: a blank disk falls through to
the installer, and after the install the server boots its disk instead of
re-running autoinstall. CIDATA stays attached for the first-boot playbook.
Never attach a pxetest VM to a physical NIC, a host bridge with physical
ports, or macvtap: the server's dnsmasq serves DHCP/PXE as 172.16.9.1.
`create.sh`, `start.sh` and `status.sh` check the isolation and refuse to
continue if `pxetest-imaging` ever gains a `<forward>`, an `<ip>`, a host
IPv4 address or a non-tap bridge port.
## Run it
export PXETEST_CACHE=~/.cache/pxetest # ISO + image (about 10 GB)
export PXETEST_PAYLOAD_ROOT=/home/camp/projects/pxe # checkout holding offline-packages/, boot-files/, ...
tests/vm/fetch-iso.sh # Ubuntu 24.04.5 live-server ISO, SHA256SUMS-verified
tests/vm/build-image.sh # build-usb.sh --image, upload as volume pxetest-usb.img
tests/vm/create.sh # network, volumes, both VMs (prints the MAC pins)
tests/vm/start.sh server # autoinstall, reboot, first-boot playbook
tests/vm/status.sh --shot /tmp/pxetest-shots # states, leases, screenshots
tests/vm/start.sh client # once the server playbook is done
tests/vm/stop.sh # ACPI shutdown both, keep them defined
tests/vm/destroy.sh # remove every pxetest-* object
`build-image.sh` skips enrollment/, bios-staging/, drivers-staging/ and
boot-tools/ by default; `--with-enrollment` and `--with-boot-tools` add
them back, `--size 20G` fixes the image size (default `auto`). If the host
has no `sfdisk` or `mtools`, the debs are extracted into
`$PXETEST_CACHE/tools` (no install, no root).
Watch the server with `virt-viewer -c qemu:///system pxetest-server` (VNC on
127.0.0.1) or screenshots. On the installed system, first-boot output goes
to `/var/log/first-boot.log`. Once the GE NIC takes a DHCP lease on the NAT
network, `status.sh` shows its address and `ssh pxe@<address>` works from
the host.
Memory: server 4 GiB + client 2 GiB. Keep other VMs (win11) off while
both run, and stop them when done.

57
tests/vm/build-image.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
# Build the installer USB as a disk image FILE with build-usb.sh image mode,
# then upload it into the libvirt 'default' pool as volume pxetest-usb.img.
#
# Usage: tests/vm/build-image.sh [--with-enrollment] [--with-boot-tools] [--size SIZE]
# Default skips enrollment/, bios-staging/, drivers-staging/ and boot-tools/
# to keep the image small; SIZE defaults to "auto".
# Env: PXETEST_PAYLOAD_ROOT (where offline-packages/, boot-files/... live)
source "$(dirname "$0")/common.sh"
SIZE=auto
SKIPS=(--skip-enrollment --skip-bios --skip-drivers --skip-boot-tools)
while [ $# -gt 0 ]; do
case "$1" in
--with-enrollment) SKIPS=("${SKIPS[@]/--skip-enrollment}"); shift ;;
--with-boot-tools) SKIPS=("${SKIPS[@]/--skip-boot-tools}"); shift ;;
--size) SIZE="$2"; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
ARGS=()
for s in "${SKIPS[@]}"; do [ -n "$s" ] && ARGS+=("$s"); done
[ -f "$UBUNTU_ISO" ] || die "no ISO at $UBUNTU_ISO; run tests/vm/fetch-iso.sh"
ensure_image_tools
# Report what the payload root has and lacks (do not invent content).
log "payload root: $PXETEST_PAYLOAD_ROOT"
for d in offline-packages pip-wheels boot-files boot-tools enrollment bios-staging drivers-staging; do
if [ -d "$PXETEST_PAYLOAD_ROOT/$d" ]; then
printf ' %-17s %s\n' "$d" "$(du -shL "$PXETEST_PAYLOAD_ROOT/$d" 2>/dev/null | cut -f1)"
else
printf ' %-17s MISSING\n' "$d"
fi
done
for f in ipxe-shimx64.efi ipxeboot.tar.gz wimboot boot.wim BCD boot.sdi bootx64.efi undionly.kpxe; do
[ -f "$PXETEST_PAYLOAD_ROOT/boot-files/$f" ] || log " boot-files/$f MISSING"
done
if [ -e "$USB_IMAGE" ] && [ ! -f "$USB_IMAGE" ]; then
die "$USB_IMAGE is not a regular file"
fi
"$REPO_ROOT/scripts/build-usb.sh" --yes --payload-root "$PXETEST_PAYLOAD_ROOT" \
"${ARGS[@]}" --image "$USB_IMAGE" "$SIZE" "$UBUNTU_ISO"
BYTES=$(stat -c%s "$USB_IMAGE")
if dom_exists "$SERVER_VM" && [ "$(dom_state "$SERVER_VM")" != "shut off" ]; then
die "$SERVER_VM is running; stop it before replacing its USB volume"
fi
if vol_exists "$USB_VOL"; then
virsh vol-delete --pool "$POOL" "$USB_VOL" >/dev/null
fi
log "uploading $USB_IMAGE ($((BYTES / 1048576)) MiB) to pool $POOL as $USB_VOL"
virsh vol-create-as "$POOL" "$USB_VOL" "$BYTES" --format raw >/dev/null
virsh vol-upload --pool "$POOL" --sparse "$USB_VOL" "$USB_IMAGE"
log "done"

113
tests/vm/common.sh Executable file
View File

@@ -0,0 +1,113 @@
# shellcheck shell=bash disable=SC2034
# Shared settings for the pxetest VM harness. Sourced, not run.
#
# Everything this harness creates is named pxetest-*: the two domains, the
# isolated network, and the volumes in the libvirt 'default' pool. Nothing
# else is touched.
set -euo pipefail
VM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$VM_DIR/../.." && pwd)"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
# Download / build cache (ISO, USB image, extracted helper tools).
PXETEST_CACHE="${PXETEST_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/pxetest}"
# Where the gitignored payload dirs (offline-packages/, pip-wheels/,
# boot-files/, boot-tools/, enrollment/, ...) live. Defaults to this repo;
# point it at the main checkout when running from a worktree.
PXETEST_PAYLOAD_ROOT="${PXETEST_PAYLOAD_ROOT:-$REPO_ROOT}"
# Ubuntu ISO
UBUNTU_SERIES="${UBUNTU_SERIES:-24.04}"
UBUNTU_ISO_NAME="${UBUNTU_ISO_NAME:-ubuntu-24.04.5-live-server-amd64.iso}"
UBUNTU_RELEASE_URL="${UBUNTU_RELEASE_URL:-https://releases.ubuntu.com/$UBUNTU_SERIES}"
UBUNTU_ISO="$PXETEST_CACHE/$UBUNTU_ISO_NAME"
USB_IMAGE="$PXETEST_CACHE/pxetest-usb.img"
# libvirt objects
POOL="default"
SERVER_VM="pxetest-server"
CLIENT_VM="pxetest-client"
IMAGING_NET="pxetest-imaging"
IMAGING_BRIDGE="virbr-pxetest"
GE_NET="default" # libvirt NAT, 192.168.122.0/24 = "GE/corp"
SERVER_DISK_VOL="pxetest-server.qcow2"
SERVER_DISK_SIZE="${SERVER_DISK_SIZE:-60G}"
USB_VOL="pxetest-usb.img"
CLIENT_DISK_VOL="pxetest-client.qcow2"
SERVER_MEM_MIB="${SERVER_MEM_MIB:-4096}"
CLIENT_MEM_MIB="${CLIENT_MEM_MIB:-2048}"
# Fixed MACs so the playbook's NIC pins can be written once for this site.
SERVER_GE_MAC="52:54:00:9e:10:01" # on 'default' NAT (GE/corp side)
SERVER_IMAGING_MAC="52:54:00:9e:10:02" # on pxetest-imaging (imaging side)
CLIENT_MAC="52:54:00:9e:10:03" # on pxetest-imaging only
log() { printf '[pxetest] %s\n' "$*"; }
die() { printf '[pxetest] ERROR: %s\n' "$*" >&2; exit 1; }
dom_exists() { virsh dominfo "$1" >/dev/null 2>&1; }
dom_state() { virsh domstate "$1" 2>/dev/null | head -1; }
net_exists() { virsh net-info "$1" >/dev/null 2>&1; }
# No "| grep -q" under pipefail: grep exits early, the writer gets SIGPIPE
# and the pipeline reads as failed even on a match.
net_active() { [ "$(virsh net-info "$1" 2>/dev/null | awk '/^Active:/{print $2}')" = "yes" ]; }
vol_exists() { virsh vol-info --pool "$POOL" "$1" >/dev/null 2>&1; }
# sfdisk and mtools are needed for image mode. If the host lacks them,
# extract the Ubuntu debs into the cache (no root, no install).
ensure_image_tools() {
local troot="$PXETEST_CACHE/tools/root"
if [ -d "$troot" ]; then
export PATH="$troot/usr/sbin:$troot/usr/bin:$PATH"
export LD_LIBRARY_PATH="$troot/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
fi
if command -v sfdisk >/dev/null 2>&1 && command -v mcopy >/dev/null 2>&1 \
&& sfdisk --version >/dev/null 2>&1; then
return 0
fi
log "sfdisk/mtools missing: extracting fdisk, libfdisk1, mtools debs into $troot"
mkdir -p "$PXETEST_CACHE/tools/debs" "$troot"
(cd "$PXETEST_CACHE/tools/debs" && apt-get download fdisk libfdisk1 mtools) \
|| die "apt-get download fdisk libfdisk1 mtools failed; install fdisk + mtools"
local d
for d in "$PXETEST_CACHE/tools/debs"/*.deb; do
dpkg-deb -x "$d" "$troot"
done
export PATH="$troot/usr/sbin:$troot/usr/bin:$PATH"
export LD_LIBRARY_PATH="$troot/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
if ! sfdisk --version >/dev/null || ! command -v mcopy >/dev/null; then
die "extracted sfdisk/mcopy do not run"
fi
}
# The imaging network must be fully isolated: no <forward>, no <ip>
# (so the host has no address on it and libvirt runs no DHCP there).
assert_imaging_isolated() {
local xml
xml=$(virsh net-dumpxml "$IMAGING_NET")
if grep -q '<forward' <<<"$xml" || grep -q '<ip' <<<"$xml"; then
die "$IMAGING_NET has a <forward> or <ip> element: NOT isolated. Run destroy.sh and create.sh."
fi
local v4
v4=$(ip -4 -o addr show dev "$IMAGING_BRIDGE" 2>/dev/null || true)
if [ -n "$v4" ]; then
die "host has an IPv4 address on $IMAGING_BRIDGE: NOT isolated"
fi
# The bridge must hold only libvirt tap devices (vnetN), never a
# physical port.
local port
for port in /sys/class/net/"$IMAGING_BRIDGE"/brif/*; do
[ -e "$port" ] || continue
port=$(basename "$port")
case "$port" in
vnet*) ;;
*) die "$IMAGING_BRIDGE has non-tap port $port: NOT isolated" ;;
esac
done
}

172
tests/vm/create.sh Executable file
View File

@@ -0,0 +1,172 @@
#!/bin/bash
# Define the pxetest network, volumes and VMs. Idempotent: existing
# pxetest-* objects are kept (run destroy.sh first to rebuild them).
#
# pxetest-imaging isolated libvirt network: no <forward>, no <ip>, so the
# host has no address on it and libvirt runs no DHCP.
# Bridge virbr-pxetest holds only the VMs' tap devices.
# pxetest-server 2 vCPU, 4 GiB, OVMF (Secure Boot, MS keys), 60G thin
# qcow2 + the USB image as a USB disk.
# NIC1 52:54:00:9e:10:01 on 'default' NAT ("GE/corp")
# NIC2 52:54:00:9e:10:02 on pxetest-imaging ("imaging")
# pxetest-client 2 vCPU, 2 GiB, OVMF Secure Boot with MS keys, 1G blank
# disk, one NIC 52:54:00:9e:10:03 on pxetest-imaging,
# network boot first (UEFI IPv4 PXE).
#
# Usage: tests/vm/create.sh
source "$(dirname "$0")/common.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# --- isolated imaging network ---
if ! net_exists "$IMAGING_NET"; then
cat >"$TMP/net.xml" <<XML
<network>
<name>$IMAGING_NET</name>
<bridge name='$IMAGING_BRIDGE' stp='on' delay='0'/>
</network>
XML
virsh net-define "$TMP/net.xml" >/dev/null
log "defined network $IMAGING_NET (isolated, no forward, no ip)"
fi
virsh net-autostart --disable "$IMAGING_NET" >/dev/null 2>&1 || true
if ! net_active "$IMAGING_NET"; then
virsh net-start "$IMAGING_NET" >/dev/null
fi
assert_imaging_isolated
net_exists "$GE_NET" || die "libvirt network '$GE_NET' (NAT) does not exist"
net_active "$GE_NET" || virsh net-start "$GE_NET" >/dev/null
# --- volumes ---
if ! vol_exists "$SERVER_DISK_VOL"; then
virsh vol-create-as "$POOL" "$SERVER_DISK_VOL" "$SERVER_DISK_SIZE" --format qcow2 >/dev/null
log "created $SERVER_DISK_VOL ($SERVER_DISK_SIZE thin qcow2)"
fi
if ! vol_exists "$CLIENT_DISK_VOL"; then
virsh vol-create-as "$POOL" "$CLIENT_DISK_VOL" 1G --format qcow2 >/dev/null
log "created $CLIENT_DISK_VOL (1G blank)"
fi
vol_exists "$USB_VOL" || log "WARNING: no $USB_VOL volume yet; run build-image.sh before start.sh"
# Disks are type='file' with the pool's path: with type='volume' sources
# qemu got "Permission denied" opening them (AppArmor profile).
POOL_DIR=$(virsh pool-dumpxml "$POOL" | sed -n 's|.*<path>\(.*\)</path>.*|\1|p' | head -1)
[ -n "$POOL_DIR" ] || die "cannot read the path of pool $POOL"
# Secure Boot with Microsoft keys enrolled: libvirt picks the edk2
# descriptor (40-edk2-x86_64-secure-enrolled.json) and copies its
# OVMF_VARS_4M.ms.fd template into a per-VM nvram.
efi_os() {
cat <<XML
<os firmware='efi'>
<type arch='x86_64' machine='q35'>hvm</type>
<firmware>
<feature enabled='yes' name='secure-boot'/>
<feature enabled='yes' name='enrolled-keys'/>
</firmware>
</os>
<features><acpi/><apic/><smm state='on'/></features>
XML
}
# --- server VM ---
# Boot order: the installed disk first, the USB second. A blank disk falls
# through to the USB; after the install the disk boots, so autoinstall does
# not loop. CIDATA stays attached for the first-boot playbook.
if ! dom_exists "$SERVER_VM"; then
cat >"$TMP/server.xml" <<XML
<domain type='kvm'>
<name>$SERVER_VM</name>
<memory unit='MiB'>$SERVER_MEM_MIB</memory>
<vcpu>2</vcpu>
$(efi_os)
<cpu mode='host-passthrough'/>
<clock offset='utc'/>
<on_reboot>restart</on_reboot>
<devices>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2' discard='unmap'/>
<source file='$POOL_DIR/$SERVER_DISK_VOL'/>
<target dev='vda' bus='virtio'/>
<boot order='1'/>
</disk>
<disk type='file' device='disk'>
<driver name='qemu' type='raw'/>
<source file='$POOL_DIR/$USB_VOL'/>
<target dev='sda' bus='usb' removable='on'/>
<boot order='2'/>
</disk>
<controller type='usb' model='qemu-xhci'/>
<interface type='network'>
<source network='$GE_NET'/>
<mac address='$SERVER_GE_MAC'/>
<model type='virtio'/>
</interface>
<interface type='network'>
<source network='$IMAGING_NET'/>
<mac address='$SERVER_IMAGING_MAC'/>
<model type='virtio'/>
</interface>
<serial type='pty'/>
<console type='pty'/>
<graphics type='vnc' listen='127.0.0.1' autoport='yes'/>
<video><model type='virtio'/></video>
<memballoon model='virtio'/>
<rng model='virtio'><backend model='random'>/dev/urandom</backend></rng>
</devices>
</domain>
XML
virsh define "$TMP/server.xml" >/dev/null
log "defined $SERVER_VM"
fi
# --- client VM ---
if ! dom_exists "$CLIENT_VM"; then
cat >"$TMP/client.xml" <<XML
<domain type='kvm'>
<name>$CLIENT_VM</name>
<memory unit='MiB'>$CLIENT_MEM_MIB</memory>
<vcpu>2</vcpu>
$(efi_os)
<cpu mode='host-passthrough'/>
<clock offset='utc'/>
<devices>
<interface type='network'>
<source network='$IMAGING_NET'/>
<mac address='$CLIENT_MAC'/>
<model type='virtio'/>
<rom enabled='no'/>
<boot order='1'/>
</interface>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='$POOL_DIR/$CLIENT_DISK_VOL'/>
<target dev='vda' bus='virtio'/>
<boot order='2'/>
</disk>
<serial type='pty'/>
<console type='pty'/>
<graphics type='vnc' listen='127.0.0.1' autoport='yes'/>
<video><model type='virtio'/></video>
<memballoon model='virtio'/>
</devices>
</domain>
XML
virsh define "$TMP/client.xml" >/dev/null
log "defined $CLIENT_VM"
fi
# Never attach a pxetest VM anywhere but default NAT / pxetest-imaging.
for vm in "$SERVER_VM" "$CLIENT_VM"; do
while read -r _ type src _ _; do
case "$type:$src" in
network:"$GE_NET") [ "$vm" = "$SERVER_VM" ] || die "$vm is on $GE_NET" ;;
network:"$IMAGING_NET") ;;
*) die "$vm has an interface on $type:$src (only $GE_NET / $IMAGING_NET allowed)" ;;
esac
done < <(virsh domiflist "$vm" | tail -n +3 | grep -v '^$')
done
log "ready. Playbook NIC pins for this site:"
log " pxe_imaging_mac: \"$SERVER_IMAGING_MAC\" (pxe_nic_mac)"
log " pxe_ge_mac: \"$SERVER_GE_MAC\" (corp_nic_mac)"

23
tests/vm/destroy.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Remove every pxetest-* object: both domains (and their nvram), the
# volumes, and the isolated network. Idempotent. Does not touch the
# cached ISO / USB image file (delete $PXETEST_CACHE by hand).
# Usage: tests/vm/destroy.sh
source "$(dirname "$0")/common.sh"
for vm in "$CLIENT_VM" "$SERVER_VM"; do
dom_exists "$vm" || continue
[ "$(dom_state "$vm")" = "shut off" ] || virsh destroy "$vm" >/dev/null
virsh undefine "$vm" --nvram >/dev/null
log "undefined $vm"
done
for v in "$SERVER_DISK_VOL" "$USB_VOL" "$CLIENT_DISK_VOL"; do
vol_exists "$v" || continue
virsh vol-delete --pool "$POOL" "$v" >/dev/null
log "deleted volume $v"
done
if net_exists "$IMAGING_NET"; then
virsh net-destroy "$IMAGING_NET" >/dev/null 2>&1 || true
virsh net-undefine "$IMAGING_NET" >/dev/null
log "removed network $IMAGING_NET"
fi

37
tests/vm/fetch-iso.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Download the Ubuntu live-server ISO into the cache and verify it against
# the release's SHA256SUMS. Idempotent: a verified ISO is not re-fetched.
#
# Usage: tests/vm/fetch-iso.sh
# Env: PXETEST_CACHE, UBUNTU_ISO_NAME (default ubuntu-24.04.5-live-server-amd64.iso)
source "$(dirname "$0")/common.sh"
mkdir -p "$PXETEST_CACHE"
cd "$PXETEST_CACHE"
log "fetching $UBUNTU_RELEASE_URL/SHA256SUMS"
curl -fsSL -m 60 -o SHA256SUMS.new "$UBUNTU_RELEASE_URL/SHA256SUMS" \
|| die "could not download SHA256SUMS (proxy? offline?)"
mv SHA256SUMS.new SHA256SUMS
if ! grep -q "[ *]$UBUNTU_ISO_NAME\$" SHA256SUMS; then
log "available live-server ISOs:"
grep live-server SHA256SUMS | sed 's/^/ /'
die "$UBUNTU_ISO_NAME is not in SHA256SUMS; set UBUNTU_ISO_NAME"
fi
verify() { grep "[ *]$UBUNTU_ISO_NAME\$" SHA256SUMS | sha256sum -c --status; }
if [ -f "$UBUNTU_ISO_NAME" ] && verify; then
log "ISO already present and verified: $UBUNTU_ISO"
exit 0
fi
log "downloading $UBUNTU_RELEASE_URL/$UBUNTU_ISO_NAME (resumable)"
curl -fL -C - --retry 3 -o "$UBUNTU_ISO_NAME" "$UBUNTU_RELEASE_URL/$UBUNTU_ISO_NAME" \
|| die "ISO download failed (proxy? offline?)"
if ! verify; then
rm -f "$UBUNTU_ISO_NAME"
die "SHA256 mismatch for $UBUNTU_ISO_NAME (deleted; run again)"
fi
log "ISO verified: $UBUNTU_ISO"

32
tests/vm/start.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Start pxetest VMs. Idempotent (a running VM is left alone).
# Usage: tests/vm/start.sh [server|client|all] (default: server)
# Start the client only once the server's playbook has finished; both
# together use 6 GiB.
source "$(dirname "$0")/common.sh"
which="${1:-server}"
case "$which" in
server) vms=("$SERVER_VM") ;;
client) vms=("$CLIENT_VM") ;;
all) vms=("$SERVER_VM" "$CLIENT_VM") ;;
*) die "usage: $0 [server|client|all]" ;;
esac
net_exists "$IMAGING_NET" || die "run create.sh first"
net_active "$IMAGING_NET" || virsh net-start "$IMAGING_NET" >/dev/null
assert_imaging_isolated
for vm in "${vms[@]}"; do
dom_exists "$vm" || die "$vm not defined; run create.sh"
if [ "$vm" = "$SERVER_VM" ]; then
vol_exists "$USB_VOL" || die "no $USB_VOL; run build-image.sh"
fi
if [ "$(dom_state "$vm")" = "running" ]; then
log "$vm already running"
else
virsh start "$vm" >/dev/null
log "started $vm (VNC: $(virsh vncdisplay "$vm" 2>/dev/null | head -1))"
fi
done
assert_imaging_isolated

39
tests/vm/status.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Show the state of every pxetest-* object, the isolation check, the NIC
# MACs, the server's GE-side DHCP lease, and optionally a screenshot.
# Usage: tests/vm/status.sh [--shot DIR]
source "$(dirname "$0")/common.sh"
SHOT_DIR=""
[ "${1:-}" = "--shot" ] && SHOT_DIR="${2:?--shot needs a dir}"
if ! net_exists "$IMAGING_NET"; then n=absent; elif net_active "$IMAGING_NET"; then n=active; else n=inactive; fi
echo "network $IMAGING_NET: $n"
if net_active "$IMAGING_NET"; then
(assert_imaging_isolated && echo " isolation: OK (no forward, no ip, no host IPv4, only vnet ports)")
fi
for v in "$SERVER_DISK_VOL" "$USB_VOL" "$CLIENT_DISK_VOL"; do
if vol_exists "$v"; then
echo "volume $v: $(virsh vol-info --pool "$POOL" "$v" | awk '/Capacity|Allocation/{printf "%s %s %s ", $1, $2, $3}')"
else
echo "volume $v: absent"
fi
done
for vm in "$SERVER_VM" "$CLIENT_VM"; do
if dom_exists "$vm"; then
echo "domain $vm: $(dom_state "$vm")"
virsh domiflist "$vm" | tail -n +3 | grep -v '^$' | sed 's/^/ /'
if [ "$(dom_state "$vm")" = "running" ]; then
echo " VNC $(virsh vncdisplay "$vm" 2>/dev/null | head -1)"
if [ -n "$SHOT_DIR" ]; then
mkdir -p "$SHOT_DIR"
f="$SHOT_DIR/$vm-$(date +%H%M%S).ppm"
virsh screenshot "$vm" "$f" >/dev/null 2>&1 && echo " screenshot $f"
fi
fi
else
echo "domain $vm: absent"
fi
done
echo "GE-side DHCP leases for $SERVER_GE_MAC (on $GE_NET):"
virsh net-dhcp-leases "$GE_NET" --mac "$SERVER_GE_MAC" 2>/dev/null | tail -n +3 | grep -v '^$' | sed 's/^/ /' || true

27
tests/vm/stop.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Shut pxetest VMs down (ACPI, then force after a timeout). Keeps them
# defined. Idempotent.
# Usage: tests/vm/stop.sh [server|client|all] (default: all)
source "$(dirname "$0")/common.sh"
which="${1:-all}"
case "$which" in
server) vms=("$SERVER_VM") ;;
client) vms=("$CLIENT_VM") ;;
all) vms=("$CLIENT_VM" "$SERVER_VM") ;;
*) die "usage: $0 [server|client|all]" ;;
esac
for vm in "${vms[@]}"; do
dom_exists "$vm" || continue
[ "$(dom_state "$vm")" = "shut off" ] && { log "$vm already off"; continue; }
virsh shutdown "$vm" >/dev/null 2>&1 || true
for _ in $(seq 1 60); do
[ "$(dom_state "$vm")" = "shut off" ] && break
sleep 1
done
if [ "$(dom_state "$vm")" != "shut off" ]; then
log "$vm ignored ACPI shutdown; forcing off"
virsh destroy "$vm" >/dev/null
fi
log "$vm off"
done