# retoor import asyncio import hashlib import io import shutil import sqlite3 import subprocess import tarfile from datetime import datetime, timedelta, timezone from pathlib import Path import brotli import zstandard from molodetz import config from molodetz.database import ( admin_uids_ordered, delete_backup_rows, enqueue_backup, list_backups, now_iso, parse_iso, pending_backups, shard_path, update_backup, ) from molodetz.services.base import BaseService, ConfigField from molodetz.utils.audit import record_system from molodetz.utils.notifications import create_notification TARGETS = ("database", "uploads", "keys", "full") def database_file(): prefix = "sqlite:///" if not config.DATABASE_URL.startswith(prefix): raise RuntimeError("backups support SQLite databases only") return Path(config.DATABASE_URL[len(prefix):]) def _snapshot_database(staging): target = staging / "molodetz.db" source = sqlite3.connect(str(database_file())) try: destination = sqlite3.connect(str(target)) try: source.backup(destination) finally: destination.close() finally: source.close() return target def build_archive(uid, target, codec="zstd"): if target not in TARGETS: raise ValueError(f"unknown backup target {target}") staging = config.DATA_PATHS["backup_staging"] / uid staging.mkdir(parents=True, exist_ok=True) try: buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w") as archive: if target in ("database", "full"): archive.add(_snapshot_database(staging), arcname="molodetz.db") if target in ("uploads", "full") and config.DATA_PATHS["uploads"].exists(): archive.add(config.DATA_PATHS["uploads"], arcname="uploads") if target in ("keys", "full") and config.DATA_PATHS["keys"].exists(): archive.add(config.DATA_PATHS["keys"], arcname="keys") raw = buffer.getvalue() if codec == "brotli": data = brotli.compress(raw, quality=6) suffix = "tar.br" else: data = zstandard.ZstdCompressor(level=10).compress(raw) suffix = "tar.zst" checksum = hashlib.sha256(data).hexdigest()[:16] directory = f"{shard_path(uid)}" stored_name = f"{target}-{uid.replace('-', '')}-{checksum}.{suffix}" path = config.DATA_PATHS["backups"] / directory / stored_name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) return directory, stored_name, len(data) finally: shutil.rmtree(staging, ignore_errors=True) def backup_path(row): root = config.DATA_PATHS["backups"].resolve() path = (root / row["directory"] / row["stored_name"]).resolve() if root not in path.parents: raise ValueError("backup path escapes the backup root") return path def prune_backups(keep_last, dry_run=False): done = [row for row in list_backups(limit=10000) if row["status"] == "done"] victims = done[keep_last:] if not dry_run: for row in victims: try: backup_path(row).unlink(missing_ok=True) except ValueError: pass delete_backup_rows([row["uid"] for row in victims]) return len(victims) def clear_backups(dry_run=False): rows = list_backups(limit=100000) if not dry_run: for row in rows: if row.get("directory") and row.get("stored_name"): try: backup_path(row).unlink(missing_ok=True) except ValueError: pass delete_backup_rows([row["uid"] for row in rows]) return len(rows) def offload(path): remote = config.BACKUP_OFFLOAD_REMOTE if not remote or shutil.which(config.RCLONE_BIN) is None: return "skipped" result = subprocess.run( [config.RCLONE_BIN, "--config", config.RCLONE_CONFIG, "copy", str(path), remote], capture_output=True, text=True, timeout=900, ) return "ok" if result.returncode == 0 else f"failed: {result.stderr.strip()[:200]}" def finish_backup(row, archive=None, error=None): if error is not None: update_backup(row["uid"], status="failed", error=str(error)[:500], finished_at=now_iso()) record_system("backup.failed", actor_kind="service", origin="service", result="error", message=str(error)[:200]) return False directory, stored_name, size = archive update_backup(row["uid"], status="done", directory=directory, stored_name=stored_name, size_bytes=size, finished_at=now_iso()) record_system("backup.finished", actor_kind="service", origin="service", payload={"uid": row["uid"], "size": size}) return True def process_backup(row, codec): try: archive = build_archive(row["uid"], row["target"], codec) except Exception as exc: return finish_backup(row, error=exc) return finish_backup(row, archive=archive) async def process_backup_async(row, codec): try: archive = await asyncio.to_thread(build_archive, row["uid"], row["target"], codec) except Exception as exc: return finish_backup(row, error=exc) return finish_backup(row, archive=archive) class BackupService(BaseService): name = "backup" title = "Back-ups" description = "Verwerkt de back-upwachtrij, plant automatische back-ups en snoeit oude archieven." default_interval = 10 min_interval = 5 metrics_interval = 30 config_fields = [ ConfigField("schedule_hours", "int", 24, "Automatisch elke N uur (0 = uit)", minimum=0, maximum=720, group="Planning"), ConfigField("schedule_target", "select", "database", "Automatisch doel", options=list(TARGETS), group="Planning"), ConfigField("keep_last", "int", 14, "Bewaar laatste N", minimum=1, maximum=1000, group="Bewaren"), ConfigField("codec", "select", "zstd", "Compressie", options=["zstd", "brotli"], group="Archief"), ConfigField("offload", "bool", "0", "Offload via rclone", group="Archief"), ] def __init__(self): super().__init__() self.processed = 0 def _schedule_due(self, hours): if hours <= 0: return False rows = list_backups(limit=1) if not rows: return True last = parse_iso(rows[0]["created_at"]) return last is None or datetime.now(timezone.utc) - last >= timedelta(hours=hours) async def run_once(self): cfg = self.get_config() if self._schedule_due(int(cfg["schedule_hours"])): enqueue_backup(cfg["schedule_target"], "system") self.log(f"automatische back-up ingepland: {cfg['schedule_target']}") for row in pending_backups(): update_backup(row["uid"], status="running") ok = await process_backup_async(row, cfg["codec"]) self.processed += 1 self.log(f"back-up {row['target']} {'klaar' if ok else 'mislukt'}") primary = admin_uids_ordered()[:1] for uid in primary: create_notification(uid, "backup.finished" if ok else "backup.failed", f"Back-up {row['target']} {'klaar' if ok else 'mislukt'}", "/admin/backups") if ok and cfg["offload"] == "1": done = [item for item in list_backups(limit=5) if item["uid"] == row["uid"]] if done: self.log(f"offload: {offload(backup_path(done[0]))}") pruned = prune_backups(int(cfg["keep_last"])) if pruned: self.log(f"{pruned} oude back-ups gesnoeid") def collect_metrics(self): rows = list_backups(limit=200) return { "cards": [ {"label": "Archieven", "value": sum(1 for row in rows if row["status"] == "done")}, {"label": "Wachtrij", "value": sum(1 for row in rows if row["status"] == "pending")}, {"label": "Verwerkt sinds start", "value": self.processed}, ], "table": None, }