"""Backup integrity checks built on top of restic CLI.""" from __future__ import annotations import json import os import subprocess from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Iterable RESTIC_REPOSITORY = "/mnt/backup-hdd/flotta-backup" RESTIC_BASE_COMMAND = [ "restic", "-r", RESTIC_REPOSITORY, "snapshots", "--json", "latest", ] STALE_THRESHOLD = timedelta(hours=25) DEFAULT_PASSWORD_FILE = Path.home() / ".config/restic/password" def _describe_unreadable_snapshots(entries: Iterable[Path]) -> str | None: """Return a human friendly description when snapshot files are not readable.""" unreadable: list[str] = [] for entry in entries: # Skip directories inside the repository; we care about snapshot files. if not entry.is_file(): continue if os.access(entry, os.R_OK): continue unreadable.append(entry.name) if len(unreadable) == 3: break if not unreadable: return None sample = ", ".join(unreadable) suffix = "..." if len(unreadable) == 3 else "" return ( f"snapshot files unreadable ({sample}{suffix}). " "Adjust repository permissions or run restic with sufficient privileges." ) def _check_repository_permissions() -> str | None: """Inspect repository layout to catch permission issues early.""" repo_path = Path(RESTIC_REPOSITORY) snapshots_dir = repo_path / "snapshots" if not snapshots_dir.exists(): return None try: unreadable = _describe_unreadable_snapshots(snapshots_dir.iterdir()) except PermissionError as exc: return f"cannot access {snapshots_dir}: {exc}" return unreadable def _permission_hint_from_output(stdout: str | bytes | None, stderr: str | bytes | None) -> str | None: """Look for permission-denied hints in restic output.""" def _as_text(fragment: str | bytes | None) -> str: if fragment is None: return "" if isinstance(fragment, bytes): return fragment.decode("utf-8", errors="ignore") return fragment combined = _as_text(stdout) + _as_text(stderr) if "permission denied" in combined.lower(): return "restic reported permission denied when reading snapshots." return None def _combine_hints(*messages: str | None) -> str | None: """Merge diagnostic hints into a single string.""" parts = [msg for msg in messages if msg] if not parts: return None return " ".join(parts) def _parse_snapshot_timestamp(payload: str) -> datetime | None: """Extract the snapshot timestamp from the restic JSON response.""" try: data = json.loads(payload) except json.JSONDecodeError: return None # The 'latest' flag typically produces a single snapshot dict. if isinstance(data, dict): timestamp = data.get("time") elif isinstance(data, list) and data: timestamp = data[0].get("time") else: timestamp = None if not isinstance(timestamp, str): return None try: return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).astimezone(timezone.utc) except ValueError: return None def check_backup_status(now: datetime | None = None) -> dict[str, Any]: """ Inspect restic snapshots to ensure backups are recent. Returns: A dictionary with keys: - last_backup_timestamp: ISO string of latest snapshot or None. - status: 'OK' when snapshot younger than threshold, else 'STALE'. - error (optional): textual description when command fails. """ command = list(RESTIC_BASE_COMMAND) env = None repo_permission_hint = _check_repository_permissions() password_env_present = any( key in os.environ for key in ("RESTIC_PASSWORD", "RESTIC_PASSWORD_FILE", "RESTIC_PASSWORD_COMMAND") ) if not password_env_present: password_file = os.environ.get("RESTIC_PASSWORD_FILE") if password_file: command.extend(["--password-file", password_file]) else: if DEFAULT_PASSWORD_FILE.exists() and DEFAULT_PASSWORD_FILE.is_file(): try: password_value = DEFAULT_PASSWORD_FILE.read_text(encoding="utf-8").strip() except OSError as exc: return { "last_backup_timestamp": None, "status": "STALE", "error": f"unable to read {DEFAULT_PASSWORD_FILE}: {exc}", } if not password_value: return { "last_backup_timestamp": None, "status": "STALE", "error": f"{DEFAULT_PASSWORD_FILE} is empty", } env = os.environ.copy() env["RESTIC_PASSWORD"] = password_value else: return { "last_backup_timestamp": None, "status": "STALE", "error": ( "restic credentials missing: set RESTIC_PASSWORD or " f"create {DEFAULT_PASSWORD_FILE}" ), } try: result = subprocess.run( command, capture_output=True, text=True, check=False, timeout=30, env=env, ) except FileNotFoundError: return { "last_backup_timestamp": None, "status": "STALE", "error": "restic command not found", } except subprocess.TimeoutExpired as exc: hint = _combine_hints( _permission_hint_from_output(getattr(exc, "stdout", None), getattr(exc, "stderr", None)), repo_permission_hint, ) if hint: return { "last_backup_timestamp": None, "status": "STALE", "error": hint, } return { "last_backup_timestamp": None, "status": "STALE", "error": "restic command timed out (likely waiting for credentials)", } if result.returncode != 0: hint = _combine_hints(_permission_hint_from_output(result.stdout, result.stderr), repo_permission_hint) return { "last_backup_timestamp": None, "status": "STALE", "error": hint or f"restic snapshots failed: {result.stderr.strip()}", } snapshot_time = _parse_snapshot_timestamp(result.stdout) if snapshot_time is None: hint = _combine_hints(_permission_hint_from_output(result.stdout, result.stderr), repo_permission_hint) return { "last_backup_timestamp": None, "status": "STALE", "error": hint or "unable to parse restic snapshot timestamp", } current_time = now or datetime.now(timezone.utc) age = current_time - snapshot_time status = "OK" if age <= STALE_THRESHOLD else "STALE" return { "last_backup_timestamp": snapshot_time.isoformat(), "status": status, }