mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 02:12:12 +00:00
Allow selecting and deleting guest backup archives in Monitor
This commit is contained in:
@@ -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
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import { MetricsView } from "./metrics-dialog"
|
|||||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
|
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 { OciConsoleLogPanel } from "./oci-console-log-panel"
|
||||||
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
||||||
import { formatStorage } from "../lib/utils"
|
import { formatStorage } from "../lib/utils"
|
||||||
@@ -382,16 +384,7 @@ interface BackupStorage {
|
|||||||
avail_human?: string
|
avail_human?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VMBackup {
|
type VMBackup = GuestBackup
|
||||||
volid: string
|
|
||||||
storage: string
|
|
||||||
type: string
|
|
||||||
size: number
|
|
||||||
size_human: string
|
|
||||||
timestamp: number
|
|
||||||
date: string
|
|
||||||
notes?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
|
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
|
||||||
// next to VMBackup since both are LXC-modal data structures.
|
// 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.
|
// Stale-while-revalidate with a 6-hour freshness gate.
|
||||||
//
|
//
|
||||||
// 1. If we already have backups (bulk hydration or a previous
|
// 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.
|
// 3. Loading spinner only on the true first load.
|
||||||
const GATE_MS = 6 * 60 * 60 * 1000
|
const GATE_MS = 6 * 60 * 60 * 1000
|
||||||
const seed = vmModalCacheRef.current.backups.get(vmid)
|
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)
|
if (!seed) setLoadingBackups(true)
|
||||||
try {
|
try {
|
||||||
const url = isStale
|
const url = isStale
|
||||||
? `/api/vms/${vmid}/backups?fresh=1`
|
? `/api/vms/${vmid}/backups?fresh=1`
|
||||||
: `/api/vms/${vmid}/backups`
|
: `/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) {
|
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() })
|
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1631,9 +1625,10 @@ export function VirtualMachines() {
|
|||||||
// Only clear the visible list if we had nothing to show
|
// Only clear the visible list if we had nothing to show
|
||||||
// in the first place — a transient network hiccup must not
|
// in the first place — a transient network hiccup must not
|
||||||
// wipe the stale-but-useful view the user is looking at.
|
// 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 {
|
} 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 */}
|
{/* Backups Tab */}
|
||||||
{activeModalTab === "backups" && (
|
{activeModalTab === "backups" && selectedVM && (
|
||||||
/* Backups tab layout: the Card fills the whole tab
|
/* Backups tab layout: the Card fills the whole tab
|
||||||
body so the backup list can scroll inside its
|
body so the backup list can scroll inside its
|
||||||
bordered card instead of leaving empty space
|
bordered card instead of leaving empty space
|
||||||
@@ -7471,48 +7466,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="border-t border-border/50 mb-4 shrink-0" />
|
<div className="border-t border-border/50 mb-4 shrink-0" />
|
||||||
|
|
||||||
{/* Backup List */}
|
<GuestBackupList
|
||||||
<div className="flex items-center justify-between mb-3 shrink-0">
|
key={selectedVM.vmid}
|
||||||
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
vmid={selectedVM.vmid}
|
||||||
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
backups={vmBackups}
|
||||||
</div>
|
loading={loadingBackups}
|
||||||
|
onRefresh={async () => {
|
||||||
{loadingBackups ? (
|
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
await fetchVmBackups(selectedVM.vmid, true)
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1131,7 +1131,21 @@
|
|||||||
"available": "Available backups",
|
"available": "Available backups",
|
||||||
"loading": "Loading backups...",
|
"loading": "Loading backups...",
|
||||||
"empty": "No backups found",
|
"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": {
|
"updatesPanel": {
|
||||||
"pendingTitle": "Pending package updates",
|
"pendingTitle": "Pending package updates",
|
||||||
|
|||||||
@@ -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/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_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/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/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_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"
|
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ from flask_notification_routes import notification_bp # noqa: E402
|
|||||||
from flask_oci_routes import oci_bp # noqa: E402
|
from flask_oci_routes import oci_bp # noqa: E402
|
||||||
from flask_audit_routes import audit_bp # noqa: E402
|
from flask_audit_routes import audit_bp # noqa: E402
|
||||||
from flask_vm_backup_routes import vm_backup_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
|
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
|
||||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
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
|
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:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
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'])
|
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_vm_backups(vmid):
|
def api_vm_backups(vmid):
|
||||||
@@ -13383,6 +13411,7 @@ def api_vm_backups(vmid):
|
|||||||
'timestamp': ctime,
|
'timestamp': ctime,
|
||||||
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
|
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
|
||||||
'notes': item.get('notes', ''),
|
'notes': item.get('notes', ''),
|
||||||
|
'protected': item.get('protected', False) not in (False, 0, '0', '', None),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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; });
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user