Files
Onyscribe-Admin/app/services/store.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

226 lines
8.4 KiB
Python

"""Inhalte eines Tenants: Blobs, Entwurf, Versionen, Diff, Bundle.
Ein Tenant besteht aus `config` (Inhalt der tenant.json ohne deploy-Block) und
`files` ({pfad: sha}). Bearbeitet wird immer ein Entwurf (tenant.draft);
„Veröffentlichen“ schreibt daraus eine neue unveränderliche Version.
"""
from __future__ import annotations
import difflib
import hashlib
import io
import json
import tarfile
from copy import deepcopy
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import AuditLog, Blob, Tenant, Version
TEXT_SUFFIXES = {".txt", ".html", ".css", ".py", ".json", ".svg", ".md", ".yaml", ".yml", ".csv"}
RESERVED = {"tenant.json"} # wird aus config erzeugt
def is_text_path(path: str) -> bool:
return any(path.lower().endswith(s) for s in TEXT_SUFFIXES)
def put_blob(db: Session, data: bytes, path: str = "") -> str:
sha = hashlib.sha256(data).hexdigest()
if db.get(Blob, sha) is None:
db.add(Blob(sha=sha, content=data, size=len(data), is_text=is_text_path(path)))
db.flush()
return sha
def get_blob(db: Session, sha: str) -> bytes:
b = db.get(Blob, sha)
if b is None:
raise KeyError(sha)
return b.content
def audit(db: Session, actor: str, action: str, tenant_id: str | None = None, detail: str = "") -> None:
db.add(AuditLog(actor=actor, action=action, tenant_id=tenant_id, detail=detail))
def current_version(db: Session, t: Tenant) -> Version | None:
if t.current_version is None:
return None
return db.scalar(select(Version).where(Version.tenant_id == t.id, Version.number == t.current_version))
def get_version(db: Session, tenant_id: str, number: int) -> Version | None:
return db.scalar(select(Version).where(Version.tenant_id == tenant_id, Version.number == number))
def content(db: Session, t: Tenant, source: str = "draft") -> tuple[dict, dict]:
"""(config, files) für source = draft | current | v<N>. Draft fällt auf current zurück."""
if source == "draft" and t.draft:
return deepcopy(t.draft["config"]), dict(t.draft["files"])
if source.startswith("v") and source[1:].isdigit():
v = get_version(db, t.id, int(source[1:]))
else:
v = current_version(db, t)
if v is None:
return {}, {}
return deepcopy(v.config), dict(v.files)
def ensure_draft(db: Session, t: Tenant) -> dict:
"""Arbeitskopie des Entwurfs (nie das am ORM-Objekt hängende Dict mutieren)."""
if t.draft:
return json.loads(json.dumps(t.draft))
cfg, files = content(db, t, "current")
return {"config": cfg, "files": files}
def _save_draft(t: Tenant, draft: dict) -> None:
t.draft = json.loads(json.dumps(draft))
flag_modified(t, "draft")
def set_config(db: Session, t: Tenant, config: dict) -> None:
d = ensure_draft(db, t)
config = {k: v for k, v in config.items() if k != "deploy"}
config["id"] = t.id
d["config"] = config
_save_draft(t, d)
_sync_draft_state(db, t)
def set_file(db: Session, t: Tenant, path: str, data: bytes) -> str:
path = clean_path(path)
d = ensure_draft(db, t)
sha = put_blob(db, data, path)
d["files"][path] = sha
_save_draft(t, d)
_sync_draft_state(db, t)
return sha
def delete_file(db: Session, t: Tenant, path: str) -> None:
d = ensure_draft(db, t)
d["files"].pop(path, None)
_save_draft(t, d)
_sync_draft_state(db, t)
def clean_path(path: str) -> str:
path = path.strip().lstrip("/")
if not path or ".." in path.split("/") or path in RESERVED or "\\" in path:
raise ValueError(f"Ungültiger Dateipfad: {path!r}")
return path
def _sync_draft_state(db: Session, t: Tenant) -> None:
"""Entwurf, der identisch zur aktuellen Version ist, wieder löschen."""
if t.draft and not diff_summary(db, t, "current", "draft")["changed"]:
t.draft = None
def discard_draft(db: Session, t: Tenant) -> None:
t.draft = None
def publish(db: Session, t: Tenant, message: str, author: str) -> Version:
if not t.draft:
raise ValueError("Kein Entwurf zum Veröffentlichen.")
number = (t.current_version or 0) + 1
last = db.scalar(select(Version.number).where(Version.tenant_id == t.id).order_by(Version.number.desc()))
number = max(number, (last or 0) + 1)
v = Version(tenant_id=t.id, number=number, message=message.strip() or f"Version {number}", author=author,
config=t.draft["config"], files=t.draft["files"])
db.add(v)
t.current_version = number
t.draft = None
t.name = v.config.get("name", t.name)
audit(db, author, "version.publish", t.id, f"v{number}: {v.message}")
db.flush()
return v
def restore_as_draft(db: Session, t: Tenant, number: int) -> None:
v = get_version(db, t.id, number)
if v is None:
raise KeyError(number)
t.draft = {"config": deepcopy(v.config), "files": dict(v.files)}
_sync_draft_state(db, t)
# ── Diff ────────────────────────────────────────────────────────────────────
def diff_summary(db: Session, t: Tenant, a: str, b: str, with_text: bool = False) -> dict:
return diff_content(db, content(db, t, a), content(db, t, b), with_text)
def diff_content(db: Session, left: tuple[dict, dict], right: tuple[dict, dict], with_text: bool = False) -> dict:
ca, fa = left
cb, fb = right
items = []
if json.dumps(ca, sort_keys=True) != json.dumps(cb, sort_keys=True):
items.append(_item("tenant.json", "geändert", db, json.dumps(ca, indent=2, ensure_ascii=False, sort_keys=True),
json.dumps(cb, indent=2, ensure_ascii=False, sort_keys=True), True, with_text))
for path in sorted(set(fa) | set(fb)):
sa, sb = fa.get(path), fb.get(path)
if sa == sb:
continue
kind = "neu" if sa is None else "gelöscht" if sb is None else "geändert"
text = is_text_path(path)
ta = get_blob(db, sa).decode("utf-8", "replace") if (sa and text and with_text) else ""
tb = get_blob(db, sb).decode("utf-8", "replace") if (sb and text and with_text) else ""
items.append(_item(path, kind, db, ta, tb, text, with_text))
return {"changed": bool(items), "items": items}
def _item(path, kind, db, ta, tb, text, with_text):
out = {"path": path, "kind": kind, "text": text}
if with_text and text:
lines = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "vorher", "nachher", lineterm="", n=2))
out["diff"] = lines
out["added"] = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
out["removed"] = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
return out
# ── Bundle (genau das, was ein Tenant-Container beim Start abholt) ──────────
def tenant_json_bytes(config: dict) -> bytes:
return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()
def materialize(db: Session, t: Tenant, source: str, target) -> dict:
"""Schreibt das Tenant-Verzeichnis (tenant.json + Dateien) nach `target`."""
cfg, files = content(db, t, source)
write_tenant_dir(db, cfg, files, target)
return cfg
def write_tenant_dir(db: Session, cfg: dict, files: dict, target) -> None:
target.mkdir(parents=True, exist_ok=True)
(target / "tenant.json").write_bytes(tenant_json_bytes(cfg))
for path, sha in files.items():
p = target / path
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(get_blob(db, sha))
def bundle_tar(db: Session, t: Tenant, source: str = "current") -> tuple[bytes, dict]:
cfg, files = content(db, t, source)
manifest = {"tenant": t.id, "version": t.current_version if source != "draft" else None,
"files": {"tenant.json": hashlib.sha256(tenant_json_bytes(cfg)).hexdigest(), **files}}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
def add(name, data):
ti = tarfile.TarInfo(name)
ti.size = len(data)
ti.mtime = 0
tar.addfile(ti, io.BytesIO(data))
add("tenant.json", tenant_json_bytes(cfg))
for path, sha in sorted(files.items()):
add(path, get_blob(db, sha))
add("MANIFEST.json", json.dumps(manifest, indent=2).encode())
return buf.getvalue(), manifest