|
|
|
|
@ -18,9 +18,162 @@ VEEAM_BACKUP_TYPES = [
|
|
|
|
|
"Scale-out Backup Repository",
|
|
|
|
|
"Health Check",
|
|
|
|
|
"Cloud Connect Report",
|
|
|
|
|
"Service Provider Console",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_vspc_summary_daily_alarm_report_from_html(html: str) -> Tuple[List[Dict], str, Optional[str]]:
|
|
|
|
|
"""Parse Veeam Service Provider Console (VSPC) *summary daily* alarm notifications.
|
|
|
|
|
|
|
|
|
|
The VSPC daily summary mail can contain alarms for multiple client companies.
|
|
|
|
|
To make this data actionable per customer in Backupchecks, we aggregate alarms
|
|
|
|
|
per company and emit one object per company.
|
|
|
|
|
|
|
|
|
|
Expected content typically includes blocks like:
|
|
|
|
|
Company: <Name> (alarms: <N>)
|
|
|
|
|
<Alarm details...>
|
|
|
|
|
|
|
|
|
|
Since templates vary by VSPC version and localization, the parser uses a
|
|
|
|
|
tolerant text-first strategy:
|
|
|
|
|
1) Convert HTML -> text while preserving meaningful line breaks.
|
|
|
|
|
2) Split into company blocks.
|
|
|
|
|
3) For each company, extract alarm lines and derive a worst-case status.
|
|
|
|
|
|
|
|
|
|
Returns: (objects, overall_status, overall_message)
|
|
|
|
|
"""
|
|
|
|
|
text = _html_to_text_preserve_lines(html)
|
|
|
|
|
if not text:
|
|
|
|
|
return [], "Success", None
|
|
|
|
|
|
|
|
|
|
lines = [ln.strip() for ln in text.split("\n") if (ln or "").strip()]
|
|
|
|
|
if not lines:
|
|
|
|
|
return [], "Success", None
|
|
|
|
|
|
|
|
|
|
# Identify company block headers.
|
|
|
|
|
company_re = re.compile(
|
|
|
|
|
r"(?i)^company\s*:\s*(?P<name>.+?)(?:\s*\(\s*alarms?\s*:\s*(?P<count>\d+)\s*\)\s*)?$"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
blocks: List[Tuple[str, Optional[int], List[str]]] = []
|
|
|
|
|
cur_name: Optional[str] = None
|
|
|
|
|
cur_count: Optional[int] = None
|
|
|
|
|
cur_lines: List[str] = []
|
|
|
|
|
|
|
|
|
|
def _flush():
|
|
|
|
|
nonlocal cur_name, cur_count, cur_lines
|
|
|
|
|
if cur_name:
|
|
|
|
|
blocks.append((cur_name, cur_count, cur_lines))
|
|
|
|
|
cur_name, cur_count, cur_lines = None, None, []
|
|
|
|
|
|
|
|
|
|
for ln in lines:
|
|
|
|
|
m = company_re.match(ln)
|
|
|
|
|
if m:
|
|
|
|
|
_flush()
|
|
|
|
|
cur_name = (m.group("name") or "").strip()
|
|
|
|
|
try:
|
|
|
|
|
cur_count = int(m.group("count")) if m.group("count") else None
|
|
|
|
|
except Exception:
|
|
|
|
|
cur_count = None
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if cur_name:
|
|
|
|
|
cur_lines.append(ln)
|
|
|
|
|
|
|
|
|
|
_flush()
|
|
|
|
|
|
|
|
|
|
if not blocks:
|
|
|
|
|
return [], "Success", None
|
|
|
|
|
|
|
|
|
|
objects: List[Dict] = []
|
|
|
|
|
saw_failed = False
|
|
|
|
|
saw_warning = False
|
|
|
|
|
total_alarms = 0
|
|
|
|
|
total_companies = 0
|
|
|
|
|
|
|
|
|
|
# Heuristics for severity classification.
|
|
|
|
|
failed_kw = re.compile(r"(?i)\b(critical|error|failed|failure)\b")
|
|
|
|
|
warning_kw = re.compile(r"(?i)\b(warn|warning)\b")
|
|
|
|
|
|
|
|
|
|
for company_name, alarms_count, company_lines in blocks:
|
|
|
|
|
total_companies += 1
|
|
|
|
|
if alarms_count is not None:
|
|
|
|
|
total_alarms += alarms_count
|
|
|
|
|
|
|
|
|
|
company_failed = False
|
|
|
|
|
company_warning = False
|
|
|
|
|
|
|
|
|
|
# Attempt to detect per-alarm object/message pairs.
|
|
|
|
|
current_object: Optional[str] = None
|
|
|
|
|
alarm_entries: List[str] = []
|
|
|
|
|
|
|
|
|
|
for ln in company_lines:
|
|
|
|
|
# Skip boilerplate separators.
|
|
|
|
|
if ln.strip("-–— ") == "":
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
m_obj = re.match(r"(?i)^(?:object|host|repository|vm)\s*[:\-]\s*(.+)$", ln)
|
|
|
|
|
if m_obj:
|
|
|
|
|
current_object = (m_obj.group(1) or "").strip() or None
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Some templates format as "<Object> - <Message>".
|
|
|
|
|
if " - " in ln and not ln.lower().startswith("http"):
|
|
|
|
|
left, right = ln.split(" - ", 1)
|
|
|
|
|
left = left.strip()
|
|
|
|
|
right = right.strip()
|
|
|
|
|
if left and right and (current_object is None):
|
|
|
|
|
current_object = left
|
|
|
|
|
alarm_entries.append(f"{left}: {right}")
|
|
|
|
|
current_object = None
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if current_object:
|
|
|
|
|
alarm_entries.append(f"{current_object}: {ln}")
|
|
|
|
|
current_object = None
|
|
|
|
|
else:
|
|
|
|
|
alarm_entries.append(ln)
|
|
|
|
|
|
|
|
|
|
# Severity inference.
|
|
|
|
|
if failed_kw.search(ln):
|
|
|
|
|
company_failed = True
|
|
|
|
|
elif warning_kw.search(ln):
|
|
|
|
|
company_warning = True
|
|
|
|
|
|
|
|
|
|
# If we did not find explicit severity, fall back to alarm count > 0.
|
|
|
|
|
if not company_failed and not company_warning:
|
|
|
|
|
if (alarms_count or 0) > 0:
|
|
|
|
|
company_warning = True
|
|
|
|
|
|
|
|
|
|
status = "Success"
|
|
|
|
|
if company_failed:
|
|
|
|
|
status = "Failed"
|
|
|
|
|
saw_failed = True
|
|
|
|
|
elif company_warning:
|
|
|
|
|
status = "Warning"
|
|
|
|
|
saw_warning = True
|
|
|
|
|
|
|
|
|
|
objects.append(
|
|
|
|
|
{
|
|
|
|
|
"name": company_name,
|
|
|
|
|
"type": "Company",
|
|
|
|
|
"status": status,
|
|
|
|
|
"error_message": "\n".join([e for e in alarm_entries if e]).strip() or None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
overall_status = "Success"
|
|
|
|
|
if saw_failed:
|
|
|
|
|
overall_status = "Failed"
|
|
|
|
|
elif saw_warning:
|
|
|
|
|
overall_status = "Warning"
|
|
|
|
|
|
|
|
|
|
overall_message = None
|
|
|
|
|
if total_companies and total_alarms:
|
|
|
|
|
overall_message = f"Companies with alarms: {total_companies}, Total alarms: {total_alarms}".strip()
|
|
|
|
|
|
|
|
|
|
return objects, overall_status, overall_message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_cloud_connect_report_from_html(html: str) -> Tuple[List[Dict], str]:
|
|
|
|
|
"""Parse Veeam Cloud Connect daily report (provider) HTML.
|
|
|
|
|
|
|
|
|
|
@ -873,17 +1026,48 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
|
|
|
|
|
and "infrastructure status" in html_lower
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Veeam Service Provider Console (VSPC) summary daily alarm notification.
|
|
|
|
|
# These mails can contain multiple client companies.
|
|
|
|
|
# Detection is intentionally tolerant: company blocks are typically formatted
|
|
|
|
|
# as "Company: <name> (alarms: N)".
|
|
|
|
|
text_body = _html_to_text_preserve_lines(html_body)
|
|
|
|
|
text_lower = (text_body or "").lower()
|
|
|
|
|
is_vspc_daily_alarm_summary = (
|
|
|
|
|
("company:" in text_lower and "alarms" in text_lower)
|
|
|
|
|
and (
|
|
|
|
|
"service provider" in text_lower
|
|
|
|
|
or "availability console" in text_lower
|
|
|
|
|
or "vac" in text_lower
|
|
|
|
|
or "veeam" in subject.lower()
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Special-case: Veeam Backup for Microsoft 365 mails can come without a
|
|
|
|
|
# subject marker. Detect via HTML and extract status from the banner.
|
|
|
|
|
is_m365 = "veeam backup for microsoft 365" in html_lower
|
|
|
|
|
|
|
|
|
|
# If we cannot detect a status marker and this is not an M365 report,
|
|
|
|
|
# we still try to parse when the subject strongly indicates a Veeam report.
|
|
|
|
|
if not m_status and not m_finished and not is_m365 and not is_cloud_connect_report:
|
|
|
|
|
if not m_status and not m_finished and not is_m365 and not is_cloud_connect_report and not is_vspc_daily_alarm_summary:
|
|
|
|
|
lowered = subject.lower()
|
|
|
|
|
if not any(k in lowered for k in ["veeam", "cloud connect", "backup job", "backup copy job", "replica job", "configuration backup", "health check"]):
|
|
|
|
|
return False, {}, []
|
|
|
|
|
|
|
|
|
|
# Handle VSPC daily alarm summary early.
|
|
|
|
|
if is_vspc_daily_alarm_summary:
|
|
|
|
|
objects, overall_status, overall_message = _parse_vspc_summary_daily_alarm_report_from_html(html_body)
|
|
|
|
|
|
|
|
|
|
result = {
|
|
|
|
|
"backup_software": "Veeam",
|
|
|
|
|
"backup_type": "Service Provider Console",
|
|
|
|
|
"job_name": "Daily alarms",
|
|
|
|
|
"overall_status": overall_status,
|
|
|
|
|
}
|
|
|
|
|
if overall_message:
|
|
|
|
|
result["overall_message"] = overall_message
|
|
|
|
|
|
|
|
|
|
return True, result, objects
|
|
|
|
|
|
|
|
|
|
# Handle Cloud Connect daily report early: overall status is derived from row colours.
|
|
|
|
|
if is_cloud_connect_report:
|
|
|
|
|
objects, overall_status = _parse_cloud_connect_report_from_html(html_body)
|
|
|
|
|
|