From 013b2c7009ae4bed4b95d64162bb252792529bb6 Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Tue, 21 Jul 2026 20:22:56 +0000 Subject: [PATCH] Add ad hoc pve1 -> pve-test clone script (vzdump + qmrestore/pct restore) Backs up a VM/CT on pve1 (snapshot mode by default, so the source stays online), relays the archive to pve-test, restores it there with fresh MAC addresses (--unique), and deletes both the source and relayed backup copies afterward -- no ad hoc backup files left behind on either node. Co-Authored-By: Claude Sonnet 5 --- scripts/proxmox/clone-pve1-to-pve-test.sh | 221 ++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100755 scripts/proxmox/clone-pve1-to-pve-test.sh diff --git a/scripts/proxmox/clone-pve1-to-pve-test.sh b/scripts/proxmox/clone-pve1-to-pve-test.sh new file mode 100755 index 0000000..4b7d75c --- /dev/null +++ b/scripts/proxmox/clone-pve1-to-pve-test.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# Ad hoc clone of a single VM/CT from pve1 (production) to pve-test +# (sandbox), via vzdump + qmrestore/pct restore -- not a general-purpose +# backup tool, just a quick "give me a disposable copy of this thing on +# pve-test" for testing against real-ish data without touching prod. +# +# Flow: +# 1. vzdump the resource on pve1 into its "local" storage (--mode +# snapshot by default, so the source keeps running throughout -- +# see --mode below for when that's not possible). +# 2. Stream the resulting archive straight from pve1 to pve-test +# (ssh pve1 cat ... | ssh pve-test cat > ...) -- this machine is +# just the relay, no separate on-disk staging copy here. +# 3. qmrestore / pct restore it on pve-test under --new-vmid (default: +# same VMID as the source -- pve-test is a separate node/cluster, so +# no collision unless that VMID is already in use there too). +# Always restored with --unique 1 (fresh MAC addresses) since the +# source is typically still running on the same LAN -- restoring +# with the *same* MAC would put two live guests on the wire with +# identical hardware addresses. +# 4. Delete the vzdump archive from pve1's local storage and the +# relayed copy on pve-test, so neither node accumulates ad hoc +# backup files from this script. Only the pve1 original is +# preserved on any failure after step 1, so a failed +# transfer/restore can be retried without re-running the backup. +# +# This script's own defaults are pve1 -> pve-test, unlike +# create-proxmox-resource.sh's --node (which defaults to production) -- +# see CLAUDE.md's "Two Proxmox nodes" section. pve1 is only ever touched +# here after typing the source VMID back to confirm; pve-test is treated +# as disposable, matching this repo's usual policy for that node. +# +# See --help for the full option list. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=../env.sh +source "${repo_root}/scripts/env.sh" +# shellcheck source=../lib/confirm.sh +source "${repo_root}/scripts/lib/confirm.sh" + +usage() { + cat < [options] + + --vmid Required: VMID on the source node to clone. + Kind (qemu VM vs LXC CT) is auto-detected. + --new-vmid VMID to restore as on the target node + (default: same as --vmid). + --mode snapshot|suspend|stop + vzdump backup mode (default: snapshot -- the + source resource keeps running throughout; + requires snapshot-capable storage, e.g. + ZFS/LVM-thin/Ceph/qcow2). Fall back to + "suspend" (brief pause) or "stop" (source + goes down for the duration) if the source's + storage doesn't support live snapshots -- + vzdump's own error will say so. + --source-node (default: \$PVE1_HOST, ${PVE1_HOST}) + --target-node (default: \$PVE_TEST_HOST, ${PVE_TEST_HOST}) + --source-storage Where vzdump writes the backup on the + source node (default: local). + --target-storage Where the restored disk/rootfs lands on + the target node (default: \$PROXMOX_STORAGE, ${PROXMOX_STORAGE}). + --keep-backup Don't delete the vzdump archive from + either node afterward (debugging aid). + --yes Skip the typed VMID confirmation + before touching the source node. + --dry-run Print the full plan and skip every + mutating step (vzdump, transfer, + restore, delete) and the confirm + prompt. Still makes read-only SSH + calls to look up the source kind + and check the target VMID is free + -- harmless on either node. + -h, --help +EOF +} + +vmid="" +new_vmid="" +mode="snapshot" +source_node="$PVE1_HOST" +target_node="$PVE_TEST_HOST" +source_storage="local" +target_storage="$PROXMOX_STORAGE" +keep_backup=0 +skip_confirm=0 +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --vmid) vmid="$2"; shift 2 ;; + --new-vmid) new_vmid="$2"; shift 2 ;; + --mode) mode="$2"; shift 2 ;; + --source-node) source_node="$2"; shift 2 ;; + --target-node) target_node="$2"; shift 2 ;; + --source-storage) source_storage="$2"; shift 2 ;; + --target-storage) target_storage="$2"; shift 2 ;; + --keep-backup) keep_backup=1; shift ;; + --yes) skip_confirm=1; shift ;; + --dry-run) dry_run=1; shift ;; + -h | --help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ -z "$vmid" ]]; then + echo "ERROR: --vmid is required." >&2 + usage >&2 + exit 1 +fi +if [[ "$mode" != "snapshot" && "$mode" != "suspend" && "$mode" != "stop" ]]; then + echo "ERROR: --mode must be snapshot, suspend, or stop." >&2 + exit 1 +fi +[[ -z "$new_vmid" ]] && new_vmid="$vmid" + +source_target="${PROXMOX_SSH_USER}@${source_node}" +target_target="${PROXMOX_SSH_USER}@${target_node}" + +# No dry-run wrapper needed for the calls below: every mutating step +# (vzdump, transfer, restore, delete) is reached only after the --dry-run +# early-exit further down, so a plain `ssh` call is never in the dry-run +# path. + +# --- identify the resource kind on the source node ----------------------- +echo "==> Looking up VMID ${vmid} on ${source_node}..." +kind="" +if ssh "$source_target" "qm status ${vmid}" >/dev/null 2>&1; then + kind="vm" +elif ssh "$source_target" "pct status ${vmid}" >/dev/null 2>&1; then + kind="lxc" +else + echo "ERROR: VMID ${vmid} doesn't exist on ${source_node} as either a VM or CT." >&2 + exit 1 +fi +echo "VMID ${vmid} on ${source_node} is a ${kind}." + +# --- refuse to clobber an existing resource on the target node ----------- +if ssh "$target_target" "qm status ${new_vmid}" >/dev/null 2>&1 \ + || ssh "$target_target" "pct status ${new_vmid}" >/dev/null 2>&1; then + echo "ERROR: VMID ${new_vmid} already exists on ${target_node}. Pass --new-vmid" >&2 + echo "with a free ID, or remove the existing resource there first." >&2 + exit 1 +fi + +echo +echo "Plan:" +echo " source: ${kind} VMID ${vmid} on ${source_node} (storage: ${source_storage}, mode: ${mode})" +echo " target: VMID ${new_vmid} on ${target_node} (storage: ${target_storage}, fresh MAC via --unique)" +[[ "$keep_backup" -eq 1 ]] && echo " backup archives are kept on both nodes afterward (--keep-backup)" + +if [[ "$dry_run" -eq 1 ]]; then + echo + echo "[dry-run] No backup, transfer, restore, or delete was performed." + exit 0 +fi + +if [[ "$skip_confirm" -ne 1 ]]; then + echo + if ! confirm_typed "$vmid" "Type the source VMID (${vmid}) to confirm backing it up from ${source_node}: "; then + echo "Cancelled -- input didn't match ${vmid}." >&2 + exit 1 + fi +fi + +# --- vzdump on the source node -------------------------------------------- +echo +echo "==> Backing up VMID ${vmid} on ${source_node} (mode=${mode}, storage=${source_storage})..." +vzdump_log="$(ssh "$source_target" \ + "vzdump ${vmid} --mode ${mode} --storage ${source_storage} --compress zstd" 2>&1)" \ + || { + echo "$vzdump_log" >&2 + echo "ERROR: vzdump failed on ${source_node}." >&2 + exit 1 + } +echo "$vzdump_log" + +archive="$(echo "$vzdump_log" | grep -oP "creating vzdump archive '\K[^']+" | tail -n1)" +if [[ -z "$archive" ]]; then + echo "ERROR: couldn't find the archive path in vzdump's output above." >&2 + exit 1 +fi +archive_basename="$(basename "$archive")" +target_tmp_archive="/var/tmp/${archive_basename}" +echo "Archive: ${archive}" + +# Always clean up the relayed copy on the target node, success or failure +# -- it's only ever a working copy, restored or not. +cleanup_target_tmp() { + if [[ "$keep_backup" -ne 1 ]]; then + ssh "$target_target" "rm -f '${target_tmp_archive}'" >/dev/null 2>&1 || true + fi +} +trap cleanup_target_tmp EXIT + +# --- relay the archive from source to target ------------------------------ +echo +echo "==> Transferring archive to ${target_node}..." +ssh "$source_target" "cat '${archive}'" | ssh "$target_target" "cat > '${target_tmp_archive}'" + +# --- restore on the target node -------------------------------------------- +echo +echo "==> Restoring as VMID ${new_vmid} on ${target_node} (storage=${target_storage})..." +if [[ "$kind" == "vm" ]]; then + ssh "$target_target" "qmrestore '${target_tmp_archive}' ${new_vmid} --storage ${target_storage} --unique 1" +else + ssh "$target_target" "pct restore ${new_vmid} '${target_tmp_archive}' --storage ${target_storage} --unique 1" +fi + +# --- clean up the source backup now that the restore succeeded ----------- +if [[ "$keep_backup" -ne 1 ]]; then + echo + echo "==> Deleting backup archive from ${source_node}'s ${source_storage} storage..." + ssh "$source_target" "rm -f '${archive}' '${archive}.notes' '${archive}.log'" >/dev/null 2>&1 || true +fi + +echo +echo "Done. VMID ${new_vmid} (${kind}) is now on ${target_node}, cloned from" \ + "VMID ${vmid} on ${source_node}." -- 2.54.0