#!/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
############################################################