From cdd87f3ad70ce3f5d6e61331a6bb22faa3b90134 Mon Sep 17 00:00:00 2001 From: Tobias Jungel <1773291+toanju@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:25:12 +0200 Subject: [PATCH] feat: add boot-uki.sh script to run UKI build in QEMU boot a locally build garden linux with qemu using EFI/OVMF boot and ignition for firstboot provisioning. The script targets macOS for now. The following tools are required: * qemu (for QEMU and OVMF firmware) * podman (for running a local OCI registry) * oras (for pushing the UKI as an OCI artifact to the local registry) * hdiutil (for creating the ignition config-drive ISO) * python3 (for merging ignition config and patching UKI cmdline) * bash >4 (for the main script) To login after provision one can use the admin/admin user created by the script, e.g. with `ssh -p 2222 admin@localhost` if --ssh is used or directly using the console. --- scripts/boot-uki.sh | 397 ++++++++++++++++++++++++++++++++++++++++++ scripts/ignition.yaml | 49 ++++++ 2 files changed, 446 insertions(+) create mode 100755 scripts/boot-uki.sh create mode 100644 scripts/ignition.yaml diff --git a/scripts/boot-uki.sh b/scripts/boot-uki.sh new file mode 100755 index 0000000..202e919 --- /dev/null +++ b/scripts/boot-uki.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +TMPDIR_IGN="" +REGISTRY_CONTAINER="" +cleanup() { + [[ -n "$REGISTRY_CONTAINER" ]] && podman rm -f "$REGISTRY_CONTAINER" >/dev/null 2>&1 || true + [[ -n "$TMPDIR_IGN" ]] && rm -rf "$TMPDIR_IGN" || true +} +trap cleanup EXIT + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +BUILD_DIR="$REPO_DIR/.build" +QEMU_PREFIX="$(brew --prefix qemu 2>/dev/null || echo /opt/homebrew)" +OVMF_CODE="${QEMU_PREFIX}/share/qemu/edk2-x86_64-code.fd" +OVMF_VARS="${QEMU_PREFIX}/share/qemu/edk2-i386-vars.fd" +REGISTRY_PORT=5001 + +start_oci_registry() { + local prefix="$1" + local ignition_file="$2" + + local release_file="${prefix}.release" + if [[ ! -f "$release_file" ]]; then + echo "Error: release file not found: $release_file" >&2 + exit 1 + fi + # shellcheck disable=SC1090 + . "$release_file" + local dashed_version="${GARDENLINUX_VERSION//./-}" + local oci_tag="${GARDENLINUX_VERSION}-${VARIANT_ID}-${dashed_version}-${GARDENLINUX_COMMIT_ID}" + oci_tag="${oci_tag//_/-}" + local oci_repo_local="localhost:${REGISTRY_PORT}/gardenlinux/gardenlinux-ccloud" + local oci_repo_guest="10.0.2.2:${REGISTRY_PORT}/gardenlinux/gardenlinux-ccloud" + echo "OCI tag: ${oci_tag}" >&2 + + podman pull docker.io/registry:2 >/dev/null + REGISTRY_CONTAINER="oci-registry-boot-uki" + podman rm -f "$REGISTRY_CONTAINER" &>/dev/null || true + podman run -d --rm --name "$REGISTRY_CONTAINER" \ + -p "${REGISTRY_PORT}:5000" \ + docker.io/registry:2 >/dev/null + echo "Started OCI registry: $REGISTRY_CONTAINER" >&2 + if ! podman container inspect "$REGISTRY_CONTAINER" &>/dev/null; then + echo "Error: failed to start OCI registry container '$REGISTRY_CONTAINER'" >&2 + exit 1 + fi + + oras push "${oci_repo_local}:${oci_tag}" \ + --artifact-type "application/vnd.oci.image.manifest.v1+json" \ + --disable-path-validation \ + "${prefix}.uki:application/io.gardenlinux.uki" >&2 + echo "Pushed UKI to local registry" >&2 + + local merged_ign="$TMPDIR_IGN/merged.ign" + local admin_hash + admin_hash="$(openssl passwd -6 "admin")" + python3 - "$ignition_file" "$oci_repo_guest" "$oci_tag" "$admin_hash" > "$merged_ign" <<'PYEOF' +import json, sys +from urllib.parse import quote + +cfg = json.load(open(sys.argv[1])) +cfg.setdefault("storage", {}).setdefault("files", []) +cfg.setdefault("passwd", {}).setdefault("users", []) + +oci_repo = sys.argv[2] +oci_tag = sys.argv[3] +admin_hash = sys.argv[4] + +# Inject admin user if not already present +if not any(u.get("name") == "admin" for u in cfg["passwd"]["users"]): + cfg["passwd"]["users"].append({ + "name": "admin", + "groups": ["wheel"], + "passwordHash": admin_hash + }) + +gl_oci_conf = ( + f"OCI_REPO={oci_repo}\n" + f"OCI_TAG={oci_tag}\n" + "PATH=/sysroot/opt/persist:$PATH\n" +) +oras_wrapper = "#!/bin/bash\nexec /usr/bin/oras \"$@\" --plain-http\n" + +cfg["storage"]["files"] += [ + { + "path": "/opt/persist/gl-oci.conf", + "overwrite": True, + "contents": {"source": "data:," + quote(gl_oci_conf)}, + "mode": 0o644 + }, + { + "path": "/opt/persist/oras", + "overwrite": True, + "contents": {"source": "data:," + quote(oras_wrapper)}, + "mode": 0o755 + } +] +print(json.dumps(cfg)) +PYEOF + echo "Merged ignition config: $merged_ign" >&2 + echo "$merged_ign" +} + +usage() { + cat >&2 < QEMU memory, e.g. 4096M or 8G (default: 4096M) + --ssh Forward host port 2222 to guest port 22 + --ignition Ignition config JSON (default: ignition.yaml); attaches as CD-ROM + (config-2 label) for ignition firstboot provisioning + --disk Add a blank SATA disk; starts a local OCI registry via podman, + pushes the UKI, and patches the UKI cmdline with ignition firstboot + params (default: 10G, disk is temporary and deleted when QEMU exits) + -h, --help Show this help + +EXAMPLES + $(basename "$0") # uses test.ign + 10G disk + $(basename "$0") .build/metal-scibase_usi-amd64-1877.13.12-local + $(basename "$0") --ssh --mem 8192 .build/metal-scibase_usi-amd64-1877.13.12-local + $(basename "$0") --ignition config.ign + $(basename "$0") --ignition config.ign --disk 20G +EOF +} + +MEM=4096M +SSH=0 +PREFIX="" +IGNITION_FILE="${SCRIPT_DIR}/ignition.yaml" +DISK_SIZE="10G" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mem) + if [[ $# -lt 2 ]]; then + echo "Error: --mem requires an argument" >&2 + usage + exit 1 + fi + MEM="$2"; shift 2 ;; + --ssh) SSH=1; shift ;; + --ignition) + if [[ $# -lt 2 ]]; then + echo "Error: --ignition requires an argument" >&2 + usage + exit 1 + fi + IGNITION_FILE="$2"; shift 2 ;; + --disk) + if [[ $# -lt 2 ]]; then + echo "Error: --disk requires an argument" >&2 + usage + exit 1 + fi + DISK_SIZE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage; exit 1 ;; + *) PREFIX="$1"; shift ;; + esac +done + +# Validate MEM value +if [[ ! "$MEM" =~ ^[0-9]+[MGmg]?$ ]]; then + echo "Error: --mem value '$MEM' is not a valid QEMU memory size (e.g. 4096, 4096M, 4G)" >&2 + exit 1 +fi + +# Validate ignition config file +if [[ ! -f "$IGNITION_FILE" || ! -r "$IGNITION_FILE" ]]; then + echo "Error: ignition config not found or not readable: $IGNITION_FILE" >&2 + exit 1 +fi + +# Validate disk size if provided +if [[ -n "$DISK_SIZE" && ! "$DISK_SIZE" =~ ^[0-9]+[MGTmgt]?$ ]]; then + echo "Error: --disk value '$DISK_SIZE' is not a valid size (e.g. 20G, 50G)" >&2 + exit 1 +fi + +# Validate required tools +if ! command -v hdiutil &>/dev/null; then + echo "Error: hdiutil is required (macOS only)" >&2; exit 1 +fi +if ! command -v qemu-system-x86_64 &>/dev/null; then + echo "Error: qemu-system-x86_64 is required (brew install qemu)" >&2; exit 1 +fi +if [[ ! -f "$OVMF_CODE" ]]; then + echo "Error: OVMF firmware not found: $OVMF_CODE (brew install qemu)" >&2; exit 1 +fi +if [[ ! -f "$OVMF_VARS" ]]; then + echo "Error: OVMF vars not found: $OVMF_VARS (brew install qemu)" >&2; exit 1 +fi +if [[ -n "$DISK_SIZE" ]]; then + if ! command -v qemu-img &>/dev/null; then + echo "Error: qemu-img is required (brew install qemu)" >&2; exit 1 + fi + if ! command -v podman &>/dev/null; then + echo "Error: podman is required (brew install podman)" >&2; exit 1 + fi + _podman_info="$(podman info 2>&1)" || { + if echo "$_podman_info" | grep -q "proxy already running"; then + echo "Error: Podman proxy is stuck from a previous run. Fix with:" >&2 + echo " podman machine stop && podman machine start" >&2 + else + echo "Error: cannot connect to Podman. Run 'podman machine start' first." >&2 + fi + exit 1 + } + unset _podman_info + if ! command -v oras &>/dev/null; then + echo "Error: oras is required (brew install oras)" >&2; exit 1 + fi + if ! command -v openssl &>/dev/null; then + echo "Error: openssl is required" >&2; exit 1 + fi +fi + +TMPDIR_IGN="$(mktemp -d)" + +# Auto-detect prefix if not given: pick the most recently modified .uki in .build/ +if [[ -z "$PREFIX" ]]; then + uki_files=("$BUILD_DIR"/*.uki) + if [[ ! -e "${uki_files[0]}" ]]; then + echo "Error: no .uki files found in $BUILD_DIR" >&2 + echo "Either run a build first or specify ARTIFACT_PREFIX" >&2 + exit 1 + fi + # Sort by modification time descending (newest first) + # shellcheck disable=SC2012 + latest_uki="$(ls -t "${uki_files[@]}" | head -1)" + PREFIX="${latest_uki%.uki}" + echo "Auto-detected artifact: $PREFIX" >&2 +fi + +UKI="${PREFIX}.uki" +if [[ ! -f "$UKI" ]]; then + echo "Error: required file not found: $UKI" >&2 + exit 1 +fi + +# Start local OCI registry, push UKI, and merge OCI config into ignition +if [[ -n "$DISK_SIZE" ]]; then + IGNITION_FILE="$(start_oci_registry "$PREFIX" "$IGNITION_FILE")" +fi + +# Build ignition config-drive ISO +mkdir -p "$TMPDIR_IGN/iso-root/openstack/latest" +cp "$IGNITION_FILE" "$TMPDIR_IGN/iso-root/openstack/latest/user_data" +# hdiutil makehybrid auto-appends .iso to the output filename +hdiutil makehybrid \ + -o "$TMPDIR_IGN/ignition-config-drive" \ + -iso -joliet \ + -iso-volume-name "config-2" \ + -joliet-volume-name "config-2" \ + "$TMPDIR_IGN/iso-root" >/dev/null +echo "Ignition config: $IGNITION_FILE (attached as CD-ROM with label config-2)" >&2 + +# Create blank SATA install disk and patch UKI cmdline for firstboot +UKI_FOR_ESP="$UKI" +if [[ -n "$DISK_SIZE" ]]; then + qemu-img create -f qcow2 "$TMPDIR_IGN/disk.qcow2" "$DISK_SIZE" >/dev/null + echo "Created blank disk: $DISK_SIZE" >&2 + + # Patch .cmdline section in UKI to add ignition firstboot params + # Extract current cmdline from UKI PE32+ .cmdline section + ORIG_CMDLINE="$(python3 - "$UKI" <<'PYEOF' +import struct, sys +with open(sys.argv[1], 'rb') as f: + f.seek(0x3c) + pe_offset = struct.unpack('&2 + exit 1 + fi + NEW_CMDLINE="$(echo "$ORIG_CMDLINE" | tr -d '\n\r') ignition.firstboot=1 ignition.platform.id=openstack" + PATCHED_UKI="$TMPDIR_IGN/uki-firstboot.efi" + python3 - "$UKI" "$PATCHED_UKI" "$NEW_CMDLINE" <<'PYEOF' +import struct, sys, shutil + +src, dst, new_cmdline = sys.argv[1], sys.argv[2], sys.argv[3].encode('utf-8') +shutil.copy2(src, dst) + +with open(dst, 'r+b') as f: + f.seek(0x3c) + pe_offset = struct.unpack(' {raw_size}" + # Update VirtualSize in section header + f.seek(hdr_offset + 8) + f.write(struct.pack('&2 +fi + +# Build ESP directory and create FAT32 disk image +ESP_DIR="$TMPDIR_IGN/esp-root" +mkdir -p "$ESP_DIR/EFI/BOOT" +cp "$UKI_FOR_ESP" "$ESP_DIR/EFI/BOOT/BOOTX64.EFI" +hdiutil create -srcfolder "$ESP_DIR" \ + -fs "MS-DOS FAT32" -volname BOOTVOL \ + -layout NONE \ + -o "$TMPDIR_IGN/esp" >/dev/null +hdiutil convert "$TMPDIR_IGN/esp.dmg" \ + -format UDTO \ + -o "$TMPDIR_IGN/esp-raw" >/dev/null +ESP_IMG="$TMPDIR_IGN/esp-raw.cdr" +echo "Created ESP image: $ESP_IMG" >&2 + +# Copy writable OVMF vars to tmpdir (OVMF writes EFI variables to it) +cp "$OVMF_VARS" "$TMPDIR_IGN/ovmf-vars.fd" + +# Build QEMU options +# shellcheck disable=SC2054 +QEMU_OPTS=( + -machine q35 + -cpu qemu64 + -m "$MEM" + -accel tcg + -drive "if=pflash,unit=0,format=raw,readonly=on,file=$OVMF_CODE" + -drive "if=pflash,unit=1,format=raw,file=$TMPDIR_IGN/ovmf-vars.fd" + -drive "id=esp,if=none,format=raw,readonly=on,file=$ESP_IMG" + -device "virtio-blk-pci,drive=esp" + -nographic + -device virtio-net-pci,netdev=net0 +) + +if ((SSH)); then + QEMU_OPTS+=(-netdev "user,id=net0,hostfwd=tcp::2222-:22") + echo "SSH forwarding: ssh -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null admin@localhost" +else + QEMU_OPTS+=(-netdev "user,id=net0") +fi + +QEMU_OPTS+=(-cdrom "$TMPDIR_IGN/ignition-config-drive.iso") + +if [[ -n "$DISK_SIZE" ]]; then + QEMU_OPTS+=(-drive "id=disk0,if=none,format=qcow2,file=$TMPDIR_IGN/disk.qcow2") + QEMU_OPTS+=(-device "ide-hd,drive=disk0,bus=ide.1") +fi + +echo "Starting QEMU (tcg software emulation - will be slow without KVM/HVF acceleration)..." +echo "Serial console on stdio. QEMU monitor shortcuts:" +echo " Ctrl-A X quit" +echo " Ctrl-A C switch to QEMU monitor (type 'quit' or 'system_powerdown')" +if [[ -n "$DISK_SIZE" ]]; then + echo " Firstboot cmdline: $NEW_CMDLINE" +fi +echo "" + +qemu-system-x86_64 "${QEMU_OPTS[@]}" diff --git a/scripts/ignition.yaml b/scripts/ignition.yaml new file mode 100644 index 0000000..5deb97a --- /dev/null +++ b/scripts/ignition.yaml @@ -0,0 +1,49 @@ +{ + "ignition": { + "version": "3.6.0" + }, + "storage": { + "files": [ + { + "overwrite": true, + "path": "/etc/hostname", + "contents": { + "source": "data:,node001-test%0A" + }, + "mode": 420 + }, + { + "overwrite": true, + "path": "/etc/multipath.conf", + "contents": { + "compression": "", + "source": "data:;base64,ZGVmYXVsdHMgewogIHVzZXJfZnJpZW5kbHlfbmFtZXMgICAgICAgICBubwogIGZpbmRfbXVsdGlwYXRocyAgICAgICAgICAgICB5ZXMKICBwcmlvICAgICAgICAgICAgICAgICAgICAgICAgImFsdWEiCiAgcGF0aF9ncm91cGluZ19wb2xpY3kgICAgICAgIGdyb3VwX2J5X3ByaW8KICBwYXRoX3NlbGVjdG9yICAgICAgICAgICAgICAgImhpc3RvcmljYWwtc2VydmljZS10aW1lIDAiCiAgZmFpbGJhY2sgICAgICAgICAgICAgICAgICAgIGltbWVkaWF0ZQogIG5vX3BhdGhfcmV0cnkgICAgICAgICAgICAgICAxCiAgcmV0YWluX2F0dGFjaGVkX2h3X2hhbmRsZXIgIHllcwp9CgpibGFja2xpc3QgewogIHByb3RvY29sIHNjc2k6c2FzCiAgcHJvdG9jb2wgc2NzaTphdGEKICBwcm90b2NvbCBudm1lOnBjaWUKfQoKZGV2aWNlcyB7CiAgZGV2aWNlIHsKICAgIHZlbmRvciAgICAgICAgICAgICAgIk5FVEFQUCIKICAgIHByb2R1Y3QgICAgICAgICAgICAgIioiCiAgICBoYXJkd2FyZV9oYW5kbGVyICAgICIxIGFsdWEiCiAgfQp9Cg==" + }, + "mode": 420 + }, + { + "path": "/opt/persist/hugepages.env", + "mode": 420, + "contents": { + "source": "data:,NON_HUGEPAGES_PM=1000%0A" + } + }, + { + "overwrite": true, + "path": "/etc/ssh/sshd_config", + "contents": { + "source": "data:;base64,IyBEaXNhYmxlIHJvb3QgbG9naW4uClBlcm1pdFJvb3RMb2dpbiBubwoKIyBPbmx5IHVzZSB0aGUgbW9yZSBzZWN1cmUgU1NIdjIgcHJvdG9jb2wuClByb3RvY29sIDIKCiMgTm8gWDExIGZvcndhcmRpbmcuClgxMUZvcndhcmRpbmcgbm8KCiMgQ2hlY2sgcGVybWlzc2lvbnMgb2YgY29uZmlndXJhdGlvbiBmaWxlcyByZWxhdGVkIHRvIFNTSCBvbiBsb2dpbi4KU3RyaWN0TW9kZXMgeWVzCgojIERpc2FibGUgaG9zdC1iYXNlZCBhdXRoZW50aWNhdGlvbnMuCklnbm9yZVJob3N0cyB5ZXMKSG9zdGJhc2VkQXV0aGVudGljYXRpb24gbm8KCiMgU2V0IGxvZyBsZXZlbCB0byBiZSB2ZXJib3NlLgpMb2dMZXZlbCBWRVJCT1NFCgojIEVuc3VyZSB1c2FnZSBvZiBQQU0KVXNlUEFNIHllcwoKIyBEaXNhYmxlIG1lc3NhZ2Ugb2YgdGhlIGRheQpQcmludE1vdGQgbm8KCiMgQWxsb3cgY2xpZW50IHRvIHBhc3MgbG9jYWxlIGVudmlyb25tZW50IHZhcmlhYmxlcwpBY2NlcHRFbnYgTEFORwoKIyBvdmVycmlkZSBkZWZhdWx0IG9mIG5vIHN1YnN5c3RlbXMKU3Vic3lzdGVtIHNmdHAgL3Vzci9saWIvb3BlbnNzaC9zZnRwLXNlcnZlciAtZiBBVVRIUFJJViAtbCBJTkZPCgojIGF1dG9sb2dvdXQgaW5hY3RpdmUgdXNlcnMgYWZ0ZXIgMTAgbWludXRlcwpDbGllbnRBbGl2ZUludGVydmFsIDYwMApDbGllbnRBbGl2ZUNvdW50TWF4IDAKCiMgQWxsb3cgcGFzc3dvcmQgYXV0aGVudGljYXRpb24KQXV0aGVudGljYXRpb25NZXRob2RzIHBhc3N3b3JkClBhc3N3b3JkQXV0aGVudGljYXRpb24geWVzCgojIFN1cHBvcnRlZCBIb3N0S2V5IGFsZ29yaXRobXMgYnkgb3JkZXIgb2YgcHJlZmVyZW5jZS4KSG9zdEtleSAvZXRjL3NzaC9zc2hfaG9zdF9lZDI1NTE5X2tleQpIb3N0S2V5IC9ldGMvc3NoL3NzaF9ob3N0X3JzYV9rZXkK" + }, + "mode": 384 + } + ], + "links": [ + { + "path": "/etc/systemd/system/multi-user.target.wants/ssh.service", + "target": "/lib/systemd/system/ssh.service", + "hard": false, + "overwrite": true + } + ] + } +}