Allow selecting and deleting guest backup archives in Monitor

This commit is contained in:
Jonatan
2026-10-16 20:23:09 +02:00
parent 3000c1cd66
commit d487f33700
10 changed files with 763 additions and 60 deletions
+129
View File
@@ -0,0 +1,129 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Checkbox } from "./ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
export function GuestBackupList({ vmid, backups, loading, onRefresh }: {
vmid: number
backups: GuestBackup[]
loading: boolean
onRefresh: () => Promise<void>
}) {
const t = useT()
const [selected, setSelected] = useState<string[]>([])
const [confirm, setConfirm] = useState(false)
const [busy, setBusy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
// A fresh list can remove a selection or reveal a newly protected archive.
useEffect(() => {
setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected)))
}, [backups])
const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
const selectable = backups.filter(backup => !backup.protected)
const disabled = loading || busy || refreshing
async function refresh() {
setRefreshing(true)
setError("")
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
finally { if (mounted.current) setRefreshing(false) }
}
async function remove() {
if (!chosen.length || busy) return
setBusy(true)
setError("")
setMessage("")
try {
const results = await deleteGuestBackups(vmid, chosen)
if (!mounted.current) return
const deleted = results.filter(result => result.status === "deleted").length
const unconfirmed = results.length - deleted
setMessage(t("vmLxc.backups.deletedCount", { count: deleted }))
if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed }))
setSelected([])
} catch (error) {
if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`)
} finally {
// A timeout can hide a successful native task. Always bypass both caches.
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
if (mounted.current) { setBusy(false); setConfirm(false) }
}
}
return <>
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
</Button>
</div>
</div>
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
{loading ? <div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" /><span className="text-sm">{t("vmLxc.backups.loading")}</span>
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div> : <>
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
{t("vmLxc.backups.selectHint")}
</label>
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
<Badge variant="outline" className="text-xs ml-auto shrink-0">{backup.storage}</Badge>
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
</label>)}
</div>
</>}
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
<DialogHeader>
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
</DialogHeader>
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
</ul>
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
+22 -59
View File
@@ -23,6 +23,8 @@ import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives"
import { OciConsoleLogPanel } from "./oci-console-log-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
@@ -382,16 +384,7 @@ interface BackupStorage {
avail_human?: string
}
interface VMBackup {
volid: string
storage: string
type: string
size: number
size_human: string
timestamp: number
date: string
notes?: string
}
type VMBackup = GuestBackup
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
// next to VMBackup since both are LXC-modal data structures.
@@ -1598,7 +1591,7 @@ export function VirtualMachines() {
}
}
const fetchVmBackups = async (vmid: number) => {
const fetchVmBackups = async (vmid: number, force = false) => {
// Stale-while-revalidate with a 6-hour freshness gate.
//
// 1. If we already have backups (bulk hydration or a previous
@@ -1615,15 +1608,16 @@ export function VirtualMachines() {
// 3. Loading spinner only on the true first load.
const GATE_MS = 6 * 60 * 60 * 1000
const seed = vmModalCacheRef.current.backups.get(vmid)
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
const isStale = force || !seed || (Date.now() - seed.fetchedAt) > GATE_MS
if (!seed) setLoadingBackups(true)
try {
const url = isStale
? `/api/vms/${vmid}/backups?fresh=1`
: `/api/vms/${vmid}/backups`
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
const response = await fetchApi<{ backups?: VMBackup[]; error?: string }>(url)
if (response.error || !Array.isArray(response.backups)) throw new Error(response.error || "Backup inventory unavailable")
if (response.backups) {
setVmBackups(response.backups)
if (selectedVMRef.current?.vmid === vmid) setVmBackups(response.backups)
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
}
} catch (error) {
@@ -1631,9 +1625,10 @@ export function VirtualMachines() {
// Only clear the visible list if we had nothing to show
// in the first place — a transient network hiccup must not
// wipe the stale-but-useful view the user is looking at.
if (!seed) setVmBackups([])
if (!seed && selectedVMRef.current?.vmid === vmid) setVmBackups([])
if (force) throw error
} finally {
if (!seed) setLoadingBackups(false)
if (!seed && selectedVMRef.current?.vmid === vmid) setLoadingBackups(false)
}
}
@@ -7436,7 +7431,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
{/* Backups Tab */}
{activeModalTab === "backups" && (
{activeModalTab === "backups" && selectedVM && (
/* Backups tab layout: the Card fills the whole tab
body so the backup list can scroll inside its
bordered card instead of leaving empty space
@@ -7471,48 +7466,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Divider */}
<div className="border-t border-border/50 mb-4 shrink-0" />
{/* Backup List */}
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
</div>
{loadingBackups ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.backups.loading")}</span>
</div>
) : vmBackups.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div>
) : (
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{vmBackups.map((backup, index) => (
<div
key={`backup-${backup.volid}-${index}`}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground">{backup.date}</span>
<Badge
variant="outline"
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
>
{backup.storage}
</Badge>
</div>
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
{backup.size_human}
</Badge>
</div>
))}
</div>
)}
<GuestBackupList
key={selectedVM.vmid}
vmid={selectedVM.vmid}
backups={vmBackups}
loading={loadingBackups}
onRefresh={async () => {
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
await fetchVmBackups(selectedVM.vmid, true)
}}
/>
</CardContent>
</Card>
</div>
+54
View File
@@ -0,0 +1,54 @@
import { fetchApi } from './api-config'
export interface GuestBackup {
volid: string
storage: string
type: string
size: number
size_human: string
timestamp: number
date: string
notes?: string
protected?: boolean
}
export interface BackupDeletionResult {
volid: string
status: 'deleted' | 'pending' | 'unconfirmed'
task?: string
error?: string
}
// Exposed separately so callers can invalidate their inventory even after a
// lost POST response. Never retry the destructive request automatically.
export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): Promise<BackupDeletionResult[]> {
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
method: 'POST',
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
signal: AbortSignal.timeout(120_000),
})
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
response.results.some((result, index) => result.volid !== backups[index].volid ||
!['deleted', 'pending', 'unconfirmed'].includes(result.status))) throw new Error('deletionUnconfirmed')
return Promise.all(response.results.map(async result => {
if (result.status !== 'pending' || !result.task) return result
const deadline = Date.now() + 120_000
let failures = 0
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1500))
try {
const status = await fetchApi<BackupDeletionResult>(
`/api/vms/${vmid}/backups/delete-status?task=${encodeURIComponent(result.task!)}`,
{ signal: AbortSignal.timeout(10_000) },
)
if (status.volid !== result.volid || !['deleted', 'pending', 'unconfirmed'].includes(status.status)) break
if (status.status !== 'pending') return status
failures = 0
} catch {
if (++failures >= 3) break
}
}
return { ...result, status: 'unconfirmed', error: 'deletionUnconfirmed' }
}))
}
+15 -1
View File
@@ -1131,7 +1131,21 @@
"available": "Available backups",
"loading": "Loading backups...",
"empty": "No backups found",
"emptyHint": "Create your first backup using the button above"
"emptyHint": "Create your first backup using the button above",
"refresh": "Refresh",
"refreshFailed": "Could not refresh backups. Refresh again to confirm their current state.",
"deleteSelected": "Delete selected ({count})",
"deletedCount": "{count} backup(s) deleted.",
"unconfirmedCount": "Deletion of {count} backup(s) was not confirmed. Refresh the list and check Proxmox Tasks before trying again.",
"deleteFailed": "Deletion could not be confirmed. Refresh the list and check Proxmox Tasks before trying again.",
"selectHint": "Select unprotected backups (up to 20 per batch)",
"selectBackup": "Select backup",
"protected": "Protected",
"confirmTitle": "Delete {count} backup(s)?",
"confirmDescription": "These backup archives for VM/CT {vmid} will be permanently deleted. Protected backups are excluded. This does not delete the guest.",
"deleting": "Waiting for Proxmox to finish deleting the selected archives…",
"cancel": "Cancel",
"confirmDelete": "Delete backups"
},
"updatesPanel": {
"pendingTitle": "Pending package updates",
+1
View File
@@ -181,6 +181,7 @@ done
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
cp "$SCRIPT_DIR/flask_vm_backup_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_vm_backup_routes.py not found"
cp "$SCRIPT_DIR/guest_backup_archives.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
+29
View File
@@ -95,6 +95,8 @@ from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from flask_audit_routes import audit_bp # noqa: E402
from flask_vm_backup_routes import vm_backup_bp # noqa: E402
from guest_backup_archives import (BackupArchiveError, delete_guest_backups,
guest_backup_deletion_status) # noqa: E402
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
@@ -13303,6 +13305,32 @@ def api_create_backup(vmid):
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/backups/delete', methods=['POST'])
@require_admin_scope
def api_delete_vm_backups(vmid):
"""Delete an explicitly confirmed selection, revalidated by Proxmox."""
try:
return jsonify(delete_guest_backups(vmid, request.get_json(silent=True)))
except BackupArchiveError as error:
return jsonify({'error': str(error)}), error.code
finally:
# Native deletion may have started even if the pvesh response was lost.
_vm_cache_invalidate(vmid, _vm_backups_cache)
@app.route('/api/vms/<int:vmid>/backups/delete-status', methods=['GET'])
@require_admin_scope
def api_vm_backup_deletion_status(vmid):
"""Only poll deletion tasks started here for this guest."""
try:
result = guest_backup_deletion_status(vmid, request.args.get('task'))
if result['status'] != 'pending':
_vm_cache_invalidate(vmid, _vm_backups_cache)
return jsonify(result)
except BackupArchiveError as error:
return jsonify({'error': str(error)}), error.code
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
@require_auth
def api_vm_backups(vmid):
@@ -13383,6 +13411,7 @@ def api_vm_backups(vmid):
'timestamp': ctime,
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
'notes': item.get('notes', ''),
'protected': item.get('protected', False) not in (False, 0, '0', '', None),
})
except Exception:
pass
+164
View File
@@ -0,0 +1,164 @@
# ==========================================================
# ProxMenux Monitor - Guest backup archives
# Author : Jonatan
# GitHub : github.com/jcastro
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Deletes explicitly selected VM/CT backups through the native Proxmox
# storage API after a fresh inventory/protection check. Tracks native
# deletion tasks so an accepted operation is not mistaken for completion.
# No filesystem paths or cached inventory authorize deletion.
# ==========================================================
import json
import re
import socket
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
MAX_SELECTION = 20
STORAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
_tasks = {}
_lock = threading.Lock()
class BackupArchiveError(Exception):
def __init__(self, message, code=400):
super().__init__(message)
self.code = code
def _pvesh(args, timeout=20):
try:
result = subprocess.run(["pvesh", *args, "--output-format", "json"],
capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as error:
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
if result.returncode:
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
try:
# pvesh can print no bytes for the API's successful null result.
return json.loads(result.stdout.strip() or "null")
except (ValueError, TypeError) as error:
raise BackupArchiveError("Invalid Proxmox response; outcome unconfirmed", 502) from error
def _node():
# Same local storage/node scope as the guest's existing backup list.
node = socket.gethostname().split(".")[0]
if not NODE_RE.fullmatch(node):
raise BackupArchiveError("Invalid local node name", 502)
return node
def _selection(vmid, data):
if not 100 <= vmid <= 999999999:
raise BackupArchiveError("Invalid VM/CT ID")
if not isinstance(data, dict) or data.get("confirm") is not True:
raise BackupArchiveError("Explicit confirmation is required")
selected = data.get("backups")
if not isinstance(selected, list) or not 1 <= len(selected) <= MAX_SELECTION:
raise BackupArchiveError(f"Select between 1 and {MAX_SELECTION} backups")
seen = set()
for item in selected:
if not isinstance(item, dict):
raise BackupArchiveError("Invalid backup selection")
storage, volid = item.get("storage"), item.get("volid")
if not isinstance(storage, str) or not STORAGE_RE.fullmatch(storage):
raise BackupArchiveError("Invalid backup storage")
if (not isinstance(volid, str) or not volid.startswith(storage + ":")
or len(volid) > 512 or not re.fullmatch(r"[A-Za-z0-9_:./+@-]+", volid)
or any(part in ("", ".", "..") for part in volid.split(":", 1)[1].split("/"))):
raise BackupArchiveError("Invalid backup volume ID")
if volid in seen:
raise BackupArchiveError("Duplicate backup selection")
seen.add(volid)
return [{"storage": item["storage"], "volid": item["volid"]} for item in selected]
def _protected(value):
# Fail closed for an unexpected protection value, including the text 'true'.
return value not in (None, False, 0, "0", "")
def _remember_task(task, vmid, node, volid):
# These are server-returned task IDs, never arbitrary client paths.
if (not isinstance(task, str) or not task.startswith(f"UPID:{node}:")
or ":imgdel:" not in task or "/" in task or len(task) > 1024):
raise BackupArchiveError("Unexpected deletion task; outcome unconfirmed", 502)
with _lock:
now = time.monotonic()
expired = [key for key, entry in _tasks.items() if now - entry["created"] > 3600]
for key in expired:
del _tasks[key]
# Bound even failed/unpolled jobs; expiry does not cancel a PVE task.
if len(_tasks) >= 512:
del _tasks[min(_tasks, key=lambda key: _tasks[key]["created"])]
_tasks[task] = {"vmid": vmid, "node": node, "volid": volid, "created": now}
def delete_guest_backups(vmid, data):
selected = _selection(vmid, data)
node = _node()
inventory = {}
# Preflight the entire batch before deleting anything. An unavailable
# storage or a protected/stale/cross-guest selection fails the whole batch.
for storage in sorted({item["storage"] for item in selected}):
entries = _pvesh(["get", f"/nodes/{node}/storage/{storage}/content",
"--content", "backup", "--vmid", str(vmid)])
if not isinstance(entries, list):
raise BackupArchiveError("Invalid backup inventory", 502)
for entry in entries:
if (isinstance(entry, dict) and entry.get("content") == "backup"
and isinstance(entry.get("volid"), str)):
inventory[(storage, entry.get("volid"))] = entry
for item in selected:
entry = inventory.get((item["storage"], item["volid"]))
if entry is None or str(entry.get("vmid")) != str(vmid):
raise BackupArchiveError("Backup no longer exists or does not belong to this VM/CT", 409)
if _protected(entry.get("protected")):
raise BackupArchiveError("Protected backups cannot be deleted", 409)
def remove(item):
try:
# Proxmox also enforces protection at deletion time. It removes
# associated notes/log files and returns null only after success.
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
"--delay", "1"])
if task is None:
return {**item, "status": "deleted"}
_remember_task(task, vmid, node, item["volid"])
return {**item, "status": "pending", "task": task}
except BackupArchiveError as error:
return {**item, "status": "unconfirmed", "error": str(error)}
with ThreadPoolExecutor(max_workers=min(4, len(selected)), thread_name_prefix="backup-delete") as pool:
results = list(pool.map(remove, selected))
return {"results": results}
def guest_backup_deletion_status(vmid, task):
with _lock:
entry = _tasks.get(task) if isinstance(task, str) else None
if not entry or entry["vmid"] != vmid or time.monotonic() - entry["created"] > 3600:
raise BackupArchiveError("Deletion task unknown or expired; refresh the backup list", 409)
entry = entry.copy()
status = _pvesh(["get", f'/nodes/{entry["node"]}/tasks/{task}/status'])
if not isinstance(status, dict) or status.get("status") not in ("running", "stopped"):
raise BackupArchiveError("Invalid deletion task status", 502)
result = {"volid": entry["volid"], "status": "pending"}
if status["status"] == "stopped":
if status.get("exitstatus") == "OK":
result["status"] = "deleted"
else:
result.update(status="unconfirmed", error=status.get("exitstatus") or "Deletion outcome unconfirmed")
return result