Skip to content

Instantly share code, notes, and snippets.

@MrZoidberg
Last active October 9, 2025 19:45
Show Gist options
  • Save MrZoidberg/99c5efca589cc83a9b89666a662861ed to your computer and use it in GitHub Desktop.
Save MrZoidberg/99c5efca589cc83a9b89666a662861ed to your computer and use it in GitHub Desktop.
Create proxmox VM
set -euo pipefail
get_storage_type() {
local id="$1"
# Prefer config file: exact and quiet
local t
t="$(awk -v id="$id" -F'[: ]+' '
$1 ~ /^(dir|zfspool|zfs|lvmthin|lvm|rbd|cephfs|nfs|cifs)$/ && $2==id { print $1; found=1; exit }
END { if (!found) exit 1 }
' /etc/pve/storage.cfg 2>/dev/null)" || true
if [[ -n "$t" ]]; then
printf '%s\n' "$t"
return 0
fi
# Fallback to pvesm status; ignore stderr noise from broken storages
pvesm status 2>/dev/null | awk -v s="$id" '$1==s{print $2; exit}'
}
current_size_gb() {
local vmid="$1" s volid path
s=$(qm config "$vmid" | sed -n 's/^scsi0: .*size=\([0-9]\+\)G.*/\1/p')
if [[ -n "$s" ]]; then printf '%s\n' "$s"; return 0; fi
# Fallback (file-based images): use pvesm to resolve path, then qemu-img info
volid=$(qm config "$vmid" | sed -n 's/^scsi0:\s\+\([^,]\+\).*/\1/p') || true
if [[ -n "$volid" ]]; then
path=$(pvesm path "$volid" 2>/dev/null) || true
if [[ -n "$path" && -e "$path" ]]; then
s=$(qemu-img info "$path" | awk '/virtual size:/{gsub(/[()]/,""); sub(/G/,"",$3); print int($3)}')
if [[ -n "$s" ]]; then printf '%s\n' "$s"; return 0; fi
fi
fi
return 1
}
resize_incremental() {
local vmid="$1" target_size="$2"
if [[ "$SLOW_STORAGE" != "true" ]]; then
echo "Resizing disk directly to ${target_size}..."
set +e
qm resize "$vmid" scsi0 "$target_size"
local rc=$?
set -e
return $rc
fi
local target_gb cur_gb step next attempt ok rc new_gb
target_gb=$(echo "$target_size" | grep -oE '[0-9]+')
cur_gb=$(current_size_gb "$vmid") || cur_gb=0
echo "SLOW_STORAGE on — resizing scsi0: ${cur_gb}G → ${target_gb}G in +${INCREMENT_STEP_GB}G steps..."
while (( cur_gb < target_gb )); do
step=$INCREMENT_STEP_GB
(( cur_gb + step > target_gb )) && step=$(( target_gb - cur_gb ))
next=$(( cur_gb + step ))
echo " → Extending by +${step}G (to ${next}G)..."
attempt=1 ok=0
while (( attempt <= RESIZE_MAX_ATTEMPTS )); do
set +e
qm resize "$vmid" scsi0 "+${step}G"
rc=$?
set -e
new_gb=$(current_size_gb "$vmid") || new_gb=$cur_gb
if (( rc == 0 )) && (( new_gb >= next )); then
ok=1
cur_gb=$new_gb
echo " ✔ step applied (now ${new_gb}G)"
break
fi
echo " ⚠️ attempt ${attempt}/${RESIZE_MAX_ATTEMPTS} failed or not applied (rc=${rc}, size=${new_gb}G). Retrying in ${RESIZE_RETRY_DELAY}s..."
sleep "$RESIZE_RETRY_DELAY"
(( attempt++ ))
done
if (( ! ok )); then
echo "❌ Failed to reach ${next}G after ${RESIZE_MAX_ATTEMPTS} attempts."
return 1
fi
done
echo "✅ Resize complete: $(current_size_gb "$vmid")G"
}
# ====== DEFAULTS (adjust if needed) ======
DISK_STORE="local" # VM disk storage (where VM disks + CI drive live)
ISO_STORE="local" # where the cloud image file resides
CLOUDIMG="ubuntu-24.04-server-cloudimg-amd64.img"
RAM_MB=1024 # 10 GB
VCPUS=1
SSH_PUBKEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKz37q3ePf//WaIA41DUvTIcl6nmfpA2wmHnxXsBH4Ls'
CI_USER="mike"
NET0_BR="vmbr0" # DHCP v4 (primary/default gw)
NET1_BR="vnet1" # DHCP v4
DISK_SIZE="8G"
SLOW_STORAGE=true
INCREMENT_STEP_GB=${INCREMENT_STEP_GB:-2}
RESIZE_MAX_ATTEMPTS=${RESIZE_MAX_ATTEMPTS:-3}
RESIZE_RETRY_DELAY=${RESIZE_RETRY_DELAY:-3}
DOMAIN="internal.mmerk.online"
# ========================================
# Derived names based on current Proxmox node
PROXMOX_HOSTNAME="$(hostname -s)"
VM_HOST_SHORT="${VMNAME}.${PROXMOX_HOSTNAME}"
VM_FQDN="${VM_HOST_SHORT}.${DOMAIN}"
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <vmname> <vmid>"
exit 1
fi
VMNAME="$1"
VMID="$2"
STYPE="$(get_storage_type "${DISK_STORE}")"
if [[ -z "${STYPE}" ]]; then
echo "ERROR: could not determine storage type for '${DISK_STORE}'"
exit 1
fi
echo "Storage '${DISK_STORE}' type: ${STYPE}"
# Pick format + disk opts based on storage type
case "${STYPE}" in
zfs|zfspool) IMG_FORMAT="raw"; DISK_OPTS="ssd=1,discard=on,backup=1" ;;
lvm|lvmthin) IMG_FORMAT="raw"; DISK_OPTS="discard=on,backup=1" ;;
dir) IMG_FORMAT="qcow2"; DISK_OPTS="discard=on,backup=1" ;;
*) IMG_FORMAT="qcow2"; DISK_OPTS="backup=1" ;;
esac
# Resolve image path (common locations)
IMG_PATH=""
for p in "./${CLOUDIMG}" "/var/lib/vz/template/iso/${CLOUDIMG}" "/var/lib/vz/template/qemu/${CLOUDIMG}"; do
[[ -f "$p" ]] && IMG_PATH="$p" && break
done
if [[ -z "$IMG_PATH" ]]; then
echo "ERROR: Cannot find ${CLOUDIMG}. Put it next to this script or in /var/lib/vz/template/iso/"
exit 1
fi
# Ensure 'local' storage supports snippets and the dir exists
# pvesm set ${DISK_STORE} --content iso,vztmpl,backup,images,snippets >/dev/null 2>&1 || true
mkdir -p /var/lib/vz/snippets
# 1) Generate a fresh random password for ${CI_USER} (printed at end)
RAND_PW="$(openssl rand -base64 18 | tr -d '\n' | sed 's/[[:space:]]//g')"
HASHED_PW="$(openssl passwd -6 "${RAND_PW}")"
# 2) Paths for snippets (user-data + meta)
CICUSTOM_DIR="/var/lib/vz/snippets"
USERDATA="${CICUSTOM_DIR}/${VMNAME}-${VMID}-cloudinit.yaml"
METADATA="${CICUSTOM_DIR}/${VMNAME}-${VMID}-meta.yaml"
# 3) Write USER-DATA (no host/guest var ambiguity; only ${VMNAME}, ${CI_USER}, ${RAND_PW}, ${SSH_PUBKEY} expand here)
cat > "${USERDATA}" <<EOF
#cloud-config
manage_etc_hosts: true
package_update: false
package_upgrade: false
ssh_pwauth: true
disable_root: true
preserve_hostname: false
hostname: ${VM_HOST_SHORT}
fqdn: ${VM_FQDN}
growpart:
mode: auto
devices: ["/"]
ignore_growroot_disabled: false
resize_rootfs: true
users:
- default
- name: ${CI_USER}
gecos: ${CI_USER}
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo, docker
lock_passwd: false
passwd: ${HASHED_PW}
ssh_authorized_keys:
- ${SSH_PUBKEY}
write_files:
# Force apt to use IPv4 (prevents IPv6 mirror failures)
- path: /etc/apt/apt.conf.d/99force-ipv4
owner: root:root
permissions: '0644'
content: |
Acquire::ForceIPv4 "true";
# sshd: allow password auth (keys still work)
- path: /etc/ssh/sshd_config.d/99-enable-passwords.conf
owner: root:root
permissions: '0644'
content: |
PasswordAuthentication yes
# Netplan overlay: default GW on eth0; suppress DHCP routes from eth1
- path: /etc/netplan/90-ci-default-gw.yaml
owner: root:root
permissions: '0600'
content: |
network:
version: 2
renderer: networkd
ethernets:
eth0:
dhcp4: true
dhcp6: false
routes:
- to: default
via: 192.168.10.1
metric: 10
eth1:
dhcp4: true
dhcp6: false
dhcp4-overrides:
use-routes: false
runcmd:
# Apply netplan first and wait for eth0 connectivity
- netplan apply
- systemctl enable systemd-networkd-wait-online.service || true
- systemd-networkd-wait-online -i eth0 --timeout=30 || true
# ---- APT (IPv4-only) ----
- apt-get update
- apt-get upgrade -y
# ---- Docker CE (official repo, per docs.docker.com) ----
- apt-get install -y ca-certificates curl gnupg
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
- chmod a+r /etc/apt/keyrings/docker.gpg
- bash -lc 'echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list'
- apt-get update
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- usermod -aG docker ${CI_USER}
- systemctl enable docker || true
- systemctl start docker || true
# ---- Other packages ----
- apt-get install -y qemu-guest-agent btop net-tools
- systemctl enable qemu-guest-agent || true
- systemctl start qemu-guest-agent || true
# ---- Micro editor ----
- bash -lc 'cd /root && curl -fsSL https://getmic.ro | bash && install -m 0755 micro /usr/local/bin/micro'
# ---- SSH service ----
- systemctl enable ssh
- systemctl restart ssh
# One-time reboot to settle everything
- reboot
EOF
# 4) Write META-DATA with a fresh instance-id (forces cloud-init re-apply)
cat > "${METADATA}" <<EOF
instance-id: vm-${VMID}-$(date +%s)
local-hostname: ${VMNAME}
EOF
# Sanity: verify snippets exist before calling qm set (prevents "volume does not exist")
if [[ ! -s "${USERDATA}" ]]; then
echo "ERROR: user-data snippet not found or empty at ${USERDATA}"
exit 1
fi
if [[ ! -s "${METADATA}" ]]; then
echo "ERROR: meta-data snippet not found or empty at ${METADATA}"
exit 1
fi
# 5) Create or update the VM
if ! qm status "${VMID}" >/dev/null 2>&1; then
echo "Creating VM ${VMID} (${VMNAME})..."
qm create "${VMID}" \
--name "${VMNAME}" \
--memory "${RAM_MB}" \
--cores "${VCPUS}" \
--net0 "virtio,bridge=${NET0_BR}" \
--net1 "virtio,bridge=${NET1_BR}" \
--agent "enabled=1,fstrim_cloned_disks=1"
echo "Attaching new disk "
qm set ${VMID} --scsihw virtio-scsi-pci --scsi0 "${DISK_STORE}:0,${DISK_OPTS},import-from=${IMG_PATH},format=${IMG_FORMAT}"
echo "Waiting for imported disk..."
for i in {1..20}; do
if qm config "${VMID}" | grep -q "scsi0: ${DISK_STORE}:"; then break; fi
sleep 2
done
resize_incremental "${VMID}" "${DISK_SIZE}"
echo "Adding Cloud-Init drive (ide2 on ${DISK_STORE})..."
qm set "${VMID}" --ide2 "${DISK_STORE}:cloudinit"
# Serial console (cloud images expect it)
qm set "${VMID}" --serial0 socket --vga serial0
# Boot from the imported cloud image
qm set "${VMID}" --boot order=scsi0 --bootdisk scsi0
# Attach snippets (user + meta)
qm set "${VMID}" \
--ciuser "${CI_USER}" \
--ipconfig0 "ip=dhcp" \
--ipconfig1 "ip=dhcp" \
--cicustom "user=local:snippets/$(basename "${USERDATA}"),meta=local:snippets/$(basename "${METADATA}")"
qm set "${VMID}" --ostype l26 --onboot 1
else
echo "VM ${VMID} already exists — updating Cloud-Init configuration..."
# Ensure it has a cloud-init drive; if not, add one
if ! qm config "${VMID}" | grep -q '^ide2: .*cloudinit'; then
qm set "${VMID}" --ide2 "${DISK_STORE}:cloudinit"
fi
# Ensure the VM uses our snippets (user + meta)
qm set "${VMID}" \
--cicustom "user=local:snippets/$(basename "${USERDATA}"),meta=local:snippets/$(basename "${METADATA}")"
fi
# 6) Rebuild Cloud-Init ISO to include the updated snippets
echo "Regenerating Cloud-Init ISO..."
qm cloudinit update "${VMID}"
# 7) Show effective user-data/meta-data (sanity)
echo "Effective user-data:"
qm cloudinit dump "${VMID}" user || true
echo "Effective meta-data:"
qm cloudinit dump "${VMID}" meta || true
# 8) Start (or restart) the VM
if qm status "${VMID}" 2>/dev/null | grep -q running; then
echo "VM ${VMID} is running. Rebooting to apply new cloud-init (guest will reboot again via runcmd)..."
# qm reboot "${VMID}" || true
else
echo "Starting VM ${VMID}..."
# qm start "${VMID}"
fi
echo
echo "✅ VM ${VMID} (${VMNAME}) provision/update triggered."
echo " - User: ${CI_USER}"
echo " - Temporary password: ${RAND_PW}"
echo " - Docker CE + compose (official repo), qemu-guest-agent, btop, net-tools, micro"
echo " - Netplan: default via 192.168.10.1 on eth0; routes from eth1 suppressed"
echo " - Cloud-Init ISO rebuilt; instance-id rotated."
echo " - Expect one additional reboot from 'runcmd'."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment