Adding ready-made usb stick images as easily as .iso ? Can it be done ?

shodan

Active Member
Sep 1, 2022
254
69
33
Hi,

I have created a usb disk image called toolsdisk.usb
This image is bootable but it is a real, modifiable vfat filesystem

I copied it to

/var/lib/vz/template/iso/toolsdisk.usb

I want to be able to add this image to my VMs easily from the web user interface.

Of course since they are editable, the process needs to be something like create a volume, make a snapshot and attach the snapshot to the VM

...

I made a script to do this

It is called

CreateUsbStick.sh

Code:
============================================================
 Create USB Stick - 2026-08-09-r6
============================================================
CreateUsbStick.sh
Create a reusable Proxmox LVM-thin USB image and optionally attach
a writable thin snapshot of it to a VM.

Syntax:
  ./CreateUsbStick.sh <file>
  ./CreateUsbStick.sh <file> <size>
  ./CreateUsbStick.sh <file> <vmid>
  ./CreateUsbStick.sh <file> <size> <vmid>
  ./CreateUsbStick.sh <file> [size] [vmid] [BOOT] [STORAGE <storage>]

Arguments:
  <file>               USB/disk image file.
  <size>               Optional volume size, for example 512M, 4G or 64G.
  <vmid>               Existing Proxmox VM to receive a writable thin snapshot.
  BOOT                 Put the attached snapshot first in the VM boot order.
  STORAGE <storage>    Use the specified LVM-thin storage instead of local-lvm.

File resolution:
  If <file> already exists as given, that path is used directly.
  Otherwise it is looked up relative to:
      /var/lib/vz/template/iso

Size and VMID rules:
  With no size, the source image size is used automatically.
  A numeric second positional argument below 99999 is a VMID.
  With three positional arguments, they are always:
      <file> <size> <vmid>
  BOOT and STORAGE <storage> may appear anywhere in the command.

Common examples:
  # Create or reuse the base image
  ./CreateUsbStick.sh toolsdisk.usb

  # Attach a writable snapshot to VM 101
  ./CreateUsbStick.sh toolsdisk.usb 101

  # Attach to VM 101 and boot from it first
  ./CreateUsbStick.sh toolsdisk.usb 101 BOOT

  # Create a 64G base and attach a snapshot to VM 110
  ./CreateUsbStick.sh ipxe-online-efi.usb 64G 110

  # Same, but make the USB image first in the boot order
  ./CreateUsbStick.sh ipxe-online-efi.usb 64G 110 BOOT

  # Use another Proxmox LVM-thin storage
  ./CreateUsbStick.sh toolsdisk.usb 101 STORAGE local-14tb-lvm

  # Options may also come first
  ./CreateUsbStick.sh STORAGE local-14tb-lvm BOOT toolsdisk.usb 16G 101

  # Use an existing absolute path directly
  ./CreateUsbStick.sh /root/images/toolsdisk.usb 101 BOOT

And here it is a work

1786270733592.png

First argument is the usb disk, if a relative path, it is relative to the iso folder, but you can give an absolute path to anywhere.
Second argument is optional and is either the size you want to make your snapshot disk or the VMID you want to attach a snapshot of the disk to.
Third argument is optional and would be the VMID if you have specified a size. If you don't specify a size then it will be the size of the original file.
You can also give the BOOT parameter at the end and this drive will be added first to the boot order.
You can also specify the STORAGE for this disk with that keyword.
 
Code:
#!/bin/sh
set -eu

############################################################
# SETUP / MAIN / END
############################################################

setup() {
    define_colours
    # Configuration
    SCRIPT_VERSION="2026-08-09-r6"
    STORAGE="local-lvm"
    IMAGE_ROOT="/var/lib/vz/template/iso"
    STORAGE_CONFIG_FILE="/etc/pve/storage.cfg"
    BASE_VMID_START=99000
    # Runtime
    BASE_EXISTS=0
    BASE_VMID=""
    BASE_VOLID=""
    BASE_LV=""
    SNAP_LV=""
    SATA_SLOT=""
    SIZE_ARG=""
    TARGET_VMID=""
    BOOT_FIRST=0
    # Setup
    print_banner "Create USB Stick - $SCRIPT_VERSION"
    parse_arguments "$@"
    validate_environment
    resolve_source_file
    resolve_image_size
    resolve_storage
    find_existing_base
    validate_target_vm
    print_configuration
}

main() {
    ensure_base_template
    if [ -n "$TARGET_VMID" ]; then create_target_snapshot
    else print_info "No target VMID supplied; base image only."; fi
}

end() {
    print_banner "Completed"
    print_success "USB disk setup completed successfully"
    print_results
}

############################################################
# ARGUMENTS
############################################################

parse_arguments() {
    [ "$#" -gt 0 ] || { print_usage; exit 1; }
    POSITIONAL_COUNT=0
    POSITIONAL_1=""
    POSITIONAL_2=""
    POSITIONAL_3=""
    print_step "Parsing command line"
    # Extract named options and collect remaining positional arguments
    while [ "$#" -gt 0 ]; do
        case "$1" in
            BOOT)
                BOOT_FIRST=1
                print_detail "Option" "BOOT"
                shift
                ;;
            STORAGE)
                [ "$#" -ge 2 ] || { print_usage; die "STORAGE requires a storage name."; }
                STORAGE="$2"
                print_detail "Storage override" "$STORAGE"
                shift 2
                ;;
            *)
                POSITIONAL_COUNT=$((POSITIONAL_COUNT + 1))
                case "$POSITIONAL_COUNT" in
                    1) POSITIONAL_1="$1" ;;
                    2) POSITIONAL_2="$1" ;;
                    3) POSITIONAL_3="$1" ;;
                    *) print_usage; die "Too many positional arguments." ;;
                esac
                shift
                ;;
        esac
    done
    [ "$POSITIONAL_COUNT" -ge 1 ] || { print_usage; exit 1; }
    FILE_ARG="$POSITIONAL_1"
    # <file> <size> <vmid>
    if [ "$POSITIONAL_COUNT" -eq 3 ]; then
        SIZE_ARG="$POSITIONAL_2"
        TARGET_VMID="$POSITIONAL_3"
        print_detail "Image" "$FILE_ARG"
        print_detail "Requested size" "$SIZE_ARG"
        print_detail "Target VMID" "$TARGET_VMID"
        return 0
    fi
    # <file> <size|vmid>
    if [ "$POSITIONAL_COUNT" -eq 2 ]; then
        if is_number "$POSITIONAL_2" && [ "$POSITIONAL_2" -lt 99999 ] 2>/dev/null; then TARGET_VMID="$POSITIONAL_2"
        else SIZE_ARG="$POSITIONAL_2"; fi
    fi
    print_detail "Image" "$FILE_ARG"
    if [ -n "$SIZE_ARG" ]; then print_detail "Requested size" "$SIZE_ARG"; fi
    if [ -n "$TARGET_VMID" ]; then print_detail "Target VMID" "$TARGET_VMID"; fi
    return 0
}

print_usage() {
    printf '%s%sCreateUsbStick.sh%s\n' "$BOLD" "$CYAN" "$RESET"
    printf 'Create a reusable Proxmox LVM-thin USB image and optionally attach\n'
    printf 'a writable thin snapshot of it to a VM.\n'
    printf '\n%sSyntax:%s\n' "$BOLD" "$RESET"
    printf '  %s <file>\n' "$0"
    printf '  %s <file> <size>\n' "$0"
    printf '  %s <file> <vmid>\n' "$0"
    printf '  %s <file> <size> <vmid>\n' "$0"
    printf '  %s <file> [size] [vmid] [BOOT] [STORAGE <storage>]\n' "$0"
    printf '\n%sArguments:%s\n' "$BOLD" "$RESET"
    printf '  %-20s USB/disk image file.\n' '<file>'
    printf '  %-20s Optional volume size, for example 512M, 4G or 64G.\n' '<size>'
    printf '  %-20s Existing Proxmox VM to receive a writable thin snapshot.\n' '<vmid>'
    printf '  %-20s Put the attached snapshot first in the VM boot order.\n' 'BOOT'
    printf '  %-20s Use the specified LVM-thin storage instead of %s.\n' 'STORAGE <storage>' "$STORAGE"
    printf '\n%sFile resolution:%s\n' "$BOLD" "$RESET"
    printf '  If <file> already exists as given, that path is used directly.\n'
    printf '  Otherwise it is looked up relative to:\n'
    printf '      %s\n' "$IMAGE_ROOT"
    printf '\n%sSize and VMID rules:%s\n' "$BOLD" "$RESET"
    printf '  With no size, the source image size is used automatically.\n'
    printf '  A numeric second positional argument below 99999 is a VMID.\n'
    printf '  With three positional arguments, they are always:\n'
    printf '      <file> <size> <vmid>\n'
    printf '  BOOT and STORAGE <storage> may appear anywhere in the command.\n'
    printf '\n%sCommon examples:%s\n' "$BOLD" "$RESET"
    printf '  # Create or reuse the base image\n'
    printf '  %s toolsdisk.usb\n' "$0"
    printf '\n  # Attach a writable snapshot to VM 101\n'
    printf '  %s toolsdisk.usb 101\n' "$0"
    printf '\n  # Attach to VM 101 and boot from it first\n'
    printf '  %s toolsdisk.usb 101 BOOT\n' "$0"
    printf '\n  # Create a 64G base and attach a snapshot to VM 110\n'
    printf '  %s ipxe-online-efi.usb 64G 110\n' "$0"
    printf '\n  # Same, but make the USB image first in the boot order\n'
    printf '  %s ipxe-online-efi.usb 64G 110 BOOT\n' "$0"
    printf '\n  # Use another Proxmox LVM-thin storage\n'
    printf '  %s toolsdisk.usb 101 STORAGE local-14tb-lvm\n' "$0"
    printf '\n  # Options may also come first\n'
    printf '  %s STORAGE local-14tb-lvm BOOT toolsdisk.usb 16G 101\n' "$0"
    printf '\n  # Use an existing absolute path directly\n'
    printf '  %s /root/images/toolsdisk.usb 101 BOOT\n' "$0"
}

############################################################
# HIGH LEVEL TASKS
############################################################

validate_environment() {
    print_step "Validating Proxmox environment"
    [ "$(id -u)" -eq 0 ] || die "This script must be run as root."
    require_directory "$IMAGE_ROOT"
    require_file "$STORAGE_CONFIG_FILE"
    require_command qm
    require_command pct
    require_command pvesm
    require_command lvs
    require_command lvcreate
    require_command lvremove
    require_command numfmt
    require_command stat
    require_command readlink
    require_command awk
    require_command grep
    require_command cut
    require_command seq
    require_command tr
    print_success "Environment looks good."
}

resolve_source_file() {
    print_step "Resolving source image"
    # First use the path exactly as provided, otherwise resolve relative to IMAGE_ROOT
    if [ -f "$FILE_ARG" ]; then
        SOURCE_FILE="$(readlink -f "$FILE_ARG")"
        print_info "Using file exactly as provided."
    elif [ -f "$IMAGE_ROOT/$FILE_ARG" ]; then
        SOURCE_FILE="$(readlink -f "$IMAGE_ROOT/$FILE_ARG")"
        print_info "Found image under $IMAGE_ROOT."
    else die "File not found: $FILE_ARG or $IMAGE_ROOT/$FILE_ARG"; fi
    IMAGE_NAME="$(basename "$SOURCE_FILE")"
    case "$IMAGE_NAME" in *[[:space:]]*) die "Filename contains whitespace and cannot be used exactly as a Proxmox VM name: $IMAGE_NAME" ;; esac
    print_detail "Resolved file" "$SOURCE_FILE"
    print_detail "Proxmox name" "$IMAGE_NAME"
}

resolve_image_size() {
    print_step "Determining image size"
    SOURCE_BYTES="$(stat -c '%s' "$SOURCE_FILE")"
    DESIRED_BYTES="$SOURCE_BYTES"
    print_detail "Source size" "$(human_size "$SOURCE_BYTES") ($SOURCE_BYTES bytes)"
    if [ -z "$SIZE_ARG" ]; then
        print_info "No size supplied; using the source image size."
        return 0
    fi
    DESIRED_BYTES="$(size_to_bytes "$SIZE_ARG")" || die "Invalid size: $SIZE_ARG"
    [ "$DESIRED_BYTES" -ge "$SOURCE_BYTES" ] || die "Requested size is smaller than the source image."
    print_detail "Virtual size" "$(human_size "$DESIRED_BYTES") ($DESIRED_BYTES bytes)"
    return 0
}

resolve_storage() {
    print_step "Resolving Proxmox storage"
    print_detail "Storage" "$STORAGE"
    STORAGE_TYPE="$(awk -v NAME="$STORAGE" '$2 == NAME && $1 ~ /:$/ {TYPE=$1; sub(/:$/, "", TYPE); print TYPE; exit}' "$STORAGE_CONFIG_FILE")"
    [ -n "$STORAGE_TYPE" ] || die "Storage does not exist in $STORAGE_CONFIG_FILE: $STORAGE"
    [ "$STORAGE_TYPE" = "lvmthin" ] || die "Storage $STORAGE is type $STORAGE_TYPE, not LVM-thin."
    VG="$(read_storage_option "$STORAGE" vgname)"
    THINPOOL="$(read_storage_option "$STORAGE" thinpool)"
    [ -n "$VG" ] && [ -n "$THINPOOL" ] || die "Could not determine VG/thinpool for $STORAGE."
    pvesm status | awk -v NAME="$STORAGE" 'NR > 1 && $1 == NAME && $3 == "active" {FOUND=1} END {exit !FOUND}' || die "Storage $STORAGE is not active."
    lvs "$VG/$THINPOOL" >/dev/null 2>&1 || die "LVM thin pool does not exist: $VG/$THINPOOL"
    print_detail "Type" "$STORAGE_TYPE"
    print_detail "Volume group" "$VG"
    print_detail "Thin pool" "$THINPOOL"
    print_success "Storage $STORAGE is active."
}

find_existing_base() {
    print_step "Looking for existing base template"
    MATCHES="$(qm list | awk -v NAME="$IMAGE_NAME" 'NR > 1 && $2 == NAME {print $1}')"
    MATCH_COUNT="$(printf '%s\n' "$MATCHES" | awk 'NF {N++} END {print N+0}')"
    [ "$MATCH_COUNT" -le 1 ] || die "More than one VM/template is named exactly $IMAGE_NAME."
    if [ "$MATCH_COUNT" -eq 1 ]; then
        BASE_VMID="$MATCHES"
        qm config "$BASE_VMID" | grep -q '^template: 1$' || die "VM $BASE_VMID is named $IMAGE_NAME but is not a template."
        BASE_EXISTS=1
        print_success "Found existing base template VM $BASE_VMID."
        return 0
    fi
    if pct list | awk -v NAME="$IMAGE_NAME" 'NR > 1 && $NF == NAME {FOUND=1} END {exit !FOUND}'; then
        die "A container already exists with the exact name $IMAGE_NAME."
    fi
    print_info "No existing base template named $IMAGE_NAME was found."
    return 0
}

validate_target_vm() {
    print_step "Validating target VM"
    if [ "$BOOT_FIRST" -eq 1 ] && [ -z "$TARGET_VMID" ]; then die "BOOT requires a target VMID."; fi
    if [ -z "$TARGET_VMID" ]; then
        print_info "No target VM was requested."
        return 0
    fi
    is_number "$TARGET_VMID" || die "VMID must be numeric: $TARGET_VMID"
    qm config "$TARGET_VMID" >/dev/null 2>&1 || die "VM $TARGET_VMID does not exist."
    if qm config "$TARGET_VMID" | grep -q '^template: 1$'; then die "VM $TARGET_VMID is a template."; fi
    TARGET_NAME="$(qm config "$TARGET_VMID" | awk -F': ' '$1=="name" {print $2; exit}')"
    print_detail "VMID" "$TARGET_VMID"
    print_detail "VM name" "${TARGET_NAME:-unnamed}"
    print_success "Target VM $TARGET_VMID is valid."
    return 0
}

print_configuration() {
    print_banner "Configuration"
    printf '%sVersion%s      : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$SCRIPT_VERSION" "$RESET"
    printf '%sSource%s       : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$SOURCE_FILE" "$RESET"
    printf '%sName%s         : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$IMAGE_NAME" "$RESET"
    printf '%sSource size%s  : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$(human_size "$SOURCE_BYTES")" "$RESET"
    printf '%sVolume size%s  : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$(human_size "$DESIRED_BYTES")" "$RESET"
    printf '%sStorage%s      : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$STORAGE" "$RESET"
    printf '%sLVM%s          : %s%s/%s%s\n' "$CYAN" "$RESET" "$BLUE" "$VG" "$THINPOOL" "$RESET"
    printf '%sBase VMIDs%s   : %s%s+%s\n' "$CYAN" "$RESET" "$BLUE" "$BASE_VMID_START" "$RESET"
    if [ "$BASE_EXISTS" -eq 1 ]; then printf '%sBase%s         : %sExisting template VM %s%s\n' "$CYAN" "$RESET" "$GREEN" "$BASE_VMID" "$RESET"
    else printf '%sBase%s         : %sWill be created%s\n' "$CYAN" "$RESET" "$YELLOW" "$RESET"; fi
    if [ -n "$TARGET_VMID" ]; then printf '%sTarget VM%s    : %s%s%s\n' "$CYAN" "$RESET" "$BLUE" "$TARGET_VMID" "$RESET"; fi
    if [ "$BOOT_FIRST" -eq 1 ]; then printf '%sBoot%s         : %sUSB image first%s\n' "$CYAN" "$RESET" "$GREEN" "$RESET"
    else printf '%sBoot%s         : %sExisting order unchanged%s\n' "$CYAN" "$RESET" "$YELLOW" "$RESET"; fi
    return 0
}

############################################################
# BASE TEMPLATE
############################################################

find_next_base_vmid() {
    print_step "Finding free base VMID"
    BASE_VMID="$BASE_VMID_START"
    while vmid_exists "$BASE_VMID"; do BASE_VMID=$((BASE_VMID + 1)); done
    print_detail "Reserved range" "${BASE_VMID_START}+"
    print_detail "Selected VMID" "$BASE_VMID"
}

vmid_exists() {
    VMID="$1"
    for NODE_DIR in /etc/pve/nodes/*; do
        [ -d "$NODE_DIR" ] || continue
        [ -e "$NODE_DIR/qemu-server/$VMID.conf" ] && return 0
        [ -e "$NODE_DIR/lxc/$VMID.conf" ] && return 0
    done
    return 1
}

ensure_base_template() {
    if [ "$BASE_EXISTS" -eq 1 ]; then
        print_banner "Using existing base - $IMAGE_NAME"
        print_info "Reusing template VM $BASE_VMID."
        if [ -n "$SIZE_ARG" ]; then print_warning "Base already exists; requested size $SIZE_ARG is ignored."; fi
    else
        print_banner "Creating base - $IMAGE_NAME"
        create_base_template
    fi
    resolve_base_volume
}

create_base_template() {
    find_next_base_vmid
    print_step "Creating holder VM"
    print_detail "VMID" "$BASE_VMID"
    print_detail "Name" "$IMAGE_NAME"
    qm create "$BASE_VMID" --name "$IMAGE_NAME" --memory 64 --ostype l26
    print_success "Holder VM $BASE_VMID created."
    import_base_disk
    resize_base_disk
    print_step "Converting holder VM to template"
    qm template "$BASE_VMID"
    BASE_EXISTS=1
    print_success "Template VM $BASE_VMID created."
}

import_base_disk() {
    print_step "Importing USB image into $STORAGE"
    print_detail "Source" "$SOURCE_FILE"
    qm importdisk "$BASE_VMID" "$SOURCE_FILE" "$STORAGE" --format raw
    IMPORTED_VOL="$(qm config "$BASE_VMID" | awk -F': ' '/^unused[0-9]+:/ {print $2; exit}' | cut -d',' -f1)"
    [ -n "$IMPORTED_VOL" ] || die "Import completed but no unused disk was found."
    case "$IMPORTED_VOL" in "$STORAGE:"*) ;; *) die "Imported volume is not on $STORAGE: $IMPORTED_VOL" ;; esac
    print_detail "Imported volume" "$IMPORTED_VOL"
    print_step "Attaching imported disk to base as sata0"
    qm set "$BASE_VMID" --sata0 "$IMPORTED_VOL"
    print_success "Base disk attached."
}

resize_base_disk() {
    if [ -z "$SIZE_ARG" ]; then
        print_info "No explicit virtual size requested; base disk will keep its imported size."
        return 0
    fi
    IMPORTED_LV="${IMPORTED_VOL#"$STORAGE:"}"
    CURRENT_BYTES="$(lvs --noheadings --units b --nosuffix -o lv_size "$VG/$IMPORTED_LV" | awk '{$1=$1; printf "%.0f\n", $1}')"
    if [ "$DESIRED_BYTES" -le "$CURRENT_BYTES" ]; then
        print_info "Imported disk is already at least the requested size."
        return 0
    fi
    DESIRED_MIB=$(( (DESIRED_BYTES + 1048575) / 1048576 ))
    print_step "Growing base virtual disk"
    print_detail "Current size" "$(human_size "$CURRENT_BYTES")"
    print_detail "New size" "${DESIRED_MIB} MiB"
    qm resize "$BASE_VMID" sata0 "${DESIRED_MIB}M"
    print_success "Base virtual disk resized."
}

resolve_base_volume() {
    print_step "Resolving base LVM volume"
    BASE_VOLID="$(qm config "$BASE_VMID" | awk -F': ' '$1=="sata0" {print $2; exit}' | cut -d',' -f1)"
    [ -n "$BASE_VOLID" ] || die "Base template $BASE_VMID does not have a sata0 disk."
    case "$BASE_VOLID" in "$STORAGE:"*) ;; *) die "Base disk is not on selected storage $STORAGE: $BASE_VOLID" ;; esac
    BASE_LV="${BASE_VOLID#"$STORAGE:"}"
    lvs "$VG/$BASE_LV" >/dev/null 2>&1 || die "Base LV does not exist: $VG/$BASE_LV"
    print_detail "Proxmox volume" "$BASE_VOLID"
    print_detail "LVM volume" "$VG/$BASE_LV"
    print_success "Base volume is ready."
}

############################################################
# TARGET SNAPSHOT
############################################################
 
Code:
############################################################
# TARGET SNAPSHOT
############################################################

create_target_snapshot() {
    print_banner "Creating snapshot for VM $TARGET_VMID"
    find_snapshot_name
    find_sata_slot
    create_lvm_snapshot
    attach_snapshot
    if [ "$BOOT_FIRST" -eq 1 ]; then set_snapshot_first_in_boot_order
    else print_info "BOOT was not requested; existing VM boot order will remain unchanged."; fi
    verify_target_attachment
}

find_snapshot_name() {
    print_step "Choosing Proxmox-style snapshot volume name"
    SNAP_LV=""
    for NUMBER in $(seq 0 999); do
        CANDIDATE="vm-${TARGET_VMID}-disk-${NUMBER}"
        if ! lvs "$VG/$CANDIDATE" >/dev/null 2>&1; then SNAP_LV="$CANDIDATE"; break; fi
    done
    [ -n "$SNAP_LV" ] || die "Could not find a free vm-${TARGET_VMID}-disk-N volume name."
    print_detail "Snapshot LV" "$SNAP_LV"
}

find_sata_slot() {
    print_step "Finding free SATA slot on VM $TARGET_VMID"
    SATA_SLOT=""
    TARGET_CONFIG="$(qm config "$TARGET_VMID")"
    for NUMBER in 0 1 2 3 4 5; do
        if ! printf '%s\n' "$TARGET_CONFIG" | grep -q "^sata${NUMBER}:"; then SATA_SLOT="sata${NUMBER}"; break; fi
    done
    [ -n "$SATA_SLOT" ] || die "VM $TARGET_VMID has no free SATA slot."
    print_detail "Selected slot" "$SATA_SLOT"
}

create_lvm_snapshot() {
    print_step "Creating writable LVM-thin snapshot"
    print_detail "Origin" "$VG/$BASE_LV"
    print_detail "Snapshot" "$VG/$SNAP_LV"
    lvcreate --snapshot --name "$SNAP_LV" "$VG/$BASE_LV" -y
    lvs "$VG/$SNAP_LV" >/dev/null 2>&1 || die "LVM snapshot was not created: $VG/$SNAP_LV"
    print_success "Thin snapshot $SNAP_LV created."
}

attach_snapshot() {
    print_step "Attaching snapshot to VM $TARGET_VMID"
    print_detail "Volume" "$STORAGE:$SNAP_LV"
    print_detail "Device" "$SATA_SLOT"
    if qm set "$TARGET_VMID" "--$SATA_SLOT" "$STORAGE:$SNAP_LV"; then
        print_success "Snapshot attached to VM $TARGET_VMID as $SATA_SLOT."
        return 0
    fi
    print_warning "Attach failed; removing snapshot $VG/$SNAP_LV"
    lvremove -f "$VG/$SNAP_LV" >/dev/null 2>&1 || :
    die "Could not attach snapshot to VM $TARGET_VMID."
}

verify_target_attachment() {
    print_step "Verifying VM configuration"
    ATTACHED_LINE="$(qm config "$TARGET_VMID" | grep "^${SATA_SLOT}:" || :)"
    [ -n "$ATTACHED_LINE" ] || die "VM configuration does not contain expected device $SATA_SLOT."
    printf '  %s%s%s\n' "$GREEN" "$ATTACHED_LINE" "$RESET"
    case "$ATTACHED_LINE" in *"$STORAGE:$SNAP_LV"*) ;; *) die "$SATA_SLOT does not reference expected volume $STORAGE:$SNAP_LV." ;; esac
    if [ "$BOOT_FIRST" -eq 1 ]; then
        BOOT_LINE="$(qm config "$TARGET_VMID" | grep '^boot:' || :)"
        [ -n "$BOOT_LINE" ] && printf '  %s%s%s\n' "$GREEN" "$BOOT_LINE" "$RESET"
    fi
    print_success "VM $TARGET_VMID configuration verified."
}

############################################################
# BOOT ORDER
############################################################

set_snapshot_first_in_boot_order() {
    print_step "Putting $SATA_SLOT first in the boot order"
    TARGET_CONFIG="$(qm config "$TARGET_VMID")"
    CURRENT_BOOT_ORDER="$(printf '%s\n' "$TARGET_CONFIG" | awk '/^boot: / && /order=/ {LINE=$0; sub(/^.*order=/, "", LINE); print LINE; exit}')"
    if [ -n "$CURRENT_BOOT_ORDER" ]; then BOOT_DEVICES="$(printf '%s' "$CURRENT_BOOT_ORDER" | tr ';' ' ')"
    else BOOT_DEVICES="$(printf '%s\n' "$TARGET_CONFIG" | awk -F: '/^(ide|sata|scsi|virtio)[0-9]+:|^net[0-9]+:/ {print $1}')"; fi
    NEW_BOOT_ORDER="$SATA_SLOT"
    for DEVICE in $BOOT_DEVICES; do
        if [ "$DEVICE" != "$SATA_SLOT" ]; then NEW_BOOT_ORDER="${NEW_BOOT_ORDER};${DEVICE}"; fi
    done
    print_detail "Old order" "${CURRENT_BOOT_ORDER:-not explicitly set}"
    print_detail "New order" "$NEW_BOOT_ORDER"
    qm set "$TARGET_VMID" --boot "order=$NEW_BOOT_ORDER"
    print_success "$SATA_SLOT is now first in the VM boot order."
}

############################################################
# RESULTS
############################################################

print_results() {
    printf '\n%s%sBase template%s\n' "$BOLD" "$CYAN" "$RESET"
    printf '  %-10s %s%s%s\n' "VMID:" "$BLUE" "$BASE_VMID" "$RESET"
    printf '  %-10s %s%s%s\n' "Name:" "$BLUE" "$IMAGE_NAME" "$RESET"
    printf '  %-10s %s%s%s\n' "Volume:" "$BLUE" "$BASE_VOLID" "$RESET"
    if [ -z "$TARGET_VMID" ]; then return 0; fi
    printf '\n%s%sVM snapshot%s\n' "$BOLD" "$CYAN" "$RESET"
    printf '  %-10s %s%s%s\n' "VMID:" "$BLUE" "$TARGET_VMID" "$RESET"
    printf '  %-10s %s%s:%s%s\n' "Volume:" "$BLUE" "$STORAGE" "$SNAP_LV" "$RESET"
    printf '  %-10s %s%s%s\n' "Device:" "$BLUE" "$SATA_SLOT" "$RESET"
    if [ "$BOOT_FIRST" -eq 1 ]; then printf '  %-10s %sFirst%s\n' "Boot:" "$GREEN" "$RESET"
    else printf '  %-10s %sUnchanged%s\n' "Boot:" "$YELLOW" "$RESET"; fi
    printf '\n%sRelevant VM configuration:%s\n' "$BOLD" "$RESET"
    qm config "$TARGET_VMID" | grep -E "^(${SATA_SLOT}|boot):" || :
    return 0
}

############################################################
# SIZE HELPERS
############################################################

size_to_bytes() (
    VALUE="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
    case "$VALUE" in *IB) VALUE="${VALUE%IB}" ;; *B) VALUE="${VALUE%B}" ;; esac
    if is_number "$VALUE"; then printf '%s\n' "$VALUE"
    else numfmt --from=iec "$VALUE"; fi
)

human_size() (
    numfmt --to=iec --suffix=B "$1"
)

############################################################
# STORAGE HELPERS
############################################################

read_storage_option() (
    STORAGE_NAME="$1"
    OPTION_NAME="$2"
    awk -v NAME="$STORAGE_NAME" -v OPTION="$OPTION_NAME" '
        $1 ~ /:$/ {ACTIVE=($2 == NAME); next}
        ACTIVE && $1 == OPTION {print $2; exit}
    ' "$STORAGE_CONFIG_FILE"
)

############################################################
# GENERAL HELPERS
############################################################

define_colours() {
    if [ -t 1 ]; then ESC="$(printf '\033')"; RESET="${ESC}[0m"; RED="${ESC}[31m"; GREEN="${ESC}[32m"; YELLOW="${ESC}[33m"; BLUE="${ESC}[34m"; CYAN="${ESC}[36m"; BOLD="${ESC}[1m"
    else RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; CYAN=""; BOLD=""; fi
}

is_number() { case "$1" in ""|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }
print_banner() { printf '\n%s%s============================================================\n %s\n============================================================%s\n' "$BOLD" "$CYAN" "$1" "$RESET"; }
print_step() { printf '%s%s==>%s %s\n' "$BOLD" "$CYAN" "$RESET" "$1"; }
print_info() { printf '%sINFO:%s %s\n' "$BLUE" "$RESET" "$1"; }
print_detail() { printf '  %s%-18s%s %s%s%s\n' "$CYAN" "$1:" "$RESET" "$BLUE" "$2" "$RESET"; }
print_success() { printf '%s%sOK:%s %s\n' "$BOLD" "$GREEN" "$RESET" "$1"; }
print_warning() { printf '%s%sWARNING:%s %s\n' "$BOLD" "$YELLOW" "$RESET" "$1"; }
die() { printf '%s%sERROR:%s %s\n' "$BOLD" "$RED" "$RESET" "$1" >&2; exit 1; }
require_directory() { [ -d "$1" ] || die "Directory does not exist: $1"; }
require_file() { [ -f "$1" ] || die "File does not exist: $1"; }
require_command() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"; }

############################################################
# START
############################################################

setup "$@"
main
end
 
It creates a base disk inside a VM with a VMID over 99000.

1786271169238.png
And the snapshot gets attached as a sataX drive

1786271188084.png
 
So my question is, was there a way to do this in the web ui ?
because that was a lot of work
thanks !