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>
226 lines
8.4 KiB
Python
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
|