Files
ProxMenux/scripts/backup_restore/vm_backup_jobs.sh
T

604 lines
24 KiB
Bash
Raw Normal View History

#!/bin/bash
# ==========================================================
# ProxMenux - VM/CT Backup Jobs Manager
# ==========================================================
# Author : MattiaC46 (PR #421), MacRimi
# Copyright : (c) 2024 MacRimi
# License : GPL-3.0
# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE
# Version : 1.0
# ==========================================================
# Description:
# View, create, edit, enable/disable, delete and run on-demand
# the PVE backup jobs normally found under
# Datacenter > Backup
# This is a thin wrapper around the official `pvesh` API
# (/cluster/backup), so jobs created/edited here show up exactly
# the same in the native PVE web UI and vice versa.
# ==========================================================
# Configuration ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
# shellcheck source=/dev/null
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
else
echo "Error: $UTILS_FILE not found"
exit 1
fi
load_language
initialize_cache
BACKTITLE="ProxMenux"
UI_W=84
UI_H=22
UI_LIST_H=12
function header() {
show_proxmenux_logo
msg_title "$(translate "VM/CT Backup Jobs (Datacenter > Backup)")"
}
# ── Sanity checks ──────────────────────────────────────────
if ! command -v pvesh >/dev/null 2>&1; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "pvesh not found. This must run on a Proxmox VE node.")" 8 60
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "jq is required for this menu and is not installed.")" 8 60
exit 1
fi
THIS_NODE=$(hostname)
# ── Helpers ────────────────────────────────────────────────
# Lists storages that accept "backup" content, one per line: id<TAB>type
list_backup_storages() {
pvesh get /storage --output-format json 2>/dev/null \
| jq -r '.[] | select(.content // "" | contains("backup")) | "\(.storage)\t\(.type)"'
}
# Lists VMs and CTs on the whole cluster, one per line: vmid<TAB>name<TAB>kind
list_guests() {
{
pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq -r '.[] | select(.type=="qemu" or .type=="lxc") | "\(.vmid)\t\(.name // "-")\t\(.type)"'
} | sort -n
}
# Host backups of ProxMenux that run with a Proxmox job, comma separated.
attached_host_backups() {
local job_id="$1" file found=()
for file in /var/lib/proxmenux/backup-jobs/*.env; do
[[ -f "$file" ]] || continue
grep -qxF "PVE_PARENT_JOB=${job_id}" "$file" 2>/dev/null && found+=("$(basename "$file" .env)")
done
local IFS=,
echo "${found[*]}"
}
# Pretty one-line summary of a job, used in the main list
job_summary_line() {
local j="$1"
local vmid storage schedule mode enabled all
vmid=$(jq -r '.vmid // empty' <<<"$j")
all=$(jq -r '.all // 0' <<<"$j")
storage=$(jq -r '.storage // "-"' <<<"$j")
schedule=$(jq -r '.schedule // "-"' <<<"$j")
mode=$(jq -r '.mode // "snapshot"' <<<"$j")
enabled=$(jq -r '.enabled // 1' <<<"$j")
local target
if [[ "$all" == "1" || "$all" == "true" ]]; then
target="$(translate "all guests")"
elif [[ -n "$vmid" ]]; then
target="VM/CT: $vmid"
else
target="$(translate "(no guests selected)")"
fi
local state
if [[ "$enabled" == "0" || "$enabled" == "false" ]]; then
state="[$(translate "disabled")]"
else
state=""
fi
echo "$schedule | $storage | $mode | $target $state"
}
# ── Guest picker (checklist) ──────────────────────────────
# Sets GUEST_SELECTION to "ALL" or a comma-separated vmid list.
# Returns 1 on cancel.
select_guests() {
local pick
pick=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Select Guests")" \
--menu "\n$(translate "Which guests should this job back up?")" 12 70 2 \
"ALL" "$(translate "All guests on the cluster")" \
"PICK" "$(translate "Choose specific VMs/CTs")" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
if [[ "$pick" == "ALL" ]]; then
GUEST_SELECTION="ALL"
return 0
fi
local guests=()
while IFS=$'\t' read -r vmid name kind; do
[[ -z "$vmid" ]] && continue
guests+=("$vmid" "$name ($kind)" "off")
done < <(list_guests)
if [[ ${#guests[@]} -eq 0 ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "No VMs or CTs found on this cluster.")" 8 60
return 1
fi
local raw
raw=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Select Guests")" \
--checklist "\n$(translate "Mark the VMs/CTs to include in this job:")" 20 70 12 \
"${guests[@]}" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
GUEST_SELECTION=$(echo "$raw" | tr -d '"' | tr -s ' ' ',' | sed 's/^,//;s/,$//')
if [[ -z "$GUEST_SELECTION" ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "No guest selected.")" 8 60
return 1
fi
return 0
}
# ── Storage picker ─────────────────────────────────────────
# Sets JOB_STORAGE. Returns 1 on cancel.
select_storage() {
local items=()
while IFS=$'\t' read -r sid stype; do
[[ -z "$sid" ]] && continue
items+=("$sid" "$stype")
done < <(list_backup_storages)
if [[ ${#items[@]} -eq 0 ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "No storage with 'backup' content enabled was found.\n\nAdd one from Datacenter > Storage first (PBS, or a directory/NFS with the Backup content type).")" 10 70
return 1
fi
JOB_STORAGE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Target Storage")" \
--menu "\n$(translate "Select the storage that will receive the backups:")" "$UI_H" "$UI_W" "$UI_LIST_H" \
"${items[@]}" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 || -z "$JOB_STORAGE" ]] && return 1
return 0
}
# ── Schedule picker ─────────────────────────────────────────
# Sets JOB_SCHEDULE (systemd calendar-event syntax). Returns 1 on cancel.
select_schedule() {
local pick
pick=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Schedule")" \
--menu "\n$(translate "Choose a schedule (systemd calendar-event syntax):")" 18 74 7 \
"daily-02" "$(translate "Daily at 02:00")" \
"daily-03" "$(translate "Daily at 03:00")" \
"weekly-sun" "$(translate "Weekly, Sunday 02:00")" \
"every-6h" "$(translate "Every 6 hours")" \
"hourly" "$(translate "Every hour")" \
"custom" "$(translate "Custom (type it myself)")" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
case "$pick" in
daily-02) JOB_SCHEDULE="02:00" ;;
daily-03) JOB_SCHEDULE="03:00" ;;
weekly-sun) JOB_SCHEDULE="sun 02:00" ;;
every-6h) JOB_SCHEDULE="00/6:00" ;;
hourly) JOB_SCHEDULE="hourly" ;;
custom)
JOB_SCHEDULE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Custom Schedule")" \
--inputbox "\n$(translate "Enter a systemd calendar event, e.g.:")\n 'mon..fri 01:30'\n '*-*-1 03:00' ($(translate "first of every month")))" \
12 70 "02:00" 3>&1 1>&2 2>&3)
[[ $? -ne 0 || -z "$JOB_SCHEDULE" ]] && return 1
;;
esac
return 0
}
# ── Mode / compression / retention pickers ─────────────────
select_mode() {
JOB_MODE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Backup Mode")" \
--radiolist "\n$(translate "Select the backup mode:")" 14 74 3 \
"snapshot" "$(translate "Snapshot - no downtime (recommended)")" on \
"suspend" "$(translate "Suspend - pauses the guest during backup")" off \
"stop" "$(translate "Stop - shuts the guest down during backup")" off \
3>&1 1>&2 2>&3)
[[ $? -ne 0 || -z "$JOB_MODE" ]] && return 1
return 0
}
select_compress() {
JOB_COMPRESS=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Compression")" \
--radiolist "\n$(translate "Select compression:")" 14 74 4 \
"zstd" "$(translate "ZSTD - fast, good ratio (recommended)")" on \
"gzip" "$(translate "GZIP - slower, widely compatible")" off \
"lzo" "$(translate "LZO - fastest, weaker ratio")" off \
"0" "$(translate "None")" off \
3>&1 1>&2 2>&3)
[[ $? -ne 0 || -z "$JOB_COMPRESS" ]] && return 1
return 0
}
input_retention() {
local default="${1:-keep-last=3}"
JOB_PRUNE=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Retention")" \
--inputbox "\n$(translate "Retention policy (prune-backups syntax).")\n$(translate "Examples:")\n keep-last=3\n keep-daily=7,keep-weekly=4,keep-monthly=6\n\n$(translate "Leave empty to use the retention of the storage.")" \
14 74 "$default" 3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
return 0
}
input_notes() {
local default="${1:-{{guestname}}}"
JOB_NOTES=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Notes Template")" \
--inputbox "\n$(translate "Notes template shown next to each backup (optional).")\n$(translate "Placeholders: {{guestname}} {{node}} {{vmid}}")" \
12 74 "$default" 3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
return 0
}
# ── Create ───────────────────────────────────────────────
create_job_wizard() {
header
GUEST_SELECTION=""; JOB_STORAGE=""; JOB_SCHEDULE=""; JOB_MODE=""; JOB_COMPRESS=""; JOB_PRUNE=""; JOB_NOTES=""
select_guests || return 1
select_storage || return 1
select_schedule || return 1
select_mode || return 1
select_compress || return 1
input_retention "keep-last=3" || return 1
input_notes "{{guestname}}" || return 1
local job_id
job_id=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Job ID (optional)")" \
--inputbox "\n$(translate "Short name for this job (letters, numbers, hyphens).")\n$(translate "Leave empty to auto-generate one.")" \
10 70 "" 3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
local args=(create /cluster/backup --storage "$JOB_STORAGE" --schedule "$JOB_SCHEDULE" \
--mode "$JOB_MODE" --compress "$JOB_COMPRESS" --enabled 1)
[[ -n "$job_id" ]] && args+=(--id "$job_id")
if [[ "$GUEST_SELECTION" == "ALL" ]]; then
args+=(--all 1)
else
args+=(--vmid "$GUEST_SELECTION")
fi
[[ -n "$JOB_PRUNE" ]] && args+=(--prune-backups "$JOB_PRUNE")
[[ -n "$JOB_NOTES" ]] && args+=(--notes-template "$JOB_NOTES")
header
msg_info "$(translate "Creating backup job...")"
local out
if out=$(pvesh "${args[@]}" 2>&1); then
msg_ok "$(translate "Backup job created successfully.")"
else
msg_error "$(translate "Failed to create the job.")"
echo "$out"
fi
echo
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
}
# ── Edit ─────────────────────────────────────────────────
edit_job_wizard() {
local job_id="$1"
local current
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
if [[ -z "$current" ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
--msgbox "$(translate "Could not read this job.")" 8 60
return 1
fi
local cur_vmid cur_all cur_storage cur_schedule cur_mode cur_compress cur_prune cur_notes
cur_vmid=$(jq -r '.vmid // empty' <<<"$current")
cur_all=$(jq -r '.all // 0' <<<"$current")
cur_storage=$(jq -r '.storage // empty' <<<"$current")
cur_schedule=$(jq -r '.schedule // empty' <<<"$current")
cur_mode=$(jq -r '.mode // "snapshot"' <<<"$current")
cur_compress=$(jq -r '.compress // "zstd"' <<<"$current")
cur_prune=$(jq -r '."prune-backups" // empty | if type == "object" then to_entries | map("\(.key)=\(.value)") | join(",") else . end' <<<"$current")
cur_notes=$(jq -r '."notes-template" // empty' <<<"$current")
local field
while true; do
field=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Edit Job") $job_id" \
--menu "\n$(translate "What do you want to change?")" 20 74 8 \
"guests" "$(translate "Guests") ($( [[ "$cur_all" == "1" ]] && echo "all" || echo "$cur_vmid" ))" \
"storage" "$(translate "Storage") ($cur_storage)" \
"schedule" "$(translate "Schedule") ($cur_schedule)" \
"mode" "$(translate "Mode") ($cur_mode)" \
"compress" "$(translate "Compression") ($cur_compress)" \
"prune" "$(translate "Retention") ($cur_prune)" \
"notes" "$(translate "Notes template")" \
"SAVE" "$(translate ">> Save and return <<")" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return 1
case "$field" in
guests)
if select_guests; then
if [[ "$GUEST_SELECTION" == "ALL" ]]; then
cur_all=1; cur_vmid=""
else
cur_all=0; cur_vmid="$GUEST_SELECTION"
fi
fi
;;
storage)
if select_storage; then
local attached
attached=$(attached_host_backups "$job_id")
if [[ -n "$attached" && "$JOB_STORAGE" != "$cur_storage" ]]; then
dialog --backtitle "$BACKTITLE" --title "$(translate "Storage")" \
--msgbox "\n$(translate "These host backups run with this job and expect its storage:") $attached\n\n$(translate "With another storage this job no longer starts them. Another job that writes to the original storage still does.")" 14 70
fi
cur_storage="$JOB_STORAGE"
fi
;;
schedule) select_schedule && cur_schedule="$JOB_SCHEDULE" ;;
mode) select_mode && cur_mode="$JOB_MODE" ;;
compress) select_compress && cur_compress="$JOB_COMPRESS" ;;
prune) input_retention "$cur_prune" && cur_prune="$JOB_PRUNE" ;;
notes) input_notes "$cur_notes" && cur_notes="$JOB_NOTES" ;;
SAVE)
local args=(set "/cluster/backup/$job_id" \
--storage "$cur_storage" --schedule "$cur_schedule" \
--mode "$cur_mode" --compress "$cur_compress")
if [[ "$cur_all" == "1" ]]; then
args+=(--all 1 --delete vmid)
else
args+=(--vmid "$cur_vmid" --delete all)
fi
if [[ -n "$cur_prune" ]]; then
args+=(--prune-backups "$cur_prune")
else
args+=(--delete prune-backups)
fi
if [[ -n "$cur_notes" ]]; then
args+=(--notes-template "$cur_notes")
else
args+=(--delete notes-template)
fi
header
msg_info "$(translate "Saving changes...")"
local out
if out=$(pvesh "${args[@]}" 2>&1); then
msg_ok "$(translate "Job updated successfully.")"
else
msg_error "$(translate "Failed to update the job.")"
echo "$out"
fi
echo
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
return 0
;;
esac
done
}
# ── Delete / Toggle / Run now ───────────────────────────────
delete_job() {
local job_id="$1"
local attached warning=""
attached=$(attached_host_backups "$job_id")
[[ -n "$attached" ]] && warning="\n\n$(translate "This job will no longer start these host backups:") $attached\n$(translate "Another job that writes to the same storage still starts them.")"
dialog --backtitle "$BACKTITLE" --title "$(translate "Confirm Delete")" \
--yesno "\n$(translate "Delete backup job") '$job_id'?\n\n$(translate "This only removes the schedule. Existing backup archives are NOT deleted.")${warning}" 16 70
[[ $? -ne 0 ]] && return 1
header
msg_info "$(translate "Deleting job...")"
local out
if out=$(pvesh delete "/cluster/backup/$job_id" 2>&1); then
msg_ok "$(translate "Job deleted.")"
else
msg_error "$(translate "Failed to delete the job.")"
echo "$out"
fi
echo
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
}
toggle_job() {
local job_id="$1" enabled="$2"
local new=1
[[ "$enabled" != "0" && "$enabled" != "false" ]] && new=0
header
msg_info "$(translate "Updating job status...")"
local out
if out=$(pvesh set "/cluster/backup/$job_id" --enabled "$new" 2>&1); then
if [[ "$new" == "1" ]]; then
msg_ok "$(translate "Job enabled.")"
else
msg_ok "$(translate "Job disabled.")"
fi
else
msg_error "$(translate "Failed to update the job.")"
echo "$out"
fi
echo
sleep 1
}
# Runs the job now with its own settings. Each node runs its part as a
# Proxmox task, so it is listed and logged like a scheduled run.
run_job_now() {
local job_id="$1"
local current
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
[[ -z "$current" ]] && return 1
local mode
mode=$(jq -r '.mode // "snapshot"' <<<"$current")
local warning=""
[[ "$mode" == "stop" ]] && warning="\n\n$(translate "The mode of this job is stop: its guests are shut down during the backup.")"
dialog --backtitle "$BACKTITLE" --title "$(translate "Run Now")" \
--yesno "\n$(translate "Run job") '$job_id' $(translate "now, with its current settings?")\n\n$(translate "The backup runs as a Proxmox task; follow it in the task log of Proxmox.")${warning}" 14 74
[[ $? -ne 0 ]] && return 1
header
msg_info "$(translate "Starting the backup...")"
local nodes=() node
node=$(jq -r '.node // empty' <<<"$current")
if [[ -n "$node" ]]; then
nodes=("$node")
elif [[ "$(jq -r '.all // 0' <<<"$current")" =~ ^(1|true)$ || -n "$(jq -r '.pool // empty' <<<"$current")" ]]; then
mapfile -t nodes < <(pvesh get /nodes --output-format json 2>/dev/null \
| jq -r '.[] | select(.status=="online") | .node')
else
local wanted
wanted=$(jq -c '(.vmid // "") | tostring | split(",") | map(gsub(" "; ""))' <<<"$current")
mapfile -t nodes < <(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq -r --argjson wanted "$wanted" '[.[] | select((.vmid|tostring) as $id | $wanted | index($id)) | .node] | unique | .[]')
fi
if [[ ${#nodes[@]} -eq 0 ]]; then
msg_error "$(translate "No online node has a guest of this job.")"
echo
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
return 1
fi
# Every option of the job that vzdump understands, as it is stored.
local run_args=()
mapfile -t run_args < <(jq -r '
del(.id, .type, .schedule, .enabled, ."next-run", .comment, ."repeat-missed", .digest, .node, .starttime, .dow)
| to_entries[] | select(.value != null and .value != "")
| "--\(.key)",
(if (.value|type) == "object" then (.value | to_entries | map("\(.key)=\(.value)") | join(","))
elif (.value|type) == "boolean" then (if .value then "1" else "0" end)
else (.value|tostring) end)' <<<"$current")
stop_spinner
local out
for node in "${nodes[@]}"; do
if out=$(pvesh create "/nodes/$node/vzdump" "${run_args[@]}" 2>&1); then
msg_ok "$(translate "Backup started on node") $node"
echo -e "${TAB}$(grep -o 'UPID:[^"[:space:]]*' <<<"$out" | head -1)"
else
msg_error "$(translate "The backup could not be started on node") $node"
echo "$out"
fi
done
echo
read -n 1 -s -r -p "$(translate "Press any key to continue...")"
}
# ── Job list / detail menu ──────────────────────────────────
job_detail_menu() {
local job_id="$1"
while true; do
local current enabled
current=$(pvesh get "/cluster/backup/$job_id" --output-format json 2>/dev/null)
[[ -z "$current" ]] && return
enabled=$(jq -r '.enabled // 1' <<<"$current")
local toggle_label
if [[ "$enabled" == "0" || "$enabled" == "false" ]]; then
toggle_label="$(translate "Enable job")"
else
toggle_label="$(translate "Disable job")"
fi
local choice
choice=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Job") $job_id" \
--menu "\n$(job_summary_line "$current")" 16 78 6 \
"run" "$(translate "Run now")" \
"edit" "$(translate "Edit")" \
"toggle" "$toggle_label" \
"delete" "$(translate "Delete")" \
"back" "$(translate "Back to job list")" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 || "$choice" == "back" ]] && return
case "$choice" in
run) run_job_now "$job_id" ;;
edit) edit_job_wizard "$job_id" ;;
toggle) toggle_job "$job_id" "$enabled" ;;
delete) delete_job "$job_id"; return ;;
esac
done
}
list_jobs_menu() {
while true; do
local jobs_json
jobs_json=$(pvesh get /cluster/backup --output-format json 2>/dev/null)
local count
count=$(jq 'length' <<<"${jobs_json:-[]}" 2>/dev/null)
[[ -z "$count" ]] && count=0
local items=()
if [[ "$count" -gt 0 ]]; then
while IFS=$'\t' read -r jid jsummary; do
[[ -z "$jid" ]] && continue
items+=("$jid" "$jsummary")
done < <(jq -r '.[] | [.id, (.schedule // "-")+" | "+(.storage // "-")+" | "+(.vmid // (if .all==1 then "all" else "-" end))] | @tsv' <<<"$jobs_json")
fi
items+=("NEW" "$(translate ">> Create a new backup job <<")")
items+=("BACK" "$(translate "Return")")
header
local choice
choice=$(dialog --backtitle "$BACKTITLE" --title "$(translate "Backup Jobs") ($THIS_NODE)" \
--menu "\n$(translate "Select a job to view/edit, or create a new one:")" 22 86 12 \
"${items[@]}" \
3>&1 1>&2 2>&3)
[[ $? -ne 0 || "$choice" == "BACK" ]] && exit 0
if [[ "$choice" == "NEW" ]]; then
create_job_wizard
else
job_detail_menu "$choice"
fi
done
}
# ── Entry point ──────────────────────────────────────────────
list_jobs_menu