mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Allow selecting and deleting guest backup archives in Monitor
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user