#!/bin/bash # felhom-vzdump-partial-clean — remove ONE dead, half-written vzdump backup (R-1006). The only delete the agent's # sudoers allows for backup files. Installed as /usr/local/sbin/felhom-vzdump-partial-clean (config bundle). # # felhom-vzdump-partial-clean # # A power cut during the night backup leaves `vzdump-lxc--.tar.dat` (+ its `.tmp/` directory) on the # storage; nothing removes it, every later backup is then skipped „not enough space", and a full host root breaks # LVM's own metadata writes (power-cut-fixes-2026-10-14 C4). This helper removes such a leftover — and NOTHING else. # It trusts nothing the agent says; every rule is checked here, as root, and any doubt REFUSES (exit 1): # 1. the arguments: a storage id, a vmid, and a name that is exactly # vzdump-(lxc|qemu)--YYYY_MM_DD-HH_MM_SS.(tar.dat|vma.dat|tmp) — never a finished archive # (.tar.zst, .vma.zst, …), never a .log/.notes, never another vmid; # 2. the storage: its section in /etc/pve/storage.cfg is `dir: ` with a `path`; the target is # /dump/, not a symlink, and its real directory IS /dump; # 3. not in use: no vzdump runs on the host at all (a task `…:vzdump:…` or a `vzdump` command — a job for several # guests carries no vmid in its task name), and no # process holds the target (or, for the .tmp directory, anything under it) open or as its working directory # (C2: after a killed worker, tar and zstd kept writing into the .tar.dat for 4 min 40 s, none named „vzdump"); # 4. dead: last modified before this boot (`btime` in /proc/stat), or untouched for at least QUIET_S seconds. # Prints one line on success: „removed B ()". Pinned by configs/test_felhom_vzdump_partial_clean.py. set -u STORAGE_CFG=/etc/pve/storage.cfg PROC=/proc QUIET_S=120 die() { echo "felhom-vzdump-partial-clean: REFUSED: $*" >&2; exit 1; } storage_path() { # $1 storage id → prints the dir storage's path, or nothing awk -v want="$1" ' /^[a-z]+:[ \t]/ { split($0, h, /:[ \t]+/); type = h[1]; id = h[2]; sub(/[ \t]+$/, "", id); in_s = (id == want); next } in_s && /^[ \t]+path[ \t]/ { if (type == "dir") { sub(/^[ \t]+path[ \t]+/, ""); sub(/[ \t]+$/, ""); print; exit } } ' "$STORAGE_CFG" } storage_type() { awk -v want="$1" '/^[a-z]+:[ \t]/ { split($0, h, /:[ \t]+/); id = h[2]; sub(/[ \t]+$/, "", id); if (id == want) { print h[1]; exit } }' "$STORAGE_CFG" } boot_time() { awk '$1 == "btime" { print $2 }' "$PROC/stat"; } newest_mtime() { # a file's mtime, or the newest mtime of anything in a directory (the dir itself included) if [[ -d "$1" ]]; then find "$1" -xdev -printf '%T@\n' 2>/dev/null | sort -n | tail -1 | cut -d. -f1 else stat -c %Y "$1"; fi } busy_reason() { # $1 vmid, $2 target → prints why it is in use, or nothing local vmid=$1 target=$2 p link cmd for p in "$PROC"/[0-9]*; do [[ -r "$p/cmdline" ]] || continue cmd=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null) # ANY vzdump on the host refuses: a job for several guests runs as `task UPID:…:vzdump::…` (no vmid in it), and # its tar may be between this guest's files. Waiting costs one backup run; deleting a live one costs the backup. if [[ "$cmd" == *":vzdump:"* ]] || [[ "$cmd" =~ (^|/)vzdump([[:space:]]|$) ]]; then echo "a vzdump runs on this host (pid ${p##*/})"; return fi for link in "$p/cwd" "$p"/fd/*; do local to to=$(readlink "$link" 2>/dev/null) || continue if [[ "$to" == "$target" || "$to" == "$target/"* ]]; then echo "pid ${p##*/} has it open"; return fi done done } main() { local storage=${1:-} vmid=${2:-} name=${3:-} [[ $# -eq 3 ]] || die "usage: felhom-vzdump-partial-clean " [[ "$storage" =~ ^[A-Za-z][A-Za-z0-9_-]{0,63}$ ]] || die "bad storage id" [[ "$vmid" =~ ^[1-9][0-9]{2,8}$ ]] || die "bad vmid" [[ "$name" =~ ^vzdump-(lxc|qemu)-${vmid}-[0-9]{4}_[0-9]{2}_[0-9]{2}-[0-9]{2}_[0-9]{2}_[0-9]{2}\.(tar\.dat|vma\.dat|tmp)$ ]] \ || die "name is not a half-written vzdump of guest $vmid: $name" [[ "$(storage_type "$storage")" == "dir" ]] || die "storage $storage is not a dir storage" local path; path=$(storage_path "$storage") [[ "$path" == /* ]] || die "storage $storage has no path" local dump="$path/dump" target="$path/dump/$name" [[ -e "$target" ]] || die "no such entry: $target" [[ ! -L "$target" ]] || die "a symlink: $target" [[ "$(realpath -e "$dump")" == "$(realpath -e "$(dirname "$target")")" ]] || die "not directly in $dump" if [[ "$name" == *.tmp ]]; then [[ -d "$target" ]] || die ".tmp is not a directory" else [[ -f "$target" ]] || die "not a regular file"; fi local busy; busy=$(busy_reason "$vmid" "$target") [[ -z "$busy" ]] || die "in use: $busy" local bt mt now why bt=$(boot_time); mt=$(newest_mtime "$target"); now=$(date +%s) [[ "$bt" =~ ^[0-9]+$ && "$mt" =~ ^[0-9]+$ ]] || die "cannot read the boot time or the modification time" if (( mt < bt )); then why="written before this boot" elif (( now - mt >= QUIET_S )); then why="untouched for $(( now - mt )) s, no vzdump for $vmid, nothing holds it" else die "modified $(( now - mt )) s ago — may still be written"; fi local size; size=$(du -sb "$target" 2>/dev/null | cut -f1) if [[ -d "$target" ]]; then rm -rf --one-file-system -- "$target"; else rm -f -- "$target"; fi [[ ! -e "$target" ]] || die "remove failed: $target" echo "removed $name ${size:-?} B ($why)" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi