"""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