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
+54
View File
@@ -0,0 +1,54 @@
name: Guest backup archives
on:
pull_request:
paths:
- '.github/workflows/test-guest-backups.yml'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'AppImage/messages/**'
- 'tests/test_guest_backup_archives.*'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'tests/test_guest_backup_archives.*'
workflow_dispatch:
permissions:
contents: read
jobs:
guest-backups:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native deletion, protection, authorization and cache invalidation
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
- name: Test frontend deletion and completion handling
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_guest_backup_archives.cjs
+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
+69
View File
@@ -0,0 +1,69 @@
// Run the shipped deletion client with only its authenticated transport replaced.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const source = fs.readFileSync('AppImage/lib/guest-backup-archives.ts', 'utf8');
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const backup = { volid: 'local:backup/vzdump-lxc-100-fixture.tar.zst', storage: 'local' };
async function run(responses) {
const calls = [];
const context = { exports: {}, AbortSignal, Date, setTimeout: callback => callback(), require: name => {
assert.equal(name, './api-config');
return { fetchApi: async (path, options) => {
calls.push({ path, options });
assert.ok(responses.length, `unexpected call: ${path}`);
const response = responses.shift();
if (response instanceof Error) throw response;
return response;
} };
} };
vm.runInNewContext(js, context);
let results, error;
try { results = JSON.parse(JSON.stringify(await context.exports.deleteGuestBackups(100, [backup]))); }
catch (caught) { error = caught; }
return { calls, results, error };
}
(async () => {
const success = await run([{ results: [{ volid: backup.volid, status: 'deleted' }] }]);
assert.equal(success.results[0].status, 'deleted');
assert.deepEqual(JSON.parse(success.calls[0].options.body), { confirm: true, backups: [backup] });
assert.equal(success.calls[0].options.method, 'POST');
assert.equal(success.calls.length, 1);
const task = 'UPID:pve1:01:02:03:imgdel:100:root@pam:';
const pending = { results: [{ volid: backup.volid, status: 'pending', task }] };
const completed = await run([pending, { volid: backup.volid, status: 'pending' }, { volid: backup.volid, status: 'deleted' }]);
assert.equal(completed.results[0].status, 'deleted');
assert.equal(completed.calls.length, 3);
assert.equal(completed.calls[1].path, `/api/vms/100/backups/delete-status?task=${encodeURIComponent(task)}`);
assert.equal(completed.calls.filter(call => call.options.method === 'POST').length, 1);
const failed = await run([pending, { volid: backup.volid, status: 'unconfirmed', error: 'protected' }]);
assert.equal(failed.results[0].status, 'unconfirmed');
const lostPost = await run([new Error('network failure')]);
assert.equal(lostPost.error.message, 'network failure');
assert.equal(lostPost.calls.length, 1, 'never retry destructive POST');
const lostStatus = await run([pending, new Error('timeout'), new Error('timeout'), new Error('timeout')]);
assert.equal(lostStatus.results[0].status, 'unconfirmed');
assert.equal(lostStatus.calls.length, 4, 'bounded read failures');
for (const response of [{ results: [] }, { results: [{ volid: 'other', status: 'deleted' }] },
{ results: [{ volid: backup.volid, status: 'accepted' }] }]) {
assert.equal((await run([response])).error.message, 'deletionUnconfirmed');
}
const foreignStatus = await run([pending, { volid: 'other', status: 'deleted' }]);
assert.equal(foreignStatus.results[0].status, 'unconfirmed');
const component = fs.readFileSync('AppImage/components/guest-backup-list.tsx', 'utf8');
const english = JSON.parse(fs.readFileSync('AppImage/messages/en/common.json')).vmLxc.backups;
for (const [, key] of component.matchAll(/t\("vmLxc\.backups\.([A-Za-z]+)"/g)) {
assert.ok(typeof english[key] === 'string' && english[key].length, `English translation: ${key}`);
}
assert.ok(component.includes('disabled={disabled || !!backup.protected'));
assert.ok(component.includes('!backup.protected'));
assert.ok(component.includes('DialogDescription'));
const parent = fs.readFileSync('AppImage/components/virtual-machines.tsx', 'utf8');
assert.ok(parent.includes('<GuestBackupList'));
assert.ok(parent.includes('await fetchVmBackups(selectedVM.vmid, true)'));
console.log('PASS: native task completion, partial/unconfirmed outcomes, lost responses, no destructive retries, translated UI and forced refresh');
})().catch(error => { console.error(error); process.exitCode = 1; });
+226
View File
@@ -0,0 +1,226 @@
"""Guest archive deletion contract. No real Proxmox commands or backups."""
import ast
import copy
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import types
import unittest
from unittest.mock import Mock, patch
from flask import Flask, jsonify, request
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "AppImage/scripts"
sys.path.insert(0, str(SCRIPTS))
import guest_backup_archives as archives
VOLUME = "local:backup/vzdump-lxc-100-2026_10_16-12_00_00.tar.zst"
OTHER = VOLUME.replace("12_00_00", "13_00_00")
TASK = "UPID:pve1:00000001:00000002:00000003:imgdel:100:root@pam:"
class DeletionTests(unittest.TestCase):
def setUp(self):
self.node = patch.object(archives, "_node", return_value="pve1")
self.node.start()
self.addCleanup(self.node.stop)
self.inventory = [{"volid": VOLUME, "vmid": 100, "content": "backup", "protected": False}]
self.calls = []
self.response = None
archives._tasks.clear()
def run_pvesh(self, args, **kwargs):
self.calls.append(args)
if args[0] == "get" and args[1].endswith("/content"):
return copy.deepcopy(self.inventory)
if args[0] == "delete":
if isinstance(self.response, Exception):
raise self.response
return self.response
return {"status": "stopped", "exitstatus": "OK"}
def delete(self, volumes=(VOLUME,), **kwargs):
data = {"confirm": True, "backups": [{"storage": v.split(":")[0], "volid": v} for v in volumes]}
data.update(kwargs)
with patch.object(archives, "_pvesh", side_effect=self.run_pvesh):
return archives.delete_guest_backups(100, data)
def test_success_uses_guest_filtered_native_inventory_and_delete(self):
self.assertEqual(self.delete()["results"], [{"storage": "local", "volid": VOLUME, "status": "deleted"}])
self.assertEqual(self.calls, [
["get", "/nodes/pve1/storage/local/content", "--content", "backup", "--vmid", "100"],
["delete", f"/nodes/pve1/storage/local/content/{VOLUME}", "--delay", "1"],
])
def test_bad_payloads_and_paths_never_reach_proxmox(self):
payloads = [None, [], {}, {"confirm": 1}, {"confirm": True, "backups": []},
{"confirm": True, "backups": [None]}, {"confirm": True, "backups": [VOLUME]},
{"confirm": True, "backups": [{"storage": "local", "volid": VOLUME}] * 21}]
for volid in ("local:../etc/passwd", "local:backup/../x", "local:/etc/pve/storage.cfg", "local:backup//x",
"local:backup/x%2fy", "local:backup/x\n--force", "local:backup/x;touch /tmp/x", "other:backup/x"):
payloads.append({"confirm": True, "backups": [{"storage": "local", "volid": volid}]})
for storage in ("../local", "-x", "local\n", 2, None):
payloads.append({"confirm": True, "backups": [{"storage": storage, "volid": VOLUME}]})
with patch.object(archives, "_pvesh") as run:
for data in payloads:
with self.subTest(data=data), self.assertRaises(archives.BackupArchiveError):
archives.delete_guest_backups(100, data)
for vmid in (0, 99, 1000000000):
with self.assertRaises(archives.BackupArchiveError):
archives.delete_guest_backups(vmid, {"confirm": True, "backups": [{"storage": "local", "volid": VOLUME}]})
run.assert_not_called()
def test_duplicate_selection_rejected_before_commands(self):
with self.assertRaises(archives.BackupArchiveError):
self.delete((VOLUME, VOLUME))
self.assertEqual(self.calls, [])
def test_newly_protected_archives_fail_the_whole_batch(self):
self.inventory.append({"volid": OTHER, "vmid": 100, "content": "backup", "protected": True})
for protection in (True, 1, "1", "true", "unexpected"):
self.inventory[-1]["protected"] = protection
with self.subTest(protection=protection), self.assertRaises(archives.BackupArchiveError) as error:
self.delete((VOLUME, OTHER))
self.assertEqual(error.exception.code, 409)
self.assertFalse(any(call[0] == "delete" for call in self.calls))
def test_missing_cross_guest_and_non_backup_volumes_are_never_deleted(self):
for entries in ([], [{"volid": VOLUME, "vmid": 101, "content": "backup"}],
[{"volid": VOLUME, "content": "backup"}],
[{"volid": VOLUME, "vmid": 100, "content": "images"}]):
self.inventory = entries
with self.subTest(entries=entries), self.assertRaises(archives.BackupArchiveError):
self.delete()
self.assertFalse(any(call[0] == "delete" for call in self.calls))
def test_storage_failure_prevents_every_delete(self):
def unavailable(args):
self.assertEqual(args[0], "get")
raise archives.BackupArchiveError("offline", 502)
with patch.object(archives, "_pvesh", side_effect=unavailable), self.assertRaises(archives.BackupArchiveError):
archives.delete_guest_backups(100, {"confirm": True, "backups": [
{"volid": VOLUME, "storage": "local"}, {"volid": "nas:backup/x", "storage": "nas"}]})
def test_partial_native_errors_remain_unconfirmed(self):
self.inventory.append({"volid": OTHER, "vmid": 100, "content": "backup"})
def native(args):
if args[0] == "delete" and OTHER in args[1]:
raise archives.BackupArchiveError("backup is protected", 502)
return self.run_pvesh(args)
with patch.object(archives, "_pvesh", side_effect=native):
result = archives.delete_guest_backups(100, {"confirm": True, "backups": [
{"volid": v, "storage": "local"} for v in (VOLUME, OTHER)]})
self.assertEqual([item["status"] for item in result["results"]], ["deleted", "unconfirmed"])
self.assertIn("protected", result["results"][1]["error"])
def test_native_task_is_not_a_success_until_exit_ok(self):
self.response = TASK
result = self.delete()["results"][0]
self.assertEqual(result["status"], "pending")
for native, expected in (({"status": "running"}, "pending"),
({"status": "stopped", "exitstatus": "OK"}, "deleted"),
({"status": "stopped", "exitstatus": "cannot delete protected backup"}, "unconfirmed"),
({"status": "stopped"}, "unconfirmed")):
with patch.object(archives, "_pvesh", return_value=native) as run:
answer = archives.guest_backup_deletion_status(100, TASK)
self.assertEqual(answer["status"], expected)
run.assert_called_once_with(["get", f"/nodes/pve1/tasks/{TASK}/status"])
def test_poll_cannot_read_arbitrary_tasks_or_other_guests(self):
self.response = TASK
self.delete()
with patch.object(archives, "_pvesh") as run:
for vmid, task in ((101, TASK), (100, "../../status"), (100, None), (100, TASK + "x")):
with self.assertRaises(archives.BackupArchiveError):
archives.guest_backup_deletion_status(vmid, task)
archives._tasks[TASK]["created"] -= 3601
with self.assertRaises(archives.BackupArchiveError):
archives.guest_backup_deletion_status(100, TASK)
run.assert_not_called()
def test_invalid_native_task_answers_never_claim_success(self):
for response in ("", {}, [TASK], TASK.replace("imgdel", "vzdump"), TASK.replace("pve1", "pve2")):
self.response = response
with self.subTest(response=response):
self.assertEqual(self.delete()["results"][0]["status"], "unconfirmed")
def test_pvesh_uses_argv_and_handles_failure_and_timeout(self):
with patch.object(archives.subprocess, "run", return_value=types.SimpleNamespace(returncode=0, stdout="null")) as run:
self.assertIsNone(archives._pvesh(["delete", "/native/path"]))
self.assertEqual(run.call_args.args[0], ["pvesh", "delete", "/native/path", "--output-format", "json"])
self.assertNotIn("shell", run.call_args.kwargs)
with patch.object(archives.subprocess, "run", return_value=types.SimpleNamespace(returncode=0, stdout="")):
self.assertIsNone(archives._pvesh(["delete", "/native/path"]))
for response in (types.SimpleNamespace(returncode=1, stderr="protected", stdout=""),
types.SimpleNamespace(returncode=0, stdout="not JSON")):
with patch.object(archives.subprocess, "run", return_value=response), self.assertRaises(archives.BackupArchiveError):
archives._pvesh(["delete", "/native/path"])
for error in (FileNotFoundError(), subprocess.TimeoutExpired("pvesh", 20)):
with patch.object(archives.subprocess, "run", side_effect=error), self.assertRaises(archives.BackupArchiveError):
archives._pvesh(["delete", "/native/path"])
class RouteTests(unittest.TestCase):
"""Compile the real route bodies/decorators without starting host services."""
def setUp(self):
auth = types.ModuleType("auth_manager")
auth.load_auth_config = lambda: {"enabled": True}
auth.verify_token = lambda token: "tester" if token == "admin" else None
auth.verify_token_full = lambda token: ("tester", "full_admin" if token == "admin" else "read_only")
spec = importlib.util.spec_from_file_location("guest_backup_test_middleware", SCRIPTS / "jwt_middleware.py")
middleware = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, auth_manager=auth):
spec.loader.exec_module(middleware)
self.app = Flask(__name__)
self.service = Mock(return_value={"results": []})
self.status = Mock(return_value={"volid": VOLUME, "status": "deleted"})
self.invalidate = Mock()
self.namespace = {"app": self.app, "require_admin_scope": middleware.require_admin_scope,
"request": request, "jsonify": jsonify, "BackupArchiveError": archives.BackupArchiveError,
"delete_guest_backups": self.service, "guest_backup_deletion_status": self.status,
"_vm_cache_invalidate": self.invalidate, "_vm_backups_cache": {}}
source = ast.parse((SCRIPTS / "flask_server.py").read_text())
wanted = {"api_delete_vm_backups", "api_vm_backup_deletion_status"}
routes = [node for node in source.body if isinstance(node, ast.FunctionDef) and node.name in wanted]
self.assertEqual(len(routes), 2)
exec(compile(ast.Module(body=routes, type_ignores=[]), "flask_server.py", "exec"), self.namespace)
self.client = self.app.test_client()
def test_both_routes_reject_unauthenticated_and_read_only_requests(self):
for headers, status in (({}, 401), ({"Authorization": "Bearer reader"}, 403)):
self.assertEqual(self.client.post("/api/vms/100/backups/delete", json={}, headers=headers).status_code, status)
self.assertEqual(self.client.get("/api/vms/100/backups/delete-status?task=x", headers=headers).status_code, status)
self.service.assert_not_called()
self.status.assert_not_called()
self.invalidate.assert_not_called()
def test_admin_route_passes_payload_and_invalidates_even_after_errors(self):
data = {"confirm": True, "backups": [{"storage": "local", "volid": VOLUME}]}
headers = {"Authorization": "Bearer admin"}
self.assertEqual(self.client.post("/api/vms/100/backups/delete", json=data, headers=headers).status_code, 200)
self.service.assert_called_once_with(100, data)
self.invalidate.assert_called_once_with(100, {})
self.service.side_effect = archives.BackupArchiveError("protected", 409)
self.assertEqual(self.client.post("/api/vms/100/backups/delete", json=data, headers=headers).status_code, 409)
self.assertEqual(self.invalidate.call_count, 2)
def test_poll_invalidates_only_after_completion(self):
headers = {"Authorization": "Bearer admin"}
self.status.return_value = {"status": "pending"}
self.client.get("/api/vms/100/backups/delete-status?task=x", headers=headers)
self.invalidate.assert_not_called()
self.status.return_value = {"status": "deleted"}
self.client.get("/api/vms/100/backups/delete-status?task=x", headers=headers)
self.status.assert_called_with(100, "x")
self.invalidate.assert_called_once_with(100, {})
def test_runtime_module_is_shipped(self):
self.assertIn('cp "$SCRIPT_DIR/guest_backup_archives.py" "$APP_DIR/usr/bin/"', (SCRIPTS / "build_appimage.sh").read_text())
if __name__ == "__main__":
unittest.main()