mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge develop and retain guest retention beside PBS navigation
This commit is contained in:
@@ -184,6 +184,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"
|
||||
|
||||
@@ -35,7 +35,7 @@ TOOL_METADATA = {
|
||||
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
|
||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
|
||||
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
|
||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
||||
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
||||
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
|
||||
|
||||
@@ -97,6 +97,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
|
||||
@@ -13309,6 +13311,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):
|
||||
@@ -13389,6 +13417,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
|
||||
|
||||
@@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs:
|
||||
DELETE /<id> delete a job (its backups are kept)
|
||||
POST /<id>/toggle enable / disable
|
||||
POST /<id>/run run it now, as a Proxmox task on each node
|
||||
|
||||
Storage retention is read/edited under /api/vm-backup-storages. Saving a
|
||||
policy only changes the native storage configuration; it never runs pruning.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -229,6 +232,58 @@ def options():
|
||||
})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages", methods=["GET"])
|
||||
@require_auth
|
||||
def backup_storage_retention():
|
||||
ok, storages = _pvesh_json(["get", "/storage"])
|
||||
if not ok or not isinstance(storages, list):
|
||||
return _err(storages if not ok else "Invalid storage inventory", 502)
|
||||
return jsonify({"storages": [
|
||||
{"id": storage["storage"], "type": storage.get("type", ""),
|
||||
"prune_backups": _flat(storage.get("prune-backups") or ""),
|
||||
"digest": storage.get("digest", "")}
|
||||
for storage in storages
|
||||
if isinstance(storage, dict) and storage.get("storage")
|
||||
and "backup" in (storage.get("content") or "").split(",")
|
||||
]})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages/<storage_id>", methods=["PUT"])
|
||||
@require_admin_scope
|
||||
def update_backup_storage_retention(storage_id):
|
||||
if not STORAGE_RE.fullmatch(storage_id):
|
||||
return _err("Invalid storage")
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return _err("A retention policy and current configuration digest are required")
|
||||
digest, prune = data.get("digest"), data.get("prune_backups")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{40}", digest):
|
||||
return _err("Refresh the storage list before editing its retention")
|
||||
# An empty policy removes the storage setting, so the Proxmox default applies.
|
||||
if not isinstance(prune, str) or (prune and not PRUNE_RE.fullmatch(prune)):
|
||||
return _err("Invalid retention policy")
|
||||
pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
|
||||
keys = [key for key, _ in pairs]
|
||||
if (len(keys) != len(set(keys)) or
|
||||
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
|
||||
any(int(value) < 1 for _, value in pairs)):
|
||||
return _err("Choose keep-all=1 or one or more positive retention counts")
|
||||
# Check that this is still a backup storage, then let Proxmox check the
|
||||
# digest under its storage lock. Other storage settings are never passed.
|
||||
ok, storage = _pvesh_json(["get", f"/storage/{storage_id}"])
|
||||
if not ok:
|
||||
return _err(storage, 502)
|
||||
if not isinstance(storage, dict) or "backup" not in (storage.get("content") or "").split(","):
|
||||
return _err("This storage does not accept backups", 409)
|
||||
if storage.get("digest") != digest:
|
||||
return _err("Storage configuration changed; refresh before saving", 409)
|
||||
change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
|
||||
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
|
||||
if not ok:
|
||||
return _err(out, 409)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def create_job():
|
||||
|
||||
@@ -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
|
||||
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
|
||||
return apprise_mod.NotifyType.FAILURE
|
||||
if sev == 'WARNING':
|
||||
return apprise_mod.NotifyType.WARNING
|
||||
if sev == 'SUCCESS':
|
||||
if sev in ('SUCCESS', 'OK'):
|
||||
return apprise_mod.NotifyType.SUCCESS
|
||||
return apprise_mod.NotifyType.INFO
|
||||
|
||||
|
||||
Reference in New Issue
Block a user