From af48b9d06180debf4114a5a54cff1f17b1a36469 Mon Sep 17 00:00:00 2001
From: Aravind Sivamani <asivaman@cisco.com>
Date: Mon, 13 Jul 2026 14:10:23 +0200
Subject: [RFC PATCH guestfs-tools] builder: templates: Add Ubuntu 24.04 cloud
 image support

Import a pinned Canonical Noble cloud image when the legacy Debian
Installer tree is unavailable.  Verify its SHA-256 before conversion,
reuse the existing template cleanup and index pipeline, and add focused
tests and documentation.
---
 builder/templates/Makefile.am                 |  12 +-
 builder/templates/README-ubuntu-cloud.md      |  67 ++++
 builder/templates/make-template.ml            | 363 ++++++++++++------
 builder/templates/prepare-cloud-image.sh      |  61 +++
 .../test-ubuntu-cloud-image-import.sh         |  93 +++++
 builder/templates/test-ubuntu-cloud-images.sh |  46 +++
 builder/templates/ubuntu-cloud-images         |   8 +
 7 files changed, 536 insertions(+), 114 deletions(-)
 create mode 100644 builder/templates/README-ubuntu-cloud.md
 create mode 100755 builder/templates/prepare-cloud-image.sh
 create mode 100755 builder/templates/test-ubuntu-cloud-image-import.sh
 create mode 100755 builder/templates/test-ubuntu-cloud-images.sh
 create mode 100644 builder/templates/ubuntu-cloud-images

diff --git a/builder/templates/Makefile.am b/builder/templates/Makefile.am
index b4d06357..371bc605 100644
--- a/builder/templates/Makefile.am
+++ b/builder/templates/Makefile.am
@@ -1,5 +1,7 @@
 # libguestfs virt-builder tool
 # Copyright (C) 2013-2025 Red Hat Inc.
+# Copyright 2026 Cisco Systems, Inc. and its affiliates
+# SPDX-License-Identifier: GPL-2.0-or-later
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -23,8 +25,13 @@ EXTRA_DIST = \
 	$(index_fragments) \
 	*.ks \
 	*.virt-install-cmd \
+	README-ubuntu-cloud.md \
 	debian.preseed \
 	make-template.ml \
+	prepare-cloud-image.sh \
+	test-ubuntu-cloud-image-import.sh \
+	test-ubuntu-cloud-images.sh \
+	ubuntu-cloud-images \
 	ubuntu.preseed \
 	validate.sh
 
@@ -42,7 +49,10 @@ index: $(index_fragments)
 TESTS_ENVIRONMENT = \
 	top_srcdir="$(top_srcdir)" \
 	$(top_builddir)/run --test
-TESTS = validate.sh
+TESTS = \
+	test-ubuntu-cloud-image-import.sh \
+	test-ubuntu-cloud-images.sh \
+	validate.sh
 
 check-valgrind:
 	$(MAKE) VG="@VG@" check
diff --git a/builder/templates/README-ubuntu-cloud.md b/builder/templates/README-ubuntu-cloud.md
new file mode 100644
index 00000000..f2b5139b
--- /dev/null
+++ b/builder/templates/README-ubuntu-cloud.md
@@ -0,0 +1,67 @@
+<!--
+Copyright 2026 Cisco Systems, Inc. and its affiliates
+SPDX-License-Identifier: GPL-2.0-or-later
+-->
+
+# Ubuntu cloud-image templates
+
+Ubuntu 24.04 does not publish the Debian Installer tree used by the historical
+`virt-install` and preseed workflow in `make-template.ml`.  It therefore uses
+a pinned Canonical cloud image as its input.
+
+The generator downloads a dated QCOW2 image, verifies its pinned SHA-256,
+converts it to sparse raw format, and then reuses the existing inspection,
+post-installation, sysprep, sparsify, compression, and index-generation path.
+After the general sysprep pass, it explicitly runs the machine-ID operation
+last because sysprep's late customization step can populate an empty
+`/etc/machine-id`.  The downloaded image is not committed to this repository.
+
+The offline import helper test covers digest rejection, payload-preserving
+QCOW2-to-raw conversion, and conversion cleanup.  It does not replace the
+full generator, inspection, sysprep, index, firmware, or boot validation.
+
+## Updating the pinned image
+
+1. Select a dated release from <https://cloud-images.ubuntu.com/releases/>.
+2. Download the image, `SHA256SUMS`, and `SHA256SUMS.gpg` from the same
+   directory.
+3. Verify the signed checksum file using the Ubuntu cloud-image keyring and
+   then verify the image:
+
+       gpgv --keyring /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg \
+         SHA256SUMS.gpg SHA256SUMS
+       grep ' \*ubuntu-24.04-server-cloudimg-amd64.img$' SHA256SUMS | \
+         sha256sum -c -
+
+4. Update the dated URL and SHA-256 in `ubuntu-cloud-images`.
+5. Run `make check`, generate the template, validate its generated index
+   fragment, and boot-test both the default image and an expanded image on
+   x86_64 KVM.
+
+The final template build, generated index fragment, catalog signature, and
+publication must all refer to the same image bytes.  Signing keys and the
+published template binary do not belong in this source repository.
+
+## Preliminary source-state inspection
+
+The signed root-filesystem artifact from the same pinned Canonical release was
+inspected before image generation.  It has an empty `/etc/machine-id`, no SSH
+host keys, no `/var/lib/cloud` instance state, and no persistent netplan file.
+The root password is locked.  The `ubuntu` account is not pre-created;
+cloud-init remains enabled and declares `ubuntu` as its default first-boot
+user.  These findings must be confirmed against the generated QCOW2-derived
+template, because the root-filesystem artifact is supporting evidence rather
+than the final input image.
+
+## Required validation before publication
+
+The source-only change does not decide these image-policy questions.  Before
+publishing an Ubuntu 24.04 template, validate and document:
+
+- whether the Canonical image boots with BIOS, UEFI, or both;
+- whether its 3.5 GiB virtual size should remain the template default or its
+  root filesystem should be expanded to the historical 6 GiB default;
+- whether cloud-init should remain enabled and create its default `ubuntu`
+  account, including behavior when no datasource is present; and
+- the generated template still has an empty machine ID, no SSH host keys, and
+  no stale cloud-init instance state before first boot.
diff --git a/builder/templates/make-template.ml b/builder/templates/make-template.ml
index c3df7ff2..de94c002 100755
--- a/builder/templates/make-template.ml
+++ b/builder/templates/make-template.ml
@@ -1,6 +1,8 @@
 #!/usr/bin/env ocaml
 (* libguestfs
  * Copyright (C) 2016-2025 Red Hat Inc.
+ * Copyright 2026 Cisco Systems, Inc. and its affiliates
+ * SPDX-License-Identifier: GPL-2.0-or-later
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -94,6 +96,16 @@ type boot_media =
   | Location of string          (* virt-install --location (preferred) *)
   | CDRom of string             (* downloaded CD-ROM *)
 
+type cloud_image = {
+  uri : string;
+  checksum_sha256 : string;
+  format : string;
+}
+
+type install_source =
+  | Installer
+  | CloudImage of cloud_image
+
 let quote = Filename.quote
 let (//) = Filename.concat
 
@@ -104,22 +116,11 @@ let rec main () =
   (* Parse the command line. *)
   let os, arch = parse_cmdline () in
 
+  let install_source = install_source_of_os os arch in
+
   (* Choose a disk size for this OS. *)
   let virtual_size_gb = get_virtual_size_gb os arch in
 
-  (* For OSes which require a kickstart, this generates one.
-   * For OSes which require a preseed file, this returns one (we
-   * don't generate preseed files at the moment).
-   * For Windows this returns an unattend file in an ISO.
-   * For OSes which cannot be automated (FreeBSD), this returns None.
-   *)
-  let ks = make_kickstart os arch in
-
-  (* Find the boot media.  Normally ‘virt-install --location’ but
-   * for FreeBSD it downloads the boot ISO.
-   *)
-  let boot_media = make_boot_media os arch in
-
   (* Choose a random temporary name for the libvirt domain. *)
   let tmpname = sprintf "tmp-%s" (random8 ()) in
 
@@ -132,79 +133,109 @@ let rec main () =
    *)
   let output = filename_of_os os arch "" in
 
-  (* Some architectures need EFI boot. *)
-  let tmpefivars =
-    if needs_uefi os arch then (
-      let code, vars =
-        match arch with
-        | X86_64 ->
-           "/usr/share/edk2/ovmf/OVMF_CODE.fd",
-           "/usr/share/edk2/ovmf/OVMF_VARS.fd"
-        | Aarch64 ->
-           "/usr/share/edk2/aarch64/QEMU_EFI-pflash.raw",
-           "/usr/share/edk2/aarch64/vars-template-pflash.raw"
-        | Armv7 ->
-           "/usr/share/edk2/arm/QEMU_EFI-pflash.raw",
-           "/usr/share/edk2/arm/vars-template-pflash.raw"
-        | _ -> assert false in
-
-      let vars_out = Sys.getcwd () // sprintf "%s.vars" tmpname in
-      unlink_on_exit vars_out;
-      let cmd = sprintf "cp %s %s" (quote vars) (quote vars_out) in
-      if Sys.command cmd <> 0 then exit 1;
-      Some (code, vars_out)
-    )
-    else None in
-
-  (* Now construct the virt-install command. *)
-  let vi = make_virt_install_command os arch ks tmpname tmpout tmpefivars
-                                     boot_media virtual_size_gb in
-
-  (* Print the virt-install command just before we run it, because
-   * this is expected to be long-running.
-   *)
-  print_virt_install_command stdout vi;
-
-  (* Save the virt-install command to a file, for documentation. *)
-  let chan = open_out (filename_of_os os arch ".virt-install-cmd") in
-  fprintf chan "# This is the virt-install command which was used to create\n";
-  fprintf chan "# the virt-builder template '%s'\n" (string_of_os os arch);
-  fprintf chan "# NB: This file is generated for documentation \
-                purposes ONLY!\n";
-  fprintf chan "# This script was never run, and is not intended to be run.\n";
-  fprintf chan "\n";
-  print_virt_install_command chan vi;
-  close_out chan;
-
-  (* Print the virt-install notes for OSes which cannot be automated
-   * fully.  (These are different from the ‘notes=’ section in the
-   * index fragment).
-   *)
-  print_install_notes os;
-  printf "\n\n%!";
-
-  (* Run the virt-install command. *)
-  let pid = Unix.fork () in
-  if pid = 0 then Unix.execvp "virt-install" vi;
-  let _, pstat = Unix.waitpid [] pid in
-  check_process_status_for_errors pstat;
-  (* If there were NVRAM variables, move them to the final name and
-   * compress them.  Doing this operation later means the cleanup of
-   * the guest will remove them as well (because of --nvram).
-   *)
   let nvram =
-    match tmpefivars with
-    | Some (_, vars) ->
-       let f = sprintf "%s-nvram" output in
-       let cmd = sprintf "mv %s %s" (quote vars) (quote f) in
-       if Sys.command cmd <> 0 then exit 1;
-       let cmd = sprintf "xz -f --best %s" (quote f) in
-       if Sys.command cmd <> 0 then exit 1;
-       Some (f ^ ".xz")
-    | None -> None in
+    match install_source with
+    | CloudImage image ->
+       import_cloud_image image tmpout;
+       None
+
+    | Installer ->
+       (* For OSes which require a kickstart, this generates one.
+        * For OSes which require a preseed file, this returns one (we
+        * don't generate preseed files at the moment).
+        * For Windows this returns an unattend file in an ISO.
+        * For OSes which cannot be automated (FreeBSD), this returns None.
+        *)
+       let ks = make_kickstart os arch in
+
+       (* Find the boot media.  Normally ‘virt-install --location’ but
+        * for FreeBSD it downloads the boot ISO.
+        *)
+       let boot_media = make_boot_media os arch in
+
+       (* Some architectures need EFI boot. *)
+       let tmpefivars =
+         if needs_uefi os arch then (
+           let code, vars =
+             match arch with
+             | X86_64 ->
+                "/usr/share/edk2/ovmf/OVMF_CODE.fd",
+                "/usr/share/edk2/ovmf/OVMF_VARS.fd"
+             | Aarch64 ->
+                "/usr/share/edk2/aarch64/QEMU_EFI-pflash.raw",
+                "/usr/share/edk2/aarch64/vars-template-pflash.raw"
+             | Armv7 ->
+                "/usr/share/edk2/arm/QEMU_EFI-pflash.raw",
+                "/usr/share/edk2/arm/vars-template-pflash.raw"
+             | _ -> assert false in
+
+           let vars_out = Sys.getcwd () // sprintf "%s.vars" tmpname in
+           unlink_on_exit vars_out;
+           let cmd = sprintf "cp %s %s" (quote vars) (quote vars_out) in
+           if Sys.command cmd <> 0 then exit 1;
+           Some (code, vars_out)
+         )
+         else None in
+
+       let vi =
+         make_virt_install_command os arch ks tmpname tmpout tmpefivars
+                                   boot_media virtual_size_gb in
+
+       (* Print the virt-install command just before we run it, because
+        * this is expected to be long-running.
+        *)
+       print_virt_install_command stdout vi;
+
+       (* Save the virt-install command to a file, for documentation. *)
+       let chan = open_out (filename_of_os os arch ".virt-install-cmd") in
+       fprintf chan "# This is the virt-install command which was used to create\n";
+       fprintf chan "# the virt-builder template '%s'\n" (string_of_os os arch);
+       fprintf chan "# NB: This file is generated for documentation \
+                     purposes ONLY!\n";
+       fprintf chan "# This script was never run, and is not intended to be run.\n";
+       fprintf chan "\n";
+       print_virt_install_command chan vi;
+       close_out chan;
+
+       (* Print the virt-install notes for OSes which cannot be automated
+        * fully.  (These are different from the ‘notes=’ section in the
+        * index fragment).
+        *)
+       print_install_notes os;
+       printf "\n\n%!";
+
+       (* Run the virt-install command. *)
+       let pid = Unix.fork () in
+       if pid = 0 then Unix.execvp "virt-install" vi;
+       let _, pstat = Unix.waitpid [] pid in
+       check_process_status_for_errors pstat;
+
+       (* If there were NVRAM variables, move them to the final name and
+        * compress them.  Doing this operation later means the cleanup of
+        * the guest will remove them as well (because of --nvram).
+        *)
+       match tmpefivars with
+       | Some (_, vars) ->
+          let f = sprintf "%s-nvram" output in
+          let cmd = sprintf "mv %s %s" (quote vars) (quote f) in
+          if Sys.command cmd <> 0 then exit 1;
+          let cmd = sprintf "xz -f --best %s" (quote f) in
+          if Sys.command cmd <> 0 then exit 1;
+          Some (f ^ ".xz")
+       | None -> None in
 
   ignore (Sys.command "sync");
 
+  (* A raw imported cloud image's apparent length is its virtual size.
+   * Keep the historical fixed size for installer-built templates.
+   *)
+  let virtual_size =
+    match install_source with
+    | CloudImage _ ->
+       (Unix.LargeFile.stat tmpout).Unix.LargeFile.st_size
+    | Installer ->
+       Int64.mul (Int64.of_int virtual_size_gb) 1073741824_L in
+
   (* Run virt-filesystems, simply to display the filesystems in the image. *)
   let cmd = sprintf "virt-filesystems -a %s --all --long -h" (quote tmpout) in
   if Sys.command cmd <> 0 then exit 1;
@@ -242,8 +273,8 @@ let rec main () =
   g#shutdown ();
   g#close ();
 
-  (match os with
-   | Ubuntu (ver, _) when ver >= "14.04" ->
+  (match install_source, os with
+   | Installer, Ubuntu (ver, _) when ver >= "14.04" ->
       (* In Ubuntu >= 14.04 you can't complete the install without creating
        * a user account.  We create one called 'builder', but we also
        * disable it.  XXX Combine with virt-sysprep step.
@@ -258,10 +289,25 @@ let rec main () =
   if can_sysprep_os os then (
     (* Sysprep.  Relabel SELinux-using guests. *)
     printf "Sysprepping ...\n%!";
-    let cmd = sprintf "virt-sysprep --quiet -a %s" (quote tmpout) in
+    let cmd =
+      sprintf "virt-sysprep --quiet --format raw -a %s" (quote tmpout) in
     if Sys.command cmd <> 0 then exit 1
   );
 
+  (match install_source with
+   | CloudImage _ when can_sysprep_os os ->
+      (* The customize operation runs late in the default sysprep pass and
+       * can populate an empty /etc/machine-id.  Make machine-id cleanup the
+       * final image-changing operation so clones generate their own ID.
+       *)
+      printf "Clearing cloud image machine ID ...\n%!";
+      let cmd =
+        sprintf "virt-sysprep --quiet --format raw \
+                 --operations machine-id -a %s" (quote tmpout) in
+      if Sys.command cmd <> 0 then exit 1
+   | CloudImage _ | Installer -> ()
+  );
+
   (* Sparsify and copy to output name. *)
   printf "Sparsifying ...\n%!";
   let cmd =
@@ -303,8 +349,8 @@ let rec main () =
         | `No_revision -> Some 2
         (* existing file with revision line *)
         | `Revision i -> Some (i+1) in
-      make_index_fragment os arch index_fragment output nvram revision
-                          expandfs lvexpandfs virtual_size_gb;
+      make_index_fragment os arch install_source index_fragment output nvram
+                          revision expandfs lvexpandfs virtual_size;
 
       (* Validate the fragment we have just created. *)
       let cmd = sprintf "virt-index-validate %s" (quote index_fragment) in
@@ -349,6 +395,88 @@ Options:
 
   os, arch
 
+and install_source_of_os os arch =
+  match os, arch with
+  | Ubuntu ("24.04", _), arch ->
+     let arch = string_of_arch arch in
+     (match find_cloud_image "24.04" arch with
+      | Some image -> CloudImage image
+      | None ->
+         eprintf "%s: Ubuntu 24.04 cloud image import is not implemented for \
+                  %s\n" prog arch;
+         exit 1
+     )
+  | _ -> Installer
+
+and find_cloud_image version arch =
+  let filename =
+    Filename.dirname Sys.argv.(0) // "ubuntu-cloud-images" in
+  let chan =
+    try open_in filename
+    with Sys_error msg ->
+      eprintf "%s: %s\n" prog msg;
+      exit 1 in
+  let regexp = Str.regexp "[ \t]+" in
+  let valid_sha256 checksum =
+    let valid_hex_digit = function
+      | '0'..'9' | 'a'..'f' -> true
+      | _ -> false in
+    String.length checksum = 64 &&
+    try
+      String.iter
+        (fun c -> if not (valid_hex_digit c) then raise Exit)
+        checksum;
+      true
+    with Exit -> false in
+  let rec loop line_number =
+    match input_line chan with
+    | line ->
+       let line = String.trim line in
+       if line = "" || line.[0] = '#' then loop (line_number + 1)
+       else (
+         match Str.split regexp line with
+         | [v; a; format; checksum_sha256; uri] ->
+            if not (valid_sha256 checksum_sha256) then (
+              eprintf "%s: %s:%d: invalid SHA-256\n"
+                      prog filename line_number;
+              exit 1
+            );
+            if v = version && a = arch then
+              Some { uri; checksum_sha256; format }
+            else
+              loop (line_number + 1)
+         | _ ->
+            eprintf "%s: %s:%d: expected five whitespace-separated fields\n"
+                    prog filename line_number;
+            exit 1
+       )
+    | exception End_of_file -> None in
+  let result = loop 1 in
+  close_in chan;
+  result
+
+and import_cloud_image { uri; checksum_sha256; format } output =
+  let downloaded = output ^ ".download" in
+  unlink_on_exit downloaded;
+
+  printf "Downloading cloud image: %s\n%!" uri;
+  let cmd =
+    sprintf "curl --fail --location --proto '=https' --proto-redir '=https' \
+             --output %s --url %s"
+            (quote downloaded) (quote uri) in
+  if Sys.command cmd <> 0 then exit 1;
+
+  printf "Verifying SHA-256 and converting cloud image from %s to raw ...\n%!"
+         format;
+  let helper =
+    Filename.dirname Sys.argv.(0) // "prepare-cloud-image.sh" in
+  let cmd =
+    sprintf "%s %s %s %s %s"
+            (quote helper) (quote downloaded) (quote checksum_sha256)
+            (quote format) (quote output) in
+  if Sys.command cmd <> 0 then exit 1;
+  Unix.unlink downloaded
+
 and os_of_string os ver =
   match os, ver with
   | "alma", ver -> let maj, min = parse_major_minor ver in Alma (maj, min)
@@ -370,6 +498,7 @@ and os_of_string os ver =
   | "ubuntu", "18.04" -> Ubuntu (ver, "bionic")
   | "ubuntu", "20.04" -> Ubuntu (ver, "focal")
   | "ubuntu", "22.04" -> Ubuntu (ver, "jammy")
+  | "ubuntu", "24.04" -> Ubuntu (ver, "noble")
   | "fedora", ver -> Fedora (int_of_string ver)
   | "freebsd", ver -> let maj, min = parse_major_minor ver in FreeBSD (maj, min)
   | "windows", ver -> parse_windows_version ver
@@ -1440,13 +1569,8 @@ keepcache=0
 
   Buffer.contents buf
 
-and make_index_fragment os arch index_fragment output nvram revision
-                        expandfs lvexpandfs virtual_size_gb =
-  let virtual_size = Int64.of_int virtual_size_gb in
-  let virtual_size = Int64.mul virtual_size 1024_L in
-  let virtual_size = Int64.mul virtual_size 1024_L in
-  let virtual_size = Int64.mul virtual_size 1024_L in
-
+and make_index_fragment os arch install_source index_fragment output nvram
+                        revision expandfs lvexpandfs virtual_size =
   let chan = open_out (index_fragment ^ ".new") in
   let fpf fs = fprintf chan fs in
 
@@ -1469,7 +1593,7 @@ and make_index_fragment os arch index_fragment output nvram revision
    | Some fs -> fpf "lvexpand=%s\n" fs
   );
 
-  let notes = notes_of_os os arch nvram in
+  let notes = notes_of_os os arch install_source nvram in
   (match notes with
    | first :: notes ->
       fpf "notes=%s\n" first;
@@ -1532,41 +1656,54 @@ and long_name_of_os os arch =
      sprintf "Windows Server 2016 (%s)" (string_of_arch arch)
   | Windows _, _ -> assert false
 
-and notes_of_os os arch nvram =
+and notes_of_os os arch install_source nvram =
   let args = ref [] in
   let add arg = args := arg :: !args in
 
   add (long_name_of_os os arch);
   add "";
 
-  (match os with
-   | Alma _ ->
+  (match install_source, os with
+   | _, Alma _ ->
       add "This AlmaLinux image contains only unmodified @Core group packages."
-   | CentOS _ ->
+   | _, CentOS _ ->
       add "This CentOS image contains only unmodified @Core group packages."
-   | CentOSStream _ ->
+   | _, CentOSStream _ ->
       add "This CentOS Stream image contains only unmodified @Core \
            group packages."
-   | Debian _ ->
+   | _, Debian _ ->
       add "This is a minimal Debian install."
-   | Fedora _ ->
+   | _, Fedora _ ->
       add "This Fedora image contains only unmodified @Core group packages.";
       add "";
       add "Fedora and the Infinity design logo are trademarks of Red Hat, Inc.";
       add "Source and further information is available from \
            http://fedoraproject.org/"
-   | RHEL _ -> assert false (* cannot happen, see caller *)
-   | Ubuntu _ ->
+   | _, RHEL _ -> assert false (* cannot happen, see caller *)
+   | Installer, Ubuntu _ ->
       add "This is a minimal Ubuntu install."
-   | FreeBSD _ ->
+   | CloudImage _, Ubuntu _ ->
+      add "This image is derived from Canonical's standard Ubuntu Server \
+           cloud image."
+   | _, FreeBSD _ ->
       add "This is an all-default FreeBSD install."
-   | Windows _ ->
+   | _, Windows _ ->
       add "This is an unattended Windows install.";
       add "";
       add "You must have an MSDN subscription to use this image."
   );
   add "";
 
+  (match install_source with
+   | Installer -> ()
+   | CloudImage { uri; checksum_sha256; format = _ } ->
+      add "This template was imported from an official Ubuntu cloud image:";
+      add (sprintf "    %s" uri);
+      add "The source image matched this pinned SHA-256:";
+      add (sprintf "    %s" checksum_sha256);
+      add ""
+  );
+
   (* Specific notes for particular versions. *)
   let reconfigure_ssh_host_keys_debian () =
     add "This image does not contain SSH host keys.  To regenerate them use:";
@@ -1591,23 +1728,23 @@ and notes_of_os os arch nvram =
     add "might still wish to delete it completely.";
     add ""
   in
-  (match os with
-   | CentOS (6, _) ->
+  (match install_source, os with
+   | _, CentOS (6, _) ->
       add "‘virt-builder centos-6’ will always install the latest 6.x release.";
       add ""
-   | Debian ((8|9), _) ->
+   | _, Debian ((8|9), _) ->
       reconfigure_ssh_host_keys_debian ();
-   | Debian _ ->
+   | _, Debian _ ->
       add "This image is so very minimal that it only includes an ssh server";
       reconfigure_ssh_host_keys_debian ();
-   | Ubuntu ("16.04", _) ->
+   | Installer, Ubuntu ("16.04", _) ->
       builder_account_warning ();
       fix_serial_console_debian ();
       reconfigure_ssh_host_keys_debian ();
-   | Ubuntu (ver, _) when ver >= "14.04" ->
+   | Installer, Ubuntu (ver, _) when ver >= "14.04" ->
       builder_account_warning ();
       reconfigure_ssh_host_keys_debian ();
-   | Ubuntu _ ->
+   | _, Ubuntu _ ->
       reconfigure_ssh_host_keys_debian ();
    | _ -> ()
   );
diff --git a/builder/templates/prepare-cloud-image.sh b/builder/templates/prepare-cloud-image.sh
new file mode 100755
index 00000000..c889ca6c
--- /dev/null
+++ b/builder/templates/prepare-cloud-image.sh
@@ -0,0 +1,61 @@
+#!/bin/bash -
+# libguestfs virt-builder cloud-image preparation helper
+# Copyright 2026 Cisco Systems, Inc. and its affiliates
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+export LANG=C
+set -euo pipefail
+
+if test "$#" -ne 4; then
+    echo "usage: $0 INPUT EXPECTED_SHA256 INPUT_FORMAT OUTPUT" >&2
+    exit 2
+fi
+
+input=$1
+expected=$2
+input_format=$3
+output=$4
+
+if [[ ! "$expected" =~ ^[0-9a-f]{64}$ ]]; then
+    echo "$0: invalid expected SHA-256: $expected" >&2
+    exit 2
+fi
+
+if [[ ! "$input_format" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
+    echo "$0: invalid input format: $input_format" >&2
+    exit 2
+fi
+
+actual=$(sha256sum -- "$input")
+actual=${actual%% *}
+if test "$actual" != "$expected"; then
+    echo "$0: cloud image checksum mismatch" >&2
+    echo "$0: expected: $expected" >&2
+    echo "$0:   actual: $actual" >&2
+    exit 1
+fi
+
+temporary_dir=$(mktemp -d -- "${output}.tmp.XXXXXX")
+temporary="$temporary_dir/image.raw"
+cleanup () {
+    rm -rf -- "$temporary_dir"
+}
+trap cleanup EXIT
+trap 'exit 1' HUP INT TERM
+
+qemu-img convert -f "$input_format" -O raw "$input" "$temporary"
+mv -f -- "$temporary" "$output"
diff --git a/builder/templates/test-ubuntu-cloud-image-import.sh b/builder/templates/test-ubuntu-cloud-image-import.sh
new file mode 100755
index 00000000..b0ff0381
--- /dev/null
+++ b/builder/templates/test-ubuntu-cloud-image-import.sh
@@ -0,0 +1,93 @@
+#!/bin/bash -
+# libguestfs virt-builder Ubuntu cloud-image import test
+# Copyright 2026 Cisco Systems, Inc. and its affiliates
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+export LANG=C
+set -euo pipefail
+
+if ! command -v qemu-img >/dev/null 2>&1; then
+    echo "$0: qemu-img is not installed; skipping" >&2
+    exit 77
+fi
+
+if ! command -v sha256sum >/dev/null 2>&1; then
+    echo "$0: sha256sum is not installed; skipping" >&2
+    exit 77
+fi
+
+helper="$top_srcdir/builder/templates/prepare-cloud-image.sh"
+tmpdir=$(mktemp -d)
+trap 'rm -rf -- "$tmpdir"' EXIT
+
+source_raw="$tmpdir/source.raw"
+input="$tmpdir/input.qcow2"
+output="$tmpdir/output.raw"
+bad_output="$tmpdir/bad-output.raw"
+failed_output="$tmpdir/conversion-failure.raw"
+
+truncate -s 8M "$source_raw"
+printf 'virt-builder cloud-image import test\n' | \
+    dd of="$source_raw" conv=notrunc status=none
+printf 'nonzero data beyond the first block\n' | \
+    dd of="$source_raw" bs=1 seek=4194304 conv=notrunc status=none
+qemu-img convert -f raw -O qcow2 "$source_raw" "$input"
+checksum=$(sha256sum -- "$input")
+checksum=${checksum%% *}
+
+"$helper" "$input" "$checksum" qcow2 "$output"
+
+info=$(qemu-img info --output=json "$output" | tr -d '[:space:]')
+case "$info" in
+    *'"format":"raw"'*) ;;
+    *) echo "$0: converted image is not raw" >&2; exit 1 ;;
+esac
+case "$info" in
+    *'"virtual-size":8388608'*) ;;
+    *) echo "$0: converted image has the wrong virtual size" >&2; exit 1 ;;
+esac
+
+qemu-img compare -f raw -F raw "$source_raw" "$output"
+
+bad_checksum=$(printf '%064d' 0)
+if "$helper" "$input" "$bad_checksum" qcow2 "$bad_output"; then
+    echo "$0: incorrect checksum unexpectedly succeeded" >&2
+    exit 1
+fi
+if test -e "$bad_output"; then
+    echo "$0: checksum failure left an output image" >&2
+    exit 1
+fi
+
+printf 'existing output must survive conversion failure\n' > "$failed_output"
+failed_checksum=$(sha256sum -- "$failed_output")
+failed_checksum=${failed_checksum%% *}
+if "$helper" "$input" "$checksum" not-a-format "$failed_output"; then
+    echo "$0: invalid input format unexpectedly converted" >&2
+    exit 1
+fi
+actual_failed_checksum=$(sha256sum -- "$failed_output")
+actual_failed_checksum=${actual_failed_checksum%% *}
+if test "$actual_failed_checksum" != "$failed_checksum"; then
+    echo "$0: conversion failure changed an existing output" >&2
+    exit 1
+fi
+if find "$tmpdir" -maxdepth 1 -name 'conversion-failure.raw.tmp.*' \
+        -print -quit | grep -q .; then
+    echo "$0: conversion failure left temporary files" >&2
+    exit 1
+fi
diff --git a/builder/templates/test-ubuntu-cloud-images.sh b/builder/templates/test-ubuntu-cloud-images.sh
new file mode 100755
index 00000000..ea2f3645
--- /dev/null
+++ b/builder/templates/test-ubuntu-cloud-images.sh
@@ -0,0 +1,46 @@
+#!/bin/bash -
+# libguestfs virt-builder Ubuntu cloud-image source test
+# Copyright 2026 Cisco Systems, Inc. and its affiliates
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+export LANG=C
+set -e
+
+sources="$top_srcdir/builder/templates/ubuntu-cloud-images"
+
+count=$(awk '$1 == "24.04" && $2 == "x86_64" { count++ } END { print count+0 }' \
+            "$sources")
+if test "$count" -ne 1; then
+    echo "$0: expected exactly one Ubuntu 24.04 x86_64 source"
+    exit 1
+fi
+
+line=$(awk '$1 == "24.04" && $2 == "x86_64" { print }' "$sources")
+read -r version arch format checksum uri <<< "$line"
+test "$version" = 24.04
+test "$arch" = x86_64
+test "$format" = qcow2
+
+if [[ ! "$checksum" =~ ^[0-9a-f]{64}$ ]]; then
+    echo "$0: invalid SHA-256: $checksum"
+    exit 1
+fi
+
+if [[ ! "$uri" =~ ^https://cloud-images\.ubuntu\.com/releases/noble/release-[0-9]{8}/ubuntu-24\.04-server-cloudimg-amd64\.img$ ]]; then
+    echo "$0: source is not a dated Canonical Noble amd64 image: $uri"
+    exit 1
+fi
diff --git a/builder/templates/ubuntu-cloud-images b/builder/templates/ubuntu-cloud-images
new file mode 100644
index 00000000..35db78ee
--- /dev/null
+++ b/builder/templates/ubuntu-cloud-images
@@ -0,0 +1,8 @@
+# Copyright 2026 Cisco Systems, Inc. and its affiliates
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+# VERSION ARCH FORMAT SHA256 URI
+#
+# Verify checksum updates against Canonical's signed SHA256SUMS using the
+# Ubuntu cloud-image signing key D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81.
+24.04 x86_64 qcow2 5fa5b05e5ec239858c4531485d6023b0896448c2df7c63b34f8dae6ea6051a44 https://cloud-images.ubuntu.com/releases/noble/release-20260615/ubuntu-24.04-server-cloudimg-amd64.img

base-commit: c3331f4967729710df069a8457f0c9787acf3310
-- 
2.50.1 (Apple Git-155)

