#!/usr/bin/env bash set -Eeuo pipefail # ------------------------- # Logging helpers (stderr!) # ------------------------- _is_tty() { [[ -t 2 ]]; } _ts() { date '+%F %T'; } 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" ]] } # Return a list of all VMIDs in the cluster (VM + CT) # Output: one vmid per line (stdout only) pve_cluster_vmids() { need_cmd pvesh python3 pvesh get /cluster/resources --type vm --output-format json \ | python3 - <<'PY' import json,sys data=json.load(sys.stdin) ids=sorted({int(x["vmid"]) for x in data if "vmid" in x}) for i in ids: print(i) PY } # Pick a free CTID cluster-wide. Default start=2000 to avoid collisions with "classic" ranges. pve_next_free_ctid() { need_cmd python3 local start="${1:-2000}" local end="${2:-9999}" local used used="$(pve_cluster_vmids || true)" python3 - < fallback. 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 template list (quietly) pveam update >/dev/null 2>&1 || true # Download if missing if ! pveam list "$store" 2>/dev/null | awk '{print $2}' | grep -qx "$tpl"; then info "Downloading CT template to ${store}: ${tpl}" pveam download "$store" "$tpl" >/dev/null fi # Print template ref for pct create echo "${store}:vztmpl/${tpl}" } pve_build_net0() { local bridge="$1" local ip="$2" if [[ "$ip" == "dhcp" ]]; then echo "name=eth0,bridge=${bridge},ip=dhcp" else # expects CIDR e.g. 192.168.45.171/24 (no gateway here; can be set later if needed) echo "name=eth0,bridge=${bridge},ip=${ip}" fi } pve_create_ct() { need_cmd pct local ctid="$1" template="$2" hostname="$3" local cores="$4" memory="$5" swap="$6" local storage="$7" disk="$8" local bridge="$9" ip="${10}" unpriv="${11}" local net0 rootfs features net0="$(pve_build_net0 "$bridge" "$ip")" rootfs="${storage}:${disk}" features="nesting=1,keyctl=1,fuse=1" info "Creating CT ${ctid} (${hostname}) from ${template}" pct create "$ctid" "$template" \ --hostname "$hostname" \ --cores "$cores" \ --memory "$memory" \ --swap "$swap" \ --net0 "$net0" \ --rootfs "$rootfs" \ --unprivileged "$unpriv" \ --features "$features" \ --start 0 } pve_start_ct() { need_cmd pct local ctid="$1" info "Starting CT ${ctid}" pct start "$ctid" >/dev/null } # Wait for an IPv4 (non-empty) from pct exec "hostname -I" pve_wait_ct_ip() { need_cmd pct awk local ctid="$1" local timeout="${2:-120}" local slept=0 while (( slept < timeout )); do # hostname -I may return multiple; pick first IPv4 local ip ip="$(pct exec "$ctid" -- bash -lc "hostname -I 2>/dev/null | awk '{print \$1}'" || true)" ip="${ip//$'\r'/}" ip="${ip//$'\n'/}" if [[ -n "$ip" ]]; then echo "$ip" return 0 fi sleep 2 slept=$((slept+2)) done return 1 } # Execute a command inside CT with proper shell ct_exec() { need_cmd pct local ctid="$1"; shift pct exec "$ctid" -- bash -lc "$*" } # Upload a local file/stream into CT (uses pct push) ct_push() { need_cmd pct local ctid="$1" src="$2" dst="$3" pct push "$ctid" "$src" "$dst" }