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>
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""Importiert die Tenants der bestehenden transkript-platform (Git-Ordner) in die Datenbank.
|
|
|
|
Idempotent: ein Tenant, dessen Inhalt bereits identisch als aktuelle Version existiert,
|
|
wird übersprungen. Secrets werden standardmäßig NUR als Schlüsselnamen importiert
|
|
(Wert „extern/SOPS“); mit with_secrets=True werden sie via `sops -d` entschlüsselt
|
|
und verschlüsselt in der DB abgelegt.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Deployment, Secret, Setting, Tenant, Version
|
|
from app.services import crypto, store
|
|
from app.services.samples import DEFAULT_SAMPLE, ROTARY_SAMPLE
|
|
|
|
IGNORE = {"secrets.enc.yaml", "tenant.json"}
|
|
IGNORE_DIRS = {"__pycache__"}
|
|
|
|
# Bisherige Gitea-„Variables“ (docs/DEPLOYMENT.md) → geteilte Einstellungen
|
|
DEFAULT_SETTINGS = {
|
|
"base_domain": "onyscribe.de",
|
|
"mittwald_project_id": "",
|
|
"mittwald_stack_id": "",
|
|
"registry": "",
|
|
"image_name": "transkript-platform",
|
|
"oidc_issuer": "https://login.onyva.dev",
|
|
"onyva_login_api_base": "",
|
|
"auth_enabled": "true",
|
|
"app_cookie_secure": "true",
|
|
"max_file_size_mb": "300",
|
|
"cortecs_base_url": "https://api.cortecs.ai/v1",
|
|
"llm_model": "claude-4-6-sonnet",
|
|
"llm_max_tokens": "16000",
|
|
"llm_temperature": "0.2",
|
|
"smtp_host": "",
|
|
"smtp_port": "587",
|
|
"smtp_user": "",
|
|
"smtp_from": "Gesprächsprotokoll",
|
|
"mistral_transcribe_model": "voxtral-mini-latest",
|
|
"mistral_diarize": "true",
|
|
"mistral_timeout": "600",
|
|
"core_image_tag": "latest",
|
|
"core_supports_bundle": "false",
|
|
"canary_tenants": "demo",
|
|
"admin_public_url": "",
|
|
"cost_api_base": "",
|
|
"cost_tracking_enabled": "",
|
|
"agent_model": "claude-4-6-sonnet",
|
|
"agent_api": "messages",
|
|
}
|
|
|
|
|
|
def seed_settings(db: Session) -> None:
|
|
for k, v in DEFAULT_SETTINGS.items():
|
|
if db.get(Setting, k) is None:
|
|
db.add(Setting(key=k, value=v))
|
|
|
|
|
|
def _sops_keys(path: Path) -> list[str]:
|
|
if not path.exists():
|
|
return []
|
|
data = yaml.safe_load(path.read_text()) or {}
|
|
return [k for k in data if k != "sops"]
|
|
|
|
|
|
def _sops_decrypt(path: Path) -> dict[str, str]:
|
|
out = subprocess.run(["sops", "-d", "--output-type", "json", str(path)], capture_output=True, text=True, check=True)
|
|
return {k: str(v) for k, v in json.loads(out.stdout).items()}
|
|
|
|
|
|
def _upsert_secrets(db: Session, tenant_id: str | None, path: Path, with_secrets: bool) -> int:
|
|
keys = _sops_keys(path)
|
|
values = _sops_decrypt(path) if (with_secrets and keys) else {}
|
|
n = 0
|
|
for key in keys:
|
|
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
|
|
if row is None:
|
|
row = Secret(tenant_id=tenant_id, key=key)
|
|
db.add(row)
|
|
if key in values and values[key]:
|
|
row.value_enc = crypto.encrypt(values[key])
|
|
row.source = "manual"
|
|
elif row.value_enc is None:
|
|
row.source = "sops-import"
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def import_all(db: Session, root: Path, with_secrets: bool = False, force: bool = False) -> list[str]:
|
|
log: list[str] = []
|
|
seed_settings(db)
|
|
tenants_dir = root / "tenants"
|
|
src_rev = ""
|
|
try:
|
|
src_rev = subprocess.run(["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
n = _upsert_secrets(db, None, root / "secrets" / "shared.enc.yaml", with_secrets)
|
|
log.append(f"geteilte Secrets: {n} Schlüssel")
|
|
|
|
for tdir in sorted(p for p in tenants_dir.iterdir() if p.is_dir()):
|
|
tid = tdir.name
|
|
cfg_file = tdir / "tenant.json"
|
|
if not cfg_file.exists():
|
|
continue
|
|
raw = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
deploy = raw.pop("deploy", {}) or {}
|
|
files: dict[str, bytes] = {}
|
|
for p in sorted(tdir.rglob("*")):
|
|
if p.is_dir() or p.name in IGNORE or any(part in IGNORE_DIRS for part in p.relative_to(tdir).parts):
|
|
continue
|
|
files[p.relative_to(tdir).as_posix()] = p.read_bytes()
|
|
|
|
t = db.get(Tenant, tid)
|
|
if t is None:
|
|
res = deploy.get("resources") or {}
|
|
t = Tenant(
|
|
id=tid, name=raw.get("name", tid), subdomain=deploy.get("subdomain", tid),
|
|
cpu=float(res.get("cpu", 0.5)), memory=str(res.get("memory", "512M")),
|
|
domain_status="verified", source="git", deployed_image="git-baked",
|
|
preview_sample=ROTARY_SAMPLE if tid.startswith("rotary") else None,
|
|
# Bestehende Clients wurden von Hand angelegt → nur „adoptieren“: fehlende Redirect-URI ergänzen, sonst nichts anfassen
|
|
oidc={"mode": "adopt"},
|
|
)
|
|
db.add(t)
|
|
db.flush()
|
|
shas = {p: store.put_blob(db, data, p) for p, data in files.items()}
|
|
cur = store.current_version(db, t)
|
|
same = cur is not None and cur.files == shas and json.dumps(cur.config, sort_keys=True) == json.dumps(raw, sort_keys=True)
|
|
if same and not force:
|
|
log.append(f"{tid}: unverändert (v{t.current_version})")
|
|
else:
|
|
number = (t.current_version or 0) + 1
|
|
db.add(Version(tenant_id=tid, number=number, message=f"Import aus transkript-platform @{src_rev}",
|
|
author="import", config=raw, files=shas))
|
|
t.current_version = number
|
|
if t.deployed_version is None:
|
|
t.deployed_version = number
|
|
store.audit(db, "import", "tenant.import", tid, f"v{number} aus {src_rev}")
|
|
log.append(f"{tid}: importiert als v{number} ({len(files)} Dateien)")
|
|
k = _upsert_secrets(db, tid, tdir / "secrets.enc.yaml", with_secrets)
|
|
log.append(f" Secrets: {k} Schlüssel")
|
|
db.flush()
|
|
return log
|