198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate daily Gmail backup logs and send report via email.
|
|
|
|
Reads /tmp/gmail-backup-daily.log, checks for today's entries,
|
|
validates no errors and positive backup counts. Optionally uses
|
|
Ollama LLM to summarize the log.
|
|
|
|
Cron: 0 4 * * * /usr/bin/python3 /home/homelab/organized/repos/homelab/scripts/backup-validator.py
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from lib.ollama import ollama_generate, ollama_available, OllamaUnavailableError
|
|
from lib.notify import send_email, send_ntfy
|
|
|
|
# ── config ───────────────────────────────────────────────────────────────────
|
|
|
|
LOG_FILE = "/tmp/gmail-backup-daily.log"
|
|
TIMEZONE = ZoneInfo("America/Los_Angeles")
|
|
NTFY_TOPIC = "REDACTED_NTFY_TOPIC"
|
|
LLM_TAIL_LINES = 50
|
|
|
|
log = logging.getLogger("backup-validator")
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
def read_log(path: str) -> list[str]:
|
|
"""Read all lines from the backup log file."""
|
|
p = Path(path)
|
|
if not p.exists():
|
|
raise FileNotFoundError(f"Log file not found: {path}")
|
|
return p.read_text().splitlines()
|
|
|
|
|
|
def filter_today_entries(lines: list[str], today_str: str) -> list[str]:
|
|
"""Filter log lines that contain today's date string."""
|
|
return [line for line in lines if today_str in line]
|
|
|
|
|
|
def check_errors(lines: list[str]) -> list[str]:
|
|
"""Return lines containing ERROR (case-insensitive)."""
|
|
return [line for line in lines if re.search(r"\bERROR\b", line, re.IGNORECASE)]
|
|
|
|
|
|
def extract_backup_counts(lines: list[str]) -> list[int]:
|
|
"""Extract numeric counts from lines like 'X new emails backed up' or 'backed up X emails'."""
|
|
counts = []
|
|
for line in lines:
|
|
m = re.search(r"(\d+)\s+(?:new\s+)?emails?\s+backed\s+up", line, re.IGNORECASE)
|
|
if m:
|
|
counts.append(int(m.group(1)))
|
|
m = re.search(r"backed\s+up\s+(\d+)", line, re.IGNORECASE)
|
|
if m:
|
|
counts.append(int(m.group(1)))
|
|
return counts
|
|
|
|
|
|
def validate(log_path: str) -> dict:
|
|
"""Run all validation checks. Returns a result dict."""
|
|
result = {
|
|
"ok": True,
|
|
"issues": [],
|
|
"today_lines": 0,
|
|
"error_lines": [],
|
|
"backup_counts": [],
|
|
"tail_lines": [],
|
|
}
|
|
|
|
try:
|
|
all_lines = read_log(log_path)
|
|
except FileNotFoundError as e:
|
|
result["ok"] = False
|
|
result["issues"].append(str(e))
|
|
return result
|
|
|
|
today_str = datetime.now(TIMEZONE).strftime("%Y-%m-%d")
|
|
today_lines = filter_today_entries(all_lines, today_str)
|
|
result["today_lines"] = len(today_lines)
|
|
result["tail_lines"] = all_lines[-LLM_TAIL_LINES:]
|
|
|
|
if not today_lines:
|
|
result["ok"] = False
|
|
result["issues"].append(f"No entries found for today ({today_str})")
|
|
return result
|
|
|
|
errors = check_errors(today_lines)
|
|
if errors:
|
|
result["ok"] = False
|
|
result["error_lines"] = errors
|
|
result["issues"].append(f"{len(errors)} ERROR line(s) found in today's entries")
|
|
|
|
counts = extract_backup_counts(today_lines)
|
|
result["backup_counts"] = counts
|
|
if not counts:
|
|
result["ok"] = False
|
|
result["issues"].append("No backup count lines found in today's entries")
|
|
elif all(c == 0 for c in counts):
|
|
result["ok"] = False
|
|
result["issues"].append("All backup counts are 0")
|
|
|
|
return result
|
|
|
|
|
|
def build_report(result: dict, llm_summary: str | None = None) -> str:
|
|
"""Build a plain-text report from validation results."""
|
|
lines = []
|
|
status = "OK" if result["ok"] else "ISSUES FOUND"
|
|
lines.append(f"Backup Validation: {status}")
|
|
lines.append("=" * 50)
|
|
lines.append(f"Today's log entries: {result['today_lines']}")
|
|
|
|
if result["backup_counts"]:
|
|
lines.append(f"Backup counts: {result['backup_counts']}")
|
|
|
|
if result["issues"]:
|
|
lines.append("")
|
|
lines.append("Issues:")
|
|
for issue in result["issues"]:
|
|
lines.append(f" - {issue}")
|
|
|
|
if result["error_lines"]:
|
|
lines.append("")
|
|
lines.append("Error lines:")
|
|
for err in result["error_lines"][:10]:
|
|
lines.append(f" {err}")
|
|
|
|
if llm_summary:
|
|
lines.append("")
|
|
lines.append("LLM Analysis:")
|
|
lines.append("-" * 40)
|
|
lines.append(llm_summary)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── main ─────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Validate daily Gmail backup logs")
|
|
parser.add_argument("--dry-run", action="store_true", help="Print report without sending notifications")
|
|
parser.add_argument("--verbose", action="store_true", help="Enable debug logging")
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
)
|
|
|
|
log.info("Starting backup validation for %s", LOG_FILE)
|
|
result = validate(LOG_FILE)
|
|
status_label = "OK" if result["ok"] else "ISSUES FOUND"
|
|
subject = f"Backup Report: {status_label}"
|
|
|
|
# LLM summary
|
|
llm_summary = None
|
|
if result["tail_lines"]:
|
|
if ollama_available():
|
|
log.info("Ollama available, requesting log summary")
|
|
tail_text = "\n".join(result["tail_lines"])
|
|
prompt = (
|
|
"Summarize this backup log. Flag any concerns.\n\n"
|
|
f"```\n{tail_text}\n```"
|
|
)
|
|
try:
|
|
llm_summary = ollama_generate(prompt)
|
|
log.info("LLM summary received (%d chars)", len(llm_summary))
|
|
except OllamaUnavailableError as e:
|
|
log.warning("Ollama became unavailable: %s", e)
|
|
else:
|
|
log.info("Ollama not available, sending raw validation results")
|
|
|
|
report = build_report(result, llm_summary)
|
|
log.info("Report:\n%s", report)
|
|
|
|
if args.dry_run:
|
|
print(report)
|
|
return
|
|
|
|
try:
|
|
send_email(subject=subject, text_body=report)
|
|
log.info("Email sent: %s", subject)
|
|
except Exception as e:
|
|
log.error("Failed to send email: %s", e)
|
|
|
|
# Issues already reported via email above
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|