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)