[cloud-init] Using Ubuntu 24.04 minimal image does not work

Herman33

New Member
Sep 4, 2024
2
0
1
I'm trying to get cloud init working with the Ubuntu 24.04 minimal image. The cloud init does not initiate at all it seems.

I'm using this image:
https://cloud-images.ubuntu.com/min...lease/ubuntu-24.04-minimal-cloudimg-amd64.img

When I switch to the full image (https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img) everything works as expected.

I've read somewhere (can't find the topic anymore) that this might be due to the minimal image not supporting cd-rom.

Is this the cause? And if so, is there a work-around?

This is the process I'm using to create my template:

Bash:
qm create 8010 \
    --name "ubuntu-2404-ci" \
    --ostype l26 \
    --memory 1024 \
    --agent 1 \
    --bios ovmf \
    --machine q35 --efidisk0 local-lvm:0,pre-enrolled-keys=0 \
    --cpu host --socket 1 --cores 1 \
    --vga serial0 --serial0 socket  \
    --net0 virtio,bridge=vmbr0

# normal image (this one works)
#qm importdisk 8010 noble-server-cloudimg-amd64.img local-lvm

# minimal image (this one does not work)
qm importdisk 8010 ubuntu-24.04-minimal-cloudimg-amd64.img local-lvm

qm set 8010 --scsihw virtio-scsi-pci --virtio0 local-lvm:vm-8010-disk-1,discard=on
qm set 8010 --boot order=virtio0
qm set 8010 --ide2 local-lvm:cloudinit
qm set 8010 --cicustom "user=local:snippets/ci-ubuntu.yml"
qm set 8010 --ipconfig0 ip=dhcp

qm cloudinit update 8010
 
Thanks for your quick answer, I got it working.

You've quoted the "--bios ovmf \" part too, should I change something there too?
 
I made the following script to streamline the creation of cloud-init templates using ubuntu-24.04-server-cloudimg-amd64.img. I am using a ZFS volume of a pool for the vm disc images storage and a USB drive for snippets. You might want to modify these for your needs;
Bash:
#!/usr/bin/env bash
set -euo pipefail

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
die() { echo -e "${RED}Error:${NC} $*" >&2; exit 1; }
info() { echo -e "${GREEN}==>${NC} $*"; }
warn() { echo -e "${YELLOW}==>${NC} $*"; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Required command '$1' not found."; }

# Pre-flight
need_cmd qm
need_cmd wget
need_cmd qemu-img
need_cmd pvesm
need_cmd zfs
pveversion -v >/dev/null 2>&1 || die "Run on a Proxmox VE host."

# Helper: numbered picker
pick_from_list() {
  local prompt="$1"; shift
  local -a items=("$@")
  local count="${#items[@]}"
  [[ $count -gt 0 ]] || die "No items to pick."
  if [[ $count -eq 1 ]]; then
    echo "${items[0]}"
    return 0
  fi
  echo "$prompt"
  local i=1
  for it in "${items[@]}"; do
    echo "  [$i] $it"
    ((i++))
  done
  local choice
  while true; do
    read -rp "Select [1-$count]: " choice
    [[ "$choice" =~ ^[0-9]+$ ]] || { echo "Enter a number."; continue; }
    (( choice>=1 && choice<=count )) || { echo "Out of range."; continue; }
    echo "${items[$((choice-1))]}"
    return 0
  done
}

confirm_or_exit() {
  local prompt="$1"
  local ans
  read -rp "$prompt [y/N]: " ans
  [[ "$ans" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 1; }
}

# Gather storages
mapfile -t ALL_STORAGES < <(pvesm status | awk 'NR>1 {print $1}' | sed '/^$/d')
[[ ${#ALL_STORAGES[@]} -gt 0 ]] || die "No storages found via pvesm status."

# Parse storage.cfg
STCFG="/etc/pve/storage.cfg"
[[ -r "$STCFG" ]] || die "Cannot read $STCFG"

declare -A ST_TYPE ST_CONTENT ST_MOUNT ST_POOL
current=""
while IFS= read -r line; do
  [[ -z "$line" ]] && continue
  if [[ "$line" =~ ^(dir|zfspool|lvmthin|rbd|zfs|nfs|cifs|pbs|lvm):[[:space:]]+(.+)$ ]]; then
    current="${BASH_REMATCH[2]}"
    ST_TYPE["$current"]="${BASH_REMATCH[1]}"
    ST_CONTENT["$current"]=""
    ST_MOUNT["$current"]=""
    ST_POOL["$current"]=""
    continue
  fi
  [[ -z "$current" ]] && continue
  key=$(awk '{print $1}' <<<"$line")
  val=$(awk '{$1=""; sub(/^ /,""); print}' <<<"$line")
  case "$key" in
    content) ST_CONTENT["$current"]="$val" ;;
    mountpoint) ST_MOUNT["$current"]="$val" ;;
    pool) ST_POOL["$current"]="$val" ;;
  esac
done < "$STCFG"

# Filter storages
declare -a IMG_STORAGES=()
for s in "${ALL_STORAGES[@]}"; do
  if grep -qw images <<<"${ST_CONTENT[$s]:-}"; then
    IMG_STORAGES+=("$s")
  fi
done
[[ ${#IMG_STORAGES[@]} -gt 0 ]] || die "No storages with 'images' content found."

declare -a SNIPPET_STORAGES=()
for s in "${ALL_STORAGES[@]}"; do
  if grep -qw snippets <<<"${ST_CONTENT[$s]:-}"; then
    SNIPPET_STORAGES+=("$s")
  fi
done
[[ ${#SNIPPET_STORAGES[@]} -gt 0 ]] || die "No storages with 'snippets' content found. Enable snippets on a storage."

# Prompts
read -rp "Template VMID (e.g. 9000): " VMID
[[ "$VMID" =~ ^[0-9]+$ ]] || die "VMID must be numeric."

read -rp "Template name (default: ubuntu-24.04-cloud): " VMNAME
VMNAME=${VMNAME:-ubuntu-24.04-cloud}

STORAGE="$(pick_from_list "Select storage for VM disks (needs 'images'):" "${IMG_STORAGES[@]}")"
info "Selected image storage: $STORAGE"

if [[ ${#SNIPPET_STORAGES[@]} -eq 1 ]]; then
  SNIPPETS_ID="${SNIPPET_STORAGES[0]}"
  info "Snippets storage auto-selected: $SNIPPETS_ID"
else
  SNIPPETS_ID="$(pick_from_list "Select storage for snippets:" "${SNIPPET_STORAGES[@]}")"
  info "Selected snippets storage: $SNIPPETS_ID"
fi

SNIPPETS_BASE="/mnt/pve/${SNIPPETS_ID}/snippets"
[[ -d "$SNIPPETS_BASE" ]] || die "Snippets path '$SNIPPETS_BASE' not found or not mounted."

read -rp "Bridge name (default vmbr0): " BRIDGE
BRIDGE=${BRIDGE:-vmbr0}

read -rp "Default cloud user (e.g. dev): " CIUSER
[[ -n "$CIUSER" ]] || die "User is required."

read -srp "Password for user '$CIUSER': " CIPASS; echo
[[ -n "$CIPASS" ]] || die "Password is required."

read -rp "Add SSH public key as well? [y/N]: " ADDKEY
ADDKEY=${ADDKEY:-N}
SSHKEY_PATH=""
if [[ "$ADDKEY" =~ ^[Yy]$ ]]; then
  read -rp "Path to public key (e.g. ~/.ssh/id_rsa.pub): " SSHKEY_PATH
  SSHKEY_PATH="${SSHKEY_PATH/#\~/$HOME}"
  [[ -f "$SSHKEY_PATH" ]] || die "Public key not found at: $SSHKEY_PATH"
fi

read -rp "vCPUs (default 2): " CORES; CORES=${CORES:-2}
read -rp "RAM MB (default 2048): " MEM; MEM=${MEM:-2048}
read -rp "Disk size (default 20G): " DISKSIZE; DISKSIZE=${DISKSIZE:-20G}

# Networking choice
read -rp "Use DHCP? [Y/n]: " USE_DHCP
USE_DHCP=${USE_DHCP:-Y}
DNS_LIST=""
if [[ "$USE_DHCP" =~ ^[Yy]$ ]]; then
  IPCONFIG0="ip=dhcp"
else
  read -rp "Static IPv4 (CIDR), e.g. 192.168.1.50/24: " IPADDR
  read -rp "Gateway IPv4, e.g. 192.168.1.1: " GW
  read -rp "DNS server(s) (space/comma-separated), e.g. 1.1.1.1 8.8.8.8: " DNS_LIST
  [[ "$IPADDR" =~ /[0-9]+$ ]] || die "Invalid CIDR for IP."
  [[ -n "$GW" ]] || die "Gateway required."
  IPCONFIG0="ip=${IPADDR},gw=${GW}"
fi

# Print summary and confirm
echo
echo "Summary:"
echo "  Template VMID: $VMID"
echo "  Name: $VMNAME"
echo "  Image storage: $STORAGE"
echo "  Snippets storage: $SNIPPETS_ID"
echo "  Bridge: $BRIDGE"
echo "  Cloud user: $CIUSER"
echo "  SSH password: (hidden)"
if [[ "$ADDKEY" =~ ^[Yy]$ ]]; then
  echo "  SSH public key: $SSHKEY_PATH"
else
  echo "  SSH public key: none"
fi
echo "  vCPUs: $CORES"
echo "  RAM: ${MEM}MB"
echo "  Disk size: $DISKSIZE"
echo "  Network: $IPCONFIG0"
[[ -n "$DNS_LIST" ]] && echo "  DNS: $DNS_LIST"
confirm_or_exit "Proceed with template creation?"

# Image download dir: ../images
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGES_DIR="${SCRIPT_DIR}/../images"
mkdir -p "$IMAGES_DIR" || die "Cannot create $IMAGES_DIR"

IMG_NAME="ubuntu-24.04-server-cloudimg-amd64.img"
IMG_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
IMG_PATH="${IMAGES_DIR}/${IMG_NAME}"

if [[ ! -f "$IMG_PATH" ]]; then
  info "Downloading Ubuntu 24.04 image -> $IMG_PATH"
  wget -O "$IMG_PATH" "$IMG_URL" || die "Download failed."
else
  info "Using existing image at $IMG_PATH"
fi

# Sanity check
qemu-img info --output=json "$IMG_PATH" >/dev/null 2>&1 || warn "qemu-img could not parse $IMG_PATH; continuing."

# Vendor-data snippet (enable password auth)
VENDOR_SNIPPET="${SNIPPETS_BASE}/ci-vendor-sshpass.yaml"
info "Writing vendor-data snippet: $VENDOR_SNIPPET"
cat >"$VENDOR_SNIPPET" <<'EOF'
#cloud-config
ssh_pwauth: true
EOF
chmod 0644 "$VENDOR_SNIPPET"

# Create/reuse VM
if qm status "$VMID" >/dev/null 2>&1; then
  warn "VMID $VMID already exists. Will reconfigure it as template."
else
  info "Creating VM $VMID ($VMNAME)"
  qm create "$VMID" --name "$VMNAME" --memory "$MEM" --cores "$CORES" --sockets 1 --cpu host --net0 "virtio,bridge=${BRIDGE}" \
    || die "qm create failed."
fi

# Import disk if missing
if ! pvesm list "$STORAGE" | grep -q "vm-${VMID}-disk-0"; then
  info "Importing disk into $STORAGE"
  qm importdisk "$VMID" "$IMG_PATH" "$STORAGE" || die "qm importdisk failed."
else
  info "Disk vm-${VMID}-disk-0 already exists on $STORAGE"
fi

# Attach disk and config
info "Configuring hardware"
qm set "$VMID" --scsihw virtio-scsi-pci --scsi0 "${STORAGE}:vm-${VMID}-disk-0" || die "attach scsi0 failed."
qm set "$VMID" --scsi0 "${STORAGE}:vm-${VMID}-disk-0,ssd=1,discard=on" || true
qm set "$VMID" --boot order=scsi0 || true
qm set "$VMID" --machine q35 || true
qm set "$VMID" --serial0 socket --vga serial0 || true
qm set "$VMID" --agent enabled=1,fstrim_cloned_disks=1 || true

# Resize
info "Resizing disk to ${DISKSIZE}"
qm resize "$VMID" scsi0 "$DISKSIZE" || warn "qm resize failed; continuing."

# Refresh cloud-init drive
info "Refreshing cloud-init drive"
if qm config "$VMID" | grep -q '^ide2: .*cloudinit'; then
  POOL="${ST_POOL[$STORAGE]}"
  qm stop "$VMID" >/dev/null 2>&1 || true
  if [[ -n "${POOL:-}" ]] && zfs list "${POOL}/vm-${VMID}-cloudinit" >/dev/null 2>&1; then
    zfs destroy "${POOL}/vm-${VMID}-cloudinit" || warn "Could not destroy ${POOL}/vm-${VMID}-cloudinit; will reuse."
  fi
fi
qm set "$VMID" --ide2 "${STORAGE}:cloudinit" || die "Adding cloud-init drive failed."

# Cloud-init settings
info "Applying cloud-init defaults"
qm set "$VMID" --ciuser "$CIUSER" --cipassword "$CIPASS" --ipconfig0 "$IPCONFIG0" || die "Setting ciuser/cipassword/ip failed."
if [[ "$ADDKEY" =~ ^[Yy]$ ]]; then
  info "Adding SSH public key from $SSHKEY_PATH"
  qm set "$VMID" --sshkeys "$SSHKEY_PATH" || warn "Could not set sshkeys."
fi

# If static and DNS specified, add resolv.conf at boot via vendor data (optional)
if [[ ! "$USE_DHCP" =~ ^[Yy]$ ]] && [[ -n "$DNS_LIST" ]]; then
  VENDOR_SNIPPET="${SNIPPETS_BASE}/ci-vendor-sshpass-dns.yaml"
  info "Writing vendor-data with ssh_pwauth + resolv.conf"
  cat >"$VENDOR_SNIPPET" <<EOF
#cloud-config
ssh_pwauth: true
write_files:
  - path: /etc/resolv.conf
    permissions: '0644'
    owner: root:root
    content: |
$(for d in ${DNS_LIST//,/ }; do echo "      nameserver $d"; done)
EOF
  chmod 0644 "$VENDOR_SNIPPET"
fi

# Attach vendor-data snippet
info "Attaching vendor-data snippet"
qm set "$VMID" --cicustom "vendor=${SNIPPETS_ID}:snippets/$(basename "$VENDOR_SNIPPET")" \
  || die "Setting cicustom vendor failed."

# Best-effort verification
info "Verifying cloud-init data"
qm cloudinit dump "$VMID" user || warn "Could not dump user-data."
qm cloudinit dump "$VMID" meta || warn "Could not dump meta-data."

# Convert to template
info "Converting to template"
qm template "$VMID" || die "Failed to convert to template."

echo
info "Template created successfully."
echo "  Template VMID: $VMID"
echo "  Name: $VMNAME"
echo "  Image storage: $STORAGE"
echo "  Snippets storage: $SNIPPETS_ID"
echo "  Cloud user: $CIUSER"
echo "  SSH password auth: enabled (vendor-data)"
echo "  Image file: $IMG_PATH"
echo "  Network: $IPCONFIG0"
[[ -n "$DNS_LIST" ]] && echo "  DNS: $DNS_LIST"
echo
echo "Clone example:"
echo "  qm clone $VMID <NEW_VMID> --name \"<NEW_NAME>\" --full 1"
echo "  qm set <NEW_VMID> --ipconfig0 \"$IPCONFIG0\""
echo "  qm start <NEW_VMID>"
echo
echo -e "${GREEN}SSH:${NC} ssh ${CIUSER}@<vm-ip> (password you set)"