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)