# ============================================================================= # Diskless fleet image build # # Builds a RHEL9-family root filesystem + fleet-generic initramfs for # PXE/HTTP-booted diskless hosts authenticating with 802.1X EAP-TLS # (TPM-held key, cert in TPM NV) and MACsec link encryption. # Root filesystem: erofs + dm-verity over a shared read-only NVMe/TCP # namespace, with a tmpfs overlay for runtime state. # # Outputs: # ${var.artifact_dir}/${var.image_version}/ # vmlinuz, initramfs.img - intermediate (consumed by ukify) # fleet-.efi - signed UKI (kernel+initramfs+cmdline+roothash) # rootfs.erofs - erofs + appended verity tree -> nvmet RO namespace # verity.json, cmdline.txt, SHA256SUMS # OCI image ${var.oci_image}:${var.image_version} (FROM scratch) # /artifacts/{fleet-.efi, rootfs.erofs, verity.json, SHA256SUMS} # = the atomic fleet release unit; deploy as initContainer / image volume # feeding the SPDK + HTTPS serving containers, pinned by digest. # # Usage: # packer init . # packer build -var iso_url=... -var iso_checksum=... . # ============================================================================= packer { required_version = ">= 1.10.0" required_plugins { qemu = { source = "github.com/hashicorp/qemu" version = ">= 1.1.0" } } } # ------------------------------------------------------------------ variables variable "iso_url" { type = string description = "RHEL 9 / Rocky 9 / Alma 9 install ISO URL or local path. The minimal ISO is sufficient: ks.cfg installs only core+sshd from it and the provisioner pulls the diskless tooling from the online repos. Defaults to the fast internal mirror (version-pinned to match iso_checksum)." default = "http://iso-server.bitasylum.org/isos/Rocky-9.8-x86_64-minimal.iso" } variable "iso_checksum" { type = string # Pinned sha256 of Rocky-9.8-x86_64-minimal.iso on the internal mirror. # When moving to a new point release, bump iso_url AND this hash together: # curl -fsSL http://iso-server.bitasylum.org/isos/Rocky--x86_64-minimal.iso | sha256sum # (or `sha256sum` the file on iso-server). Set "none" to skip verification. description = "ISO checksum as sha256: (or file:, or none)." default = "sha256:d338032cd1cdd41c67139f2f71b4c832c8e4a21943106519db9c7137df7a63d4" } variable "image_version" { type = string description = "Version stamp for the published artifact directory" default = "0.1.0" } variable "artifact_dir" { type = string description = "Where extracted boot artifacts and rootfs tar are written" default = "artifacts" } variable "nic_drivers" { type = string description = "Kernel modules for the fleet's real NICs (build VM only has virtio!)" default = "igb igc ixgbe i40e ice mlx5_core" } variable "build_password" { type = string description = "Throwaway root password used only during the Packer build (must match http/ks.cfg)" default = "packer-build-only" sensitive = true } variable "oci_image" { type = string description = "OCI image ref for the release-artifact image (FROM scratch, holds UKI + rootfs.erofs)" default = "registry.example.org/fleet-boot" } variable "oci_push" { type = bool description = "Push the release-artifact image to the registry after building" default = false } variable "disable_dot1x" { type = bool description = "TEST ONLY: bake rd.dot1x=0 into the UKI cmdline so the host boots with 802.1X + MACsec disabled (open-port NVMe-oF boot test). Leave false for any real/secure build." default = false } variable "nvmet_traddr" { type = string description = "NVMe/TCP discovery address baked into the UKI cmdline (rd.nvmf.discover). Use a per-environment DNS name resolved at boot so one UKI works everywhere; the lab serving stack's dnsmasq resolves this name to the serving host. Immutable once the UKI is signed." default = "nvme.fleet.lab" } variable "sb_key" { type = string description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM." default = "" sensitive = true } variable "sb_cert" { type = string description = "Secure Boot signing certificate (PEM) matching sb_key" default = "" } # --------------------------------------------------------------------- source source "qemu" "diskless" { iso_url = var.iso_url iso_checksum = var.iso_checksum output_directory = "output-qemu" vm_name = "diskless-build.qcow2" disk_size = "12G" format = "qcow2" accelerator = "kvm" memory = 4096 cpus = 2 headless = true vnc_bind_address = "0.0.0.0" qemuargs = [["-cpu", "host"], [ "-serial", "stdio" ]] http_directory = "http" # BIOS/isolinux boot. For an OVMF/UEFI build VM, replace with the grub # variant: ["c", "linuxefi /images/pxeboot/vmlinuz inst.text ", # "inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg", # "initrdefi /images/pxeboot/initrd.img", "boot"] boot_wait = "5s" boot_command = [ "", " inst.text inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg", "", ] ssh_username = "root" ssh_password = var.build_password ssh_timeout = "60m" shutdown_command = "shutdown -P now" } # ---------------------------------------------------------------------- build build { sources = ["source.qemu.diskless"] # --- 1. Stage the dracut module, configs, and CA placeholder ------------- # Pre-create the staging dir. Packer's directory-contents upload (a file # provisioner whose source ends in "/") requires the destination to already # exist; otherwise the uploaded tree lands outside /tmp/etc-stage and the # install steps below can't find it. provisioner "shell" { inline = ["mkdir -p /tmp/etc-stage"] } provisioner "file" { source = "files/dracut/95dot1x-macsec" destination = "/usr/lib/dracut/modules.d/" } provisioner "file" { source = "files/etc/" destination = "/tmp/etc-stage" } # --- 2. Packages, configs, diskless adaptations -------------------------- provisioner "shell" { inline = [ "set -euxo pipefail", # dhcp-client provides dhclient, which the dracut 'network-legacy' module # (and therefore the 95dot1x-macsec hook's ifup step) requires. RHEL9 # deprecated dhclient in favour of NetworkManager, so the minimal ISO # omits it; without it network-legacy fails dracut's check() and the # diskless module 'cannot be installed'. (Strategic path: port the hook to # the 'network-manager' dracut module — see CLAUDE.md bench-verify notes.) # zstd: dracut compresses the initramfs with compress="zstd" # (90-diskless.conf); the minimal ISO lacks the zstd binary, so without it # dracut warns and silently falls back to gzip (a larger initramfs). "dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network dhcp-client zstd openssl", # Stage etc files into place "install -D -m0644 /tmp/etc-stage/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem", "install -D -m0644 /tmp/etc-stage/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf", "install -D -m0644 /tmp/etc-stage/dracut.conf.d/90-diskless.conf /etc/dracut.conf.d/90-diskless.conf", "install -D -m0644 /tmp/etc-stage/NetworkManager/99-dot1x-unmanaged.conf /etc/NetworkManager/conf.d/99-dot1x-unmanaged.conf", "chmod 0755 /usr/lib/dracut/modules.d/95dot1x-macsec/*.sh", # Inject the fleet NIC drivers into the dracut config "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", # ---- Diskless adaptations ------------------------------------------ # Per-host machine-id is generated at boot into the overlay "truncate -s 0 /etc/machine-id", # Read-only erofs/verity root; runtime state on a tmpfs overlay provided # by systemd.volatile=overlay (set in the cmdline by extract-artifacts.sh). # RHEL/Rocky dracut has no 'overlayfs'/'rootovl' module. "printf '%s\\n' '# diskless: root is provided by netroot; no local mounts' > /etc/fstab", # No local bootloader regeneration wanted on kernel updates inside CI "command -v grub2-mkconfig >/dev/null && echo 'GRUB updates not used on diskless hosts' || true", # ---- Build the fleet-generic initramfs ------------------------------ # %%{ escapes the rpm query tags so Packer's HCL template engine treats # them as literals (a bare %{...} is parsed as a template directive). "KVER=$(rpm -q --qf '%%{VERSION}-%%{RELEASE}.%%{ARCH}\\n' kernel | sort -V | tail -1)", "dracut -f --no-hostonly --kver \"$KVER\" /boot/initramfs-generic.img", "lsinitrd /boot/initramfs-generic.img | grep -E 'macsec|wpa_supplicant|tpm2_nvread|nvme.tcp|erofs|veritysetup' ", # ---- Build hygiene --------------------------------------------------- "dnf clean all", "rm -rf /tmp/etc-stage /var/log/anaconda /root/anaconda-ks.cfg", "truncate -s 0 /var/log/messages /var/log/secure 2>/dev/null || true", ] } # --- 3. Extract kernel, initramfs, rootfs from the built disk ------------ # --- 4. Assemble + sign the UKI; build the OCI release-artifact image ---- post-processor "shell-local" { inline = [ "NVMET_TRADDR='${var.nvmet_traddr}' bash scripts/extract-artifacts.sh output-qemu/diskless-build.qcow2 ${var.artifact_dir}/${var.image_version} ${var.disable_dot1x}", "bash scripts/build-oci.sh ${var.artifact_dir}/${var.image_version} ${var.oci_image} ${var.image_version} '${var.sb_key}' '${var.sb_cert}' ${var.oci_push}", ] } }