Last active
January 11, 2026 01:33
-
-
Save jpawlowski/c8181d4c456563c569ca239ecbd8d143 to your computer and use it in GitHub Desktop.
Proxmox Hourly Snapshot Rotation (JSON + Tag-Aware): A lightweight Bash script that creates hourly snapshots for running Proxmox guests (QEMU VMs and LXC containers) and automatically prunes older auto_YYYYMMDDHH snapshots to keep a fixed rotation. It queries the Proxmox API via pvesh in JSON (parsed with jq) for reliable guest discovery across …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -euo pipefail | |
| PATH="/usr/sbin:/usr/bin:/sbin:/bin" | |
| DATE="$(date +'%Y%m%d%H')" | |
| DEFAULT_KEEP=24 | |
| DEFAULT_RETAIN_DAYS=7 | |
| DEFAULT_TIMEOUT="30m" | |
| DEFAULT_SKIP_TAGS="nosnap" | |
| DRY_RUN=false | |
| QUIET=true # default quiet for cron | |
| KEEP=$DEFAULT_KEEP | |
| RETAIN_DAYS=$DEFAULT_RETAIN_DAYS | |
| TIMEOUT=$DEFAULT_TIMEOUT | |
| SKIP_TAGS="$DEFAULT_SKIP_TAGS" # comma-separated list | |
| show_help() { | |
| echo "Usage: $0 [-n|--dry-run] [-v|--verbose] [-q|--quiet] [-k|--keep <n>]" | |
| echo " [--retain-days <n>] [--timeout <e.g. 30m>] [--skip-tags <t1,t2,...>] [-h|--help]" | |
| echo | |
| echo "Notes:" | |
| echo " - Skips guests that are not running (no create, no cleanup)." | |
| echo " - Skips guests that have any tag listed in --skip-tags (default: nosnap)." | |
| exit 0 | |
| } | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -n|--dry-run) DRY_RUN=true ;; | |
| -v|--verbose) QUIET=false ;; | |
| -q|--quiet) QUIET=true ;; | |
| -k|--keep) KEEP="${2:-}"; shift ;; | |
| --retain-days) RETAIN_DAYS="${2:-}"; shift ;; | |
| --timeout) TIMEOUT="${2:-}"; shift ;; | |
| --skip-tags) SKIP_TAGS="${2:-}"; shift ;; | |
| -h|--help) show_help ;; | |
| -*|--*) echo "Unrecognized option: $1"; show_help ;; | |
| esac | |
| shift | |
| done | |
| log() { $QUIET || echo "$@"; } | |
| say() { echo "$@"; } # always prints (dry-run + important info) | |
| err() { echo "ERROR: $*" >&2; } | |
| command -v pvesh >/dev/null 2>&1 || { err "pvesh not found"; exit 1; } | |
| command -v jq >/dev/null 2>&1 || { err "jq not found (install with: apt install -y jq)"; exit 1; } | |
| command -v timeout >/dev/null 2>&1 || { err "timeout not found (coreutils)"; exit 1; } | |
| if ! [[ "$KEEP" =~ ^[0-9]+$ ]] || [[ "$KEEP" -lt 1 ]]; then | |
| err "--keep must be a positive integer (got: $KEEP)" | |
| exit 1 | |
| fi | |
| if ! [[ "$RETAIN_DAYS" =~ ^[0-9]+$ ]] || [[ "$RETAIN_DAYS" -lt 0 ]]; then | |
| err "--retain-days must be an integer >= 0 (got: $RETAIN_DAYS)" | |
| exit 1 | |
| fi | |
| # Normalize SKIP_TAGS (remove spaces) | |
| SKIP_TAGS="$(echo "$SKIP_TAGS" | tr -d '[:space:]')" | |
| # Prevent overlapping cron runs | |
| LOCKFILE="/var/lock/pve-auto-snapshots.lock" | |
| exec 200>"$LOCKFILE" | |
| flock -n 200 || { log "Another run is active, exiting."; exit 0; } | |
| delete_old_lvm_archives() { | |
| local archive_path="/etc/lvm/archive" | |
| [[ -d "$archive_path" ]] || return 0 | |
| log "Cleaning LVM archive files older than $RETAIN_DAYS days..." | |
| if $DRY_RUN; then | |
| find "$archive_path" -type f -mtime +"$RETAIN_DAYS" -print | |
| else | |
| find "$archive_path" -type f -mtime +"$RETAIN_DAYS" -exec rm -f {} + | |
| fi | |
| } | |
| pvesh_json() { | |
| local out | |
| if ! out="$(timeout "$TIMEOUT" pvesh "$@" --output-format json 2>&1)"; then | |
| err "pvesh failed: pvesh $*" | |
| err "$out" | |
| return 1 | |
| fi | |
| if ! echo "$out" | jq -e . >/dev/null 2>&1; then | |
| err "pvesh returned non-JSON output for: pvesh $*" | |
| err "$out" | |
| return 1 | |
| fi | |
| echo "$out" | |
| } | |
| # Return TSV: TYPE<TAB>VMID<TAB>NODE<TAB>STATUS<TAB>TAGS<TAB>NAME | |
| list_cluster_vms_tsv() { | |
| pvesh_json get /cluster/resources --type vm | | |
| jq -r --arg skip "$SKIP_TAGS" ' | |
| # build skip tag set (["nosnap","foo"]): | |
| ($skip | split(",") | map(select(length>0))) as $skipTags | |
| | .[] | |
| | select(.type=="qemu" or .type=="lxc") | |
| | (.tags // "") as $tags | |
| | ($tags | split(";") | map(select(length>0))) as $tagList | |
| # if any tag matches skipTags -> skip | |
| | select( ([ $tagList[]? ] | any(. as $t | $skipTags | index($t))) | not ) | |
| | [ .type | |
| , (.vmid|tostring) | |
| , (.node // "") | |
| , (.status // "") | |
| , $tags | |
| , (.name // "") | |
| ] | |
| | @tsv | |
| ' | |
| } | |
| list_auto_snapshots() { | |
| local type="$1" vmid="$2" node="$3" | |
| pvesh_json get "/nodes/$node/$type/$vmid/snapshot" | | |
| jq -r '.[].name | select(test("^auto_[0-9]{10}$"))' | | |
| sort -r | |
| } | |
| snapshot_exists() { | |
| local type="$1" vmid="$2" node="$3" snap="$4" | |
| pvesh_json get "/nodes/$node/$type/$vmid/snapshot" | | |
| jq -e --arg s "$snap" 'any(.[]?; .name == $s)' >/dev/null | |
| } | |
| create_snapshot() { | |
| local type="$1" vmid="$2" node="$3" | |
| local snap="auto_$DATE" | |
| local path="/nodes/$node/$type/$vmid/snapshot" | |
| if snapshot_exists "$type" "$vmid" "$node" "$snap"; then | |
| log "Snapshot already exists: $type/$vmid on $node -> $snap (skipping create)" | |
| return 0 | |
| fi | |
| if $DRY_RUN; then | |
| say "[Dry-Run] Would create snapshot: pvesh create $path --snapname $snap" | |
| else | |
| log "Creating snapshot: $type/$vmid on $node -> $snap" | |
| timeout "$TIMEOUT" pvesh create "$path" --snapname "$snap" >/dev/null | |
| fi | |
| } | |
| delete_snapshot() { | |
| local type="$1" vmid="$2" node="$3" snap="$4" | |
| local path="/nodes/$node/$type/$vmid/snapshot/$snap" | |
| if $DRY_RUN; then | |
| say "[Dry-Run] Would delete snapshot: pvesh delete $path" | |
| else | |
| log "Deleting snapshot: $type/$vmid on $node -> $snap" | |
| timeout "$TIMEOUT" pvesh delete "$path" >/dev/null | |
| fi | |
| } | |
| # --- run --- | |
| delete_old_lvm_archives | |
| log "Fetching VM list from pvesh (JSON)..." | |
| log "Skip tags: ${SKIP_TAGS:-<none>}" | |
| while IFS=$'\t' read -r TYPE VMID NODE STATUS TAGS NAME; do | |
| # Requirement: if not running -> neither create nor cleanup | |
| if [[ "$STATUS" != "running" ]]; then | |
| log "Skipping $TYPE/$VMID ($NAME) on $NODE - Status: $STATUS" | |
| continue | |
| fi | |
| if [[ -z "$NODE" ]]; then | |
| log "Skipping $TYPE/$VMID ($NAME) - missing node" | |
| continue | |
| fi | |
| { | |
| create_snapshot "$TYPE" "$VMID" "$NODE" | |
| mapfile -t snaps < <(list_auto_snapshots "$TYPE" "$VMID" "$NODE") | |
| snap_count="${#snaps[@]}" | |
| if (( snap_count > KEEP )); then | |
| for (( i=KEEP; i<snap_count; i++ )); do | |
| delete_snapshot "$TYPE" "$VMID" "$NODE" "${snaps[$i]}" | |
| done | |
| fi | |
| } || { | |
| err "VM task failed for $TYPE/$VMID ($NAME) on $NODE (continuing)" | |
| } | |
| done < <(list_cluster_vms_tsv) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment