#!/usr/bin/env bash set -Eeuo pipefail # --------------------------- # Logging / helpers # --------------------------- _ts() { date '+%F %T'; } _is_tty() { [[ -t 1 ]]; } log() { echo "[$(_ts)] $*" >&2; } info() { log "INFO: $*"; } warn() { log "WARN: $*"; } die() { log "ERROR: $*"; exit 1; } need_cmd() { local c for c in "$@"; do command -v "$c" >/dev/null 2>&1 || die "Missing command: $c" done } on_error() { local lineno="$1" cmd="$2" code="$3" die "Failed at line ${lineno}: ${cmd} (exit=${code})" } setup_traps() { trap 'on_error "${LINENO}" "${BASH_COMMAND}" "$?"' ERR } # --------------------------- # Proxmox helpers # --------------------------- pve_storage_exists() { local st="$1" pvesm status --storage "$st" >/dev/null 2>&1 } pve_bridge_exists() { local br="$1" [[ -d "/sys/class/net/${br}/bridge" ]] } # Ensure we return ONLY the template reference on stdout. # All logs go to stderr to avoid contaminating command substitution. pve_template_ensure_debian12() { local preferred_store="$1" local tpl="debian-12-standard_12.12-1_amd64.tar.zst" local store="$preferred_store" need_cmd pveam awk grep # Some storages (e.g. local-zfs) are not template storages for pveam. if ! pveam list "$store" >/dev/null 2>&1; then warn "pveam storage '${store}' not available for templates; falling back to 'local'" store="local" fi # Update list and download if missing pveam update >/dev/null 2>&1 || true if ! pveam list "$store" | awk '{print $2}' | grep -qx "$tpl"; then info "Downloading CT template to ${store}: ${tpl}" pveam download "$store" "$tpl" >/dev/null fi echo "${store}:vztmpl/${tpl}" } pve_build_net0() { local bridge="$1" ip="$2" if [[ "$ip" == "dhcp" ]]; then echo "name=eth0,bridge=${bridge},ip=dhcp" else echo "name=eth0,bridge=${bridge},ip=${ip},gw=$(echo "$ip" | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+)\.[0-9]+\/[0-9]+/\1.1/')" fi } # Execute inside CT with sane locale env to avoid perl warnings mid-run. pct_exec() { local ctid="$1"; shift local cmd="$*" need_cmd pct pct exec "$ctid" -- bash -lc "export LANG=C.UTF-8 LC_ALL=C.UTF-8; ${cmd}" } pct_wait_for_ip() { local ctid="$1" local i ip for i in {1..60}; do # Try eth0 first (standard CT) ip="$(pct exec "$ctid" -- bash -lc "ip -4 -o addr show dev eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | head -n1" 2>/dev/null || true)" if [[ -n "$ip" ]]; then echo "$ip" return 0 fi sleep 1 done return 1 } # --------------------------- # Cluster VMID discovery (robust) # --------------------------- # Outputs one VMID per line (containers + VMs), cluster-wide if possible. pve_cluster_vmids() { need_cmd pvesh awk sed grep # Try cluster resources; output-format json gives stable machine output. # If pvesh fails (no cluster or temporary glitch), fall back to local IDs. if pvesh get /cluster/resources --output-format json >/dev/null 2>&1; then # No python/jq parsing. Extract "vmid": safely via awk/grep. pvesh get /cluster/resources --output-format json \ | tr -d '\n' \ | sed 's/},{/}\n{/g' \ | grep -oE '"vmid":[0-9]+' \ | grep -oE '[0-9]+' \ | sort -n -u return 0 fi # Fallback: local node only { pct list 2>/dev/null | awk 'NR>1 {print $1}' qm list 2>/dev/null | awk 'NR>1 {print $1}' } | sort -n -u } pve_vmid_exists_cluster() { local id="$1" pve_cluster_vmids | awk -v x="$id" '$0==x {found=1} END{exit(found?0:1)}' } # Customer CTID: unix_time - 1_000_000_000 (good until 2038), # then increment until free cluster-wide. pve_select_customer_ctid() { local base now ctid tries now="$(date +%s)" base=$(( now - 1000000000 )) # keep it within a sane positive range if (( base < 100 )); then base=100 fi ctid="$base" for tries in {1..2000}; do if ! pve_vmid_exists_cluster "$ctid"; then echo "$ctid" return 0 fi ctid=$((ctid + 1)) done die "CTID selection failed: tried ${base}..$((base+1999)) (all occupied?)" }