Files
Onyscribe-Admin/app/services/importer.py
RubenRWU 7d8c226da2 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>
2026-10-15 15:30:26 +02:00

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