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>
This commit is contained in:
225
app/services/store.py
Normal file
225
app/services/store.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user