220 lines
7.4 KiB
Python
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
|