From cd5e549039b17add0a2ce09713e1a034bb3efccf Mon Sep 17 00:00:00 2001
From: Noel Georgi <git@frezbo.dev>
Date: Fri, 24 Jan 2025 19:57:08 +0530
Subject: [PATCH] feat: generate iso's with both UKI and grub

Starting with Talos 1.10, the default generated ISO's will use GRUB for
BIOS boot and sd-boot for EFI boot.

Fixes: #10192

Signed-off-by: Noel Georgi <git@frezbo.dev>
---
 .github/workflows/ci.yaml                     |  12 ++-
 .../workflows/integration-misc-2-cron.yaml    |  12 ++-
 .kres.yaml                                    |  10 ++
 Makefile                                      |   2 +-
 hack/release.toml                             |   6 ++
 hack/test/e2e-iso.sh                          |   4 +-
 hack/test/e2e-qemu.sh                         |   1 +
 pkg/imager/imager.go                          |   4 +-
 pkg/imager/iso/grub.go                        |  82 ++++----------
 pkg/imager/iso/hybrid.go                      |  43 ++++++++
 pkg/imager/iso/iso.go                         |  84 ++++++++++++++-
 pkg/imager/iso/uefi.go                        | 102 +++++++++---------
 pkg/imager/out.go                             |  53 +++++++--
 pkg/provision/providers/qemu/launch.go        |   1 +
 pkg/provision/providers/qemu/node.go          |   2 +-
 15 files changed, 284 insertions(+), 134 deletions(-)
 create mode 100644 pkg/imager/iso/hybrid.go

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index a81677fceb..6c71f0e12b 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -1,6 +1,6 @@
 # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
 #
-# Generated on 2025-01-22T17:37:55Z by kres 3075de9.
+# Generated on 2025-01-24T14:30:35Z by kres 3075de9.
 
 name: default
 concurrency:
@@ -2199,6 +2199,16 @@ jobs:
           WITH_UEFI: "false"
         run: |
           sudo -E make e2e-qemu
+      - name: e2e-bios-iso
+        env:
+          GITHUB_STEP_NAME: ${{ github.job}}-e2e-bios-iso
+          IMAGE_REGISTRY: registry.dev.siderolabs.io
+          SHORT_INTEGRATION_TEST: "yes"
+          VIA_MAINTENANCE_MODE: "true"
+          WITH_ISO: "true"
+          WITH_UEFI: "false"
+        run: |
+          sudo -E make e2e-qemu
       - name: e2e-disk-image
         env:
           GITHUB_STEP_NAME: ${{ github.job}}-e2e-disk-image
diff --git a/.github/workflows/integration-misc-2-cron.yaml b/.github/workflows/integration-misc-2-cron.yaml
index b2eab7265a..52a098ffde 100644
--- a/.github/workflows/integration-misc-2-cron.yaml
+++ b/.github/workflows/integration-misc-2-cron.yaml
@@ -1,6 +1,6 @@
 # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
 #
-# Generated on 2024-12-18T13:55:17Z by kres b9507d6.
+# Generated on 2025-01-24T14:30:35Z by kres 3075de9.
 
 name: integration-misc-2-cron
 concurrency:
@@ -99,6 +99,16 @@ jobs:
           WITH_UEFI: "false"
         run: |
           sudo -E make e2e-qemu
+      - name: e2e-bios-iso
+        env:
+          GITHUB_STEP_NAME: ${{ github.job}}-e2e-bios-iso
+          IMAGE_REGISTRY: registry.dev.siderolabs.io
+          SHORT_INTEGRATION_TEST: "yes"
+          VIA_MAINTENANCE_MODE: "true"
+          WITH_ISO: "true"
+          WITH_UEFI: "false"
+        run: |
+          sudo -E make e2e-qemu
       - name: e2e-disk-image
         env:
           GITHUB_STEP_NAME: ${{ github.job}}-e2e-disk-image
diff --git a/.kres.yaml b/.kres.yaml
index 94116f41ef..4ccff8762e 100644
--- a/.kres.yaml
+++ b/.kres.yaml
@@ -838,6 +838,16 @@ spec:
             SHORT_INTEGRATION_TEST: yes
             WITH_UEFI: false
             IMAGE_REGISTRY: registry.dev.siderolabs.io
+        - name: e2e-bios-iso
+          command: e2e-qemu
+          withSudo: true
+          environment:
+            GITHUB_STEP_NAME: ${{ github.job}}-e2e-bios-iso
+            SHORT_INTEGRATION_TEST: yes
+            WITH_UEFI: false
+            VIA_MAINTENANCE_MODE: true
+            WITH_ISO: true
+            IMAGE_REGISTRY: registry.dev.siderolabs.io
         - name: e2e-disk-image
           command: e2e-qemu
           withSudo: true
diff --git a/Makefile b/Makefile
index d35ccbce6f..6812798cb3 100644
--- a/Makefile
+++ b/Makefile
@@ -456,7 +456,7 @@ secureboot-iso: image-secureboot-iso ## Builds UEFI only ISO which uses UKI and
 
 .PHONY: secureboot-installer
 secureboot-installer: ## Builds UEFI only installer which uses UKI and push it to the registry.
-	@$(MAKE) image-secureboot-installer IMAGER_ARGS="--base-installer-image $(REGISTRY_AND_USERNAME)/installer:$(IMAGE_TAG) $(IMAGER_ARGS)"
+	@$(MAKE) image-secureboot-installer IMAGER_ARGS="--base-installer-image $(REGISTRY_AND_USERNAME)/installer:$(IMAGE_TAG) --extra-kernel-arg=console=ttyS0 $(IMAGER_ARGS)"
 	@for platform in $(subst $(,),$(space),$(PLATFORM)); do \
 		arch=$$(basename "$${platform}") && \
 		crane push $(ARTIFACTS)/installer-$${arch}-secureboot.tar $(REGISTRY_AND_USERNAME)/installer:$(IMAGE_TAG)-$${arch}-secureboot ; \
diff --git a/hack/release.toml b/hack/release.toml
index 5eed3869e1..afcfeb4d4e 100644
--- a/hack/release.toml
+++ b/hack/release.toml
@@ -92,6 +92,12 @@ The iqn can be read by `talosctl read /etc/iscsi/initiatorname.iscsi`
 Talos now generates `/etc/nvme/hostnqn` and `/etc/nvme/hostid` files based on the node identity which is tied to the lifecycle of the node.
 
 The NQN can be read by `talosctl read /etc/nvme/hostnqn`
+"""
+
+    [notes.iso]
+        title = "ISO"
+        description = """\
+Talos starting with 1.10 will have ISO's that will use GRUB only for legacy BIOS and systemd-boot for modern UEFI systems.
 """
 
 [make_deps]
diff --git a/hack/test/e2e-iso.sh b/hack/test/e2e-iso.sh
index 1caf0f36ce..8d0b91aa79 100755
--- a/hack/test/e2e-iso.sh
+++ b/hack/test/e2e-iso.sh
@@ -9,6 +9,8 @@ CLUSTER_NAME=e2e-iso
 
 NODE="172.20.2.2"
 
+INSTALLER_IMAGE=${INSTALLER_IMAGE}-amd64-secureboot # we don't use secureboot part here, but this installer contains UKIs
+
 function create_cluster {
   build_registry_mirrors
 
@@ -24,7 +26,7 @@ function create_cluster {
     --cpus=2.0 \
     --cidr=172.20.2.0/24 \
     --with-apply-config \
-    --install-image=${REGISTRY:-ghcr.io}/siderolabs/installer:${TAG} \
+    --install-image="${INSTALLER_IMAGE}" \
     --cni-bundle-url=${ARTIFACTS}/talosctl-cni-bundle-'${ARCH}'.tar.gz \
     "${REGISTRY_MIRROR_FLAGS[@]}"
 
diff --git a/hack/test/e2e-qemu.sh b/hack/test/e2e-qemu.sh
index 3b52dec940..1758d18d2d 100755
--- a/hack/test/e2e-qemu.sh
+++ b/hack/test/e2e-qemu.sh
@@ -135,6 +135,7 @@ case "${WITH_ISO:-false}" in
   false)
     ;;
   *)
+    INSTALLER_IMAGE=${INSTALLER_IMAGE}-amd64-secureboot # we don't use secureboot part here, but this installer contains UKIs
     QEMU_FLAGS+=("--iso-path=${ARTIFACTS}/metal-amd64.iso")
     ;;
 esac
diff --git a/pkg/imager/imager.go b/pkg/imager/imager.go
index 4bbbc88cfa..fc26be3df2 100644
--- a/pkg/imager/imager.go
+++ b/pkg/imager/imager.go
@@ -111,9 +111,9 @@ func (i *Imager) Execute(ctx context.Context, outputPath string, report *reporte
 		if !needBuildUKI {
 			return "", fmt.Errorf("UKI output is not supported in this Talos version")
 		}
-	case profile.OutKindISO, profile.OutKindImage:
+	case profile.OutKindImage:
 		needBuildUKI = needBuildUKI && i.prof.SecureBootEnabled()
-	case profile.OutKindInstaller:
+	case profile.OutKindISO, profile.OutKindInstaller:
 		needBuildUKI = needBuildUKI || quirks.New(i.prof.Version).UseSDBootForUEFI()
 	case profile.OutKindCmdline, profile.OutKindKernel, profile.OutKindInitramfs:
 		needBuildUKI = false
diff --git a/pkg/imager/iso/grub.go b/pkg/imager/iso/grub.go
index 12de88a823..c125a4a6e7 100644
--- a/pkg/imager/iso/grub.go
+++ b/pkg/imager/iso/grub.go
@@ -7,44 +7,28 @@ package iso
 import (
 	"bytes"
 	_ "embed"
-	"fmt"
 	"os"
 	"path/filepath"
 	"text/template"
-	"time"
-
-	"github.com/siderolabs/go-cmd/pkg/cmd"
 
 	"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub"
 	"github.com/siderolabs/talos/pkg/imager/utils"
 	"github.com/siderolabs/talos/pkg/machinery/imager/quirks"
 )
 
-// GRUBOptions described the input for the CreateGRUB function.
-type GRUBOptions struct {
-	KernelPath    string
-	InitramfsPath string
-	Cmdline       string
-	Version       string
-
-	ScratchDir string
-
-	OutPath string
-}
-
 //go:embed grub.cfg
 var grubCfgTemplate string
 
 // CreateGRUB creates a GRUB-based ISO image.
 //
 // This iso supports both BIOS and UEFI booting.
-func CreateGRUB(printf func(string, ...any), options GRUBOptions) error {
+func (options Options) CreateGRUB(printf func(string, ...any)) (Generator, error) {
 	if err := utils.CopyFiles(
 		printf,
 		utils.SourceDestination(options.KernelPath, filepath.Join(options.ScratchDir, "boot", "vmlinuz")),
 		utils.SourceDestination(options.InitramfsPath, filepath.Join(options.ScratchDir, "boot", "initramfs.xz")),
 	); err != nil {
-		return err
+		return nil, err
 	}
 
 	printf("creating grub.cfg")
@@ -57,7 +41,7 @@ func CreateGRUB(printf func(string, ...any), options GRUBOptions) error {
 		}).
 		Parse(grubCfgTemplate)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	if err = tmpl.Execute(&grubCfg, struct {
@@ -67,64 +51,34 @@ func CreateGRUB(printf func(string, ...any), options GRUBOptions) error {
 		Cmdline:        options.Cmdline,
 		AddResetOption: quirks.New(options.Version).SupportsResetGRUBOption(),
 	}); err != nil {
-		return err
+		return nil, err
 	}
 
 	cfgPath := filepath.Join(options.ScratchDir, "boot/grub/grub.cfg")
 
 	if err = os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
-		return err
+		return nil, err
 	}
 
 	if err = os.WriteFile(cfgPath, grubCfg.Bytes(), 0o666); err != nil {
-		return err
+		return nil, err
 	}
 
 	if err = utils.TouchFiles(printf, options.ScratchDir); err != nil {
-		return err
+		return nil, err
 	}
 
 	printf("creating ISO image")
 
-	return grubMkrescue(options)
-}
-
-func grubMkrescue(options GRUBOptions) error {
-	args := []string{
-		"--compress=xz",
-		"--output=" + options.OutPath,
-		"--verbose",
-		options.ScratchDir,
-		"--",
-	}
-
-	if epoch, ok, err := utils.SourceDateEpoch(); err != nil {
-		return err
-	} else if ok {
-		// set EFI FAT image serial number
-		if err := os.Setenv("GRUB_FAT_SERIAL_NUMBER", fmt.Sprintf("%x", uint32(epoch))); err != nil {
-			return err
-		}
-
-		args = append(args,
-			"-volume_date", "all_file_dates", fmt.Sprintf("=%d", epoch),
-			"-volume_date", "uuid", time.Unix(epoch, 0).Format("2006010215040500"),
-		)
-	}
-
-	if quirks.New(options.Version).SupportsISOLabel() {
-		label := Label(options.Version, false)
-
-		args = append(args,
-			"-volid", VolumeID(label),
-			"-volset-id", label,
-		)
-	}
-
-	_, err := cmd.Run("grub-mkrescue", args...)
-	if err != nil {
-		return fmt.Errorf("failed to create ISO: %w", err)
-	}
-
-	return nil
+	return &ExecutorOptions{
+		Command: "grub-mkrescue",
+		Version: options.Version,
+		Arguments: []string{
+			"--compress=xz",
+			"--output=" + options.OutPath,
+			"--verbose",
+			options.ScratchDir,
+			"--",
+		},
+	}, nil
 }
diff --git a/pkg/imager/iso/hybrid.go b/pkg/imager/iso/hybrid.go
new file mode 100644
index 0000000000..29c8c4f44b
--- /dev/null
+++ b/pkg/imager/iso/hybrid.go
@@ -0,0 +1,43 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package iso
+
+import "path/filepath"
+
+// CreateHybrid creates an ISO image that supports both BIOS and UEFI booting.
+func (options Options) CreateHybrid(printf func(string, ...any)) (Generator, error) {
+	if _, err := options.CreateGRUB(printf); err != nil {
+		return nil, err
+	}
+
+	if _, err := options.CreateUEFI(printf); err != nil {
+		return nil, err
+	}
+
+	efiBootImg := filepath.Join(options.ScratchDir, "efiboot.img")
+
+	return &ExecutorOptions{
+		Command: "grub-mkrescue",
+		Version: options.Version,
+		Arguments: []string{
+			"--compress=xz",
+			"--output=" + options.OutPath,
+			"--verbose",
+			"--directory=/usr/lib/grub/i386-pc", // only for BIOS boot
+			"-m", "efiboot.img",                 // exclude the EFI boot image from the ISO
+			options.ScratchDir,
+			"-eltorito-alt-boot",
+			"-e", "--interval:appended_partition_2:all::", // use appended partition 2 for EFI
+			"-append_partition", "2", "0xef", efiBootImg,
+			"-appended_part_as_gpt",
+			"-partition_cyl_align", // pad partition to cylinder boundary
+			"all",
+			"-partition_offset", "16", // support booting from USB
+			"-iso_mbr_part_type", "0x83", // just to have more clear info when doing a fdisk -l
+			"-no-emul-boot",
+			"--",
+		},
+	}, nil
+}
diff --git a/pkg/imager/iso/iso.go b/pkg/imager/iso/iso.go
index 076fea6ddd..5c10c6cdfa 100644
--- a/pkg/imager/iso/iso.go
+++ b/pkg/imager/iso/iso.go
@@ -5,7 +5,17 @@
 // Package iso contains functions for creating ISO images.
 package iso
 
-import "strings"
+import (
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/siderolabs/go-cmd/pkg/cmd"
+
+	"github.com/siderolabs/talos/pkg/imager/utils"
+	"github.com/siderolabs/talos/pkg/machinery/imager/quirks"
+)
 
 // VolumeID returns a valid volume ID for the given label.
 func VolumeID(label string) string {
@@ -41,3 +51,75 @@ func Label(version string, secureboot bool) string {
 
 	return label + version
 }
+
+// ExecutorOptions defines the iso generation options.
+type ExecutorOptions struct {
+	Command   string
+	Version   string
+	Arguments []string
+}
+
+// Generator is an interface for executing the iso generation.
+type Generator interface {
+	Generate() error
+}
+
+// Options describe the input generating different types of ISOs.
+type Options struct {
+	KernelPath    string
+	InitramfsPath string
+	Cmdline       string
+
+	UKIPath    string
+	SDBootPath string
+
+	Arch    string
+	Version string
+
+	// A value in loader.conf secure-boot-enroll: off, manual, if-safe, force.
+	SDBootSecureBootEnrollKeys string
+
+	// UKISigningCertDer is the DER encoded UKI signing certificate.
+	UKISigningCertDerPath string
+
+	// optional, for auto-enrolling secureboot keys
+	PlatformKeyPath    string
+	KeyExchangeKeyPath string
+	SignatureKeyPath   string
+
+	ScratchDir string
+	OutPath    string
+}
+
+// Generate creates an ISO image.
+func (e *ExecutorOptions) Generate() error {
+	if epoch, ok, err := utils.SourceDateEpoch(); err != nil {
+		return err
+	} else if ok {
+		// set EFI FAT image serial number
+		if err := os.Setenv("GRUB_FAT_SERIAL_NUMBER", fmt.Sprintf("%x", uint32(epoch))); err != nil {
+			return err
+		}
+
+		e.Arguments = append(e.Arguments,
+			"-volume_date", "all_file_dates", fmt.Sprintf("=%d", epoch),
+			"-volume_date", "uuid", time.Unix(epoch, 0).Format("2006010215040500"),
+		)
+	}
+
+	if quirks.New(e.Version).SupportsISOLabel() {
+		label := Label(e.Version, false)
+
+		e.Arguments = append(e.Arguments,
+			"-volid", VolumeID(label),
+			"-volset-id", label,
+		)
+	}
+
+	_, err := cmd.Run(e.Command, e.Arguments...)
+	if err != nil {
+		return fmt.Errorf("failed to create ISO: %w", err)
+	}
+
+	return nil
+}
diff --git a/pkg/imager/iso/uefi.go b/pkg/imager/iso/uefi.go
index 15418a1a3c..47a5ead614 100644
--- a/pkg/imager/iso/uefi.go
+++ b/pkg/imager/iso/uefi.go
@@ -17,7 +17,6 @@ import (
 
 	"github.com/siderolabs/talos/pkg/imager/utils"
 	"github.com/siderolabs/talos/pkg/machinery/constants"
-	"github.com/siderolabs/talos/pkg/machinery/imager/quirks"
 	"github.com/siderolabs/talos/pkg/makefs"
 )
 
@@ -57,9 +56,9 @@ var loaderConfigTemplate string
 // The ISO created supports only booting in UEFI mode, and supports SecureBoot.
 //
 //nolint:gocyclo,cyclop
-func CreateUEFI(printf func(string, ...any), options UEFIOptions) error {
+func (options Options) CreateUEFI(printf func(string, ...any)) (Generator, error) {
 	if err := os.MkdirAll(options.ScratchDir, 0o755); err != nil {
-		return err
+		return nil, err
 	}
 
 	printf("preparing raw image")
@@ -75,14 +74,14 @@ func CreateUEFI(printf func(string, ...any), options UEFIOptions) error {
 	} {
 		st, err := os.Stat(path)
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		isoSize += (st.Size() + mib - 1) / mib * mib
 	}
 
 	if err := utils.CreateRawDisk(printf, efiBootImg, isoSize); err != nil {
-		return err
+		return nil, err
 	}
 
 	printf("preparing loader.conf")
@@ -94,7 +93,7 @@ func CreateUEFI(printf func(string, ...any), options UEFIOptions) error {
 	}{
 		SecureBootEnroll: options.SDBootSecureBootEnrollKeys,
 	}); err != nil {
-		return fmt.Errorf("error rendering loader.conf: %w", err)
+		return nil, fmt.Errorf("error rendering loader.conf: %w", err)
 	}
 
 	printf("creating vFAT EFI image")
@@ -105,23 +104,19 @@ func CreateUEFI(printf func(string, ...any), options UEFIOptions) error {
 	}
 
 	if err := makefs.VFAT(efiBootImg, fopts...); err != nil {
-		return err
+		return nil, err
 	}
 
 	if err := os.MkdirAll(filepath.Join(options.ScratchDir, "EFI/Linux"), 0o755); err != nil {
-		return err
+		return nil, err
 	}
 
 	if err := os.MkdirAll(filepath.Join(options.ScratchDir, "EFI/BOOT"), 0o755); err != nil {
-		return err
+		return nil, err
 	}
 
-	if err := os.MkdirAll(filepath.Join(options.ScratchDir, "EFI/keys"), 0o755); err != nil {
-		return err
-	}
-
-	if err := os.MkdirAll(filepath.Join(options.ScratchDir, "loader/keys/auto"), 0o755); err != nil {
-		return err
+	if err := os.MkdirAll(filepath.Join(options.ScratchDir, "loader"), 0o755); err != nil {
+		return nil, err
 	}
 
 	efiBootPath := "EFI/BOOT/BOOTX64.EFI"
@@ -131,36 +126,48 @@ func CreateUEFI(printf func(string, ...any), options UEFIOptions) error {
 	}
 
 	if err := copy.File(options.SDBootPath, filepath.Join(options.ScratchDir, efiBootPath)); err != nil {
-		return err
+		return nil, err
 	}
 
 	if err := copy.File(options.UKIPath, filepath.Join(options.ScratchDir, fmt.Sprintf("EFI/Linux/Talos-%s.efi", options.Version))); err != nil {
-		return err
+		return nil, err
 	}
 
 	if err := os.WriteFile(filepath.Join(options.ScratchDir, "loader/loader.conf"), loaderConfigOut.Bytes(), 0o644); err != nil {
-		return err
+		return nil, err
+	}
+
+	if options.UKISigningCertDerPath != "" {
+		if err := os.MkdirAll(filepath.Join(options.ScratchDir, "EFI/keys"), 0o755); err != nil {
+			return nil, err
+		}
+
+		if err := copy.File(options.UKISigningCertDerPath, filepath.Join(options.ScratchDir, "EFI/keys/uki-signing-cert.der")); err != nil {
+			return nil, err
+		}
 	}
 
-	if err := copy.File(options.UKISigningCertDerPath, filepath.Join(options.ScratchDir, "EFI/keys/uki-signing-cert.der")); err != nil {
-		return err
+	if options.PlatformKeyPath != "" || options.KeyExchangeKeyPath != "" || options.SignatureKeyPath != "" {
+		if err := os.MkdirAll(filepath.Join(options.ScratchDir, "loader/keys/auto"), 0o755); err != nil {
+			return nil, err
+		}
 	}
 
 	if options.PlatformKeyPath != "" {
 		if err := copy.File(options.PlatformKeyPath, filepath.Join(options.ScratchDir, "loader/keys/auto", constants.PlatformKeyAsset)); err != nil {
-			return err
+			return nil, err
 		}
 	}
 
 	if options.KeyExchangeKeyPath != "" {
 		if err := copy.File(options.KeyExchangeKeyPath, filepath.Join(options.ScratchDir, "loader/keys/auto", constants.KeyExchangeKeyAsset)); err != nil {
-			return err
+			return nil, err
 		}
 	}
 
 	if options.SignatureKeyPath != "" {
 		if err := copy.File(options.SignatureKeyPath, filepath.Join(options.ScratchDir, "loader/keys/auto", constants.SignatureKeyAsset)); err != nil {
-			return err
+			return nil, err
 		}
 	}
 
@@ -176,42 +183,31 @@ func CreateUEFI(printf func(string, ...any), options UEFIOptions) error {
 		filepath.Join(options.ScratchDir, "loader"),
 		"::",
 	); err != nil {
-		return err
+		return nil, err
 	}
 
 	// fixup directory timestamps recursively
 	if err := utils.TouchFiles(printf, options.ScratchDir); err != nil {
-		return err
+		return nil, err
 	}
 
 	printf("creating ISO image")
 
-	// ref: https://askubuntu.com/questions/1110651/how-to-produce-an-iso-image-that-boots-only-on-uefi/1111760#1111760
-	args := []string{
-		"-e", "--interval:appended_partition_2:all::", // use appended partition 2 for EFI
-		"-append_partition", "2", "0xef", efiBootImg,
-		"-partition_cyl_align", // pad partition to cylinder boundary
-		"all",
-		"-partition_offset", "16", // support booting from USB
-		"-iso_mbr_part_type", "0x83", // just to have more clear info when doing a fdisk -l
-		"-no-emul-boot",
-		"-m", "efiboot.img", // exclude the EFI boot image from the ISO
-		"-o", options.OutPath,
-		options.ScratchDir,
-	}
-
-	if quirks.New(options.Version).SupportsISOLabel() {
-		label := Label(options.Version, true)
-
-		args = append(args,
-			"-volid", VolumeID(label),
-			"-volset", label,
-		)
-	}
-
-	if _, err := cmd.Run("xorrisofs", args...); err != nil {
-		return err
-	}
-
-	return nil
+	return &ExecutorOptions{
+		Command: "xorrisofs",
+		Version: options.Version,
+		Arguments: []string{
+			"-e", "--interval:appended_partition_2:all::", // use appended partition 2 for EFI
+			"-append_partition", "2", "0xef", efiBootImg,
+			"-partition_cyl_align", // pad partition to cylinder boundary
+			"all",
+			"-partition_offset", "16", // support booting from USB
+			"-iso_mbr_part_type", "0x83", // just to have more clear info when doing a fdisk -l
+			"-no-emul-boot",
+			"-m", "efiboot.img", // exclude the EFI boot image from the ISO
+			"-o", options.OutPath,
+			options.ScratchDir,
+			"--",
+		},
+	}, nil
 }
diff --git a/pkg/imager/out.go b/pkg/imager/out.go
index 92485feb4a..9da778caec 100644
--- a/pkg/imager/out.go
+++ b/pkg/imager/out.go
@@ -83,7 +83,7 @@ func (i *Imager) outCmdline(path string) error {
 	return os.WriteFile(path, []byte(i.cmdline), 0o644)
 }
 
-//nolint:gocyclo
+//nolint:gocyclo,cyclop
 func (i *Imager) outISO(ctx context.Context, path string, report *reporter.Reporter) error {
 	printf := progressPrintf(report, reporter.Update{Message: "building ISO...", Status: reporter.StatusRunning})
 
@@ -104,7 +104,10 @@ func (i *Imager) outISO(ctx context.Context, path string, report *reporter.Repor
 		}
 	}
 
-	if i.prof.SecureBootEnabled() {
+	var generator iso.Generator
+
+	switch {
+	case i.prof.SecureBootEnabled():
 		isoOptions := pointer.SafeDeref(i.prof.Output.ISOOptions)
 
 		var signer pesign.CertificateSigner
@@ -120,7 +123,7 @@ func (i *Imager) outISO(ctx context.Context, path string, report *reporter.Repor
 			return fmt.Errorf("failed to write uki.der: %w", err)
 		}
 
-		options := iso.UEFIOptions{
+		options := iso.Options{
 			UKIPath:    i.ukiPath,
 			SDBootPath: i.sdBootPath,
 
@@ -179,20 +182,52 @@ func (i *Imager) outISO(ctx context.Context, path string, report *reporter.Repor
 			options.SignatureKeyPath = i.prof.Input.SecureBoot.SignatureKeyPath
 		}
 
-		err = iso.CreateUEFI(printf, options)
-	} else {
-		err = iso.CreateGRUB(printf, iso.GRUBOptions{
+		generator, err = options.CreateUEFI(printf)
+		if err != nil {
+			return err
+		}
+	case quirks.New(i.prof.Version).UseSDBootForUEFI():
+		options := iso.Options{
 			KernelPath:    i.prof.Input.Kernel.Path,
 			InitramfsPath: i.initramfsPath,
 			Cmdline:       i.cmdline,
-			Version:       i.prof.Version,
+
+			UKIPath:    i.ukiPath,
+			SDBootPath: i.sdBootPath,
+
+			SDBootSecureBootEnrollKeys: "off",
+
+			Arch:    i.prof.Arch,
+			Version: i.prof.Version,
 
 			ScratchDir: scratchSpace,
 			OutPath:    path,
-		})
+		}
+
+		generator, err = options.CreateHybrid(printf)
+		if err != nil {
+			return err
+		}
+	default:
+		options := iso.Options{
+			KernelPath:    i.prof.Input.Kernel.Path,
+			InitramfsPath: i.initramfsPath,
+			Cmdline:       i.cmdline,
+
+			Arch:    i.prof.Arch,
+			Version: i.prof.Version,
+
+			ScratchDir: scratchSpace,
+			OutPath:    path,
+		}
+
+		generator, err = options.CreateGRUB(printf)
+		if err != nil {
+			return err
+		}
 	}
 
-	if err != nil {
+	if err := generator.Generate(); err != nil {
 		return err
 	}
 
diff --git a/pkg/provision/providers/qemu/launch.go b/pkg/provision/providers/qemu/launch.go
index f0fb053b6f..3073908bdc 100644
--- a/pkg/provision/providers/qemu/launch.go
+++ b/pkg/provision/providers/qemu/launch.go
@@ -319,6 +319,7 @@ func launchVM(config *LaunchConfig) error {
 		"-no-reboot",
 		"-boot", fmt.Sprintf("order=%s,reboot-timeout=5000", bootOrder),
 		"-smbios", fmt.Sprintf("type=1,uuid=%s", config.NodeUUID),
+		"-smbios", "type=11,value=io.systemd.stub.kernel-cmdline-extra=console=ttyS0",
 		"-chardev", fmt.Sprintf("socket,path=%s/%s.sock,server=on,wait=off,id=qga0", config.StatePath, config.Hostname),
 		"-device", "virtio-serial",
 		"-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0",
diff --git a/pkg/provision/providers/qemu/node.go b/pkg/provision/providers/qemu/node.go
index 9bdb8134ff..f12027d3bf 100644
--- a/pkg/provision/providers/qemu/node.go
+++ b/pkg/provision/providers/qemu/node.go
@@ -132,7 +132,7 @@ func (p *provisioner) createNode(state *vm.State, clusterReq provision.ClusterRe
 		return provision.NodeInfo{}, fmt.Errorf("error finding listen address for the API: %w", err)
 	}
 
-	defaultBootOrder := "cn"
+	defaultBootOrder := "cd"
 	if nodeReq.DefaultBootOrder != "" {
 		defaultBootOrder = nodeReq.DefaultBootOrder
 	}