diff --git a/docs/services/lxc208-manage/alert/pbs.py b/docs/services/lxc208-manage/alert/pbs.py new file mode 100644 index 0000000..14fb634 --- /dev/null +++ b/docs/services/lxc208-manage/alert/pbs.py @@ -0,0 +1,226 @@ +import re +import json +import logging +from datetime import datetime +from enum import Enum +from typing import Optional, Dict, Tuple + +from alerts.utils import escape_html, format_size +from alerts.alerts import ALERTS_CONFIG +from alerts.formatters import get_formatter + +logger = logging.getLogger(__name__) + +BACKUP_CHANNEL = "backup" + +PBS_REQUIRED_FIELDS = {"backup_size", "guest", "timestamp"} + + +class BackupSource(Enum): + PVE = "pve" + PBS = "pbs" + + +def detect_backup_source(obj: dict) -> Optional[BackupSource]: + if not isinstance(obj, dict): + return None + + text = ( + str(obj.get("title", "")) + + "\n" + + str(obj.get("message", "")) + ).lower() + + if "vzdump" in text: + logger.debug("Detected PVE backup source (vzdump in text)") + return BackupSource.PVE + + if "proxmox backup" in text: + logger.debug("Detected PBS backup source (proxmox backup in text)") + return BackupSource.PBS + + if "backup" in text: + pbs_keys = PBS_REQUIRED_FIELDS & set(obj.keys()) + if pbs_keys: + logger.debug("Detected PBS backup source (keys: %s)", pbs_keys) + return BackupSource.PBS + + return None + + +def parse_size_to_bytes(size_str: str) -> Optional[float]: + try: + parts = size_str.strip().split() + if len(parts) != 2: + return None + value = float(parts[0]) + unit = parts[1].upper() + multipliers = { + "B": 1, + "KB": 1024, + "KIB": 1024, + "MB": 1024 ** 2, + "MIB": 1024 ** 2, + "GB": 1024 ** 3, + "GIB": 1024 ** 3, + "TB": 1024 ** 4, + "TIB": 1024 ** 4, + "PB": 1024 ** 5, + "PIB": 1024 ** 5, + } + multiplier = multipliers.get(unit) + if multiplier is None: + return None + return value * multiplier + except Exception: + logger.exception("Failed to parse size") + return None + + +def parse_pve_backup_table(message: str) -> Optional[Dict]: + table_pattern = r'^(\d+)\s+(\S+)\s+(ok|error|failed)\s+(\d+(?:m\s+\d+)?s)\s+([\d.]+\s+\S+)\s+((?:ct|vm)/\S+)' + matches = re.findall(table_pattern, message, re.MULTILINE) + + if not matches: + return None + + backups = [] + total_size_bytes = 0 + + for match in matches: + vmid, name, status, duration, size_str, filename = match + + prefix = "ct" if filename.startswith("ct/") else "vm" + backup_id = f"{prefix}-{vmid}" + size_bytes = parse_size_to_bytes(size_str) + if size_bytes is not None: + total_size_bytes += size_bytes + + backups.append({ + "backup_id": backup_id, + "name": name, + "status": status, + "duration": duration, + "size": size_str, + "size_bytes": size_bytes, + }) + + time_match = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', message) + time_str = time_match.group(1) if time_match else datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + all_success = all(b["status"] == "ok" for b in backups) + has_errors = any(b["status"] in ("error", "failed") for b in backups) + + return { + "backups": backups, + "time": time_str, + "total_size": format_size(total_size_bytes), + "total_size_bytes": total_size_bytes, + "success": all_success and not has_errors, + "has_errors": has_errors, + } + + +def parse_pbs_backup(obj: dict) -> Optional[Dict]: + node = obj.get("node", "") + vmid = obj.get("vmid", "") or obj.get("guest", "") + timestamp = obj.get("time") or obj.get("timestamp") + size = obj.get("size") or obj.get("backup_size") + status = str(obj.get("status", "unknown")).lower() + + if not vmid and "backup" in obj: + match = re.search(r"(vm|ct)-(\d+)", obj.get("backup", "")) + if match: + vmid = f"{match.group(1)}-{match.group(2)}" + + if not vmid: + return None + + backup_id = f"{node}:{vmid}" if node else vmid + + if timestamp: + try: + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + else: + dt = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")) + time_str = dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + logger.debug("Failed to parse timestamp: %s", timestamp) + time_str = str(timestamp) + else: + time_str = "Unknown" + + return { + "backups": [{ + "backup_id": backup_id, + "name": "", + "status": status, + "duration": "", + "size": format_size(size), + "size_bytes": 0, + }], + "time": time_str, + "total_size": format_size(size), + "total_size_bytes": 0, + "success": status in ("ok", "success"), + "has_errors": status not in ("ok", "success"), + } + + +def parse_backup(obj: dict, source: Optional[BackupSource] = None) -> Optional[Dict]: + if not obj: + return None + if isinstance(obj, str): + try: + obj = json.loads(obj) + except Exception: + return None + + if source is None: + source = detect_backup_source(obj) + + if source == BackupSource.PVE: + return parse_pve_backup_table(obj.get("message", "")) + if source == BackupSource.PBS: + return parse_pbs_backup(obj) + + return None + + +def build_backup_message(info: Dict) -> Tuple[str, str]: + alertname = "Backup Success" if info.get("success") else "Backup Failed" + config = ALERTS_CONFIG.get(alertname) + + if not config: + return ( + "Backup notification received

" +
+            escape_html(json.dumps(info, ensure_ascii=False, indent=2)) +
+            "
", + BACKUP_CHANNEL + ) + + formatter = get_formatter(config.get("formatter")) + text = f"{config['title']}\n\n" + + if formatter: + text += formatter(info) + + room = config.get("room", BACKUP_CHANNEL) + return text, room + + +def process_backup(obj: dict, send_func, source: Optional[BackupSource] = None) -> None: + info = parse_backup(obj, source) + + if not info: + send_func( + "Backup notification received

" +
+            escape_html(json.dumps(obj, ensure_ascii=False, indent=2)) +
+            "
", + BACKUP_CHANNEL + ) + return + + message, room = build_backup_message(info) + send_func(message, room) \ No newline at end of file