chore: initial import
This commit is contained in:
+219
@@ -0,0 +1,219 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user