Files
boehmitools-core/core/backups.py
T
2026-07-24 21:37:03 +02:00

220 lines
7.4 KiB
Python

# -*- coding: utf-8 -*-
"""
Gemeinsame Sicherungs-Verwaltung für die schreibenden Plugins.
Jeder verändernde Lauf legt einen Ordner unter `data/<plugin>/laeufe/<zeit>/`
an und schreibt dort eine `manifest.json`. Darin steht Schritt für Schritt, was
geändert wurde und wie der Zustand vorher aussah — genug, um es zurückzudrehen.
Was ein Rückspielen leisten kann, unterscheidet sich je nach Eingriff. Das steht
ehrlich im Manifest, damit die Oberfläche es anzeigen kann statt zu behaupten,
alles ließe sich rückstandsfrei rückgängig machen:
"voll" Der vorherige Zustand lässt sich exakt wiederherstellen.
Beispiel: geänderte Nährwerte — der alte Wert wird
zurückgeschrieben.
"neue_id" Der Eintrag kommt zurück, bekommt aber eine neue ID. Bei
Einträgen, auf die nichts zeigte (unbenutzt gelöscht), ist das
folgenlos.
"teilweise" Der Eintrag kommt mit neuer ID zurück und die aufgezeichneten
Verweise werden zurückgehängt. Was nicht aufgezeichnet wurde
(Einkaufslisten, Automatisierungen, Umrechnungen), bleibt beim
Ziel.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pydantic import BaseModel
class RestoreRequest(BaseModel):
run: str
apply: bool = False
force: bool = False
MANIFEST = "manifest.json"
RESTORE_MARK = "zurueckgespielt.json"
RESTORE_LEVELS = {
"voll": "vollständig rückspielbar",
"neue_id": "rückspielbar, Eintrag bekommt neue ID",
"teilweise": "nur teilweise rückspielbar",
"nein": "nicht rückspielbar",
}
def stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def write_json(path: Path, payload: Any) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
temporary.replace(path)
return path
def write_manifest(run_dir: Path, payload: dict[str, Any]) -> Path:
return write_json(run_dir / MANIFEST, payload)
def read_manifest(run_dir: Path) -> dict[str, Any] | None:
file = run_dir / MANIFEST
if not file.is_file():
return None
try:
return json.loads(file.read_text(encoding="utf-8"))
except Exception:
return None
def mark_restored(run_dir: Path, info: dict[str, Any]) -> None:
"""Vermerkt, dass dieser Lauf zurückgespielt wurde."""
existing = []
file = run_dir / RESTORE_MARK
if file.is_file():
try:
existing = json.loads(file.read_text(encoding="utf-8"))
except Exception:
existing = []
if not isinstance(existing, list):
existing = [existing]
existing.append({"at": datetime.now(timezone.utc).isoformat(), **info})
write_json(file, existing)
def restore_history(run_dir: Path) -> list[dict[str, Any]]:
file = run_dir / RESTORE_MARK
if not file.is_file():
return []
try:
data = json.loads(file.read_text(encoding="utf-8"))
return data if isinstance(data, list) else [data]
except Exception:
return []
def summarize(manifest: dict[str, Any]) -> dict[str, Any]:
"""Kurzfassung eines Laufs für die Übersicht."""
steps = manifest.get("steps") or []
done = [s for s in steps if s.get("status") == "done"]
levels = {s.get("restore_level", "nein") for s in done}
worst = ("nein" if "nein" in levels
else "teilweise" if "teilweise" in levels
else "neue_id" if "neue_id" in levels
else "voll" if levels else "nein")
return {
"steps": len(steps),
"done": len(done),
"restore_level": worst,
"restore_label": RESTORE_LEVELS[worst],
}
def list_runs(runs_dir: Path) -> list[dict[str, Any]]:
"""Alle Läufe eines Plugins, neueste zuerst."""
if not runs_dir.is_dir():
return []
runs: list[dict[str, Any]] = []
for path in sorted(runs_dir.iterdir(), reverse=True):
if not path.is_dir():
continue
manifest = read_manifest(path)
if manifest is None:
continue
history = restore_history(path)
runs.append({
"id": path.name,
"created_at": manifest.get("created_at"),
"tandoor": manifest.get("tandoor"),
"label": manifest.get("label") or "",
**summarize(manifest),
"restored": bool(history),
"restored_at": history[-1]["at"] if history else None,
})
return runs
def resolve_run(runs_dir: Path, name: str) -> Path:
"""Lauf-Ordner sicher auflösen — kein Ausbrechen aus dem Datenordner."""
candidate = runs_dir / Path(name).name
if not candidate.is_dir() or not (candidate / MANIFEST).is_file():
raise FileNotFoundError(f"Lauf „{name}“ gibt es nicht.")
return candidate
# ------------------------------------------------------- Router fürs Plugin
def backup_router(ctx, runs_subdir: str = "laeufe", restore_argv=None):
"""
Fertiger Satz Endpunkte für die Sicherungen eines Plugins.
Damit sieht der Reiter „Sicherungen“ in jedem schreibenden Plugin gleich
aus und verhält sich gleich:
GET /api/backups Läufe auflisten
GET /api/backups/{id} ein Lauf im Detail
POST /api/run/restore zurückspielen (Trockenübung oder echt)
`restore_argv(run_id, apply, force)` liefert die Kommandozeile für den
Job-Runner. Das Zurückspielen läuft damit über dasselbe Skript und
denselben Weg wie das Ändern selbst — nicht über einen zweiten,
ungetesteten Pfad.
"""
from fastapi import APIRouter, HTTPException
runs_dir = ctx.data_dir / runs_subdir
router = APIRouter()
@router.get("/api/backups")
def backups_list() -> dict[str, Any]:
return {"runs": list_runs(runs_dir), "dir": str(runs_dir)}
@router.get("/api/backups/{name}")
def backup_detail(name: str) -> dict[str, Any]:
try:
run = resolve_run(runs_dir, name)
except FileNotFoundError as exc:
raise HTTPException(404, str(exc)) from exc
manifest = read_manifest(run) or {}
return {
"id": run.name,
"manifest": manifest,
"summary": summarize(manifest),
"history": restore_history(run),
"files": sorted(p.name for p in run.glob("*.json")),
}
@router.post("/api/run/restore")
async def run_restore(request: RestoreRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["tandoor"]:
raise HTTPException(400, "Tandoor-URL und Token fehlen.")
try:
run = resolve_run(runs_dir, request.run)
except FileNotFoundError as exc:
raise HTTPException(404, str(exc)) from exc
env = ctx.settings.tool_env()
env["DATA_DIR"] = str(ctx.data_dir)
job = await ctx.jobs.start(
plugin=ctx.id,
label=("Zurückspielen" if request.apply else "Trockenübung Zurückspielen")
+ f": {run.name}",
argv=restore_argv(run.name, request.apply, request.force),
cwd=ctx.data_dir, env=env,
)
return job.info()
return router