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:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

14
scripts/export_seed.py Normal file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Schreibt den aktuellen DB-Stand (Tenants, Versionen, Dateien; ohne Secret-Werte) nach seed/tenants.seed.json.gz."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import session # noqa: E402
from app.services import seed # noqa: E402
out = Path(__file__).resolve().parent.parent / "seed" / "tenants.seed.json.gz"
with session() as db:
seed.export(db, out)
print(f"geschrieben: {out} ({out.stat().st_size/1024:.0f} KB)")

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Lokaler Fake von login.onyva.dev /api/v1 – zum Ausprobieren des OIDC-Abgleichs ohne echtes Login-System.
python scripts/fake_login_server.py # http://127.0.0.1:8399/api/v1, Key: onyva_testkey, Nutzer: anna@x.de, bob@x.de
ONYVA_LOGIN_API_KEY=onyva_testkey OIDC_SYNC_LIVE=true uvicorn app.main:app --port 8300
→ Einstellungen → „API-Basis“ = http://127.0.0.1:8399/api/v1
"""
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tests.fakelogin import FakeLogin # noqa: E402
fake = FakeLogin()
class H(BaseHTTPRequestHandler):
def _do(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n)) if n else None
status, data, hdrs = fake(self.command, f"http://x{self.path}", {"Authorization": self.headers.get("Authorization", "")}, body)
self.send_response(status)
for k, v in hdrs.items():
self.send_header(k, v)
self.send_header("Content-Type", "application/json")
out = json.dumps(data).encode() if data is not None else b""
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
print(f"{self.command} {self.path} → {status}", flush=True)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _do
def log_message(self, *a):
pass
if __name__ == "__main__":
print("Fake-Login auf http://127.0.0.1:8399/api/v1 (Key onyva_testkey)", flush=True)
HTTPServer(("127.0.0.1", 8399), H).serve_forever()

27
scripts/import_tenants.py Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Importiert die Tenants aus ../transkript-platform in die Admin-DB.
python scripts/import_tenants.py # nur Schlüsselnamen der Secrets
python scripts/import_tenants.py --with-secrets # Secrets via `sops -d` entschlüsseln und verschlüsselt speichern
python scripts/import_tenants.py --force # neue Version auch bei identischem Inhalt
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import init_db, session # noqa: E402
from app.services.importer import import_all # noqa: E402
from app.settings import LEGACY_PLATFORM # noqa: E402
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=str(LEGACY_PLATFORM))
ap.add_argument("--with-secrets", action="store_true")
ap.add_argument("--force", action="store_true")
a = ap.parse_args()
init_db()
with session() as db:
for line in import_all(db, Path(a.root), a.with_secrets, a.force):
print(line)

13
scripts/sync_core.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Aktualisiert den vendored Core-Snapshot (nur für lokale Entwicklung ohne Core-Image) aus der transkript-platform.
# Standard: aktueller Stand des Quell-Repos. Im Container/Coolify wird stattdessen FROM <core-image> gebaut (Dockerfile.fromcore).
set -euo pipefail
SRC="${1:-$(cd "$(dirname "$0")/../.." && pwd)/transkript-platform}"
DST="$(cd "$(dirname "$0")/.." && pwd)/vendor/platform"
rm -rf "$DST/core" && mkdir -p "$DST"
cp -r "$SRC/core" "$DST/core" && cp "$SRC/main.py" "$SRC/requirements.txt" "$DST/"
find "$DST" -name __pycache__ -prune -exec rm -rf {} +
SHA="$(git -C "$SRC" rev-parse HEAD)"; BRANCH="$(git -C "$SRC" rev-parse --abbrev-ref HEAD)"
echo "$SHA $BRANCH" > "$DST/CORE_VERSION"
printf '{"sha": "%s", "built_at": "%s"}\n' "$SHA" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DST/core/BUILD_INFO.json"
echo "Core synchronisiert: ${SHA:0:10} ($BRANCH)"

39
scripts/verify_import.py Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Beweis für den Cut-over: Bundle aus der DB == Git-Ordner (Hash-Vergleich, ohne deploy-Block)."""
import hashlib
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import session # noqa: E402
from app.models import Tenant # noqa: E402
from app.services import store # noqa: E402
from app.settings import LEGACY_PLATFORM # noqa: E402
from sqlalchemy import select # noqa: E402
root = Path(sys.argv[1]) if len(sys.argv) > 1 else LEGACY_PLATFORM
bad = 0
with session() as db:
for t in db.scalars(select(Tenant).order_by(Tenant.id)):
tdir = root / "tenants" / t.id
cfg, files = store.content(db, t, "current")
git_cfg = json.loads((tdir / "tenant.json").read_text(encoding="utf-8"))
git_cfg.pop("deploy", None)
problems = []
if json.dumps(git_cfg, sort_keys=True) != json.dumps(cfg, sort_keys=True):
problems.append("tenant.json weicht ab")
for path, sha in files.items():
p = tdir / path
if not p.exists():
problems.append(f"{path}: fehlt in Git")
elif hashlib.sha256(p.read_bytes()).hexdigest() != sha:
problems.append(f"{path}: Hash weicht ab")
for p in tdir.rglob("*"):
rel = p.relative_to(tdir).as_posix()
if p.is_file() and rel not in files and rel not in ("tenant.json", "secrets.enc.yaml") and "__pycache__" not in rel:
problems.append(f"{rel}: fehlt in DB")
print(("✔" if not problems else "✘"), t.id, "; ".join(problems))
bad += bool(problems)
sys.exit(1 if bad else 0)