Merge pull request #461 from jcastro/feature/guest-backup-management

Allow selecting and deleting guest backup archives in Monitor
This commit is contained in:
MacRimi
2026-10-16 23:51:46 +02:00
committed by GitHub
17 changed files with 897 additions and 67 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
+135
View File
@@ -0,0 +1,135 @@
"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, storageColor }: {
vmid: number
backups: GuestBackup[]
loading: boolean
onRefresh: () => Promise<void>
storageColor?: (storage: string) => { bg: string; text: string; border: string }
}) {
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
const storageClass = (storage: string) => {
const color = storageColor?.(storage)
return color ? `${color.bg} ${color.text} ${color.border}` : ""
}
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))} />
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
<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 ${storageClass(backup.storage)}`}>{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>
</>
}
+23 -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"
@@ -399,16 +401,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.
@@ -1615,7 +1608,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
@@ -1632,15 +1625,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) {
@@ -1648,9 +1642,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)
}
}
@@ -7460,7 +7455,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
@@ -7495,48 +7490,17 @@ 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)
}}
storageColor={getStorageColor}
/>
</CardContent>
</Card>
</div>
+55
View File
@@ -0,0 +1,55 @@
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 })) }),
// Up to 20 archives, checked first and then deleted four at a time.
signal: AbortSignal.timeout(300_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
@@ -1110,7 +1110,21 @@
"available": "Verfügbare Backups",
"loading": "Backups werden geladen...",
"empty": "Keine Backups gefunden",
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben"
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben",
"refresh": "Aktualisieren",
"refreshFailed": "Sicherungen konnten nicht aktualisiert werden. Aktualisieren Sie erneut, um ihren aktuellen Zustand zu bestätigen.",
"deleteSelected": "Ausgewählte löschen ({count})",
"deletedCount": "{count} Sicherung(en) gelöscht.",
"unconfirmedCount": "Die Löschung von {count} Sicherung(en) wurde nicht bestätigt. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
"deleteFailed": "Die Löschung konnte nicht bestätigt werden. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
"selectHint": "Ungeschützte Sicherungen auswählen (bis zu 20 pro Durchgang)",
"selectBackup": "Sicherung auswählen",
"protected": "Geschützt",
"confirmTitle": "{count} Sicherung(en) löschen?",
"confirmDescription": "Diese Sicherungsarchive für VM/CT {vmid} werden dauerhaft gelöscht. Geschützte Sicherungen sind ausgeschlossen. Der Gast wird dadurch nicht gelöscht.",
"deleting": "Warten, bis Proxmox die ausgewählten Archive gelöscht hat…",
"cancel": "Abbrechen",
"confirmDelete": "Sicherungen löschen"
},
"updatesPanel": {
"pendingTitle": "Ausstehende Paketaktualisierungen",
+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",
+15 -1
View File
@@ -1110,7 +1110,21 @@
"available": "Backups disponibles",
"loading": "Cargando backups...",
"empty": "No se encontraron backups",
"emptyHint": "Crea tu primer backup con el botón de arriba"
"emptyHint": "Crea tu primer backup con el botón de arriba",
"refresh": "Actualizar",
"refreshFailed": "No se pudieron actualizar los backups. Vuelve a actualizar para confirmar su estado actual.",
"deleteSelected": "Eliminar los seleccionados ({count})",
"deletedCount": "{count} backup(s) eliminado(s).",
"unconfirmedCount": "No se ha confirmado la eliminación de {count} backup(s). Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
"deleteFailed": "No se pudo confirmar la eliminación. Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
"selectHint": "Selecciona backups sin protección (hasta 20 por lote)",
"selectBackup": "Seleccionar backup",
"protected": "Protegido",
"confirmTitle": "¿Eliminar {count} backup(s)?",
"confirmDescription": "Los archivos de backup de la VM/CT {vmid} se eliminarán de forma permanente. Los backups protegidos quedan fuera. La VM o el contenedor no se eliminan.",
"deleting": "Esperando a que Proxmox termine de eliminar los archivos seleccionados…",
"cancel": "Cancelar",
"confirmDelete": "Eliminar backups"
},
"updatesPanel": {
"pendingTitle": "Actualizaciones de paquetes pendientes",
+15 -1
View File
@@ -1110,7 +1110,21 @@
"available": "Sauvegardes disponibles",
"loading": "Chargement des sauvegardes...",
"empty": "Aucune sauvegarde trouvée",
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus"
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus",
"refresh": "Actualiser",
"refreshFailed": "Impossible d'actualiser les sauvegardes. Actualisez à nouveau pour confirmer leur état actuel.",
"deleteSelected": "Supprimer la sélection ({count})",
"deletedCount": "{count} sauvegarde(s) supprimée(s).",
"unconfirmedCount": "La suppression de {count} sauvegarde(s) n'a pas été confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
"deleteFailed": "La suppression n'a pas pu être confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
"selectHint": "Sélectionnez les sauvegardes non protégées (jusqu'à 20 par lot)",
"selectBackup": "Sélectionner la sauvegarde",
"protected": "Protégée",
"confirmTitle": "Supprimer {count} sauvegarde(s) ?",
"confirmDescription": "Ces archives de sauvegarde de la VM/CT {vmid} seront définitivement supprimées. Les sauvegardes protégées sont exclues. Cela ne supprime pas l'invité.",
"deleting": "En attente de la fin de la suppression des archives sélectionnées par Proxmox…",
"cancel": "Annuler",
"confirmDelete": "Supprimer les sauvegardes"
},
"updatesPanel": {
"pendingTitle": "Mises à jour des packages en attente",
+15 -1
View File
@@ -1110,7 +1110,21 @@
"available": "Backup disponibili",
"loading": "Caricamento backup...",
"empty": "Nessun backup trovato",
"emptyHint": "Crea il primo backup con il pulsante in alto"
"emptyHint": "Crea il primo backup con il pulsante in alto",
"refresh": "Aggiorna",
"refreshFailed": "Impossibile aggiornare i backup. Aggiorna di nuovo per confermarne lo stato attuale.",
"deleteSelected": "Elimina selezionati ({count})",
"deletedCount": "{count} backup eliminati.",
"unconfirmedCount": "L'eliminazione di {count} backup non è stata confermata. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
"deleteFailed": "Impossibile confermare l'eliminazione. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
"selectHint": "Seleziona i backup non protetti (fino a 20 per gruppo)",
"selectBackup": "Seleziona backup",
"protected": "Protetto",
"confirmTitle": "Eliminare {count} backup?",
"confirmDescription": "Questi archivi di backup della VM/CT {vmid} verranno eliminati definitivamente. I backup protetti sono esclusi. Questo non elimina l'ospite.",
"deleting": "In attesa che Proxmox termini l'eliminazione degli archivi selezionati…",
"cancel": "Annulla",
"confirmDelete": "Elimina backup"
},
"updatesPanel": {
"pendingTitle": "Aggiornamenti pacchetti in sospeso",
+15 -1
View File
@@ -1110,7 +1110,21 @@
"available": "Backups disponíveis",
"loading": "Carregando backups...",
"empty": "Nenhum backup encontrado",
"emptyHint": "Crie seu primeiro backup usando o botão acima"
"emptyHint": "Crie seu primeiro backup usando o botão acima",
"refresh": "Atualizar",
"refreshFailed": "Não foi possível atualizar as cópias de segurança. Atualize novamente para confirmar o seu estado atual.",
"deleteSelected": "Eliminar selecionadas ({count})",
"deletedCount": "{count} cópia(s) de segurança eliminada(s).",
"unconfirmedCount": "A eliminação de {count} cópia(s) de segurança não foi confirmada. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
"deleteFailed": "Não foi possível confirmar a eliminação. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
"selectHint": "Selecione cópias de segurança sem proteção (até 20 por lote)",
"selectBackup": "Selecionar cópia de segurança",
"protected": "Protegida",
"confirmTitle": "Eliminar {count} cópia(s) de segurança?",
"confirmDescription": "Estes arquivos de cópia de segurança da VM/CT {vmid} serão eliminados permanentemente. As cópias protegidas estão excluídas. Isto não elimina o convidado.",
"deleting": "À espera que o Proxmox termine de eliminar os arquivos selecionados…",
"cancel": "Cancelar",
"confirmDelete": "Eliminar cópias de segurança"
},
"updatesPanel": {
"pendingTitle": "Atualizações de pacote pendentes",
+15 -1
View File
@@ -1131,7 +1131,21 @@
"available": "Dostupné zálohy",
"loading": "Načítavam zálohy...",
"empty": "Nenašli sa žiadne zálohy",
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie"
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie",
"refresh": "Obnoviť",
"refreshFailed": "Nepodarilo sa obnoviť zálohy. Obnovte ich znova, aby ste potvrdili ich aktuálny stav.",
"deleteSelected": "Odstrániť vybrané ({count})",
"deletedCount": "Odstránené zálohy: {count}.",
"unconfirmedCount": "Odstránenie záloh ({count}) nebolo potvrdené. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
"deleteFailed": "Odstránenie sa nepodarilo potvrdiť. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
"selectHint": "Vyberte nechránené zálohy (najviac 20 v jednej dávke)",
"selectBackup": "Vybrať zálohu",
"protected": "Chránená",
"confirmTitle": "Odstrániť zálohy ({count})?",
"confirmDescription": "Tieto archívy záloh pre VM/CT {vmid} budú natrvalo odstránené. Chránené zálohy sú vylúčené. Hosť sa tým neodstráni.",
"deleting": "Čaká sa, kým Proxmox dokončí odstránenie vybraných archívov…",
"cancel": "Zrušiť",
"confirmDelete": "Odstrániť zálohy"
},
"updatesPanel": {
"pendingTitle": "Čakajúce aktualizácie balíkov",
+15 -1
View File
@@ -1110,7 +1110,21 @@
"available": "Tillgängliga säkerhetskopior",
"loading": "Laddar säkerhetskopior...",
"empty": "Inga säkerhetskopior hittades",
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan"
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan",
"refresh": "Uppdatera",
"refreshFailed": "Det gick inte att uppdatera säkerhetskopiorna. Uppdatera igen för att bekräfta deras aktuella tillstånd.",
"deleteSelected": "Ta bort markerade ({count})",
"deletedCount": "{count} säkerhetskopior har tagits bort.",
"unconfirmedCount": "Borttagning av {count} säkerhetskopior har inte bekräftats. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
"deleteFailed": "Borttagningen kunde inte bekräftas. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
"selectHint": "Välj oskyddade säkerhetskopior (upp till 20 per omgång)",
"selectBackup": "Välj säkerhetskopia",
"protected": "Skyddad",
"confirmTitle": "Ta bort {count} säkerhetskopior?",
"confirmDescription": "Dessa säkerhetskopieringsarkiv för VM/CT {vmid} kommer att tas bort permanent. Skyddade säkerhetskopior är undantagna. Detta tar inte bort gästen.",
"deleting": "Väntar på att Proxmox ska slutföra borttagningen av de valda arkiven…",
"cancel": "Avbryt",
"confirmDelete": "Ta bort säkerhetskopior"
},
"updatesPanel": {
"pendingTitle": "Väntande paketuppdateringar",
+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
+172
View File
@@ -0,0 +1,172 @@
# ==========================================================
# 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
REMOVED_RE = re.compile(r"Removed volume '[^'\n]+'")
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)
output = result.stdout.strip()
try:
# pvesh can print no bytes for the API's successful null result.
return json.loads(output or "null")
except (ValueError, TypeError) as error:
# With --delay, a deletion that ends in time prints the line of its
# task log instead of JSON. Only that exact line is accepted.
if args[0] == "delete" and REMOVED_RE.fullmatch(output):
return output
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 isinstance(task, str) and task.startswith("UPID:"):
_remember_task(task, vmid, node, item["volid"])
return {**item, "status": "pending", "task": task}
if task is None or task == f"Removed volume '{item['volid']}'":
return {**item, "status": "deleted"}
return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"}
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; });
+239
View File
@@ -0,0 +1,239 @@
"""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_a_deletion_that_ends_within_the_delay_prints_its_log_and_counts_as_deleted(self):
# Real pvesh output on a directory storage: the task log, not JSON.
run = subprocess.CompletedProcess([], 0, stdout=f"Removed volume '{VOLUME}'\n", stderr="")
with patch.object(archives.subprocess, "run", return_value=run):
self.assertEqual(archives._pvesh(["delete", f"/nodes/pve1/storage/local/content/{VOLUME}", "--delay", "1"]),
f"Removed volume '{VOLUME}'")
self.response = f"Removed volume '{VOLUME}'"
self.assertEqual(self.delete()["results"][0]["status"], "deleted")
self.response = "Removed volume 'local:backup/another-archive.tar.zst'"
self.assertEqual(self.delete()["results"][0]["status"], "unconfirmed")
with patch.object(archives.subprocess, "run", return_value=run), self.assertRaises(archives.BackupArchiveError):
archives._pvesh(["get", "/nodes/pve1/storage/local/content"])
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()