Files
kubesolo-os/build/scripts/create-disk-image.sh
Adolfo Delorenzo 80aca5e372
Some checks failed
CI / Go Tests (push) Successful in 2m38s
CI / Shellcheck (push) Failing after 37s
CI / Build Go Binaries (amd64, linux, linux-amd64) (push) Failing after 1m22s
CI / Build Go Binaries (arm64, linux, linux-arm64) (push) Failing after 1m11s
feat: ARM64 generic UEFI disk image (GPT + GRUB A/B)
Produces a UEFI-bootable raw disk image for generic ARM64 hosts (QEMU virt,
Ampere/Graviton cloud, ARM64 SBCs with UEFI). Reuses the existing 4-partition
A/B layout from x86 (EFI 256 MB FAT32 + System A 512 MB ext4 + System B 512 MB
ext4 + Data ext4 remainder).

Changes:
- build/scripts/create-disk-image.sh: TARGET_ARCH env var (amd64 default,
  arm64). Selects kernel source path, grub-mkimage target (x86_64-efi vs
  arm64-efi), EFI binary name (bootx64.efi vs BOOTAA64.EFI), grub.cfg variant,
  and whether to also install BIOS GRUB (x86 only).
- build/grub/grub-arm64.cfg: ARM64 variant of grub.cfg. Identical A/B logic;
  console=ttyAMA0+ttyS0 to cover QEMU virt PL011, Ampere PL011, and Graviton
  16550-compat.
- build/Dockerfile.builder: add grub-efi-amd64-bin, grub-efi-arm64-bin,
  grub-pc-bin, grub-common, grub2-common so the builder container can produce
  EFI images for both architectures.
- hack/dev-vm-arm64.sh: split into kernel mode (direct -kernel/-initrd, fast
  iteration) and --disk mode (UEFI firmware + GRUB + disk image, full
  integration test). Probes common UEFI firmware paths on Ubuntu/Fedora/macOS.
  Default kernel path now points at kernel-arm64-generic/Image with fallback
  to the renamed custom-kernel-rpi/Image.
- test/qemu/test-boot-arm64-disk.sh: new CI test for the full UEFI -> GRUB ->
  kernel -> stage-90 boot chain. Uses a scratch copy of the disk so grubenv
  writes don't mutate the source artifact.
- Makefile: new disk-image-arm64 target (depends on rootfs-arm64 + kernel-arm64),
  new test-boot-arm64-disk target, .PHONY + help updates.

Phase 3 scaffold is in place. First real end-to-end ARM64 build runs in the
next step on the Odroid runner — that's where we find out what's actually
broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:36:08 -06:00

265 lines
9.0 KiB
Bash
Executable File

#!/bin/bash
# create-disk-image.sh — Create a raw disk image with A/B system partitions
#
# Partition layout (GPT):
# Part 1: EFI/Boot (256 MB, FAT32) — GRUB + grubenv + A/B boot logic
# Part 2: System A (512 MB, ext4) — vmlinuz + kubesolo-os.gz (active)
# Part 3: System B (512 MB, ext4) — vmlinuz + kubesolo-os.gz (passive)
# Part 4: Data (remaining, ext4) — persistent K8s state
#
# Supports both x86_64 (default) and ARM64 generic UEFI targets. ARM64 RPi
# uses a different image format — see build/scripts/create-rpi-image.sh.
#
# Environment:
# TARGET_ARCH amd64 (default) or arm64
# IMG_SIZE_MB Image size in MB (default 4096)
# CACHE_DIR Build cache (default <project>/build/cache)
# ROOTFS_DIR Rootfs work dir (default <project>/build/rootfs-work)
# OUTPUT_DIR Output dir (default <project>/output)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ROOTFS_DIR="${ROOTFS_DIR:-$PROJECT_ROOT/build/rootfs-work}"
CACHE_DIR="${CACHE_DIR:-$PROJECT_ROOT/build/cache}"
OUTPUT_DIR="${OUTPUT_DIR:-$PROJECT_ROOT/output}"
VERSION="$(cat "$PROJECT_ROOT/VERSION")"
OS_NAME="kubesolo-os"
TARGET_ARCH="${TARGET_ARCH:-amd64}"
IMG_SIZE_MB="${IMG_SIZE_MB:-4096}" # 4 GB default (larger for A/B)
# --- Arch-specific paths ---
case "$TARGET_ARCH" in
amd64)
IMG_OUTPUT="$OUTPUT_DIR/${OS_NAME}-${VERSION}.img"
VMLINUZ="$ROOTFS_DIR/vmlinuz"
GRUB_CFG="$PROJECT_ROOT/build/grub/grub.cfg"
GRUB_TARGET="x86_64-efi"
GRUB_EFI_BIN="bootx64.efi"
GRUB_INSTALL_BIOS=true
;;
arm64)
IMG_OUTPUT="$OUTPUT_DIR/${OS_NAME}-${VERSION}.arm64.img"
VMLINUZ="$CACHE_DIR/kernel-arm64-generic/Image"
GRUB_CFG="$PROJECT_ROOT/build/grub/grub-arm64.cfg"
GRUB_TARGET="arm64-efi"
GRUB_EFI_BIN="BOOTAA64.EFI"
GRUB_INSTALL_BIOS=false
;;
*)
echo "ERROR: TARGET_ARCH must be 'amd64' or 'arm64' (got: $TARGET_ARCH)"
exit 1
;;
esac
INITRAMFS="$ROOTFS_DIR/kubesolo-os.gz"
GRUB_ENV_DEFAULTS="$PROJECT_ROOT/build/grub/grub-env-defaults"
for f in "$VMLINUZ" "$INITRAMFS" "$GRUB_CFG" "$GRUB_ENV_DEFAULTS"; do
[ -f "$f" ] || { echo "ERROR: Missing $f"; exit 1; }
done
echo "==> Creating ${IMG_SIZE_MB}MB ${TARGET_ARCH} disk image with A/B partitions..."
mkdir -p "$OUTPUT_DIR"
# Create sparse image
dd if=/dev/zero of="$IMG_OUTPUT" bs=1M count=0 seek="$IMG_SIZE_MB" 2>/dev/null
# Partition (GPT):
# Part 1: 256 MB EFI System Partition (FAT32)
# Part 2: 512 MB System A (Linux filesystem)
# Part 3: 512 MB System B (Linux filesystem)
# Part 4: Remaining — Data (Linux filesystem)
sfdisk "$IMG_OUTPUT" << EOF
label: gpt
# EFI/Boot partition: 256 MB
start=2048, size=524288, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="EFI"
# System A partition: 512 MB
size=1048576, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="SystemA"
# System B partition: 512 MB
size=1048576, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="SystemB"
# Data partition: remaining
type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="Data"
EOF
# Set up loop device with partition mappings
LOOP=$(losetup --show -f "$IMG_OUTPUT")
echo "==> Loop device: $LOOP"
# Use kpartx for reliable partition device nodes (works in Docker/containers)
USE_KPARTX=false
if [ ! -b "${LOOP}p1" ]; then
if command -v kpartx >/dev/null 2>&1; then
kpartx -a "$LOOP"
USE_KPARTX=true
sleep 1
LOOP_NAME=$(basename "$LOOP")
P1="/dev/mapper/${LOOP_NAME}p1"
P2="/dev/mapper/${LOOP_NAME}p2"
P3="/dev/mapper/${LOOP_NAME}p3"
P4="/dev/mapper/${LOOP_NAME}p4"
else
# Retry with -P flag
losetup -d "$LOOP"
LOOP=$(losetup --show -fP "$IMG_OUTPUT")
sleep 1
P1="${LOOP}p1"
P2="${LOOP}p2"
P3="${LOOP}p3"
P4="${LOOP}p4"
fi
else
P1="${LOOP}p1"
P2="${LOOP}p2"
P3="${LOOP}p3"
P4="${LOOP}p4"
fi
MNT_EFI=$(mktemp -d)
MNT_SYSA=$(mktemp -d)
MNT_SYSB=$(mktemp -d)
MNT_DATA=$(mktemp -d)
cleanup() {
umount "$MNT_EFI" 2>/dev/null || true
umount "$MNT_SYSA" 2>/dev/null || true
umount "$MNT_SYSB" 2>/dev/null || true
umount "$MNT_DATA" 2>/dev/null || true
if [ "$USE_KPARTX" = true ]; then
kpartx -d "$LOOP" 2>/dev/null || true
fi
losetup -d "$LOOP" 2>/dev/null || true
rm -rf "$MNT_EFI" "$MNT_SYSA" "$MNT_SYSB" "$MNT_DATA" 2>/dev/null || true
}
trap cleanup EXIT
# Format partitions
mkfs.vfat -F 32 -n KSOLOEFI "$P1"
mkfs.ext4 -q -L KSOLOA "$P2"
mkfs.ext4 -q -L KSOLOB "$P3"
mkfs.ext4 -q -L KSOLODATA "$P4"
# Mount all partitions
mount "$P1" "$MNT_EFI"
mount "$P2" "$MNT_SYSA"
mount "$P3" "$MNT_SYSB"
mount "$P4" "$MNT_DATA"
# --- EFI/Boot Partition ---
echo " Installing GRUB..."
mkdir -p "$MNT_EFI/EFI/BOOT"
mkdir -p "$MNT_EFI/boot/grub"
# Copy GRUB config
cp "$GRUB_CFG" "$MNT_EFI/boot/grub/grub.cfg"
# Create GRUB environment file from defaults
if command -v grub-editenv >/dev/null 2>&1; then
GRUB_EDITENV=grub-editenv
elif command -v grub2-editenv >/dev/null 2>&1; then
GRUB_EDITENV=grub2-editenv
else
GRUB_EDITENV=""
fi
GRUBENV_FILE="$MNT_EFI/boot/grub/grubenv"
if [ -n "$GRUB_EDITENV" ]; then
# Create grubenv with defaults
"$GRUB_EDITENV" "$GRUBENV_FILE" create
while IFS='=' read -r key value; do
# Skip comments and empty lines
case "$key" in
'#'*|'') continue ;;
esac
"$GRUB_EDITENV" "$GRUBENV_FILE" set "$key=$value"
done < "$GRUB_ENV_DEFAULTS"
echo " GRUB environment created with grub-editenv"
else
# Fallback: write grubenv file manually (1024 bytes, padded with '#')
echo " WARN: grub-editenv not found — writing grubenv manually"
{
echo "# GRUB Environment Block"
while IFS='=' read -r key value; do
case "$key" in
'#'*|'') continue ;;
esac
echo "$key=$value"
done < "$GRUB_ENV_DEFAULTS"
} > "$GRUBENV_FILE.tmp"
# Pad to 1024 bytes (GRUB requirement)
truncate -s 1024 "$GRUBENV_FILE.tmp"
mv "$GRUBENV_FILE.tmp" "$GRUBENV_FILE"
fi
# Install GRUB EFI binary
# Modules required: part_gpt + fat (boot partition), ext2 (system A/B),
# normal + linux + echo + configfile + loadenv (boot menu + grubenv),
# search_* (locate partitions by label).
# all_video + test are x86-specific (DRM init); leave them out on arm64.
if [ "$TARGET_ARCH" = "arm64" ]; then
GRUB_MODULES="part_gpt ext2 fat normal linux echo test search search_fs_uuid search_label configfile loadenv"
else
GRUB_MODULES="part_gpt ext2 fat normal linux echo all_video test search search_fs_uuid search_label configfile loadenv"
fi
# shellcheck disable=SC2086 # GRUB_MODULES is intentionally word-split
if command -v grub-mkimage >/dev/null 2>&1; then
grub-mkimage -O "$GRUB_TARGET" -o "$MNT_EFI/EFI/BOOT/$GRUB_EFI_BIN" \
-p /boot/grub $GRUB_MODULES \
|| echo " WARN: grub-mkimage failed — use QEMU -bios flag"
elif command -v grub2-mkimage >/dev/null 2>&1; then
grub2-mkimage -O "$GRUB_TARGET" -o "$MNT_EFI/EFI/BOOT/$GRUB_EFI_BIN" \
-p /boot/grub $GRUB_MODULES \
|| echo " WARN: grub2-mkimage failed — use QEMU -bios flag"
else
echo " WARN: grub-mkimage not found — EFI boot image not created"
echo " Install grub-efi-${TARGET_ARCH}-bin or use QEMU -kernel/-initrd flags"
fi
# For BIOS boot: install GRUB i386-pc modules (x86 only — ARM64 is UEFI-only).
if [ "$GRUB_INSTALL_BIOS" = "true" ]; then
if command -v grub-install >/dev/null 2>&1; then
grub-install --target=i386-pc --boot-directory="$MNT_EFI/boot" \
--no-floppy "$LOOP" 2>/dev/null || {
echo " WARN: BIOS GRUB install failed — EFI-only or use QEMU -kernel"
}
elif command -v grub2-install >/dev/null 2>&1; then
grub2-install --target=i386-pc --boot-directory="$MNT_EFI/boot" \
--no-floppy "$LOOP" 2>/dev/null || {
echo " WARN: BIOS GRUB install failed — EFI-only or use QEMU -kernel"
}
fi
fi
# --- System A Partition (active) ---
echo " Populating System A (active)..."
cp "$VMLINUZ" "$MNT_SYSA/vmlinuz"
cp "$INITRAMFS" "$MNT_SYSA/kubesolo-os.gz"
echo "$VERSION" > "$MNT_SYSA/version"
# --- System B Partition (passive, initially same as A) ---
echo " Populating System B (passive)..."
cp "$VMLINUZ" "$MNT_SYSB/vmlinuz"
cp "$INITRAMFS" "$MNT_SYSB/kubesolo-os.gz"
echo "$VERSION" > "$MNT_SYSB/version"
# --- Data Partition ---
echo " Preparing data partition..."
for dir in kubesolo containerd etc-kubesolo log usr-local network images; do
mkdir -p "$MNT_DATA/$dir"
done
sync
echo ""
echo "==> ${TARGET_ARCH} disk image created: $IMG_OUTPUT"
echo " Size: $(du -h "$IMG_OUTPUT" | cut -f1)"
echo " Part 1 (KSOLOEFI): GRUB ($GRUB_TARGET) + A/B boot config"
echo " Part 2 (KSOLOA): System A — kernel + initramfs (active)"
echo " Part 3 (KSOLOB): System B — kernel + initramfs (passive)"
echo " Part 4 (KSOLODATA): Persistent K8s state"
echo ""