Molodetz blogrol volgens DPP-template
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
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 process_backup(row, codec):
|
||||
try:
|
||||
directory, stored_name, size = build_archive(row["uid"], row["target"], codec)
|
||||
except Exception as exc:
|
||||
update_backup(row["uid"], status="failed", error=str(exc)[:500], finished_at=now_iso())
|
||||
record_system("backup.failed", actor_kind="service", origin="service", result="error", message=str(exc)[:200])
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
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 asyncio.to_thread(process_backup, 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,
|
||||
}
|
||||
Reference in New Issue
Block a user