#!/usr/bin/env python3 """Raccoglie gli ultimi eventi dei log critici e produce un report giornaliero.""" from __future__ import annotations import argparse import datetime as dt import subprocess from collections import deque from pathlib import Path from typing import Iterable BASE_DIR = Path(__file__).resolve().parent REPORTS_DIR = BASE_DIR / "reports" ADRIAN_ROOT = Path("/mnt/ssd/data/Dropbox/adrian") MAILBOX_WRITE = ADRIAN_ROOT / "scripts" / "slave_mailbox_write.sh" LOG_SOURCES = [ ("backup_completo", ADRIAN_ROOT / "gemello-nave/logs/backup_completo.log"), ("pcloud_mirror", ADRIAN_ROOT / "gemello-nave/logs/pcloud_mirror.log"), # snapshot_local rimosso 11/08/2026: snapshot_configs.py è disabilitato dal 15/07/2026 # ("ridondante con restic", vedi crontab) — il log non esisterà mai più, teneva vivo un # symlink penzolante in logbook/ che il mirror pCloud non riusciva ad attraversare. ("snapshot_avamposto", ADRIAN_ROOT / "gemello-nave/config-backups/avamposto_snapshot.log"), ] # Marker che segna l'inizio di una nuova run in ciascun log — usato per limitare la scansione # di anomalie alla run più recente, invece che alle ultime N righe grezze. Senza questo, un # log che accumula più run per file (pcloud_mirror, backup_completo) ripropone in eterno un # errore risolto giorni prima, finché resta dentro la finestra di tail (bug reale, 28/08/2026: # 82 "segnalazioni" tutte datate 10/08, mai più successe da allora). Un log senza marker noto # (snapshot_avamposto: una riga per run) usa l'intero tail come prima. RUN_START_MARKERS = { "backup_completo": "===== INIZIO BACKUP COMPLETO", "pcloud_mirror": "--- Inizio del processo di mirroring", } # Parole chiave che indicano possibili problemi KEYWORDS = ( "fatal", "error", "failed", "fallito", "permission denied", "timeout", "unable", ) def _ensure_symlink(name: str, path: Path) -> None: """Crea/aggiusta un symlink nel logbook per facile consultazione.""" link_path = BASE_DIR / f"{name}.log" try: if link_path.is_symlink() or link_path.exists(): if link_path.resolve() == path: return link_path.unlink() link_path.symlink_to(path) except OSError: # se non riusciamo a creare il symlink, proseguiamo comunque pass def _tail(path: Path, max_lines: int = 120) -> list[str]: """Ritorna le ultime max_lines dal file.""" try: with path.open("r", encoding="utf-8", errors="ignore") as handle: dq: deque[str] = deque(handle, max_lines) return list(dq) except OSError: return [] def _scan_for_issues(lines: Iterable[str]) -> list[str]: """Ritorna le righe che contengono parole chiave critiche.""" issues = [] for line in lines: lower = line.lower() if "completed without errors" in lower or "no errors were found" in lower: continue if any(keyword in lower for keyword in KEYWORDS): issues.append(line.rstrip()) return issues def build_report(now: dt.datetime, max_lines: int) -> tuple[str, bool]: """Crea il report testuale e indica se sono emerse anomalie.""" REPORTS_DIR.mkdir(parents=True, exist_ok=True) report_lines = [ f"# Logbook report – {now.isoformat(timespec='seconds')}", "", ] global_issues: list[str] = [] for name, path in LOG_SOURCES: _ensure_symlink(name, path) report_lines.append(f"## {name}") report_lines.append(f"Percorso: {path}") if not path.exists(): report_lines.append("⚠️ Log mancante.") report_lines.append("") global_issues.append(f"{name}: log assente") continue tail_lines = _tail(path, max_lines=max_lines) if not tail_lines: report_lines.append("ℹ️ Nessun contenuto (file vuoto?).") report_lines.append("") continue marker = RUN_START_MARKERS.get(name) scan_lines = tail_lines if marker: last_start = None for idx, line in enumerate(tail_lines): if marker in line: last_start = idx if last_start is not None: scan_lines = tail_lines[last_start:] issues = _scan_for_issues(scan_lines) if issues: report_lines.append("⚠️ Anomalie rilevate nelle ultime righe:") report_lines.extend(f" {line}" for line in issues) report_lines.append("") global_issues.append(f"{name}: {len(issues)} segnalazioni") else: report_lines.append("✅ Nessuna anomalia rilevata nelle ultime righe.") report_lines.append("") report_lines.append("```\n" + "\n".join(tail_lines) + "\n```") report_lines.append("") header = ( "⚠️ Problemi individuati: " + ", ".join(global_issues) if global_issues else "✅ Tutti i log analizzati risultano OK." ) report_lines.insert(2, header) report_text = "\n".join(report_lines).strip() + "\n" return report_text, bool(global_issues) def write_report(content: str, now: dt.datetime) -> Path: filename = f"report_{now.strftime('%Y%m%d')}.txt" report_path = REPORTS_DIR / filename report_path.write_text(content, encoding="utf-8") latest = REPORTS_DIR / "latest.txt" latest.write_text(content, encoding="utf-8") return report_path def _notify_mailbox(report_text: str) -> None: """Scrive in mailbox verso gemello-nave (perimetro esteso 11/09/2026) - stesso pattern di check_job_health.py. Nessuna dedup: gira una sola volta al giorno, il rischio di spam da ripetizione e' trascurabile rispetto a check_job_health (ogni 30 min).""" issues_line = next((l for l in report_text.splitlines() if l.startswith("⚠️ Problemi")), "") text = issues_line.replace("⚠️ Problemi individuati: ", "nuovi problemi rilevati: ") if text: subprocess.run( ["bash", str(MAILBOX_WRITE), "logbook", text], cwd=str(ADRIAN_ROOT), check=False, ) def main() -> None: parser = argparse.ArgumentParser( description="Genera un riepilogo dai log principali della Flotta." ) parser.add_argument( "--lines", type=int, default=120, help="Numero di righe da includere per ciascun log (default: 120).", ) args = parser.parse_args() now = dt.datetime.now() report_text, has_issues = build_report(now, max_lines=args.lines) path = write_report(report_text, now) if has_issues: _notify_mailbox(report_text) print(report_text) if has_issues: print(f"⚠️ Anomalie rilevate. Vedi {path}") else: print(f"Report salvato in {path}") if __name__ == "__main__": main()