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>
110 lines
4.5 KiB
Python
110 lines
4.5 KiB
Python
"""Live-Vorschau: führt den vendored Core mit dem (Entwurfs-)Tenant in einem Subprozess aus."""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import Tenant
|
||
from app.services import store
|
||
from app.services.samples import DEFAULT_SAMPLE
|
||
from app.settings import PLATFORM_DIR, PREVIEW_CACHE
|
||
|
||
WORKER = Path(__file__).with_name("preview_worker.py")
|
||
TIMEOUT = 90
|
||
|
||
|
||
class PreviewError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def core_version() -> str:
|
||
from app.services import contract
|
||
return contract.sha()[:10]
|
||
|
||
|
||
def sample_for(t: Tenant) -> str:
|
||
return t.preview_sample or DEFAULT_SAMPLE
|
||
|
||
|
||
EXT = {"index": "html", "pdf": "pdf", "validate": "json", "prompt": "json"}
|
||
|
||
|
||
class SourceGone(LookupError):
|
||
pass
|
||
|
||
|
||
def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
|
||
"""draft | current | v<N> | ws:<run> (Live-Arbeitsstand des Agenten) | proposal:<msg> (Vorschlag des Agenten)."""
|
||
if source.startswith("ws:"):
|
||
from app.agent.loop import WORKSPACES
|
||
if source[3:] not in WORKSPACES:
|
||
raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.")
|
||
cfg, files = WORKSPACES[source[3:]]
|
||
return dict(cfg), dict(files)
|
||
if source.startswith("proposal:"):
|
||
from app.agent.loop import proposal_content
|
||
from app.models import ChatMessage
|
||
m = db.get(ChatMessage, int(source[9:] or 0))
|
||
if m is None or m.tenant_id != t.id or not m.proposal:
|
||
raise SourceGone("Vorschlag nicht gefunden.")
|
||
return proposal_content(db, t, m.proposal)
|
||
return store.content(db, t, source)
|
||
|
||
|
||
def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict:
|
||
cfg, files = resolve_content(db, t, source)
|
||
return run_content(db, t, cfg, files, kind, sample, args)
|
||
|
||
|
||
def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None,
|
||
args: dict | None = None) -> bytes | dict:
|
||
"""Rendert beliebigen Inhalt (cfg + {pfad: sha}) – für Entwurf, Version oder Agent-Workspace."""
|
||
sample = sample if sample is not None else sample_for(t)
|
||
h = hashlib.sha256(json.dumps([cfg, files, kind, sample, args or {}, core_version()], sort_keys=True).encode())
|
||
cached = PREVIEW_CACHE / f"{h.hexdigest()[:32]}.{EXT[kind]}"
|
||
if not cached.exists():
|
||
_execute(db, t.id, cfg, files, kind, sample, cached, args or {})
|
||
data = cached.read_bytes()
|
||
return json.loads(data) if EXT[kind] == "json" else data
|
||
|
||
|
||
def _limits() -> None:
|
||
import resource
|
||
resource.setrlimit(resource.RLIMIT_CPU, (90, 90))
|
||
resource.setrlimit(resource.RLIMIT_FSIZE, (200 * 1024 * 1024,) * 2)
|
||
os.setsid()
|
||
|
||
|
||
def _execute(db: Session, tenant_id: str, cfg: dict, files: dict, kind: str, sample: str, out: Path, args: dict) -> None:
|
||
with tempfile.TemporaryDirectory(prefix="onyscribe-preview-") as tmp_s:
|
||
tmp = Path(tmp_s)
|
||
shutil.copytree(PLATFORM_DIR / "core", tmp / "core", ignore=shutil.ignore_patterns("__pycache__"))
|
||
store.write_tenant_dir(db, cfg, files, tmp / "tenants" / tenant_id)
|
||
(tmp / "data").mkdir()
|
||
shutil.copy(WORKER, tmp / "preview_worker.py")
|
||
env = {
|
||
"PATH": os.environ.get("PATH", ""), "HOME": str(tmp), "LANG": "C.UTF-8",
|
||
"TENANT": tenant_id, "DATA_DIR": str(tmp / "data"), "AUTH_ENABLED": "false",
|
||
"PYTHONPATH": str(tmp), "PYTHONDONTWRITEBYTECODE": "1",
|
||
# Kein einziges echtes Secret im Sandbox-Prozess
|
||
"OIDC_ISSUER": "https://login.example.invalid", "SESSION_SECRET": "preview",
|
||
}
|
||
result = tmp / f"result.{out.suffix.lstrip('.')}"
|
||
payload = json.dumps({"summary": sample, "title": f"{cfg.get('name', tenant_id)} – Vorschau", **args})
|
||
try:
|
||
proc = subprocess.run([sys.executable, "-I", "preview_worker.py", kind, str(result)], input=payload,
|
||
cwd=tmp, env=env, capture_output=True, text=True, timeout=TIMEOUT, preexec_fn=_limits)
|
||
except subprocess.TimeoutExpired as exc:
|
||
raise PreviewError("Vorschau-Timeout (Tenant-Code läuft zu lange)") from exc
|
||
if proc.returncode != 0 or not result.exists():
|
||
raise PreviewError((proc.stderr or proc.stdout or "unbekannter Fehler")[-2500:])
|
||
shutil.copy(result, out)
|