From a7021de8723484038aac9d57c43e8c8bb29ca6ab Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 12 Jan 2026 09:52:29 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-12 09:52:29) --- .last-branch | 2 +- .../src/backend/app/parsers/synology.py | 189 ++++++++++-------- docs/changelog.md | 9 + 3 files changed, 118 insertions(+), 82 deletions(-) diff --git a/.last-branch b/.last-branch index 7a3c260..944571c 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260112-01-synology-abb-partial-warning +v20260112-02-synology-abb-subject-partial-warning diff --git a/containers/backupchecks/src/backend/app/parsers/synology.py b/containers/backupchecks/src/backend/app/parsers/synology.py index 93a2517..f2d7ff4 100644 --- a/containers/backupchecks/src/backend/app/parsers/synology.py +++ b/containers/backupchecks/src/backend/app/parsers/synology.py @@ -173,40 +173,33 @@ def _extract_totals(text: str) -> Tuple[int, int, int]: _ABB_SUBJECT_RE = re.compile(r"\bactive\s+backup\s+for\s+business\b", re.I) -# Example (NL): +# Examples (NL): # "De back-uptaak vSphere-Task-1 op KANTOOR-NEW is voltooid." -# Example (EN): +# "Virtuele machine back-uptaak vSphere-Task-1 op KANTOOR-NEW is gedeeltelijk voltooid." +# Examples (EN): # "The backup task vSphere-Task-1 on KANTOOR-NEW has completed." +# "Virtual machine backup task vSphere-Task-1 on KANTOOR-NEW partially completed." _ABB_COMPLETED_RE = re.compile( - # NL examples: - # "De back-uptaak op is voltooid." - # "De virtuele machine back-uptaak is voltooid." - # EN examples: - # "The backup task on has completed." - r"\b(?:de\s+)?(?:virtuele\s+machine\s+)?back-?up\s*taak\s+(?P.+?)(?:\s+op\s+(?P.+?))?\s+is\s+voltooid\b" - r"|\b(?:the\s+)?back-?up\s+task\s+(?P.+?)(?:\s+on\s+(?P.+?))?\s+(?:is\s+)?(?:completed|finished|has\s+completed)\b", - re.I, -) - -_ABB_PARTIAL_RE = re.compile( - # NL examples: - # " ... is gedeeltelijk voltooid." - # EN examples: - # " ... is/has partially completed." - r"\b(?:de\s+)?(?:virtuele\s+machine\s+)?back-?up\s*taak\s+(?P.+?)(?:\s+op\s+(?P.+?))?\s+is\s+gedeeltelijk\s+voltooid\b" - r"|\b(?:the\s+)?back-?up\s+task\s+(?P.+?)(?:\s+on\s+(?P.+?))?\s+(?:has\s+)?(?:partially\s+completed|completed\s+partially|is\s+partially\s+completed)\b", + r"\b(?:virtuele\s+machine\s+)?(?:de\s+)?back-?up\s*taak\s+(?P.+?)\s+op\s+(?P[A-Za-z0-9._-]+)\s+is\s+(?Pvoltooid|gedeeltelijk\s+voltooid)\b" + r"|\b(?:virtual\s+machine\s+)?(?:the\s+)?back-?up\s+task\s+(?P.+?)\s+on\s+(?P[A-Za-z0-9._-]+)\s+(?:is\s+)?(?Pcompleted|finished|has\s+completed|partially\s+completed)\b", re.I, ) _ABB_FAILED_RE = re.compile( - r"\b(?:de\s+)?back-?up\s*taak\s+.+?\s+op\s+.+?\s+is\s+mislukt\b" - r"|\b(?:the\s+)?back-?up\s+task\s+.+?\s+on\s+.+?\s+(?:has\s+)?failed\b", + r"\b(?:virtuele\s+machine\s+)?(?:de\s+)?back-?up\s*taak\s+.+?\s+op\s+.+?\s+is\s+mislukt\b" + r"|\b(?:virtual\s+machine\s+)?(?:the\s+)?back-?up\s+task\s+.+?\s+on\s+.+?\s+(?:has\s+)?failed\b", re.I, ) -_ABB_DEVICE_LIST_RE = re.compile(r"^\s*(?:Apparaatlijst|Device\s+list)\s*:\s*(?P.+?)\s*$", re.I) -_ABB_DEVICE_SUCCESS_RE = re.compile(r"^\s*(?:Lijst\s+met\s+apparaten\s*\(back-?up\s+gelukt\)|List\s+of\s+devices\s*\(backup\s+succeeded\)|Device\s+list\s*\(backup\s+succeeded\))\s*:\s*(?P.*)\s*$", re.I) -_ABB_DEVICE_FAILED_RE = re.compile(r"^\s*(?:Lijst\s+met\s+apparaten\s*\(back-?up\s+mislukt\)|List\s+of\s+devices\s*\(backup\s+failed\)|Device\s+list\s*\(backup\s+failed\))\s*:\s*(?P.*)\s*$", re.I) +# Device list lines in body, e.g. +# "Apparaatlijst (back-up gelukt): DC01, SQL01" +# "Apparaatlijst (back-up mislukt): FS01" +# "Device list (backup succeeded): DC01, SQL01" +# "Device list (backup failed): FS01" +_ABB_DEVICE_LIST_RE = re.compile( + r"^\s*(?:Apparaatlijst|Device\s+list)\s*(?:\((?P[^)]+)\))?\s*:\s*(?P.+?)\s*$", + re.I, +) def _is_synology_active_backup_for_business(subject: str, text: str) -> bool: @@ -219,78 +212,50 @@ def _is_synology_active_backup_for_business(subject: str, text: str) -> bool: def _parse_active_backup_for_business(subject: str, text: str) -> Tuple[bool, Dict, List[Dict]]: haystack = f"{subject}\n{text}" - - # ABB mails always contain a completion marker (completed / partially completed) in subject or body. - m = _ABB_COMPLETED_RE.search(haystack) or _ABB_PARTIAL_RE.search(haystack) + m = _ABB_COMPLETED_RE.search(haystack) if not m: # Not our ABB format return False, {}, [] - job_name = (m.groupdict().get("job") or m.groupdict().get("job_en") or "").strip() - host = (m.groupdict().get("host") or m.groupdict().get("host_en") or "").strip() - if not host: - m_host = re.search(r"\b(?:Van|From)\s+(?P[^\r\n<]+)", text or "", re.I) - if m_host: - host = (m_host.group("host") or "").strip() + job_name = (m.group("job") or m.group("job_en") or "").strip() + host = (m.group("host") or m.group("host_en") or "").strip() + + # Determine overall status based on completion type and failure markers + status_raw = (m.group("status") or m.group("status_en") or "").lower() - # Determine overall status: - # - Failed -> Error - # - Partially completed -> Warning - # - Otherwise -> Success overall_status = "Success" overall_message = "Success" - is_failed = _ABB_FAILED_RE.search(haystack) is not None - is_partial = _ABB_PARTIAL_RE.search(haystack) is not None - - if is_failed: - overall_status = "Error" - overall_message = "Failed" - elif is_partial: + # "gedeeltelijk voltooid" / "partially completed" should be treated as Warning + if "gedeeltelijk" in status_raw or "partially" in status_raw: overall_status = "Warning" overall_message = "Partially completed" - # Parse device lists (newer ABB mail templates). - # NL examples: - # "Lijst met apparaten (back-up gelukt): SQL01, DC01" - # "Lijst met apparaten (back-up mislukt): DC02" - # EN examples: - # "List of devices (backup succeeded): ..." - # "List of devices (backup failed): ..." + # Explicit failure wording overrides everything + if _ABB_FAILED_RE.search(haystack): + overall_status = "Error" + overall_message = "Failed" + objects: List[Dict] = [] - seen: set[str] = set() - - def _add_devices(raw: str, status: str) -> None: - if raw is None: - return - raw = raw.strip() - if not raw: - return - for name in [p.strip() for p in raw.split(",")]: - if not name or name in seen: - continue - seen.add(name) - objects.append({"name": name, "status": status}) - - # First parse explicit succeeded/failed device lists when present. for line in (text or "").splitlines(): - mm_ok = _ABB_DEVICE_SUCCESS_RE.match(line.strip()) - if mm_ok: - _add_devices(mm_ok.group("list"), "Success") + mm = _ABB_DEVICE_LIST_RE.match(line.strip()) + if not mm: + continue + raw_list = (mm.group("list") or "").strip() - mm_fail = _ABB_DEVICE_FAILED_RE.match(line.strip()) - if mm_fail: - _add_devices(mm_fail.group("list"), "Error") + kind = (mm.group("kind") or "").lower() + line_status = overall_status + if "gelukt" in kind or "succeeded" in kind or "success" in kind: + line_status = "Success" + elif "mislukt" in kind or "failed" in kind or "error" in kind: + line_status = "Error" - # Fallback: generic "Apparaatlijst:" / "Device list:" (older templates) - if not objects: - for line in (text or "").splitlines(): - mm = _ABB_DEVICE_LIST_RE.match(line.strip()) - if not mm: - continue - _add_devices(mm.group("list"), overall_status) + # "DC01, SQL01" + for name in [p.strip() for p in raw_list.split(",")]: + if name: + objects.append({"name": name, "status": line_status}) - result: Dict = { + result = { "backup_software": "Synology", "backup_type": "Active Backup for Business", "job_name": job_name, @@ -299,11 +264,73 @@ def _parse_active_backup_for_business(subject: str, text: str) -> Tuple[bool, Di } # Provide a slightly nicer overall message when host is available - if host and overall_message in ("Success", "Failed", "Partially completed"): + if host and overall_message in ("Success", "Failed"): result["overall_message"] = f"{overall_message} ({host})" return True, result, objects +def _is_synology_active_backup(subject: str, text: str) -> bool: + # Keep matching conservative to avoid false positives. + subj = (subject or "").lower() + if "active backup" in subj: + return True + + # Fallback for senders that don't include it in the subject + t = (text or "").lower() + return "active backup" in t and ("adminconsole" in t or "back-up" in t or "backup" in t) + + +def _is_synology_hyper_backup(subject: str, text: str) -> bool: + # Subject often does not mention Hyper Backup; body typically contains it. + s = (subject or "").lower() + t = (text or "").lower() + + if "hyper backup" in s: + return True + + # Dutch/English variants that appear in Hyper Backup task notifications. + if ("hyper backup" in t) and ( + ("taaknaam:" in t) + or ("task name:" in t) + or ("gegevensback-uptaak" in t) + or ("data backup task" in t) + ): + return True + + # Newer task notification variant (often used for cloud destinations like HiDrive) + # does not always include "Hyper Backup" in the subject/body but contains these fields. + if ("backup task:" in t) and ("backup destination:" in t): + return True + + # Dutch task notification variant (e.g. "Uw back-uptaak ... is nu voltooid") + return ("back-uptaak:" in t) and ("back-updoel:" in t) + + +_HB_TASKNAME_RE = re.compile( + r"^(?:Taaknaam|Task name|Back-uptaak|Backup Task|Backup task)\s*:\s*(?P.+)$", + re.I | re.M, +) +_HB_BACKUP_TASK_RE = re.compile(r"^Backup Task\s*:\s*(?P.+)$", re.I | re.M) +_HB_FAILED_RE = re.compile(r"\bis\s+mislukt\b|\bhas\s+failed\b|\bfailed\b", re.I) +_HB_SUCCESS_RE = re.compile( + r"\bis\s+(?:nu\s+)?voltooid\b|\bhas\s+completed\b|\bsuccessful\b|\bgeslaagd\b", + re.I, +) +_HB_WARNING_RE = re.compile(r"\bgedeeltelijk\s+voltooid\b|\bpartially\s+completed\b|\bwarning\b|\bwaarschuwing\b", re.I) + + +# Synology Network Backup / R-Sync task notifications +# Example (NL): +# "Uw back-uptaak R-Sync ASP-NAS02 is nu voltooid." +# "Back-uptaak: R-Sync ASP-NAS02" +# Example (EN): +# "Your backup task R-Sync ASP-NAS02 has completed." +# "Backup task: R-Sync ASP-NAS02" +_RSYNC_MARKER_RE = re.compile(r"\br-?sync\b", re.I) +_RSYNC_TASK_RE = re.compile(r"^(?:Back-uptaak|Backup\s+task)\s*:\s*(?P.+)$", re.I | re.M) +_RSYNC_FAILED_RE = re.compile(r"\bis\s+mislukt\b|\bhas\s+failed\b|\bfailed\b", re.I) +_RSYNC_WARNING_RE = re.compile(r"\bgedeeltelijk\s+voltooid\b|\bpartially\s+completed\b|\bwarning\b|\bwaarschuwing\b", re.I) +_RSYNC_SUCCESS_RE = re.compile(r"\bis\s+(?:nu\s+)?voltooid\b|\bhas\s+completed\b|\bcompleted\b|\bsuccessful\b|\bgeslaagd\b", re.I) def _is_synology_rsync(subject: str, text: str) -> bool: diff --git a/docs/changelog.md b/docs/changelog.md index 2ad97fc..3591cda 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -116,6 +116,15 @@ - Improved host detection by extracting the system name from the “From” header when not present in the subject/body. - Extended object parsing to recognize per-device backup results and correctly classify them as Success or Error. - Fixed the ABB-specific regular expression to prevent parser failures on valid warning mails. + +--- + +## v20260112-02-synology-abb-subject-partial-warning + +- Updated the Synology Active Backup for Business subject matching to also recognize “Virtual machine backup task …” variants. +- Added subject-based detection for “partially completed / gedeeltelijk voltooid” and map this to Overall status: Warning. +- Fixed the ABB completion regex (it previously failed to match valid subjects). +- Improved per-device object parsing by detecting “backup succeeded/failed” device-list variants and mapping them to Success/Error accordingly. ================================================================================================================================================ ## v0.1.19 This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows. The changes aim to make message handling more robust, provide better insight for administrators, and give operators clearer and more flexible control when reviewing backup runs.