commit 7d8c226da2a7fffef70b5d715dda0e138a9aa9c0 Author: RubenRWU Date: Thu Oct 15 15:30:26 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eba7c53 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.venv +.git +.data +.preview-cache +__pycache__ +tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..78a90d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ── Pflicht ─────────────────────────────────────────────────────────────────── +# Datenbank: Postgres (docker-compose setzt sie selbst) oder SQLite für lokale Entwicklung +DATABASE_URL=sqlite:///./.data/onyscribe-admin.db +# Master-Key für Secrets in der DB UND Session-Cookies (Fernet-Key). SICHERN – ohne ihn sind Secrets unlesbar. +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +SECRET_KEY= + +# ── Login der Admin-Plattform (login.onyva.dev) ─────────────────────────────── +AUTH_ENABLED=false +OIDC_ISSUER=https://login.onyva.dev +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://admin.example.de/auth/callback +OIDC_LOGOUT_REDIRECT_URI=https://admin.example.de/ +APP_COOKIE_SECURE=true +# Zugriffsregel (mindestens eine, sonst Default-Deny): +ADMIN_ALLOWED_EMAILS= +ADMIN_REQUIRED_ROLE= + +# ── Deploys ─────────────────────────────────────────────────────────────────── +DEPLOY_ALLOWED_TENANTS= # Tenants, die LIVE angefasst werden dürfen (kommagetrennt, * = alle, leer = keiner) – zum Start nur den Test-Tenant, z. B. demo-admin +DEPLOY_LIVE=false # true erlaubt ECHTE Schreibzugriffe auf Mittwald – bewusst aus lassen, bis der Core umgebaut ist +MITTWALD_API_TOKEN= # optional für lesende Dry-Run-Aufrufe + +# ── OIDC-Clients der Tenants automatisch verwalten (login.onyva.dev /api/v1) ── +ONYVA_LOGIN_API_KEY= # Admin-Key aus dem Login-Dashboard (API-Keys); wie ein Passwort behandeln +OIDC_SYNC_LIVE=false # true = darf im Login Apps anlegen/ändern; ohne: nur lesen/Plan (Dry-Run) + +# ── KI ──────────────────────────────────────────────────────────────────────── +CORTECS_API_KEY= # alternativ: Einstellungen → Geteilte Secrets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4e2a81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +.env +*.db +.preview-cache/ +.data/ +.idea/ +.gstack/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de5fb8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +# WeasyPrint (PDF-Vorschau) + Fonts – identisch zum Core-Image der Plattform +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi8 \ + fonts-noto-core fonts-noto-color-emoji fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /srv +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY vendor ./vendor +COPY seed ./seed +COPY scripts ./scripts + +ENV PORT=8300 PYTHONUNBUFFERED=1 +EXPOSE 8300 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8300')+'/healthz')" +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8300} --proxy-headers --forwarded-allow-ips='*'"] diff --git a/Dockerfile.fromcore b/Dockerfile.fromcore new file mode 100644 index 0000000..a7782c3 --- /dev/null +++ b/Dockerfile.fromcore @@ -0,0 +1,24 @@ +# Admin-Image auf Basis des Core-Images: Sandbox-Vorschau, Schriften, Python-Pakete und WeasyPrint sind +# damit PER KONSTRUKTION identisch zur Produktion, und der Core-Vertrag (Schema, Manifest) kommt direkt aus dem Image. +# +# docker build -f Dockerfile.fromcore --build-arg CORE_IMAGE=/transkript-platform: -t onyscribe-admin . +# +# Coolify: ADMIN_DOCKERFILE=Dockerfile.fromcore, CORE_IMAGE=/transkript-platform:. +# Läuft der Admin auf einem anderen Core als ein gemeldetes Release, zeigt „Core“ das an und lässt keine Prüfung/Freigabe zu. +ARG CORE_IMAGE +FROM ${CORE_IMAGE} + +WORKDIR /srv +COPY requirements-extra.txt . +RUN pip install --no-cache-dir -r requirements-extra.txt \ + && apt-get update && apt-get install -y --no-install-recommends fonts-liberation && rm -rf /var/lib/apt/lists/* + +COPY app ./app +COPY seed ./seed +COPY scripts ./scripts + +# Core liegt im Image unter /app (core/, requirements.txt, BUILD_INFO.json) +ENV PLATFORM_DIR=/app PORT=8300 PYTHONUNBUFFERED=1 ONYSCRIBE_SKIP_DOTENV=1 +EXPOSE 8300 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8300')+'/healthz')" +CMD ["sh", "-c", "cd /srv && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8300} --proxy-headers --forwarded-allow-ips='*'"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a50374 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# Onyscribe Admin + +Separat gehostete Admin-Plattform für die Tenants von **Onyscribe** (Repo `transkript-platform`): Tenants anlegen und bearbeiten, +Live-Vorschau mit dem echten Core, versionieren, Secrets/OIDC/Domain pflegen, deployen, mit KI-Agent (Cortecs). + +## Stand + +Alles, was **ohne Änderung am Core und ohne echte Deployments** möglich ist, ist umgesetzt: + +| Bereich | Stand | +|---|---| +| Tenants (7 importiert, Hash-identisch zu Git) | ✔ Import, Seed, Anlegen/Klonen, Status, Ressourcen, Notizen, Archivieren/Löschen | +| Entwurf → Diff → Version | ✔ unveränderliche Snapshots, Rollback als Entwurf, Hook-Code-Freigabe | +| Editor | ✔ Marke/Farben, Absender, PDF-Layout, Prompt, Dateien, Flags/STT, Hook-Code, tenant.json | +| Live-Vorschau | ✔ Web-UI + PDF mit dem echten Core in Sandbox (kein Secret, CPU-/Datei-Limits), Validierung | +| Prompt-Test | ✔ echter LLM-Lauf (braucht Cortecs-Key), finaler Prompt inkl. Hooks | +| KI-Agent | ✔ echter Tool-Loop (Messages- oder Chat-API gegen Cortecs), Workspace-Entwurf, Diff, Freigabe; ✔ Demo-Modus ohne Key; ⚠ gegen Live-Cortecs noch **ungetestet** (kein Key) | +| Login (OIDC) | ✔ Code + Tests (Mock-IdP); ⚠ gegen login.onyva.dev noch **ungetestet** | +| Secrets | ✔ Fernet-verschlüsselt, Import aus SOPS (`--with-secrets`) | +| OIDC-Clients | ✔ **automatischer Abgleich bei jedem Deploy** über die Login-Developer-API: Client anlegen, Redirect-URIs, auth/totp, Freigaben; Secret wird sofort verschlüsselt gespeichert; Dry-Run liest nur. Gegen Fake-Login getestet, gegen login.onyva.dev **ungetestet** (kein Key) | +| Domain | ✔ Subdomain/eigene Domain, DNS-Check | +| Deploy-Engine | ✔ Port von `infra/deploy.py` (PATCH, Guard, Ingress, Offboard, Abbruch bei Fehler), per Fake-HTTP getestet; **Schreibzugriffe gesperrt** (`DEPLOY_LIVE`) | +| Bundle-Endpoint | ✔ `GET /api/tenants//bundle` (Token); Gegenstück im Core: Branch `admin-platform-integration` (`core/bundle.py`) | +| **Core-Vertrag** | ✔ Schema/Manifest/Build-Info aus dem Core gelesen (`app/services/contract.py`); Formulare („Funktion & STT“ + automatisch alle neuen Optionen), Einstellungen und Container-Env werden daraus **erzeugt**; Pflicht-Secrets laut Schema | +| **Core-Releases** (Menü „Core“) | ✔ Release-Eingang für die Core-CI, Kompatibilitätsprüfung **aller** Tenants mit dem Core des Admins (Validierung + Fingerabdrücke von PDF/Web/Prompt gegen Baseline), Freigabe, Dry-Run, Canary-Rollout mit Abbruch, Health-Verifikation (Core-SHA, Bundle-Version), Pins pro Tenant, Rollback (Release/Tenant) | +| Zustandsabgleich | ✔ „Zustand prüfen“ im Dashboard und „Läuft?“ pro Tenant: fragt `/health` ab und zeigt Abweichungen | + +**Bewusst offen (braucht Core- bzw. Produktionsentscheidung):** +1. Core-Branch `admin-platform-integration` (Vertrag, Bundle-Loader, `/health`) reviewen und mergen/deployen. Erst danach `core_supports_bundle` aktivieren (Einstellungen) – bis dahin sind Deploys von Tenants mit Quelle „Admin“ gesperrt. +2. `DEPLOY_LIVE=true` + `MITTWALD_API_TOKEN` + Projekt-/Stack-ID, sobald echte Deployments gewollt sind. +3. Cortecs-Key hinterlegen → Agent-Modell auswählen (Einstellungen → „Modelle von Cortecs laden“) und gegen Live testen. +4. OIDC-Client für die Admin-Plattform in login.onyva.dev anlegen, `AUTH_ENABLED=true`. +4b. Admin-Key im Login-Dashboard erzeugen → `ONYVA_LOGIN_API_KEY` setzen; mit „Verbindung testen“ (Einstellungen) prüfen; zum Schreiben `OIDC_SYNC_LIVE=true`. +5. Datenbank-Migrationen (Alembic) vor dem ersten produktiven Schemawechsel; Tabellen entstehen derzeit per `create_all`. + +## Starten + +```bash +# lokal (SQLite, kein Docker) +python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt +# System-Libs für WeasyPrint: libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-core +cp .env.example .env # Keys eintragen (wird beim Start eingelesen; echte Env-Variablen haben Vorrang) +uvicorn app.main:app --port 8300 # http://localhost:8300 (Seed lädt die 7 Tenants automatisch) – nach .env-Änderungen neu starten + +# mit Postgres (wie Coolify) +export POSTGRES_PASSWORD=... SECRET_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") +docker compose up -d --build +``` + +**Coolify:** Neue Ressource → Docker Compose → dieses Repo. Env `POSTGRES_PASSWORD` und `SECRET_KEY` setzen (weitere Variablen: `.env.example`), +Domain auf Service `admin`, Port `8300`. Backup: Volume `pgdata` **und** `SECRET_KEY`. + +**Login aktivieren:** Client in login.onyva.dev anlegen (Redirect `https:///auth/callback`), dann `AUTH_ENABLED=true`, `OIDC_CLIENT_ID/SECRET`, +`OIDC_REDIRECT_URI`, `OIDC_LOGOUT_REDIRECT_URI` und eine Zugriffsregel (`ADMIN_ALLOWED_EMAILS` und/oder `ADMIN_REQUIRED_ROLE`). Ohne Regel wird jeder Login abgelehnt. + +## Zusammenspiel mit dem Core + +Spezifikation: `transkript-platform/docs/CONTRACT.md`. Kurz: Der Core liefert **Image + Schema + Manifest + Bundle-Format + `/health`**, der Admin liest das – +er hält keine eigene Kopie der Tenant-Optionen. + +``` +Core-Push → CI: Tests → Image (:sha) → POST /api/core/releases (Job „notify-admin“) + → Admin neu bauen (Dockerfile.fromcore, FROM Core-Image) → „Core“: Prüfen (alle Tenants) → Freigeben → Ausrollen (Canary zuerst) → Health-Check je Tenant +Tenant-Änderung (ohne Core): Veröffentlichen → Deploy mit dem aktiven Core-Tag (nur Bundle-Neustart, kein Image-Build) +``` + +- **Admin auf Basis des Core-Images:** `Dockerfile.fromcore` (`CORE_IMAGE=/transkript-platform:`). Sandbox, Schriften und Pakete sind identisch zur Produktion. Läuft der Admin auf einem anderen Core als ein gemeldetes Release, zeigt „Core“ das an und erlaubt keine Prüfung/Freigabe. *Noch zu verifizieren in Coolify:* wie der Neubau mit neuem Tag ausgelöst wird (Webhook + `CORE_IMAGE` per Env/API oder `latest` mit „immer pullen“). +- **Lokal ohne Core-Image:** vendored Snapshot (`scripts/sync_core.sh`, zieht den aktuellen Stand der Quelle inkl. Branch). +- **Neue Core-Option?** Steht sie im Schema des Cores (CI erzwingt das), erscheint sie hier automatisch in Formularen, Einstellungen und Container-Env. +- **Release-Eingang:** Env `CORE_RELEASE_TOKEN` (Admin) = Secret `ADMIN_RELEASE_TOKEN` (Core-CI) + Variable `ADMIN_URL`. + +## OIDC-Abgleich (Login-Developer-API) + +Pro Tenant (Domain & Login) gibt es einen Soll-Zustand und eine **Verwaltung**: + +| Modus | Verhalten beim Deploy | +|---|---| +| `off` | nichts anfassen | +| `adopt` (Standard für die 7 importierten Altclients) | prüfen, dass die Redirect-URIs (Subdomain + ggf. eigene Domain) enthalten sind, fehlende ergänzen – sonst nichts ändern | +| `managed` (Standard für neue Tenants) | Client fehlt → **anlegen**; sonst Redirect-URIs, `auth_mode`, `totp_mode`, `allow_registration`, Backchannel exakt wie konfiguriert; Freigaben setzen (optional „exakt“: überzählige entziehen) | + +Ablauf eines Deploys: **1.** OIDC abgleichen (bei Neuanlage Client-ID/-Secret sofort verschlüsselt speichern) → **2.** Pflicht-Secrets prüfen → **3.** Service-PATCH mit den Werten +im Env → **4.** Ingress/Health. Schlägt der Abgleich fehl, wird **vor** dem Service-Eingriff abgebrochen. Dry-Run liest nur und zeigt „würde anlegen/anpassen“. +Zusätzlich manuell: „Gegen Login prüfen“, „Jetzt abgleichen“, „Aus Login übernehmen“ (Freigaben), „Secret rotieren“, „OIDC-Client im Login löschen“ (Gefahrenbereich). +Das Secret ist nur bei Anlage/Rotation sichtbar; ein Altclient ohne Secret in der Admin-DB wird **nie** automatisch rotiert (er würde laufende Logins unterbrechen) – Secret eintragen oder bewusst rotieren. +Lokal ausprobieren ohne echtes Login: `python scripts/fake_login_server.py` (siehe Datei-Kopf). + +## KI-Agent + +**Bedienung:** Header → „KI-Agent“. Der Chat ersetzt in der Mitte das Formular, die **Live-Vorschau bleibt rechts** und zeigt den Arbeitsstand des Agenten *während er arbeitet* +(„Agent-Stand (live)“, rendert nach jeder Änderung neu), danach den „Agent-Vorschlag“. „In Entwurf übernehmen“ schreibt ihn in den Entwurf, „Verwerfen“ lässt alles unverändert. +**Key-Suche:** geteiltes Secret (Einstellungen) → Env/`.env` `CORTECS_API_KEY` → Key des Tenants. Der Chat zeigt, woher der Key kommt, bzw. „Demo-Modus – kein Key gefunden“. + +Cortecs bietet neben Chat-Completions auch das **Anthropic-Messages-Protokoll** (`/v1/messages`) – der Agent nutzt standardmäßig dieses +(Einstellung `agent_api`), mit Fallback auf Chat-Completions. Modell: Einstellung `agent_model` (Standard `claude-4-6-sonnet`, wie das Protokoll-Modell; +„Modelle von Cortecs laden“ zeigt die verfügbaren IDs und markiert Tool-Unterstützung). Der Agent arbeitet nur auf einem Entwurfs-Workspace; Namenslisten +sind gesperrt; Secrets kennt er nicht; Veröffentlichen/Deployen kann er nicht. + +## Aufbau + +``` +app/ + main.py FastAPI, Startup (DB anlegen, Seed laden) + models.py Tenant, Version (unveränderlich), Blob (content-addressed), Secret (verschlüsselt), Deployment, AuditLog, ChatMessage + services/ + store.py Entwurf ↔ Version, Diff, Bundle (tar.gz) – Kern der Versionierung + preview.py Live-Vorschau: Subprozess mit Kopie des Cores + Tenant, ohne Secrets (preview_worker.py) + importer.py Import aus ../transkript-platform (tenants/*, Secrets nur als Schlüsselnamen) + deploy_sim.py Deploy-Plan (wie infra/deploy.py) + Simulation ← hier später die echte Mittwald-Logik + agent_mock.py Mock-Agent ← hier später der Cortecs-Loop + seed.py Start-Daten (seed/tenants.seed.json.gz) + routers/ api.py (JSON), pages.py (HTML) +vendor/platform/ Snapshot des Cores (CORE_VERSION) – Vorschau führt genau diesen Stand aus +scripts/ import_tenants.py, verify_import.py, export_seed.py, sync_core.sh +tests/test_flow.py E2E: Import, Vorschau, Entwurf→Veröffentlichen, Validierung, Bundle, Deploy-Sim, Agent +``` + +## Wichtige Abläufe + +```bash +python scripts/import_tenants.py # Tenants (+ Secret-SCHLÜSSEL) aus ../transkript-platform importieren +python scripts/import_tenants.py --with-secrets # zusätzlich Secret-WERTE via `sops -d` lesen und verschlüsselt speichern (Cut-over) +python scripts/verify_import.py # Beweis: DB-Bundle == Git-Ordner (Hash-Vergleich) +python scripts/export_seed.py # DB-Stand als Seed-Datei ins Repo (ohne Secret-Werte) +scripts/sync_core.sh # vendored Core aus transkript-platform aktualisieren +python -m pytest tests # Tests (51) (isoliert: lesen weder .env noch echte Keys, eigene Temp-DB) +``` + +## Bundle-Endpoint (Schnittstelle zum Core) + +`GET /api/tenants//bundle` mit `Authorization: Bearer ` liefert `tenant.json` + alle Dateien +(+ `MANIFEST.json` mit Hashes) – ohne Secrets. Das ist, was ein Tenant-Container später beim Start abholt +(`TENANT_SOURCE=admin`, Phase 1 im Core – noch nicht umgesetzt). + +## Nächste Schritte + +1. Core: `TENANT_SOURCE=git|admin` + Bundle-Loader mit Cache (Phase 1) +2. OIDC-Login der Admin-Plattform über login.onyva.dev +3. Spike: Cortecs-Modell für den Agent (Tool-Calling, Streaming, Bild-Input) → `agent_mock.py` ersetzen +4. Echte Deploys (Mittwald) aus `deploy_sim.py`, Rollout-Abbruch bei Fehler, Health-Check +5. Hook-Code-Freigabe härten (Sandbox ohne Netz), Rollen diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agent/__init__.py b/app/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agent/backends.py b/app/agent/backends.py new file mode 100644 index 0000000..8e5726f --- /dev/null +++ b/app/agent/backends.py @@ -0,0 +1,129 @@ +"""LLM-Backends für den Agenten. Interne Nachrichten im Anthropic-Block-Format: + assistant: [{"type":"text"|"tool_use",...}] · user: str | [{"type":"tool_result","tool_use_id","content"}] +""" +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass, field + + +@dataclass +class Turn: + blocks: list[dict] + usage: dict = field(default_factory=dict) + + +class MessagesBackend: + """Anthropic-Messages-Protokoll – Cortecs bietet es unter https://api.cortecs.ai/v1/messages an.""" + name = "messages" + + def __init__(self, api_key: str, base_url: str, model: str): + import anthropic + root = re.sub(r"/v1/?$", "", base_url.rstrip("/")) # SDK hängt /v1/messages selbst an + self.client = anthropic.Anthropic(api_key=api_key, auth_token=api_key, base_url=root, max_retries=2, timeout=180) + self.model = model + + def step(self, system: str, messages: list[dict], tools: list[dict]) -> Turn: + r = self.client.messages.create( + model=self.model, max_tokens=8000, system=system, messages=messages, + tools=[{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools]) + blocks = [] + for b in r.content: + if b.type == "text": + blocks.append({"type": "text", "text": b.text}) + elif b.type == "tool_use": + blocks.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input}) + return Turn(blocks, {"in": r.usage.input_tokens, "out": r.usage.output_tokens}) + + +class ChatBackend: + """OpenAI-Chat-Completions (Fallback, falls ein Modell nur diesen Weg mit Tools kann).""" + name = "chat" + + def __init__(self, api_key: str, base_url: str, model: str): + from openai import OpenAI + self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=2, timeout=180) + self.model = model + + @staticmethod + def _convert(system: str, messages: list[dict]) -> list[dict]: + out = [{"role": "system", "content": system}] + for m in messages: + c = m["content"] + if isinstance(c, str): + out.append({"role": m["role"], "content": c}) + elif m["role"] == "assistant": + text = "".join(b["text"] for b in c if b["type"] == "text") + calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b["input"])}} + for b in c if b["type"] == "tool_use"] + msg = {"role": "assistant", "content": text or None} + if calls: + msg["tool_calls"] = calls + out.append(msg) + else: + for b in c: + out.append({"role": "tool", "tool_call_id": b["tool_use_id"], "content": b["content"]}) + return out + + def step(self, system, messages, tools) -> Turn: + r = self.client.chat.completions.create( + model=self.model, max_tokens=8000, temperature=0.2, messages=self._convert(system, messages), + tools=[{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}} for t in tools]) + msg = r.choices[0].message + blocks = [] + if msg.content: + blocks.append({"type": "text", "text": msg.content}) + for tc in msg.tool_calls or []: + try: + args = json.loads(tc.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + blocks.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": args}) + u = r.usage + return Turn(blocks, {"in": getattr(u, "prompt_tokens", 0), "out": getattr(u, "completion_tokens", 0)}) + + +HEX = re.compile(r"#[0-9a-fA-F]{6}\b") + + +def _shade(h, f): + h = h.lstrip("#"); c = [max(0, min(255, int(int(h[i:i + 2], 16) * f))) for i in (0, 2, 4)] + return "#%02X%02X%02X" % tuple(c) + + +def _tint(h, a): + h = h.lstrip("#"); c = [int(int(h[i:i + 2], 16) + (255 - int(h[i:i + 2], 16)) * a) for i in (0, 2, 4)] + return "#%02X%02X%02X" % tuple(c) + + +class MockBackend: + """Demo ohne Cortecs-Key: spielt über dieselben Tools zwei einfache Aufgaben durch.""" + name = "mock" + model = "mock" + + def step(self, system, messages, tools) -> Turn: + used = sum(1 for m in messages if m["role"] == "assistant") + user = next(m["content"] for m in messages if m["role"] == "user" and isinstance(m["content"], str)) + low = user.lower() + tid = lambda: "mock_" + uuid.uuid4().hex[:8] # noqa: E731 + call = lambda name, **a: {"type": "tool_use", "id": tid(), "name": name, "input": a} # noqa: E731 + hexes = HEX.findall(user) + if hexes and any(w in low for w in ("farbe", "akzent", "color", "design")): + a = hexes[0].upper() + plan = [[call("set_config", key=f"colors.{k}", value=v) for k, v in + {"accent": a, "accent_dark": _shade(a, .72), "accent_mid": _shade(a, 1.12), + "accent_light": _tint(a, .88), "accent_xlight": _tint(a, .94)}.items()], + [call("validate")]] + final = f"Akzentfarbe auf {a} gesetzt und die abgeleiteten Töne angepasst." + elif "prompt" in low or "regel" in low: + rule = re.sub(r"^(bitte\s+)?(füge\s+)?(dem\s+)?prompt\s*(hinzu)?[:,]?\s*", "", user.strip(), flags=re.I) + plan = [[call("read_core_default", name="prompt.txt")], [call("__append_prompt__", rule=rule)], [call("validate")]] + final = "Zusatzregel im Prompt ergänzt." + else: + return Turn([{"type": "text", "text": "Demo-Modus – es wurde kein Cortecs-Key gefunden (geprüft: Einstellungen → Geteilte Secrets, Env/.env CORTECS_API_KEY, Key des Tenants). " + "Key setzen und die Seite neu laden. Ohne Key verstehe ich nur „Setze die Akzentfarbe auf #RRGGBB“ und „Füge dem Prompt hinzu: …“."}]) + if used < len(plan): + return Turn(plan[used]) + return Turn([{"type": "text", "text": final}]) diff --git a/app/agent/loop.py b/app/agent/loop.py new file mode 100644 index 0000000..9bc1293 --- /dev/null +++ b/app/agent/loop.py @@ -0,0 +1,175 @@ +"""Agent-Loop: Modell ↔ Werkzeuge auf dem Entwurfs-Workspace → Vorschlag zur Freigabe.""" +from __future__ import annotations + +import json +import uuid +from collections import OrderedDict +from copy import deepcopy +from typing import Iterator + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.agent import backends +from app.agent.tools import TOOLS, ToolRunner +from app.agent.workspace import Workspace +from app.models import ChatMessage, Setting, Tenant +from app.services import llm, store + +MAX_TURNS = 14 + +# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:"). Nur im Speicher, höchstens 24. +WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict() + + +def remember(run_id: str, ws: Workspace) -> None: + WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files)) + WORKSPACES.move_to_end(run_id) + while len(WORKSPACES) > 24: + WORKSPACES.popitem(last=False) +DEFAULT_MODEL = "claude-4-6-sonnet" + +SYSTEM = """Du bist der Konfigurations-Agent der Onyscribe-Admin-Plattform. Onyscribe erzeugt aus Gesprächsaufzeichnungen Protokolle +(Transkription → LLM-Protokoll → PDF/Word). Jeder Kunde ist ein „Tenant“; alles, was ihn vom Standard unterscheidet, sind DATEN: +tenant.json (Name, Farben, Kontaktdaten, Flags, STT), prompt.txt, protocol.html/protocol.css (PDF-Layout), optional overrides.py +(Hook-Code) und Zusatzdateien. Der Core-Code der Plattform ist NICHT veränderbar und nicht sichtbar – du arbeitest ausschließlich an Tenant-Daten. + +Arbeitsweise: +- Du arbeitest auf einem ENTWURF im Speicher. Nichts ist live; am Ende prüft ein Mensch deinen Vorschlag und gibt ihn frei. +- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese. +- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht. +- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest. +- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes. +- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an. +- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden. +- Antworte am Ende kurz auf Deutsch (du-Form): was du geändert hast und worauf der Mensch beim Prüfen achten soll. Keine Markdown-Überschriften.""" + + +def _setting(db: Session, key: str, default: str = "") -> str: + s = db.get(Setting, key) + return s.value if s and s.value else default + + +def make_backend(db: Session, tenant_id: str | None = None): + model = _setting(db, "agent_model", DEFAULT_MODEL) + key = llm.api_key(db, tenant_id) + if not key: + return backends.MockBackend() + api = _setting(db, "agent_api", "messages") + cls = backends.MessagesBackend if api == "messages" else backends.ChatBackend + return cls(key, llm.base_url(db), model) + + +def _context(tenant: Tenant, ws: Workspace) -> str: + return (f"Tenant: {tenant.id} („{ws.cfg.get('name', '')}“). Dateien: " + + ", ".join(f['path'] for f in ws.list_files()) + ". Der Entwurf kann bereits unveröffentlichte Änderungen enthalten.") + + +def _history(db: Session, tenant_id: str, limit: int = 8) -> list[dict]: + rows = db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == tenant_id).order_by(ChatMessage.id.desc()).limit(limit)).all() + out = [] + for m in reversed(rows): + if m.content: + out.append({"role": m.role, "content": m.content}) + # Anthropic verlangt Rollenwechsel beginnend mit user + while out and out[0]["role"] != "user": + out.pop(0) + merged = [] + for m in out: + if merged and merged[-1]["role"] == m["role"]: + merged[-1]["content"] += "\n" + m["content"] + else: + merged.append(dict(m)) + return merged + + +def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterator[dict]: + """Generator von Ereignissen: step | text | proposal | usage | error. Schreibt NICHT in den Entwurf.""" + backend = backend or make_backend(db, tenant.id) + ws = Workspace(db, tenant) + run_id = uuid.uuid4().hex[:12] + remember(run_id, ws) + last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True) + runner = ToolRunner(ws) + system = SYSTEM + "\n\n" + _context(tenant, ws) + messages = _history(db, tenant.id) + messages.append({"role": "user", "content": user_message}) + usage = {"in": 0, "out": 0} + final_text = "" + yield {"type": "mode", "backend": backend.name, "model": getattr(backend, "model", ""), "run": run_id} + try: + for _ in range(MAX_TURNS): + turn = backend.step(system, messages, TOOLS) + usage["in"] += turn.usage.get("in", 0) + usage["out"] += turn.usage.get("out", 0) + messages.append({"role": "assistant", "content": turn.blocks}) + calls = [b for b in turn.blocks if b["type"] == "tool_use"] + texts = [b["text"] for b in turn.blocks if b["type"] == "text" and b["text"].strip()] + if texts: + final_text = "\n".join(texts) + if calls: + yield {"type": "thought", "text": final_text} + if not calls: + break + results = [] + for c in calls: + name, args = c["name"], c["input"] + if name == "__append_prompt__": # nur Mock: Standard-Prompt kopieren + Regel anhängen + base = ws.read("prompt.txt") if "prompt.txt" in ws.files else ( + __import__("app.settings", fromlist=["PLATFORM_DIR"]).PLATFORM_DIR / "core" / "defaults" / "prompt.txt").read_text(encoding="utf-8") + ws.write("prompt.txt", base.rstrip() + f"\n\nZusätzliche Regel:\n{args['rule'].strip()}\n") + arg, res = "prompt.txt", "Zusatzregel angehängt" + name = "edit_file" + else: + arg, res = runner.run(name, args) + short = res if len(res) < 220 else res[:220] + " …" + if name == "validate" and not res.startswith("FEHLER"): + try: + v = json.loads(res) + short = "✔ bestanden" + (f" ({len(v['warnings'])} Hinweis(e))" if v.get("warnings") else "") if v.get("ok") else "✘ " + "; ".join(v.get("errors", []))[:200] + except (ValueError, KeyError): + pass + yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")} + sig = json.dumps([ws.cfg, ws.files], sort_keys=True) + if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen + last_sig = sig + remember(run_id, ws) + yield {"type": "changed", "run": run_id} + results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res}) + messages.append({"role": "user", "content": results}) + else: + final_text = (final_text + "\n\n(Abgebrochen: maximale Schrittzahl erreicht.)").strip() + except llm.LLMNotConfigured as exc: + yield {"type": "error", "text": str(exc)} + return + except Exception as exc: # noqa: BLE001 – Provider-/Netzfehler sauber an die UI melden + yield {"type": "error", "text": f"{type(exc).__name__}: {str(exc)[:400]}"} + return + prop = ws.proposal(final_text.split("\n")[0][:140] or "Änderungsvorschlag") + if prop is None and any(True for m in messages if m["role"] == "assistant" and isinstance(m["content"], list) + and any(b.get("name") in ("write_file", "edit_file", "set_config", "delete_file") for b in m["content"] if b["type"] == "tool_use")): + final_text = (final_text + "\n\n(Keine Änderung gegenüber dem aktuellen Entwurf.)").strip() + yield {"type": "text", "text": final_text or "(keine Antwort)"} + yield {"type": "proposal", "proposal": prop} + yield {"type": "usage", "usage": usage} + + +def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None: + """Freigabe: Vorschlag in den Entwurf übernehmen (nur die betroffenen Pfade).""" + if proposal.get("config") is not None: + store.set_config(db, tenant, proposal["config"]) + for path, sha in (proposal.get("files") or {}).items(): + store.set_file(db, tenant, path, store.get_blob(db, sha)) + for path in proposal.get("deleted") or []: + store.delete_file(db, tenant, path) + + +def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]: + """Inhalt, der nach Freigabe im Entwurf stünde (für die Diff-Anzeige).""" + cfg, files = store.content(db, tenant, "draft") + if proposal.get("config") is not None: + cfg = proposal["config"] + files = {**files, **(proposal.get("files") or {})} + for p in proposal.get("deleted") or []: + files.pop(p, None) + return cfg, files diff --git a/app/agent/testrun.py b/app/agent/testrun.py new file mode 100644 index 0000000..5b935d6 --- /dev/null +++ b/app/agent/testrun.py @@ -0,0 +1,24 @@ +"""Prompt-Test: finalen Prompt vom Core bauen lassen (inkl. Hooks), LLM aufrufen.""" +from __future__ import annotations + +from app.services import llm, preview +from app.services.samples import SAMPLE_TRANSCRIPT, ROTARY_TRANSCRIPT + + +def user_content(transcript: str, terminart="", projekt="", kunde="", title="Beispiel") -> str: + return (f"Termin-Art: {terminart or '(aus Gespräch erkennen)'}\nProjektname: {projekt or '(keiner)'}\n" + f"Kunde / Unternehmen: {kunde or '(aus Gespräch erkennen)'}\nAnsprechpartner Kunde: (aus Gespräch erkennen)\n" + f"Meeting-Titel: {title}\n\nGesprächsprotokoll:\n{transcript}") + + +def default_transcript(tenant_id: str) -> str: + return ROTARY_TRANSCRIPT if tenant_id.startswith("rotary") else SAMPLE_TRANSCRIPT + + +def prompt_test(db, tenant, cfg, files, transcript: str | None, terminart="", projekt="", kunde="") -> dict: + transcript = (transcript or "").strip() or default_transcript(tenant.id) + r = preview.run_content(db, tenant, cfg, files, "prompt", + args={"title": "Prompt-Test", "terminart": terminart, "projekt": projekt, "kunde": kunde}) + out = llm.summarize(db, r["prompt"], user_content(transcript, terminart, projekt, kunde, "Prompt-Test"), tenant_id=tenant.id) + out["prompt_chars"] = r["chars"] + return out diff --git a/app/agent/tools.py b/app/agent/tools.py new file mode 100644 index 0000000..509220f --- /dev/null +++ b/app/agent/tools.py @@ -0,0 +1,110 @@ +"""Werkzeuge des Agenten (neutrale Definition + Ausführung auf dem Workspace).""" +from __future__ import annotations + +import io +import json + +from app.agent.workspace import Workspace, is_personal +from app.services import llm, preview +from app.settings import PLATFORM_DIR + +TOOLS = [ + {"name": "list_files", "description": "Listet alle Dateien des Tenants (inkl. tenant.json) mit Größe und Änderungsstatus.", + "schema": {"type": "object", "properties": {}}}, + {"name": "read_file", "description": "Liest eine Textdatei des Tenants (prompt.txt, protocol.html, protocol.css, overrides.py, tenant.json …). " + "Fehlt eine Datei, gilt der Core-Standard – nutze dann read_core_default.", + "schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}, + {"name": "read_core_default", "description": "Liest eine Standarddatei des Cores als Vorlage: prompt.txt, protocol.html, protocol.css oder hooks.py (Hook-Dokumentation).", + "schema": {"type": "object", "properties": {"name": {"type": "string", "enum": ["prompt.txt", "protocol.html", "protocol.css", "hooks.py"]}}, "required": ["name"]}}, + {"name": "write_file", "description": "Schreibt/ersetzt eine Textdatei des Tenants vollständig (z. B. um eine eigene prompt.txt oder protocol.css anzulegen). Nur im Entwurf – ein Mensch gibt frei.", + "schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, + {"name": "edit_file", "description": "Ersetzt in einer Datei genau eine Stelle: old_str muss exakt 1× vorkommen. Bevorzuge das gegenüber write_file bei kleinen Änderungen.", + "schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_str": {"type": "string"}, "new_str": {"type": "string"}}, "required": ["path", "old_str", "new_str"]}}, + {"name": "delete_file", "description": "Löscht eine Tenant-Datei (z. B. um auf den Core-Standard zurückzufallen).", + "schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}, + {"name": "set_config", "description": "Setzt einen Wert in tenant.json per Punkt-Pfad, z. B. key='colors.accent', value='#0B6E4F' oder key='flags.ui_kontakt_field', value=false.", + "schema": {"type": "object", "properties": {"key": {"type": "string"}, "value": {}}, "required": ["key", "value"]}}, + {"name": "validate", "description": "Prüft den aktuellen Workspace mit dem ECHTEN Core (Web-UI + PDF rendern, Prompt laden). Gibt Fehler/Warnungen zurück. Nach Änderungen immer aufrufen.", + "schema": {"type": "object", "properties": {}}}, + {"name": "render_pdf", "description": "Rendert das Beispielprotokoll als PDF und gibt Seitenzahl und extrahierten Text zurück, damit du Aufbau und Inhalt prüfen kannst.", + "schema": {"type": "object", "properties": {}}}, + {"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.", + "schema": {"type": "object", "properties": {}}}, + {"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.", + "schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}}, +] + + +class ToolRunner: + def __init__(self, ws: Workspace): + self.ws, self.db, self.tenant = ws, ws.db, ws.tenant + self.llm_calls = 0 + + def _preview(self, kind, **kw): + return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw) + + def run(self, name: str, a: dict) -> tuple[str, str]: + """→ (Kurzbeschreibung des Arguments für die Anzeige, Ergebnistext).""" + try: + return getattr(self, f"t_{name}")(a) + except (KeyError, ValueError, PermissionError, SyntaxError, json.JSONDecodeError) as exc: + return a.get("path") or a.get("key") or "", f"FEHLER: {exc}" + except preview.PreviewError as exc: + return "", f"FEHLER (Core-Rendering): {str(exc)[-900:]}" + + def t_list_files(self, a): + rows = [{"path": "tenant.json", "text": True}] + self.ws.list_files() + return "", json.dumps(rows, ensure_ascii=False) + + def t_read_file(self, a): + return a["path"], self.ws.read(a["path"]) + + def t_read_core_default(self, a): + n = a["name"] + p = (PLATFORM_DIR / "core" / ("hooks.py" if n == "hooks.py" else ("defaults" if n == "prompt.txt" else "templates")) / n) \ + if n != "hooks.py" else PLATFORM_DIR / "core" / "hooks.py" + return n, p.read_text(encoding="utf-8")[:60_000] + + def t_write_file(self, a): + self.ws.write(a["path"], a["content"]) + return a["path"], f"geschrieben ({len(a['content'])} Zeichen)" + + def t_edit_file(self, a): + self.ws.edit(a["path"], a["old_str"], a["new_str"]) + return a["path"], "ersetzt" + + def t_delete_file(self, a): + self.ws.delete(a["path"]) + return a["path"], "gelöscht" + + def t_set_config(self, a): + self.ws.set_config_value(a["key"], a["value"]) + return a["key"], f"gesetzt = {json.dumps(a['value'], ensure_ascii=False)}" + + def t_validate(self, a): + r = self._preview("validate") + return "Core-Rendering", json.dumps(r, ensure_ascii=False) + + def t_render_pdf(self, a): + pdf = self._preview("pdf") + from pypdf import PdfReader + rd = PdfReader(io.BytesIO(pdf)) + text = "\n".join((pg.extract_text() or "") for pg in rd.pages) + return "Beispielprotokoll", f"{len(rd.pages)} Seite(n)\n---\n{text[:3500]}" + + def t_show_final_prompt(self, a): + r = self._preview("prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""}) + p = r["prompt"] + # Namenslisten (personenbezogen) nicht an das Agenten-Modell geben + marker = "Namensregister" + if marker in p: + p = p[:p.index(marker)] + "[Namensregister mit personenbezogenen Daten ausgeblendet]" + return f"{r['chars']} Zeichen", p[:40_000] + + def t_run_prompt_test(self, a): + if self.llm_calls >= 1: + return "", "FEHLER: Pro Antwort ist nur ein Prompt-Test erlaubt." + self.llm_calls += 1 + from app.agent.testrun import prompt_test + res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript")) + return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}" diff --git a/app/agent/workspace.py b/app/agent/workspace.py new file mode 100644 index 0000000..5b56d3c --- /dev/null +++ b/app/agent/workspace.py @@ -0,0 +1,106 @@ +"""Entwurfs-Workspace des Agenten: Kopie des Tenant-Entwurfs im Speicher. + +Der Agent ändert NUR diese Kopie. Daraus entsteht ein Vorschlag (Diff), den der Mensch freigibt. +Datenschutz: Dateien mit personenbezogenem Inhalt (Namenslisten) sind für den Agenten nicht lesbar. +""" +from __future__ import annotations + +import json +from copy import deepcopy + +from sqlalchemy.orm import Session + +from app.models import Tenant +from app.services import store + +PERSONAL_PREFIXES = ("namensliste", "mitglieder", "members", "personen") +READ_LIMIT = 60_000 +PROTECTED = {"tenant.json"} + + +def is_personal(path: str) -> bool: + name = path.rsplit("/", 1)[-1].lower() + return name.startswith(PERSONAL_PREFIXES) + + +class Workspace: + def __init__(self, db: Session, tenant: Tenant): + self.db, self.tenant = db, tenant + cfg, files = store.content(db, tenant, "draft") + self.base_cfg, self.base_files = deepcopy(cfg), dict(files) + self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha} + + # ── Lesen ── + def list_files(self) -> list[dict]: + out = [] + for p, sha in sorted(self.files.items()): + b = self.db.get(__import__("app.models", fromlist=["Blob"]).Blob, sha) + out.append({"path": p, "bytes": b.size, "text": store.is_text_path(p), "personal_data": is_personal(p), + "changed": self.base_files.get(p) != sha}) + return out + + def read(self, path: str) -> str: + if path in ("tenant.json",): + return json.dumps(self.cfg, indent=2, ensure_ascii=False) + if path not in self.files: + raise KeyError(f"Datei nicht im Tenant: {path}") + if is_personal(path): + n = len(store.get_blob(self.db, self.files[path]).decode("utf-8", "replace").splitlines()) + return f"[Datei enthält personenbezogene Daten und ist für den Agenten gesperrt – {n} Zeilen. Inhalt nicht verfügbar.]" + if not store.is_text_path(path): + return "[Binärdatei]" + text = store.get_blob(self.db, self.files[path]).decode("utf-8", "replace") + return text[:READ_LIMIT] + ("\n[… gekürzt]" if len(text) > READ_LIMIT else "") + + # ── Schreiben ── + def write(self, path: str, content: str) -> None: + path = store.clean_path(path) + if is_personal(path): + raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht ändern.") + if path.endswith(".py"): + compile(content, path, "exec") # Syntaxfehler früh melden + self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path) + + def edit(self, path: str, old: str, new: str) -> None: + cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False) + if cur.count(old) != 1: + raise ValueError(f"old_str muss genau 1× vorkommen (gefunden: {cur.count(old)}×).") + if path == "tenant.json": + self.set_config_json(cur.replace(old, new)) + else: + self.write(path, cur.replace(old, new)) + + def delete(self, path: str) -> None: + if is_personal(path): + raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht löschen.") + self.files.pop(path, None) + + def set_config_json(self, text: str) -> None: + cfg = json.loads(text) + cfg.pop("deploy", None) + cfg["id"] = self.tenant.id + self.cfg = cfg + + def set_config_value(self, key_path: str, value) -> None: + cur = self.cfg + keys = key_path.split(".") + for k in keys[:-1]: + cur = cur.setdefault(k, {}) + cur[keys[-1]] = value + + # ── Ergebnis ── + def changed(self) -> bool: + return self.cfg != self.base_cfg or self.files != self.base_files + + def proposal(self, summary: str) -> dict | None: + if not self.changed(): + return None + files = {p: s for p, s in self.files.items() if self.base_files.get(p) != s} + deleted = [p for p in self.base_files if p not in self.files] + items = [] + if self.cfg != self.base_cfg: + items.append({"path": "tenant.json", "kind": "geändert"}) + items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files] + items += [{"path": p, "kind": "gelöscht"} for p in deleted] + return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None, + "files": files, "deleted": deleted, "items": items} diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..00f4764 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,162 @@ +"""OIDC-Login der Admin-Plattform über login.onyva.dev (Authorization-Code + PKCE, wie im Core). + +Aktiv mit AUTH_ENABLED=true. Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, +OIDC_LOGOUT_REDIRECT_URI, APP_COOKIE_SECURE, sowie die Zugriffsregel (mind. eine): + ADMIN_ALLOWED_EMAILS=a@x.de,b@y.de und/oder ADMIN_REQUIRED_ROLE=admin (gegen das Claim `app_roles`) +Ohne Zugriffsregel wird jeder Login abgelehnt (Default-Deny) – die Admin-Plattform hält alle Secrets. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import re +import secrets +import time +from urllib.parse import urlencode + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.settings import AUTH_ENABLED, SECRET_KEY + +COOKIE, STATE_COOKIE, STATE_TTL, SESSION_TTL = "oa_session", "oa_oauth", 600, 12 * 3600 +router = APIRouter() + +PUBLIC = re.compile(r"^/(healthz|auth/.*|static/.*|api/tenants/[^/]+/bundle|api/core/releases)$") # bundle: Token · releases: CI-Token (POST), GET prüft die Session selbst +DEV_USER = {"sub": "dev", "email": "dev@local", "name": "Dev (Auth aus)", "app_roles": ["admin"]} + + +def cfg() -> dict: + g = lambda k, d="": os.getenv(k, d).strip() # noqa: E731 + return { + "issuer": g("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/"), "client_id": g("OIDC_CLIENT_ID"), + "client_secret": g("OIDC_CLIENT_SECRET"), "redirect": g("OIDC_REDIRECT_URI", "http://localhost:8300/auth/callback"), + "logout_redirect": g("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8300/"), + "secure": g("APP_COOKIE_SECURE", "false").lower() == "true", + "emails": {e.strip().lower() for e in g("ADMIN_ALLOWED_EMAILS").split(",") if e.strip()}, + "role": g("ADMIN_REQUIRED_ROLE"), + } + + +def _sign(data: dict) -> str: + payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=") + return f"{payload}.{hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()}" + + +def _verify(token: str) -> dict | None: + try: + payload, _, sig = token.partition(".") + if not sig or not hmac.compare_digest(sig, hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()): + return None + data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + return data if int(data.get("exp", 0)) > time.time() else None + except Exception: # noqa: BLE001 + return None + + +def session_user(request: Request) -> dict | None: + if not AUTH_ENABLED: + return DEV_USER + tok = request.cookies.get(COOKIE) + return _verify(tok) if tok else None + + +def is_allowed(user: dict) -> bool: + c = cfg() + if not c["emails"] and not c["role"]: + return False + if user.get("email", "").lower() in c["emails"]: + return True + return bool(c["role"]) and c["role"] in (user.get("app_roles") or []) + + +async def middleware(request: Request, call_next): + user = session_user(request) + request.state.user = user or {} + if user is None and not PUBLIC.match(request.url.path): + if request.url.path.startswith(("/api/", "/preview/")): + return JSONResponse({"detail": "Nicht angemeldet"}, status_code=401) + return RedirectResponse("/auth/login", status_code=302) + return await call_next(request) + + +def actor_name(request: Request) -> str: + u = getattr(request.state, "user", None) or {} + return u.get("email") or u.get("name") or "unbekannt" + + +# ── Routen ────────────────────────────────────────────────────────────────── + +def exchange_code(code: str, verifier: str) -> dict: + """Token-Tausch beim IdP (in Tests ersetzbar).""" + c = cfg() + r = httpx.post(f"{c['issuer']}/oauth/token", timeout=20, data={ + "grant_type": "authorization_code", "code": code, "redirect_uri": c["redirect"], "client_id": c["client_id"], + "client_secret": c["client_secret"], "code_verifier": verifier}) + if r.status_code >= 400: + raise HTTPException(502, f"Token-Tausch fehlgeschlagen ({r.status_code})") + return r.json() + + +def _claims(id_token: str) -> dict: + try: + p = id_token.split(".")[1] + return json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))) + except Exception: # noqa: BLE001 + return {} + + +@router.get("/auth/login") +def login(): + c = cfg() + if not AUTH_ENABLED: + return RedirectResponse("/", 302) + if not c["client_id"] or not c["client_secret"]: + raise HTTPException(503, "OIDC nicht konfiguriert (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).") + state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + q = urlencode({"response_type": "code", "client_id": c["client_id"], "redirect_uri": c["redirect"], "scope": "openid profile email", + "state": state, "code_challenge": challenge, "code_challenge_method": "S256"}) + resp = RedirectResponse(f"{c['issuer']}/oauth/authorize?{q}", 302) + resp.set_cookie(STATE_COOKIE, _sign({"state": state, "cv": verifier, "exp": int(time.time()) + STATE_TTL}), + max_age=STATE_TTL, httponly=True, secure=c["secure"], samesite="lax") + return resp + + +@router.get("/auth/callback") +def callback(request: Request, code: str = "", state: str = "", error: str = ""): + c = cfg() + if error: + raise HTTPException(400, f"Login abgebrochen: {error}") + pending = _verify(request.cookies.get(STATE_COOKIE, "") or "") + if not pending or not hmac.compare_digest(str(pending.get("state", "")), state) or not code: + return RedirectResponse("/auth/login", 303) # abgelaufen/Reload → neu starten + tokens = exchange_code(code, pending["cv"]) + cl = _claims(tokens.get("id_token", "")) + user = {"sub": cl.get("sub", ""), "email": cl.get("email", ""), "name": cl.get("name", ""), "app_roles": cl.get("app_roles", [])} + if not is_allowed(user): + return HTMLResponse(f"

Kein Zugriff

{user['email'] or 'Dieses Konto'} " + "ist für die Onyscribe-Admin-Plattform nicht freigeschaltet.

Abmelden

", status_code=403) + resp = RedirectResponse("/", 303) + resp.set_cookie(COOKIE, _sign({**user, "exp": int(time.time()) + SESSION_TTL}), max_age=SESSION_TTL, httponly=True, + secure=c["secure"], samesite="lax") + resp.delete_cookie(STATE_COOKIE) + return resp + + +@router.get("/auth/logout") +def logout(): + c = cfg() + url = f"{c['issuer']}/logout?{urlencode({'post_logout_redirect_uri': c['logout_redirect']})}" if AUTH_ENABLED else "/" + resp = RedirectResponse(url, 302) + resp.delete_cookie(COOKIE) + return resp + + +@router.get("/auth/me") +def me(request: Request): + return {"user": getattr(request.state, "user", {}), "auth_enabled": AUTH_ENABLED} diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..47e3dc4 --- /dev/null +++ b/app/db.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.settings import DATABASE_URL + +_kwargs = {"connect_args": {"check_same_thread": False}} if DATABASE_URL.startswith("sqlite") else {"pool_pre_ping": True} +engine = create_engine(DATABASE_URL, **_kwargs) +SessionLocal = sessionmaker(bind=engine, expire_on_commit=False, autoflush=True) + + +class Base(DeclarativeBase): + pass + + +def init_db() -> None: + from app import models # noqa: F401 + Base.metadata.create_all(engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@contextmanager +def session(): + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/app/deps.py b/app/deps.py new file mode 100644 index 0000000..214a229 --- /dev/null +++ b/app/deps.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.auth import actor_name +from app.db import get_db +from app.models import Tenant + + +def actor(request: Request) -> str: + return actor_name(request) + + +def tenant_or_404(tenant_id: str, db: Session = Depends(get_db)) -> Tenant: + t = db.get(Tenant, tenant_id) + if t is None: + raise HTTPException(404, f"Tenant „{tenant_id}“ nicht gefunden") + return t diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2afcecc --- /dev/null +++ b/app/main.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.db import init_db, session +from app import auth +from app.routers import api, pages +from sqlalchemy import func, select + +from app.models import Tenant +from app.services import seed +from app.services.importer import seed_settings +from app.settings import ROOT + +SEED_FILE = ROOT / "seed" / "tenants.seed.json.gz" + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + with session() as db: + # Frische Instanz → mit den importierten Tenants starten (ohne Secret-Werte) + if SEED_FILE.exists() and not db.scalar(select(func.count()).select_from(Tenant)): + print(f"[seed] {seed.load(db, SEED_FILE)} Tenants aus {SEED_FILE.name} geladen") + seed_settings(db) + yield + + +app = FastAPI(title="Onyscribe Admin", lifespan=lifespan) +app.middleware("http")(auth.middleware) +app.include_router(auth.router) +app.mount("/static", StaticFiles(directory=str(ROOT / "app" / "static")), name="static") +app.include_router(api.router) +app.include_router(pages.router) + + +@app.get("/healthz") +def healthz(): + return {"ok": True} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..6d31de9 --- /dev/null +++ b/app/models.py @@ -0,0 +1,149 @@ +"""Datenmodell. Inhalte sind content-addressed (Blob) und versioniert (Version).""" +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +class Tenant(Base): + __tablename__ = "tenants" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) # Slug, == TENANT-Env im Container + name: Mapped[str] = mapped_column(String(200)) + status: Mapped[str] = mapped_column(String(20), default="active") # active | paused | archived + source: Mapped[str] = mapped_column(String(10), default="git") # TENANT_SOURCE im Container: git | admin + subdomain: Mapped[str] = mapped_column(String(100), default="") + custom_domain: Mapped[str] = mapped_column(String(200), default="") + domain_status: Mapped[str] = mapped_column(String(20), default="none") # none | pending | verified + cpu: Mapped[float] = mapped_column(Float, default=0.5) + memory: Mapped[str] = mapped_column(String(20), default="512M") + current_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + deployed_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + deployed_image: Mapped[str] = mapped_column(String(100), default="") + deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + draft: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"config": {...}, "files": {path: sha}} + preview_sample: Mapped[str | None] = mapped_column(Text, nullable=True) + oidc: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Soll-Zustand des OIDC-Clients + letzter Abgleich + core_pin: Mapped[str | None] = mapped_column(String(100), nullable=True) # fester Core-Tag (nimmt an Rollouts nicht teil) + render_baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {version, core, fp:{…}} Referenz für die Kompatibilitätsprüfung + notes: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + versions: Mapped[list["Version"]] = relationship(back_populates="tenant", order_by="Version.number.desc()") + + +class Blob(Base): + """Dateiinhalt, adressiert über SHA-256. Später: große Binärdateien nach S3 auslagern.""" + __tablename__ = "blobs" + + sha: Mapped[str] = mapped_column(String(64), primary_key=True) + content: Mapped[bytes] = mapped_column(LargeBinary) + size: Mapped[int] = mapped_column(Integer) + is_text: Mapped[bool] = mapped_column(Boolean, default=True) + + +class Version(Base): + """Unveränderlicher Snapshot eines Tenants.""" + __tablename__ = "versions" + __table_args__ = (UniqueConstraint("tenant_id", "number"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + number: Mapped[int] = mapped_column(Integer) + message: Mapped[str] = mapped_column(String(500), default="") + author: Mapped[str] = mapped_column(String(100), default="") # "ruben" | "agent:" | "import" + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + config: Mapped[dict] = mapped_column(JSON) # Inhalt der tenant.json (ohne deploy-Block) + files: Mapped[dict] = mapped_column(JSON) # {pfad: sha} + + tenant: Mapped[Tenant] = relationship(back_populates="versions") + + +class Secret(Base): + """Verschlüsselter Secret-Wert. tenant_id NULL = geteilt für alle Tenants.""" + __tablename__ = "secrets" + __table_args__ = (UniqueConstraint("tenant_id", "key"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + key: Mapped[str] = mapped_column(String(100)) + value_enc: Mapped[str | None] = mapped_column(Text, nullable=True) + source: Mapped[str] = mapped_column(String(30), default="manual") # manual | sops-import + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now) + + +class Setting(Base): + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[str] = mapped_column(Text, default="") + + +class Deployment(Base): + __tablename__ = "deployments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + batch_id: Mapped[str] = mapped_column(String(40), default="", index=True) + tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + kind: Mapped[str] = mapped_column(String(20), default="deploy") # deploy | dry-run | offboard + version: Mapped[int | None] = mapped_column(Integer, nullable=True) + image_tag: Mapped[str] = mapped_column(String(100), default="") + status: Mapped[str] = mapped_column(String(20), default="running") # running | success | failed + simulated: Mapped[bool] = mapped_column(Boolean, default=True) + log: Mapped[str] = mapped_column(Text, default="") + actor: Mapped[str] = mapped_column(String(100), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True) + actor: Mapped[str] = mapped_column(String(100), default="") + action: Mapped[str] = mapped_column(String(60)) + tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + detail: Mapped[str] = mapped_column(Text, default="") + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + role: Mapped[str] = mapped_column(String(20)) # user | assistant + content: Mapped[str] = mapped_column(Text, default="") + steps: Mapped[list | None] = mapped_column(JSON, nullable=True) # Tool-Aufrufe (Anzeige) + proposal: Mapped[dict | None] = mapped_column(JSON, nullable=True) # vorgeschlagene Änderung + proposal_status: Mapped[str] = mapped_column(String(20), default="") # "" | open | applied | rejected + usage: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"in":…, "out":…, "backend":…, "model":…} + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + +class CoreRelease(Base): + """Vom Core-CI gemeldetes Release (Image-Tag). Wird geprüft, freigegeben und ausgerollt.""" + __tablename__ = "core_releases" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + sha: Mapped[str] = mapped_column(String(64), unique=True, index=True) + tag: Mapped[str] = mapped_column(String(100)) + ref: Mapped[str] = mapped_column(String(100), default="") + image: Mapped[str] = mapped_column(String(300), default="") + message: Mapped[str] = mapped_column(String(500), default="") + announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + # new → checked → approved → rolling → active (rejected / failed / superseded) + status: Mapped[str] = mapped_column(String(20), default="new") + compat: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {checked_at, core_sha, tenants:{id:{status,…}}, summary:{…}} + approved_by: Mapped[str] = mapped_column(String(100), default="") + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + previous_tag: Mapped[str] = mapped_column(String(100), default="") # aktiver Tag vor dem Rollout (für Rollback) + rollout_batch: Mapped[str] = mapped_column(String(40), default="") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..6f00559 --- /dev/null +++ b/app/routers/api.py @@ -0,0 +1,770 @@ +from __future__ import annotations + +import json +import re +import socket + +import hmac +import os + +from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Request, Response, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.agent import loop as agent_loop +from app.agent.testrun import default_transcript, prompt_test +from app.agent.workspace import is_personal +from app.db import get_db, session +from app.deps import actor, tenant_or_404 +from app.models import Blob, ChatMessage, CoreRelease, Deployment, Secret, Setting, Tenant, Version +from app.services import contract, crypto, defaults, deploy, guard, llm, onyva_login, preflight, preview, releases, status as status_svc, store, tenants as tsvc + +router = APIRouter(prefix="/api") + + +def _iso(d): + return d.isoformat() if d else None + + +# ── Tenants ───────────────────────────────────────────────────────────────── + +class NewTenant(BaseModel): + id: str + name: str + clone_from: str | None = None + + +@router.post("/tenants") +def create_tenant(body: NewTenant, db: Session = Depends(get_db), who: str = Depends(actor)): + try: + t = tsvc.create(db, body.id, body.name, body.clone_from or None, who) + except ValueError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"id": t.id} + + +@router.get("/tenants/{tenant_id}/state") +def state(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + cfg, files = store.content(db, t, "draft") + meta = {p: {"size": db.get(Blob, sha).size, "text": store.is_text_path(p), "personal": is_personal(p)} for p, sha in files.items()} + return { + "id": t.id, "name": t.name, "status": t.status, "source": t.source, "notes": t.notes, + "has_draft": bool(t.draft), "current_version": t.current_version, "deployed_version": t.deployed_version, + "deployed_image": t.deployed_image, "config": cfg, "files": meta, + "host": deploy.host_for(db, t), "subdomain": t.subdomain, "custom_domain": t.custom_domain, + "domain_status": t.domain_status, "cpu": t.cpu, "memory": t.memory, + "base_domain": deploy.setting(db, "base_domain", "example.de"), + "color_labels": defaults.COLOR_LABELS, "core_version": preview.core_version(), + "sample": preview.sample_for(t), "has_custom_sample": bool(t.preview_sample), + "sample_transcript": default_transcript(t.id), "llm_ready": llm.configured(db, t.id), + "core": {"tag": deploy.effective_tag(db, t), "pin": t.core_pin, "sha": contract.sha()[:10], "contract_ok": contract.supported()}, + "schema_problems": contract.check_tenant_json(cfg), + } + + +@router.put("/tenants/{tenant_id}/meta") +def put_meta(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + if "cpu" in body: + cpu = float(body["cpu"]) + if not 0.1 <= cpu <= 8: + raise HTTPException(400, "CPU zwischen 0,1 und 8") + t.cpu = cpu + if "memory" in body: + if not re.fullmatch(r"\d{1,5}[MG]", str(body["memory"])): + raise HTTPException(400, "RAM z. B. 512M oder 1G") + t.memory = str(body["memory"]) + if "status" in body: + if body["status"] not in ("active", "paused", "archived"): + raise HTTPException(400, "status: active | paused | archived") + t.status = body["status"] + if "notes" in body: + t.notes = str(body["notes"])[:5000] + store.audit(db, who, "tenant.meta", t.id, ", ".join(body)) + db.commit() + return {"ok": True} + + +@router.delete("/tenants/{tenant_id}") +def delete_tenant(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + """Löscht nur die Datenbank-Einträge. Infrastruktur (Container/Ingress) vorher per Offboard entfernen.""" + if t.status != "archived": + raise HTTPException(400, "Nur archivierte Tenants können gelöscht werden.") + if confirm != t.id: + raise HTTPException(400, "Bestätigung: ID des Tenants angeben.") + for model in (Secret, Version, ChatMessage): + for row in db.scalars(select(model).where(model.tenant_id == t.id)): + db.delete(row) + store.audit(db, who, "tenant.delete", t.id) + db.delete(t) + db.commit() + return {"ok": True} + + +@router.put("/tenants/{tenant_id}/config") +def put_config(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + store.set_config(db, t, body) + db.commit() + return {"has_draft": bool(t.draft)} + + +@router.get("/tenants/{tenant_id}/files/{path:path}") +def get_file(path: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + try: + _, files = preview.resolve_content(db, t, source) + except preview.SourceGone: + _, files = store.content(db, t, "draft") + if path not in files: + from app.settings import PLATFORM_DIR + for base in ("defaults", "templates"): + p = PLATFORM_DIR / "core" / base / path + if p.exists() and p.is_file(): + return Response(p.read_bytes(), headers={"X-Default": "1"}, media_type="text/plain; charset=utf-8") + raise HTTPException(404, "Datei nicht vorhanden") + data = store.get_blob(db, files[path]) + mt = "image/svg+xml" if path.endswith(".svg") else "image/png" if path.endswith(".png") else \ + "application/pdf" if path.endswith(".pdf") else "text/plain; charset=utf-8" + return Response(data, media_type=mt) + + +@router.put("/tenants/{tenant_id}/files/{path:path}") +def put_file(path: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + try: + if path.endswith(".py"): + compile(body.get("content", ""), path, "exec") + store.set_file(db, t, path, body.get("content", "").encode("utf-8")) + except SyntaxError as exc: + raise HTTPException(400, f"Python-Syntaxfehler in Zeile {exc.lineno}: {exc.msg}") + except ValueError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"has_draft": bool(t.draft)} + + +@router.post("/tenants/{tenant_id}/upload") +async def upload(file: UploadFile = File(...), path: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + data = await file.read() + if len(data) > 8 * 1024 * 1024: + raise HTTPException(413, "Datei größer als 8 MB") + try: + store.set_file(db, t, path or file.filename, data) + except ValueError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"ok": True} + + +@router.delete("/tenants/{tenant_id}/files/{path:path}") +def del_file(path: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + store.delete_file(db, t, path) + db.commit() + return {"has_draft": bool(t.draft)} + + +@router.put("/tenants/{tenant_id}/sample") +def put_sample(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + t.preview_sample = body.get("sample") or None + db.commit() + return {"ok": True} + + +@router.post("/tenants/{tenant_id}/draft/discard") +def discard(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + store.discard_draft(db, t) + store.audit(db, who, "draft.discard", t.id) + db.commit() + return {"ok": True} + + +@router.get("/tenants/{tenant_id}/diff") +def diff(a: str = "current", b: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + return store.diff_summary(db, t, a, b, with_text=True) + + +class PublishBody(BaseModel): + message: str = "" + + +@router.post("/tenants/{tenant_id}/publish") +def publish(body: PublishBody, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + try: + v = store.publish(db, t, body.message, who) + except ValueError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"version": v.number} + + +@router.post("/tenants/{tenant_id}/validate") +def validate(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "draft"): + try: + res = dict(preview.run(db, t, source, "validate")) + except (preview.PreviewError, preview.SourceGone) as exc: + return {"ok": False, "errors": [str(exc)], "warnings": []} + cfg, _ = preview.resolve_content(db, t, source) + res["warnings"] = [*res.get("warnings", []), *(f"Schema: {p}" for p in contract.check_tenant_json(cfg))] # unbekannte Optionen/Typfehler (Tippfehler) + return res + + +@router.get("/tenants/{tenant_id}/versions") +def versions(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + out = [] + for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number.desc())): + out.append({"number": v.number, "message": v.message, "author": v.author, "created_at": _iso(v.created_at), + "files": len(v.files), "current": v.number == t.current_version, "deployed": v.number == t.deployed_version}) + return out + + +@router.post("/tenants/{tenant_id}/versions/{number}/restore") +def restore(number: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + try: + store.restore_as_draft(db, t, number) + except KeyError: + raise HTTPException(404, "Version nicht gefunden") + store.audit(db, who, "version.restore_draft", t.id, f"v{number}") + db.commit() + return {"has_draft": bool(t.draft)} + + +# ── Prompt-Test & LLM ─────────────────────────────────────────────────────── + +@router.post("/tenants/{tenant_id}/prompt-test") +def prompt_test_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + """Echter LLM-Lauf mit dem (Entwurfs-)Prompt auf einem Beispieltranskript. Kostet Cortecs-Guthaben.""" + cfg, files = store.content(db, t, body.get("source", "draft")) + try: + r = prompt_test(db, t, cfg, files, body.get("transcript"), body.get("terminart", ""), body.get("projekt", ""), body.get("kunde", "")) + except llm.LLMNotConfigured as exc: + raise HTTPException(400, str(exc)) + except preview.PreviewError as exc: + raise HTTPException(422, f"Core-Rendering fehlgeschlagen: {str(exc)[-600:]}") + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"LLM-Aufruf fehlgeschlagen: {type(exc).__name__}: {str(exc)[:300]}") + store.audit(db, who, "prompt.test", t.id, f"{r['model']} {r['input_tokens']}→{r['output_tokens']} Tokens") + db.commit() + return r + + +@router.get("/tenants/{tenant_id}/final-prompt") +def final_prompt(source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + """Finaler System-Prompt, wie der Core ihn an das LLM schickt (Platzhalter ersetzt, Hooks angewandt).""" + try: + return preview.run(db, t, source, "prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""}) + except preview.PreviewError as exc: + raise HTTPException(422, str(exc)[-800:]) + + +@router.get("/llm/status") +def llm_status(tenant: str = "", db: Session = Depends(get_db)): + key, source, error = llm.key_info(db, tenant or None) + return {"configured": bool(key), "source": source, "error": error, "model": deploy.setting(db, "agent_model", "claude-4-6-sonnet"), + "api": deploy.setting(db, "agent_api", "messages"), "base_url": llm.base_url(db)} + + +@router.get("/llm/models") +def llm_models(db: Session = Depends(get_db)): + try: + return llm.list_models(db) + except llm.LLMNotConfigured as exc: + raise HTTPException(400, str(exc)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"Modellliste nicht abrufbar: {type(exc).__name__}: {str(exc)[:200]}") + + +# ── Domain, Secrets, Quelle ───────────────────────────────────────────────── + +@router.put("/tenants/{tenant_id}/domain") +def put_domain(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + sub = (body.get("subdomain") or "").strip().lower() + custom = (body.get("custom_domain") or "").strip().lower() + if sub and not re.fullmatch(r"[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?", sub): + raise HTTPException(400, "Subdomain: a-z, 0-9, Bindestrich") + if custom and not re.fullmatch(r"([a-z0-9-]+\.)+[a-z]{2,}", custom): + raise HTTPException(400, "Eigene Domain ungültig") + t.subdomain = sub or t.subdomain + if custom != t.custom_domain: + t.custom_domain = custom + t.domain_status = "pending" if custom else ("verified" if t.source == "git" else "none") + store.audit(db, who, "domain.set", t.id, f"{t.subdomain} / {custom or '–'}") + db.commit() + return {"host": deploy.host_for(db, t), "domain_status": t.domain_status} + + +@router.post("/tenants/{tenant_id}/domain/check") +def check_domain(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + host = t.custom_domain or deploy.host_for(db, t) + try: + ips = sorted({ai[4][0] for ai in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)}) + except OSError: + ips = [] + if ips: + t.domain_status = "verified" + msg = f"{host} löst auf {', '.join(ips)} auf. (Die Ziel-Prüfung gegen das Mittwald-Projekt folgt mit dem Live-Deploy.)" + else: + t.domain_status = "pending" if t.custom_domain else t.domain_status + msg = f"{host} löst noch nicht auf. DNS-Einträge prüfen; Änderungen können bis zu 24 h dauern." + db.commit() + return {"status": t.domain_status, "message": msg} + + +# ── OIDC-Client (Developer-API von login.onyva.dev) ───────────────────────── + +def _oidc_view(db: Session, t: Tenant) -> dict: + cfg = onyva_login.config_of(t) + cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID")) + sec = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_SECRET")) + base = deploy.setting(db, "base_domain", "example.de") + return {**cfg, "client_id": crypto.decrypt(cid.value_enc) if cid and cid.value_enc else None, "secret_set": bool(sec and sec.value_enc), + "redirect_uris": onyva_login.desired_redirects(base, t), "api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), + "api_base": onyva_login.base_url(db)} + + +@router.get("/tenants/{tenant_id}/oidc") +def oidc_get(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + return _oidc_view(db, t) + + +@router.put("/tenants/{tenant_id}/oidc") +def oidc_put(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + try: + new = onyva_login.validate_config({**onyva_login.config_of(t), **body}) + except ValueError as exc: + raise HTTPException(400, str(exc)) + new["last_sync"] = onyva_login.config_of(t)["last_sync"] + t.oidc = new + store.audit(db, who, "oidc.config", t.id, f"mode={new['mode']} auth={new['auth_mode']} members={len(new['members'])}") + db.commit() + return _oidc_view(db, t) + + +@router.post("/tenants/{tenant_id}/oidc/sync") +def oidc_sync(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)): + """apply=false: nur lesen und Plan zeigen. apply=true: schreiben (nur mit OIDC_SYNC_LIVE/DEPLOY_LIVE).""" + apply = bool(body.get("apply")) + if apply and not guard.live_allowed(t.id): + raise HTTPException(403, guard.denial(t.id)) + if apply and not onyva_login.writes_allowed(): + raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt (Env OIDC_SYNC_LIVE oder DEPLOY_LIVE ≠ true). Plan (Dry-Run) ist möglich.") + lines: list[str] = [] + rep = onyva_login.sync_tenant(t.id, apply=apply, log=lines.append, rotate=bool(body.get("rotate")), actor=who) + return {**rep, "log": lines} + + +@router.post("/tenants/{tenant_id}/oidc/import-members") +def oidc_import_members(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + """Liest die aktuellen Freigaben aus dem Login und übernimmt sie als Soll-Liste (nur lesender API-Zugriff).""" + client = onyva_login.client_from_env(db) + cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID")) + if client is None: + raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.") + if not (cid and cid.value_enc): + raise HTTPException(400, "Tenant hat noch keine OIDC_CLIENT_ID.") + try: + ms = client.memberships(crypto.decrypt(cid.value_enc)) + except onyva_login.OnyvaError as exc: + raise HTTPException(502, str(exc)) + cfg = onyva_login.config_of(t) + cfg["members"] = [{"email": m["email"], "role": m.get("role", "viewer")} for m in ms] + t.oidc = {**cfg, "last_sync": cfg["last_sync"]} + store.audit(db, who, "oidc.import_members", t.id, f"{len(ms)} Freigaben") + db.commit() + return _oidc_view(db, t) + + +@router.delete("/tenants/{tenant_id}/oidc/app") +def oidc_delete_app(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + """Löscht den OIDC-Client im Login (nicht umkehrbar, beendet alle Logins dieser App).""" + if confirm != t.id: + raise HTTPException(400, "Bestätigung: ID des Tenants angeben.") + if not onyva_login.writes_allowed(): + raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt.") + if not guard.live_allowed(t.id): + raise HTTPException(403, guard.denial(t.id)) + client = onyva_login.client_from_env(db) + cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID")) + if client is None or not (cid and cid.value_enc): + raise HTTPException(400, "Kein API-Key oder keine Client-ID vorhanden.") + try: + client.delete_app(crypto.decrypt(cid.value_enc)) + except onyva_login.OnyvaError as exc: + raise HTTPException(502, str(exc)) + for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"): + tsvc.set_secret(db, t.id, key, None, who) + store.audit(db, who, "oidc.app_deleted", t.id) + db.commit() + return _oidc_view(db, t) + + +@router.get("/oidc/status") +def oidc_status(db: Session = Depends(get_db)): + return {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)} + + +@router.post("/oidc/test") +def oidc_test(db: Session = Depends(get_db)): + client = onyva_login.client_from_env(db) + if client is None: + raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.") + try: + apps = client.list_apps() + except onyva_login.OnyvaError as exc: + raise HTTPException(502, str(exc)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"Login-API nicht erreichbar: {type(exc).__name__}: {str(exc)[:200]}") + return {"ok": True, "apps": len(apps)} + + +@router.get("/tenants/{tenant_id}/secrets") +def secrets_list(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + return tsvc.secret_rows(db, t.id) + + +@router.put("/tenants/{tenant_id}/secrets/{key}") +def secret_put(key: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + value = body.get("value") + if body.get("generate"): + value = defaults.new_token() + try: + tsvc.set_secret(db, t.id, key, value, who) + except ValueError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"ok": True} + + +@router.put("/tenants/{tenant_id}/source") +def set_source(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + src = body.get("source") + if src not in ("git", "admin"): + raise HTTPException(400, "source: git | admin") + t.source = src + store.audit(db, who, "tenant.source", t.id, f"TENANT_SOURCE={src}") + db.commit() + return {"source": t.source} + + +@router.get("/tenants/{tenant_id}/cutover") +def cutover(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + try: + val = preview.run(db, t, "current", "validate") + except preview.PreviewError: + val = {"ok": False} + return {"checks": tsvc.cutover_checks(db, t, val), "source": t.source} + + +# ── Deploy ────────────────────────────────────────────────────────────────── + +def _dep(d: Deployment): + return {"id": d.id, "batch": d.batch_id, "tenant": d.tenant_id, "kind": d.kind, "version": d.version, + "image": d.image_tag, "status": d.status, "simulated": d.simulated, "log": d.log, "actor": d.actor, + "created_at": _iso(d.created_at), "finished_at": _iso(d.finished_at)} + + +@router.get("/deploy/mode") +def deploy_mode(db: Session = Depends(get_db)): + return {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe(), "project": bool(deploy.setting(db, "mittwald_project_id")), + "core_bundle": deploy.setting(db, "core_supports_bundle", "false") == "true"} + + +@router.get("/tenants/{tenant_id}/preflight") +def tenant_preflight(t: Tenant = Depends(tenant_or_404)): + """Vorflug-Check für einen Live-Deploy (liest nur, schreibt nichts).""" + return preflight.run(t.id) + + +@router.get("/tenants/{tenant_id}/plan") +def plan(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + return deploy.plan(db, t) + + +def _kind(body: dict, base: str, tenant_id: str | None = None) -> str: + dry = bool(body.get("dry_run")) + if not dry and not deploy.live_enabled(): + raise HTTPException(403, "Echte Deployments sind gesperrt (Env DEPLOY_LIVE != true). Nur Dry-Run möglich.") + if not dry and tenant_id and not guard.live_allowed(tenant_id): + raise HTTPException(403, guard.denial(tenant_id)) + return f"{base}-dry" if base == "offboard" and dry else ("dry-run" if dry else base) + + +@router.post("/tenants/{tenant_id}/deploy") +def deploy_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)): + return {"id": deploy.start(t.id, _kind(body, "deploy", t.id), who)} + + +@router.post("/tenants/{tenant_id}/offboard") +def offboard_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)): + return {"id": deploy.start(t.id, _kind(body, "offboard", t.id), who)} + + +@router.post("/deploy-all") +def deploy_all(body: dict = Body(default={}), who: str = Depends(actor)): + batch, ids = deploy.start_all(_kind(body, "deploy"), who) + return {"batch": batch, "ids": ids} + + +@router.get("/deployments/{dep_id}") +def deployment(dep_id: int, db: Session = Depends(get_db)): + d = db.get(Deployment, dep_id) + if d is None: + raise HTTPException(404) + return _dep(d) + + +@router.get("/deployments") +def deployments(batch: str = "", db: Session = Depends(get_db)): + q = select(Deployment).order_by(Deployment.id.desc()).limit(60) + if batch: + q = select(Deployment).where(Deployment.batch_id == batch).order_by(Deployment.id) + return [_dep(d) for d in db.scalars(q)] + + +# ── Core: Vertrag, Releases, Rollout, Zustand ─────────────────────────────── + +@router.get("/contract") +def contract_info(): + return {"available": contract.available(), "supported": contract.supported(), "manifest": contract.manifest(), "schema": contract.schema(), + "supported_versions": sorted(contract.SUPPORTED_CONTRACT)} + + +def _require_session(request: Request) -> None: + if not getattr(request.state, "user", None): + raise HTTPException(401, "Nicht angemeldet") + + +@router.post("/core/releases") +def core_release_intake(request: Request, body: dict = Body(...), authorization: str = Header(default=""), db: Session = Depends(get_db)): + """Release-Eingang für die Core-CI (Bearer CORE_RELEASE_TOKEN). Meldet nur an – ausgerollt wird erst nach Prüfung und Freigabe.""" + expected = os.getenv("CORE_RELEASE_TOKEN", "").strip() + if not expected: + raise HTTPException(503, "Release-Eingang nicht konfiguriert (Env CORE_RELEASE_TOKEN fehlt).") + if not hmac.compare_digest(authorization.removeprefix("Bearer ").strip(), expected): + raise HTTPException(401, "Ungültiges Token") + try: + r, created = releases.announce(db, str(body.get("sha", "")), body.get("tag"), str(body.get("ref", "")), str(body.get("image", "")), str(body.get("message", ""))) + except releases.ReleaseError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"id": r.id, "created": created, "status": r.status, "admin_matches": releases.admin_matches(r)} + + +@router.get("/core/releases") +def core_releases(request: Request, db: Session = Depends(get_db)): + _require_session(request) + active = deploy.setting(db, "core_image_tag", "latest") + rows = db.scalars(select(CoreRelease).order_by(CoreRelease.id.desc()).limit(40)).all() + return {"admin_core": {**contract.build_info(), "contract": contract.contract_version(), "supported": contract.supported(), + "schema_sha": contract.manifest().get("schema_sha256"), "fields": contract.manifest().get("fields"), + "env": contract.manifest().get("env")}, + "active_tag": active, "canary": releases.canary_list(db), "live": deploy.live_enabled(), + "intake": bool(os.getenv("CORE_RELEASE_TOKEN", "").strip()), "releases": [releases.view(r, active) for r in rows]} + + +@router.post("/core/releases/{release_id}/check") +def core_check(release_id: int, body: dict = Body(default={})): + try: + releases.check(release_id, force=bool(body.get("force"))) + except releases.ReleaseError as exc: + raise HTTPException(400, str(exc)) + return {"started": True} + + +@router.post("/core/releases/{release_id}/approve") +def core_approve(release_id: int, body: dict = Body(default={}), db: Session = Depends(get_db), who: str = Depends(actor)): + try: + releases.approve(db, release_id, who, bool(body.get("acknowledge"))) + except releases.ReleaseError as exc: + raise HTTPException(400, str(exc)) + db.commit() + return {"ok": True} + + +@router.post("/core/releases/{release_id}/reject") +def core_reject(release_id: int, db: Session = Depends(get_db), who: str = Depends(actor)): + r = db.get(CoreRelease, release_id) + if r is None: + raise HTTPException(404) + r.status = "rejected" + store.audit(db, who, "core.reject", None, r.sha[:10]) + db.commit() + return {"ok": True} + + +@router.post("/core/releases/{release_id}/rollout") +def core_rollout(release_id: int, body: dict = Body(default={}), who: str = Depends(actor)): + try: + batch, ids = releases.rollout(release_id, who, dry_run=bool(body.get("dry_run", True))) + except releases.ReleaseError as exc: + raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc)) + return {"batch": batch, "ids": ids} + + +@router.post("/core/releases/{release_id}/rollback") +def core_rollback(release_id: int, who: str = Depends(actor)): + try: + batch, ids = releases.rollback_release(release_id, who) + except releases.ReleaseError as exc: + raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc)) + return {"batch": batch, "ids": ids} + + +@router.put("/tenants/{tenant_id}/core-pin") +def tenant_pin(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + pin = (body.get("pin") or "").strip() or None + if pin and not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", pin): + raise HTTPException(400, "Tag: Buchstaben, Ziffern, . _ -") + t.core_pin = pin + store.audit(db, who, "tenant.core_pin", t.id, pin or "aufgehoben") + db.commit() + return {"pin": t.core_pin} + + +@router.post("/tenants/{tenant_id}/rollback") +def tenant_rollback(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + if not deploy.live_enabled(): + raise HTTPException(403, "Echte Deployments sind gesperrt (DEPLOY_LIVE != true).") + if not guard.live_allowed(t.id): + raise HTTPException(403, guard.denial(t.id)) + tag = releases.tenant_rollback_tag(db, t) + if not tag: + raise HTTPException(400, "Kein früherer, erfolgreich deployter Core-Tag bekannt.") + return {"id": deploy.start(t.id, "deploy", who, core_tag=tag), "tag": tag} + + +@router.get("/status") +def tenants_status(refresh: bool = False): + """Was läuft wirklich? Fragt /health jedes aktiven Tenants ab (30 s Cache).""" + return status_svc.probe_all(refresh=refresh) + + +# ── Einstellungen ─────────────────────────────────────────────────────────── + +@router.get("/settings") +def get_settings(db: Session = Depends(get_db)): + return {s.key: s.value for s in db.scalars(select(Setting))} + + +@router.put("/settings") +def put_settings(body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)): + for k, v in body.items(): + s = db.get(Setting, k) + if s is None: + s = Setting(key=k) + db.add(s) + s.value = str(v) + store.audit(db, who, "settings.update", None, ", ".join(body)) + db.commit() + return {"ok": True} + + +@router.put("/shared-secrets/{key}") +def shared_secret(key: str, body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)): + r = db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == key)) + if r is None: + r = Secret(tenant_id=None, key=key) + db.add(r) + r.value_enc = crypto.encrypt(body["value"]) if body.get("value") else None + r.source = "manual" + store.audit(db, who, "shared-secret.set", None, key) + db.commit() + return {"ok": True} + + +# ── Agent ─────────────────────────────────────────────────────────────────── + +def _msg(m: ChatMessage): + return {"id": m.id, "role": m.role, "content": m.content, "steps": m.steps or [], "proposal": m.proposal, + "proposal_status": m.proposal_status, "usage": m.usage, "created_at": _iso(m.created_at)} + + +@router.get("/tenants/{tenant_id}/chat") +def chat_history(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + return [_msg(m) for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id).order_by(ChatMessage.id))] + + +def _sse(ev: dict) -> str: + return f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" + + +@router.post("/tenants/{tenant_id}/chat") +def chat(tenant_id: str, body: dict = Body(...), who: str = Depends(actor)): + """Agent-Lauf als Server-Sent-Events: mode, step*, thought*, text, proposal, usage, done.""" + text = (body.get("message") or "").strip() + if not text: + raise HTTPException(400, "Leere Nachricht") + + def gen(): + with session() as db: + t = db.get(Tenant, tenant_id) + steps, final, proposal, usage, mode, error = [], "", None, None, {}, None + for ev in agent_loop.run(db, t, text): + if ev["type"] == "step": + steps.append({k: ev[k] for k in ("tool", "arg", "result", "error")}) + elif ev["type"] == "text": + final = ev["text"] + elif ev["type"] == "proposal": + proposal = ev["proposal"] + elif ev["type"] == "usage": + usage = ev["usage"] + elif ev["type"] == "mode": + mode = ev + elif ev["type"] == "error": + error = ev["text"] + yield _sse(ev if ev["type"] != "proposal" else {"type": "proposal", "summary": (proposal or {}).get("summary"), + "items": (proposal or {}).get("items", [])}) + db.add(ChatMessage(tenant_id=tenant_id, role="user", content=text)) + m = ChatMessage(tenant_id=tenant_id, role="assistant", content=error and f"⚠ {error}" or final, steps=steps, proposal=proposal, + proposal_status="open" if proposal else "", + usage={**(usage or {}), "backend": mode.get("backend"), "model": mode.get("model")}) + db.add(m) + store.audit(db, who, "agent.run", tenant_id, f"{mode.get('backend')}/{mode.get('model')} {text[:80]}") + db.commit() + yield _sse({"type": "done", "message": _msg(m)}) + + return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@router.get("/tenants/{tenant_id}/chat/{msg_id}/diff") +def chat_diff(msg_id: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + m = db.get(ChatMessage, msg_id) + if m is None or m.tenant_id != t.id or not m.proposal: + raise HTTPException(404) + return store.diff_content(db, store.content(db, t, "draft"), agent_loop.proposal_content(db, t, m.proposal), with_text=True) + + +@router.post("/tenants/{tenant_id}/chat/{msg_id}/{decision}") +def chat_decide(msg_id: int, decision: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)): + m = db.get(ChatMessage, msg_id) + if m is None or m.tenant_id != t.id or m.proposal_status != "open": + raise HTTPException(400, "Kein offener Vorschlag") + if decision == "apply": + agent_loop.apply_proposal(db, t, m.proposal) + m.proposal_status = "applied" + store.audit(db, "agent", "agent.proposal.apply", t.id, f"freigegeben von {who}: {(m.proposal or {}).get('summary')}") + elif decision == "reject": + m.proposal_status = "rejected" + else: + raise HTTPException(400) + db.commit() + return _msg(m) + + +@router.delete("/tenants/{tenant_id}/chat") +def chat_clear(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id)): + db.delete(m) + db.commit() + return {"ok": True} + + +# ── Bundle: genau das holt ein Tenant-Container beim Start ────────────────── + +@router.get("/tenants/{tenant_id}/bundle") +def bundle(tenant_id: str, authorization: str = Header(default=""), db: Session = Depends(get_db)): + token = authorization.removeprefix("Bearer ").strip() + if not tsvc.check_bundle_token(db, tenant_id, token): + raise HTTPException(401, "Ungültiges Bundle-Token") + t = db.get(Tenant, tenant_id) + data, manifest = store.bundle_tar(db, t, "current") + return Response(data, media_type="application/gzip", headers={"X-Bundle-Version": str(t.current_version), "ETag": f'"{t.current_version}"'}) diff --git a/app/routers/pages.py b/app/routers/pages.py new file mode 100644 index 0000000..76d89ad --- /dev/null +++ b/app/routers/pages.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import HTMLResponse +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.db import get_db +from app.deps import tenant_or_404 +from app.models import AuditLog, Deployment, Secret, Setting, Tenant +from app.services import deploy, guard, onyva_login, preview, store +from app.services.importer import DEFAULT_SETTINGS +from app.templating import templates + +router = APIRouter() + + +def _logo(db, t: Tenant, source="current") -> str | None: + _, files = store.content(db, t, source) + for name in ("logo.svg", "logo.png"): + if name in files: + return f"/api/tenants/{t.id}/files/{name}?source={source}" + return None + + +@router.get("/", response_class=HTMLResponse) +def dashboard(request: Request, db: Session = Depends(get_db)): + rows = [] + for t in db.scalars(select(Tenant).order_by(Tenant.id)): + cfg, files = store.content(db, t, "current") + missing = db.scalar(select(func.count()).select_from(Secret).where(Secret.tenant_id == t.id, Secret.value_enc.is_(None))) or 0 + rows.append({ + "t": t, "cfg": cfg, "accent": (cfg.get("colors") or {}).get("accent", "#327F24"), + "logo": _logo(db, t), "host": deploy.host_for(db, t), "outdated": t.deployed_version != t.current_version, + "missing_secrets": missing, "stt": (cfg.get("stt") or {}).get("provider", "mistral"), + "has_code": "overrides.py" in files, "custom_prompt": "prompt.txt" in files, + "custom_pdf": "protocol.html" in files or "protocol.css" in files, + }) + stats = { + "tenants": len(rows), "admin": sum(1 for r in rows if r["t"].source == "admin"), + "drafts": sum(1 for r in rows if r["t"].draft), "outdated": sum(1 for r in rows if r["outdated"]), + "secrets": sum(1 for r in rows if r["missing_secrets"]), + } + last = db.scalars(select(Deployment).order_by(Deployment.id.desc()).limit(5)).all() + return templates.TemplateResponse(request, "dashboard.html", { + "rows": rows, "stats": stats, "last": last, "core": preview.core_version(), + "tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "dashboard"}) + + +@router.get("/tenants/{tenant_id}", response_class=HTMLResponse) +def tenant_page(request: Request, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + return templates.TemplateResponse(request, "tenant.html", {"t": t, "nav": "dashboard", "logo": _logo(db, t, "draft")}) + + +@router.get("/tenants/{tenant_id}/bundle.tar.gz") +def bundle_download(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "current"): + data, _ = store.bundle_tar(db, t, source) + return Response(data, media_type="application/gzip", + headers={"Content-Disposition": f'attachment; filename="{t.id}-v{t.current_version}.tar.gz"'}) + + +@router.get("/preview/{tenant_id}/{kind}") +def preview_view(kind: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): + if kind not in ("web", "pdf"): + raise HTTPException(404) + try: + data = preview.run(db, t, source, "index" if kind == "web" else "pdf") + except (preview.PreviewError, preview.SourceGone) as exc: + return Response(str(exc), status_code=422, media_type="text/plain; charset=utf-8") + if kind == "pdf": + return Response(data, media_type="application/pdf", headers={"Content-Disposition": "inline"}) + html = data.decode("utf-8") + # PNG-Logos referenziert der Core über /brand/logo → auf die Admin-Datei umbiegen + html = html.replace('src="/brand/logo"', f'src="/api/tenants/{t.id}/files/logo.png?source={source}"') + html = html.replace("", "", 1) + return HTMLResponse(html) + + +@router.get("/deployments", response_class=HTMLResponse) +def deployments_page(request: Request, db: Session = Depends(get_db)): + tenants = db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)).all() + return templates.TemplateResponse(request, "deployments.html", { + "tenants": tenants, "tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "deployments"}) + + +@router.get("/settings", response_class=HTMLResponse) +def settings_page(request: Request, db: Session = Depends(get_db)): + values = {k: deploy.setting(db, k, "") for k in DEFAULT_SETTINGS} + shared = {s.key: bool(s.value_enc) for s in db.scalars(select(Secret).where(Secret.tenant_id.is_(None)))} + from app.services import llm + return templates.TemplateResponse(request, "settings.html", { + "v": values, "shared": shared, "core": preview.core_version(), "nav": "settings", + "llm_ready": llm.configured(db), "mode": {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe()}, + "oidc": {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}}) + + +@router.get("/audit", response_class=HTMLResponse) +def audit_page(request: Request, db: Session = Depends(get_db)): + rows = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(200)).all() + return templates.TemplateResponse(request, "audit.html", {"rows": rows, "nav": "audit"}) + + +@router.get("/core", response_class=HTMLResponse) +def core_page(request: Request): + return templates.TemplateResponse(request, "core.html", {"nav": "core"}) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/contract.py b/app/services/contract.py new file mode 100644 index 0000000..9be6d47 --- /dev/null +++ b/app/services/contract.py @@ -0,0 +1,125 @@ +"""Vertrag mit dem Core (siehe transkript-platform/docs/CONTRACT.md): Schema, Manifest, Build-Info, Env-Spezifikation. + +Gelesen wird aus dem Core, auf dem die Admin-Plattform läuft (PLATFORM_DIR: im Container das Core-Image, lokal der vendored Stand). +`core/contract.py` ist import-sicher (nur Standardbibliothek) und wird per Dateipfad geladen – der Core-Code selbst wird NIE +in diesem Prozess importiert (er hält Prozess-Singletons); gerendert wird in der Sandbox. +Ältere Cores ohne Vertrag („legacy“) funktionieren weiter: Schema fehlt → feste Admin-Formulare und feste Env-Liste. +""" +from __future__ import annotations + +import importlib.util +import json +from functools import lru_cache +from pathlib import Path + +from app.settings import PLATFORM_DIR + +SUPPORTED_CONTRACT = {1} + +# Rückfall für Cores ohne Schema (entspricht dem Stand vor dem Vertrag) +LEGACY_SHARED_ENV = { + "OIDC_ISSUER": "oidc_issuer", "AUTH_ENABLED": "auth_enabled", "APP_COOKIE_SECURE": "app_cookie_secure", + "MAX_FILE_SIZE_MB": "max_file_size_mb", "LLM_MODEL": "llm_model", "LLM_MAX_TOKENS": "llm_max_tokens", + "LLM_TEMPERATURE": "llm_temperature", "CORTECS_BASE_URL": "cortecs_base_url", "SMTP_HOST": "smtp_host", + "SMTP_PORT": "smtp_port", "SMTP_USER": "smtp_user", "SMTP_FROM": "smtp_from", + "MISTRAL_TRANSCRIBE_MODEL": "mistral_transcribe_model", "MISTRAL_DIARIZE": "mistral_diarize", + "MISTRAL_TIMEOUT": "mistral_timeout", "COST_API_BASE": "cost_api_base", "COST_TRACKING_ENABLED": "cost_tracking_enabled", +} +LEGACY_REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET"] + + +@lru_cache(maxsize=1) +def _module(): + path = Path(PLATFORM_DIR) / "core" / "contract.py" + if not path.exists(): + return None + spec = importlib.util.spec_from_file_location("onyscribe_core_contract", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def reload() -> None: + _module.cache_clear() + schema.cache_clear() + + +def available() -> bool: + return _module() is not None + + +@lru_cache(maxsize=1) +def schema() -> dict | None: + m = _module() + return m.load_schema() if m else None + + +def build_info() -> dict: + m = _module() + if m: + return m.build_info() + f = Path(PLATFORM_DIR) / "CORE_VERSION" + return {"sha": f.read_text().split()[0] if f.exists() else "unbekannt", "built_at": None, "source": "legacy"} + + +def manifest() -> dict: + m = _module() + if m: + return m.build_manifest() + return {"contract": 0, "core": build_info(), "legacy": True} + + +def contract_version() -> int: + return int(manifest().get("contract", 0)) + + +def supported() -> bool: + return (not available()) or contract_version() in SUPPORTED_CONTRACT + + +def sha() -> str: + return build_info()["sha"] + + +def same_sha(a: str, b: str) -> bool: + a, b = (a or "").lower(), (b or "").lower() + return bool(a and b) and (a.startswith(b) or b.startswith(a)) and min(len(a), len(b)) >= 7 + + +def check_tenant_json(raw: dict) -> list[str]: + m = _module() + return m.check_tenant_json(raw, schema()) if m else [] + + +def field_paths() -> set[str]: + s = schema() + return {f["path"] for f in s["fields"]} if s else set() + + +def env_spec() -> list[dict]: + s = schema() + if s: + return s["env"] + return [{"name": n, "kind": "setting", "scope": "shared", "setting": k, "default": None, "label": n, "group": "Allgemein"} for n, k in LEGACY_SHARED_ENV.items()] + + +def required_secrets(tenant_cfg: dict | None = None) -> list[str]: + """Pflicht-Secrets laut Schema (required, required_if wie „stt.provider!=cortecs“ gegen die Tenant-Config ausgewertet).""" + s = schema() + if not s: + return list(LEGACY_REQUIRED_SECRETS) + out = [] + for e in s["env"]: + if e["kind"] != "secret": + continue + if e.get("required"): + out.append(e["name"]) + elif e.get("required_if"): + path, _, rhs = e["required_if"].partition("!=") + cur = tenant_cfg or {} + for k in path.split("."): + cur = cur.get(k, {}) if isinstance(cur, dict) else {} + val = cur if isinstance(cur, str) else "" + if (val or "mistral") != rhs: # Provider-Standard ist mistral + out.append(e["name"]) + return out diff --git a/app/services/crypto.py b/app/services/crypto.py new file mode 100644 index 0000000..d988ccb --- /dev/null +++ b/app/services/crypto.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from cryptography.fernet import Fernet + +from app.settings import SECRET_KEY + +_f = Fernet(SECRET_KEY.encode()) + + +def encrypt(v: str) -> str: + return _f.encrypt(v.encode()).decode() + + +def decrypt(v: str) -> str: + return _f.decrypt(v.encode()).decode() diff --git a/app/services/defaults.py b/app/services/defaults.py new file mode 100644 index 0000000..7ff894d --- /dev/null +++ b/app/services/defaults.py @@ -0,0 +1,44 @@ +import json +import secrets + +# Standardfarben des Cores (core/config.py DEFAULT_COLORS) – Basis für neue Tenants +DEFAULT_COLORS = { + "accent": "#327F24", "accent_dark": "#235A19", "accent_mid": "#3E9130", "accent_light": "#EAF4E7", + "accent_xlight": "#F3FAF1", "bg": "#F1F5F0", "surface": "#FFFFFF", "surface2": "#F6FAF5", + "border": "#DCE8D9", "border2": "#BDD1B8", "text": "#444444", "text_2": "#666666", "text_3": "#9AA89A", + "red": "#DC2626", "progress_to": "#8bbf43", +} + +COLOR_LABELS = { + "accent": "Akzent", "accent_dark": "Akzent dunkel", "accent_mid": "Akzent mittel", "accent_light": "Akzent hell", + "accent_xlight": "Akzent extra hell", "bg": "Seitenhintergrund", "surface": "Karten", "surface2": "Karten 2", + "border": "Rahmen", "border2": "Rahmen 2", "text": "Text", "text_2": "Text sekundär", "text_3": "Text tertiär", + "red": "Rot (Hinweis)", "progress_to": "Fortschritt (Verlauf-Ende)", +} + +# Secret-Schlüssel = Env-Namen im Container (siehe infra/deploy.py) +REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY"] +OPTIONAL_SECRETS = ["MISTRAL_API_KEY"] +SECRET_HELP = { + "OIDC_CLIENT_ID": "Client-ID aus login.onyva.dev (manuell angelegt)", + "OIDC_CLIENT_SECRET": "Client-Secret aus login.onyva.dev", + "SESSION_SECRET": "Signiert Session-Cookies – kann hier erzeugt werden", + "COST_INGEST_KEY": "Schlüssel für die Kostenerfassung (Cost-API)", + "MISTRAL_API_KEY": "Nur nötig bei STT-Provider „mistral“ (bei „cortecs“ wird der geteilte Key genutzt)", + "ADMIN_BUNDLE_TOKEN": "Token, mit dem der Container sein Bundle bei der Admin-Plattform abholt", +} + + +def new_token() -> str: + return secrets.token_urlsafe(48) + + +def skeleton_config(tenant_id: str, name: str) -> dict: + return { + "id": tenant_id, "name": name, "title": f"{name} – Gesprächsprotokoll", + "description": f"Gesprächsprotokoll-Tool für {name}", "ai_detect_org": name, + "org_short": name, "org_profile": "", + "flags": {}, "stt": {"provider": "cortecs", "model": "voxtral-mini-transcribe-2602"}, + "colors": dict(DEFAULT_COLORS), + "office": {"name": name, "subtitle": "", "address": "", "tel": "", "email": "", "website": ""}, + } diff --git a/app/services/deploy.py b/app/services/deploy.py new file mode 100644 index 0000000..7e8324f --- /dev/null +++ b/app/services/deploy.py @@ -0,0 +1,314 @@ +"""Deploy-Orchestrierung: Plan (wie infra/deploy.py), Ausführung über den Mittwald-Client. + +Sicherheit: Ohne `DEPLOY_LIVE=true` (Env der Admin-Plattform) ist JEDE schreibende Operation gesperrt – es gibt nur Dry-Runs. +Außerdem blockiert der Core-Kompatibilitäts-Schalter (`core_supports_bundle`) Tenants mit Quelle „Admin“, +solange der Core das Bundle noch nicht laden kann. +""" +from __future__ import annotations + +import os +import threading +import time +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.db import session +from app.models import Deployment, Secret, Setting, Tenant +from app.services import contract, crypto, guard, onyva_login, store +from app.services.mittwald import DeployError, Mittwald, mask_env + +PORT = "8276" + + +def live_enabled() -> bool: + return os.getenv("DEPLOY_LIVE", "false").lower() == "true" + + +def token_present() -> bool: + return bool(os.getenv("MITTWALD_API_TOKEN", "").strip()) + + +def setting(db, key: str, default: str = "") -> str: + s = db.get(Setting, key) + return s.value if s and s.value != "" else default + + +def host_for(db, t: Tenant) -> str: + if t.custom_domain and t.domain_status == "verified": + return t.custom_domain + return f"{t.subdomain or t.id}.{setting(db, 'base_domain', 'example.de')}" + + +def effective_tag(db, t: Tenant | None = None, override: str | None = None) -> str: + """Welcher Core-Tag gilt? Override (Rollout) > Pin des Tenants > freigegebener Standard-Tag.""" + return override or (t.core_pin if t is not None and t.core_pin else None) or setting(db, "core_image_tag", "latest") + + +def image_ref(db, t: Tenant | None = None, tag: str | None = None) -> str: + registry = setting(db, "registry", "").rstrip("/") + name = setting(db, "image_name", "transkript-platform") + base = f"{registry}/{name}" if registry else name + return f"{base.lower()}:{effective_tag(db, t, tag)}" + + +def build_env(db, t: Tenant, reveal: bool) -> dict[str, str]: + """Container-Env, abgeleitet aus dem Env-Schema des Cores (kind/scope) – neue Core-Variablen erscheinen ohne Admin-Änderung. + reveal=False → Secrets als Platzhalter.""" + host = host_for(db, t) + env = {"TENANT": t.id, "DATA_DIR": "/data", "PORT": PORT, + "OIDC_REDIRECT_URI": f"https://{host}/auth/callback", "OIDC_LOGOUT_REDIRECT_URI": f"https://{host}/"} + spec = contract.env_spec() + for e in spec: # geteilte, nicht geheime Einstellungen + if e["kind"] == "setting" and e["scope"] == "shared": + v = setting(db, e.get("setting") or e["name"].lower()) + if v: + env[e["name"]] = v + shared = db.scalars(select(Secret).where(Secret.tenant_id.is_(None))).all() + own = db.scalars(select(Secret).where(Secret.tenant_id == t.id)).all() + for s_ in [*shared, *own]: # Tenant-Werte überschreiben geteilte; auch Secrets ohne Schema-Eintrag werden durchgereicht + if s_.key == "ADMIN_BUNDLE_TOKEN" and t.source != "admin": + continue + if s_.value_enc: + env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt" + elif not reveal: + env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)" + if t.source == "admin": + env["TENANT_SOURCE"] = "admin" + env["ADMIN_URL"] = setting(db, "admin_public_url", "https://") + return env + + +def missing_secrets(db, t: Tenant) -> list[str]: + """Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben.""" + cfg, _ = store.content(db, t, "current") + have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc} + need = set(contract.required_secrets(cfg)) + if t.source == "admin": + need.add("ADMIN_BUNDLE_TOKEN") + return sorted(need - have) + + +def plan(db, t: Tenant) -> dict: + cfg, _ = store.content(db, t, "current") + return { + "service": f"app-{t.id}", "volume": f"vol-{t.id}", + "patch": {"description": cfg.get("name") or f"Onyscribe {t.id}", "image": image_ref(db, t), + "environment": build_env(db, t, reveal=False), "ports": [f"{PORT}:{PORT}/tcp"], "volumes": [f"vol-{t.id}:/data"]}, + "ingress": {"host": host_for(db, t), "target": f"app-{t.id}:{PORT}"}, + "missing_secrets": missing_secrets(db, t), + "oidc": {"mode": onyva_login.config_of(t)["mode"], "key": onyva_login.key_present(), "writes": onyva_login.writes_allowed(), + "last_sync": onyva_login.config_of(t)["last_sync"]}, + "mode": {"live": live_enabled(), "token": token_present(), "tenant_allowed": guard.live_allowed(t.id), "allowed": guard.describe(), "project": bool(setting(db, "mittwald_project_id")), + "core_bundle": setting(db, "core_supports_bundle", "false") == "true"}, + "core": {"tag": effective_tag(db, t), "pinned": bool(t.core_pin), "admin_core": contract.sha()[:10], "contract": contract.contract_version()}, + } + + +# ── Ausführung ────────────────────────────────────────────────────────────── + +def _append(dep_id: int, line: str) -> None: + with session() as db: + db.get(Deployment, dep_id).log += f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} {line}\n" + + +def _finish(dep_id: int, status: str) -> None: + with session() as db: + d = db.get(Deployment, dep_id) + d.status, d.finished_at = status, datetime.now(timezone.utc) + + +def _client(live: bool, log, http=None) -> Mittwald: + return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http) + + +def _hex_tag(tag: str) -> bool: + return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7 + + +def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0, + sleep=time.sleep) -> None: + """Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle). + Wirft DeployError, wenn der Container nach `tries` Versuchen nicht gesund ist oder die falsche Version meldet.""" + if get is None: + import httpx + + def get(url): + r = httpx.get(url, timeout=8, follow_redirects=True) + return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {}) + last = "keine Antwort" + for i in range(tries): + try: + status, body = get(f"https://{host}/health") + problems = [] + if status != 200 or body.get("status") != "ok": + problems.append(f"HTTP {status}") + core = body.get("core") + if expect_sha and _hex_tag(expect_sha): + if core is None: + log("⚠ /health meldet keine Core-Version (Image vor dem Vertrag) – Version nicht prüfbar") + elif not contract.same_sha(core.get("version", ""), expect_sha): + problems.append(f"Core {str(core.get('version'))[:10]} statt {expect_sha[:10]}") + if expect_bundle is not None and body.get("bundle"): + b = body["bundle"] + if b.get("source") != "admin" or b.get("version") != expect_bundle or b.get("stale"): + problems.append(f"Bundle {b.get('source')} v{b.get('version')}{' (veraltet)' if b.get('stale') else ''} statt admin v{expect_bundle}" + + (f" – {b.get('error')}" if b.get("error") else "")) + if not problems: + log(f"Health-Check https://{host}/health ✔" + (f" · Core {core['version'][:10]}" if core else "") + + (f" · Bundle v{body['bundle']['version']}" if body.get("bundle", {}).get("source") == "admin" else "")) + return + last = "; ".join(problems) + except Exception as exc: # noqa: BLE001 + last = f"{type(exc).__name__}: {exc}" + if i < tries - 1: + sleep(delay) + raise DeployError(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}") + + +def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None) -> bool: + """Führt einen Deployment-Datensatz aus. kind: dry-run | deploy | offboard-dry | offboard. + core_tag überschreibt den Core-Tag (Rollout/Rollback); `health(host, expect_sha, expect_bundle, log)` ist in Tests ersetzbar.""" + log = lambda m: _append(dep_id, m) # noqa: E731 + dry = kind in ("dry-run", "offboard-dry") + live = live_enabled() and not dry + try: + with session() as db: + t = db.get(Tenant, tenant_id) + ver = t.current_version + project, stack = setting(db, "mittwald_project_id"), setting(db, "mittwald_stack_id") + host = host_for(db, t) if setting(db, "base_domain") else None + source, bundle_ok = t.source, setting(db, "core_supports_bundle", "false") == "true" + image, tag = image_ref(db, t, core_tag), effective_tag(db, t, core_tag) + pinned = bool(t.core_pin) and not core_tag + log(f"▶ {kind} „{tenant_id}“ · Version v{ver} · {image}{' (gepinnt)' if pinned else ''} · Modus: {'LIVE' if live else 'Dry-Run (keine Schreibzugriffe)'}") + if not contract.supported(): + raise DeployError(f"Core-Vertrag {contract.contract_version()} wird von dieser Admin-Version nicht unterstützt – Admin aktualisieren.") + if source == "admin" and not bundle_ok and not dry: + raise DeployError("Quelle „Admin“ ist noch gesperrt: Der Core kann das Bundle noch nicht laden (Einstellung core_supports_bundle).") + if not dry and not live_enabled(): + raise DeployError("Echte Deployments sind gesperrt (DEPLOY_LIVE != true).") + if not dry and not guard.live_allowed(tenant_id): + raise DeployError(guard.denial(tenant_id)) + if live and source == "admin": + with session() as db: + url = setting(db, "admin_public_url") + if not url.startswith("https://") or "<" in url: + raise DeployError("Einstellung „Öffentliche URL der Admin-Plattform“ (https://…) fehlt – der Container könnte sein Bundle nicht holen.") + + if not kind.startswith("offboard"): + # 1) OIDC-Client abgleichen (legt bei Bedarf an) – VOR dem Service, weil dessen Env die Client-Daten braucht + rep = onyva_login.sync_tenant(tenant_id, apply=live, log=log, client=oidc_client, actor="deploy") + if live and not rep["ok"]: + raise DeployError(f"OIDC-Abgleich fehlgeschlagen: {rep['error']}") + with session() as db: + t = db.get(Tenant, tenant_id) + if live: + lacking = missing_secrets(db, t) + if lacking: + raise DeployError("Pflicht-Secrets ohne Wert: " + ", ".join(lacking) + " (Tenant → Secrets bzw. Domain & Login, geteilte unter Einstellungen).") + definition = {"description": plan(db, t)["patch"]["description"], "image": image, "ports": [f"{PORT}:{PORT}/tcp"], + "volumes": [f"vol-{t.id}:/data"], "environment": build_env(db, t, reveal=live)} + mw = _client(live, log, http) + sid = mw.resolve_stack_id(project, stack) + if kind.startswith("offboard"): + mw.offboard_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", project_id=project, hostname=host) + log("Hinweis: Der OIDC-Client bleibt im Login bestehen (Tenant → Übersicht → Gefahrenbereich: „OIDC-Client im Login löschen“).") + else: + mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition, + container_port=PORT, project_id=project, hostname=host) + if live and not kind.startswith("offboard") and host: + (health or verify_health)(host, tag, ver if source == "admin" else None, log) + if not dry: + with session() as db: + t = db.get(Tenant, tenant_id) + if kind == "deploy": + t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc) + store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}") + log("✔ Fertig" if live else "Dry-Run beendet – es wurde nichts geschrieben.") + return True + except DeployError as exc: + log(f"✘ {exc}") + return False + except Exception as exc: # noqa: BLE001 + log(f"✘ Unerwarteter Fehler: {type(exc).__name__}: {exc}") + return False + + +def start(tenant_id: str, kind: str, actor: str, batch_id: str = "", http=None, wait: bool = False, oidc_client=None, + core_tag: str | None = None, health=None) -> int: + with session() as db: + t = db.get(Tenant, tenant_id) + d = Deployment(tenant_id=tenant_id, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag), + actor=actor, batch_id=batch_id, simulated=not (live_enabled() and kind in ("deploy", "offboard"))) + db.add(d) + db.flush() + dep_id = d.id + + def work(): + ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health) + _finish(dep_id, "success" if ok else "failed") + + th = threading.Thread(target=work, daemon=True) + th.start() + if wait: + th.join() + return dep_id + + +def run_sequence(tenant_ids: list[str], kind: str, actor: str, *, batch: str, core_tag: str | None = None, http=None, oidc_client=None, + health=None, canary: list[str] | None = None, wait: bool = False, on_done=None) -> list[int]: + """Tenants nacheinander (zuerst die Canary-Tenants), Abbruch beim ersten Fehler – der Rest wird „cancelled“. + on_done(ok, results) wird nach dem Lauf aufgerufen (z. B. um ein Release abzuschließen).""" + if kind in ("deploy", "offboard") and live_enabled(): # Live: nur freigegebene Tenants (siehe guard.py) + skipped = [t for t in tenant_ids if not guard.live_allowed(t)] + tenant_ids = [t for t in tenant_ids if guard.live_allowed(t)] + if skipped: + with session() as db: + store.audit(db, actor, "deploy.skipped_not_allowed", None, ", ".join(skipped)) + canary = [c for c in (canary or []) if c in tenant_ids] + order = canary + [t for t in tenant_ids if t not in canary] + with session() as db: + rows = [] + for tid in order: + t = db.get(Tenant, tid) + d = Deployment(tenant_id=tid, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag), actor=actor, + batch_id=batch, status="queued", simulated=not (live_enabled() and kind == "deploy")) + db.add(d) + rows.append(d) + db.flush() + dep_ids = [d.id for d in rows] + + def seq(): + failed, results = False, {} + for tid, did in zip(order, dep_ids): + if failed: + _append(did, "übersprungen – vorheriger Tenant fehlgeschlagen (Rollout abgebrochen)") + _finish(did, "cancelled") + results[tid] = "cancelled" + continue + with session() as db: + db.get(Deployment, did).status = "running" + ok = execute(did, tid, kind, http, oidc_client, core_tag, health) + _finish(did, "success" if ok else "failed") + results[tid] = "success" if ok else "failed" + failed = not ok + if tid in canary and ok and tid == canary[-1]: + _append(did, "Canary erfolgreich – restliche Tenants folgen") + if on_done: + on_done(not failed, results) + + th = threading.Thread(target=seq, daemon=True) + th.start() + if wait: + th.join() + return dep_ids + + +def start_all(kind: str, actor: str, http=None, wait: bool = False, oidc_client=None, core_tag: str | None = None, health=None) -> tuple[str, list[int]]: + """Alle aktiven, nicht gepinnten Tenants nacheinander; beim ersten Fehler Abbruch.""" + batch = uuid.uuid4().hex[:10] + with session() as db: + ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not (core_tag and t.core_pin)] + return batch, run_sequence(ids, kind, actor, batch=batch, core_tag=core_tag, http=http, oidc_client=oidc_client, health=health, wait=wait) diff --git a/app/services/guard.py b/app/services/guard.py new file mode 100644 index 0000000..272f365 --- /dev/null +++ b/app/services/guard.py @@ -0,0 +1,35 @@ +"""Schreib-Freigabe für externe Systeme (Mittwald, Login-API) – pro Tenant. + +`DEPLOY_ALLOWED_TENANTS` (Env der Admin-Plattform) legt fest, welche Tenants live angefasst werden dürfen: + (leer/nicht gesetzt) keiner – auch mit DEPLOY_LIVE=true wird nichts geschrieben + demo-admin,demo2 nur diese + * alle +Greift bei Live-Deploy, Offboard, Rollout/Rollback und beim Schreiben im Login-System (OIDC-Abgleich/Löschen). +Dry-Runs und Lesezugriffe sind nie betroffen. Damit lässt sich die Plattform schrittweise und gefahrlos parallel zum Altbetrieb einführen. +""" +from __future__ import annotations + +import os + + +def allowed_tenants() -> set[str] | None: + """None = alle, sonst die erlaubten IDs (leere Menge = keiner).""" + raw = os.getenv("DEPLOY_ALLOWED_TENANTS", "").strip() + if raw == "*": + return None + return {x.strip() for x in raw.split(",") if x.strip()} + + +def live_allowed(tenant_id: str) -> bool: + a = allowed_tenants() + return a is None or tenant_id in a + + +def describe() -> str: + a = allowed_tenants() + return "alle Tenants" if a is None else (", ".join(sorted(a)) if a else "keiner") + + +def denial(tenant_id: str) -> str: + return (f"Tenant „{tenant_id}“ ist für Live-Schreibzugriffe nicht freigegeben (Env DEPLOY_ALLOWED_TENANTS = {describe()}). " + "Dry-Runs sind weiterhin möglich.") diff --git a/app/services/importer.py b/app/services/importer.py new file mode 100644 index 0000000..0e4a24b --- /dev/null +++ b/app/services/importer.py @@ -0,0 +1,151 @@ +"""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 diff --git a/app/services/llm.py b/app/services/llm.py new file mode 100644 index 0000000..cbb3ba0 --- /dev/null +++ b/app/services/llm.py @@ -0,0 +1,121 @@ +"""Zugang zu Cortecs (OpenAI-kompatibel + Anthropic-Messages) für Agent und Prompt-Test. + +Der Key kommt aus dem geteilten Secret CORTECS_API_KEY (verschlüsselt in der DB) oder der Env CORTECS_API_KEY. +""" +from __future__ import annotations + +import os + +import httpx +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Secret, Setting +from app.services import crypto + +DEFAULT_BASE = "https://api.cortecs.ai/v1" + + +class LLMNotConfigured(RuntimeError): + pass + + +def _setting(db: Session, key: str, default: str = "") -> str: + s = db.get(Setting, key) + return s.value if s and s.value else default + + +def key_info(db: Session, tenant_id: str | None = None) -> tuple[str, str, str | None]: + """(key, quelle, fehler). Reihenfolge: geteiltes Secret (DB) → Env CORTECS_API_KEY → Key des Tenants (Rückfall). + Quelle: 'shared' | 'env' | 'tenant' | 'none'. fehler: z. B. nicht entschlüsselbar (SECRET_KEY geändert?).""" + error = None + rows = [("shared", db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == "CORTECS_API_KEY")))] + for source, row in rows: + if row and row.value_enc: + try: + return crypto.decrypt(row.value_enc), source, None + except Exception: # noqa: BLE001 – falscher SECRET_KEY o. Ä. + error = "Geteilter CORTECS_API_KEY in der DB ist nicht entschlüsselbar (SECRET_KEY geändert?) – bitte neu setzen." + env = os.getenv("CORTECS_API_KEY", "").strip() + if env: + return env, "env", error + if tenant_id: + row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "CORTECS_API_KEY")) + if row and row.value_enc: + try: + return crypto.decrypt(row.value_enc), "tenant", error + except Exception: # noqa: BLE001 + error = error or "CORTECS_API_KEY dieses Tenants ist nicht entschlüsselbar." + return "", "none", error + + +def api_key(db: Session, tenant_id: str | None = None) -> str: + return key_info(db, tenant_id)[0] + + +def base_url(db: Session) -> str: + return _setting(db, "cortecs_base_url", DEFAULT_BASE).rstrip("/") + + +def configured(db: Session, tenant_id: str | None = None) -> bool: + return bool(api_key(db, tenant_id)) + + +def require_key(db: Session, tenant_id: str | None = None) -> str: + k, _, err = key_info(db, tenant_id) + if not k: + raise LLMNotConfigured(err or "CORTECS_API_KEY fehlt – unter Einstellungen → Geteilte Secrets setzen (oder Env CORTECS_API_KEY).") + return k + + +def list_models(db: Session) -> list[dict]: + """GET /v1/models – Rohdaten von Cortecs, auf die für die Auswahl nötigen Felder reduziert.""" + key = require_key(db) + r = httpx.get(f"{base_url(db)}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + data = r.json() + items = data.get("data") if isinstance(data, dict) else data + out = [] + for m in items or []: + if isinstance(m, str): + m = {"id": m} + caps = {k: v for k, v in m.items() if isinstance(v, (bool, list, dict)) or k in ("context_size", "context_length")} + out.append({"id": m.get("id") or m.get("name"), "raw": caps, + "tools": _supports_tools(m)}) + return sorted((o for o in out if o["id"]), key=lambda o: o["id"]) + + +def _supports_tools(m: dict) -> bool | None: + """Cortecs dokumentiert die Feldnamen nicht eindeutig → mehrere plausible Varianten prüfen; None = unbekannt.""" + flat = {} + def walk(prefix, v): + if isinstance(v, dict): + for k, x in v.items(): + walk(f"{prefix}.{k}".lower(), x) + elif isinstance(v, list): + flat[prefix] = [str(i).lower() for i in v] + else: + flat[prefix] = v + walk("", m) + for k, v in flat.items(): + if "tool" in k or "function" in k: + return bool(v) + if isinstance(v, list) and any("tool" in i or "function" in i for i in v): + return True + return None + + +def summarize(db: Session, system_prompt: str, user_content: str, model: str | None = None, tenant_id: str | None = None) -> dict: + """Ein Protokoll-Lauf wie im Core (Chat Completions), für den Prompt-Test.""" + from openai import OpenAI + client = OpenAI(api_key=require_key(db, tenant_id), base_url=base_url(db), max_retries=2, timeout=300) + model = model or _setting(db, "llm_model", "claude-4-6-sonnet") + resp = client.chat.completions.create( + model=model, temperature=float(_setting(db, "llm_temperature", "0.2")), + max_tokens=int(_setting(db, "llm_max_tokens", "16000")), stream=False, + messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}], + ) + text = "\n".join(c.message.content for c in resp.choices if c.message and c.message.content).strip() + u = getattr(resp, "usage", None) + return {"text": text, "model": model, "input_tokens": getattr(u, "prompt_tokens", 0), "output_tokens": getattr(u, "completion_tokens", 0), + "truncated": any(getattr(c, "finish_reason", "") in ("length", "max_tokens") for c in resp.choices)} diff --git a/app/services/mittwald.py b/app/services/mittwald.py new file mode 100644 index 0000000..e92af31 --- /dev/null +++ b/app/services/mittwald.py @@ -0,0 +1,159 @@ +"""Mittwald-API-Client (Port der Logik aus transkript-platform/infra/deploy.py). + +Sicherheitsnetze wie dort: differenzielles PATCH (nie PUT), Namensraum `app-`, Post-Write-Guard. +Zusätzlich: `live=False` (Standard) blockiert JEDEN schreibenden Aufruf – es wird nur geloggt, was passieren würde. +Lesende Aufrufe laufen nur, wenn ein Token gesetzt ist. +""" +from __future__ import annotations + +import json +from typing import Callable + +import httpx + + +class DeployError(RuntimeError): + pass + + +class DryRunSkip(Exception): + pass + + +def mask_env(env: dict) -> dict: + import re + return {k: ("••••" if re.search(r"(SECRET|PASSWORD|KEY|TOKEN)$", k) else v) for k, v in env.items()} + + +class Mittwald: + def __init__(self, token: str, base: str, live: bool, log: Callable[[str], None], http: Callable | None = None): + self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log + self._http = http or self._real_http + self._project_uuid: str | None = None + + @staticmethod + def _real_http(method, url, headers, body): + r = httpx.request(method, url, headers=headers, json=body, timeout=60) + try: + data = r.json() if r.content else None + except ValueError: + data = None + return r.status_code, data, r.text[:500] + + def api(self, method: str, path: str, body: dict | None = None, *, write: bool, fatal: bool = True): + if write and not self.live: + self.log(f"[DRY-RUN] {method} {path}") + if body is not None: + shown = json.loads(json.dumps(body)) + for svc in (shown.get("services") or {}).values(): + if isinstance(svc, dict) and "environment" in svc: + svc["environment"] = mask_env(svc["environment"]) + self.log(" body: " + json.dumps(shown, ensure_ascii=False)[:1500]) + raise DryRunSkip() + if not self.token: + if not self.live: + self.log(f"(übersprungen: lesender Aufruf {method} {path} – kein MITTWALD_API_TOKEN)") + raise DryRunSkip() + raise DeployError("MITTWALD_API_TOKEN nicht gesetzt.") + status, data, text = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, body) + if status >= 400: + msg = f"{method} {path} → HTTP {status}: {text}" + if fatal: + raise DeployError(msg) + self.log(f"WARNUNG (nicht fatal): {msg}") + return None + return data + + def try_api(self, *a, **kw): + try: + return self.api(*a, **kw) + except DryRunSkip: + return None + + # ── Stack ── + def resolve_stack_id(self, project_id: str, stack_id: str) -> str: + if stack_id: + return stack_id + if not project_id: + if not self.live: + self.log("(Hinweis: Mittwald PROJECT_ID/STACK_ID sind noch nicht in den Einstellungen gesetzt – Platzhalter im Dry-Run)") + return "" + raise DeployError("Mittwald PROJECT_ID (oder STACK_ID) fehlt in den Einstellungen.") + try: + stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False) + except DryRunSkip: + return "" + items = stacks if isinstance(stacks, list) else (stacks or {}).get("items", []) + for s in items: + if str(s.get("description") or s.get("name") or "").lower() == "default": + return s["id"] + if items: + return items[0]["id"] + raise DeployError("Kein Stack im Projekt gefunden.") + + def get_stack(self, stack_id: str) -> dict | None: + return self.try_api("GET", f"/stacks/{stack_id}/", write=False) + + @staticmethod + def services_of(stack: dict | None) -> dict[str, dict]: + if not stack: + return {} + svcs = stack.get("services") + if isinstance(svcs, dict): + return svcs + if isinstance(svcs, list): + return {(s.get("serviceName") or s.get("name") or s.get("id")): s for s in svcs if (s.get("serviceName") or s.get("name") or s.get("id"))} + return {} + + def project_uuid(self, project_id: str) -> str: + if self._project_uuid: + return self._project_uuid + proj = self.try_api("GET", f"/projects/{project_id}/", write=False, fatal=False) + self._project_uuid = (proj or {}).get("id") or project_id + return self._project_uuid + + # ── Operationen ── + def deploy_service(self, *, stack_id: str, service: str, volume: str, definition: dict, container_port: str, + project_id: str, hostname: str | None) -> None: + stack = self.get_stack(stack_id) + existing = set(self.services_of(stack)) + protected = existing - {service} + if protected: + self.log(f"geschützte bestehende Services (bleiben unangetastet): {sorted(protected)}") + self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {"name": volume}}}, write=True) + self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: definition}}, write=True) + svc_id = "" + if self.live: + after = self.services_of(self.get_stack(stack_id)) + missing = protected - set(after) + if missing: + raise DeployError(f"ABBRUCH: bestehende Services verschwunden nach PATCH: {sorted(missing)}") + svc_id = after.get(service, {}).get("id", service) + self.try_api("POST", f"/stacks/{stack_id}/services/{svc_id}/actions/recreate/", write=True) + self.log("Post-Write-Guard: alle vorherigen Services noch vorhanden ✔") + if hostname: + self.ensure_ingress(project_id, hostname, svc_id, container_port) + + def ensure_ingress(self, project_id: str, hostname: str, container_id: str, port: str) -> None: + listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False) + items = listing if isinstance(listing, list) else (listing or {}).get("items", []) + if any(i.get("hostname") == hostname for i in items): + self.log(f"Ingress {hostname} existiert bereits → ok") + return + body = {"hostname": hostname, "projectId": self.project_uuid(project_id), + "paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]} + res = self.try_api("POST", "/ingresses", body, write=True, fatal=False) + if self.live: + self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else + f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).") + + def offboard_service(self, *, stack_id: str, service: str, volume: str, project_id: str, hostname: str | None) -> None: + self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: {}}}, write=True) + self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {}}}, write=True) + if hostname: + listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False) + items = listing if isinstance(listing, list) else (listing or {}).get("items", []) + for i in items: + if i.get("hostname") == hostname: + self.try_api("DELETE", f"/ingresses/{i['id']}", write=True, fatal=False) + self.log(f"Ingress {hostname} entfernt") diff --git a/app/services/onyva_login.py b/app/services/onyva_login.py new file mode 100644 index 0000000..d1c8123 --- /dev/null +++ b/app/services/onyva_login.py @@ -0,0 +1,312 @@ +"""Abgleich der OIDC-Clients mit der Developer-API von login.onyva.dev (`/api/v1`, siehe LoginSeite/docs/developer-api.md). + +Pro Tenant gibt es einen Soll-Zustand (`tenant.oidc`). Bei jedem Deploy wird der Ist-Zustand gelesen und bei Bedarf angelegt/angepasst: + + mode = off nichts anfassen + mode = adopt (Standard für importierte Altclients) nur sicherstellen, dass die Redirect-URIs des Tenants enthalten sind + mode = managed Redirect-URIs, auth_mode, totp_mode, allow_registration, backchannel_logout_uri exakt wie konfiguriert + +Freigaben (Memberships): `members` werden angelegt/aktualisiert; mit members_mode = exact werden zusätzliche Freigaben entfernt. + +Sicherheit: Lesen (GET) läuft immer, wenn ein Key da ist. SCHREIBEN nur mit apply=True – und apply erlaubt die Plattform nur, +wenn OIDC_SYNC_LIVE=true oder DEPLOY_LIVE=true. Das `client_secret` ist nur bei Anlage/Rotation sichtbar und wird SOFORT verschlüsselt gespeichert. +""" +from __future__ import annotations + +import os +import re +import time +from datetime import datetime, timezone +from typing import Callable + +import httpx +from sqlalchemy import select + +from app.db import session +from app.models import Secret, Setting, Tenant +from app.services import crypto, guard, store + +MODES = ("off", "adopt", "managed") +AUTH_MODES = ("required", "optional", "disabled") +TOTP_MODES = ("disabled", "optional", "required") +ROLES = ("viewer", "editor", "admin") +DEFAULTS = {"mode": "adopt", "auth_mode": "required", "totp_mode": "disabled", "allow_registration": False, + "backchannel_logout_uri": None, "members": [], "members_mode": "add", "last_sync": None} + + +class OnyvaError(RuntimeError): + pass + + +def key_present() -> bool: + return bool(os.getenv("ONYVA_LOGIN_API_KEY", "").strip()) + + +def writes_allowed() -> bool: + return os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true" + + +def base_url(db) -> str: + s = db.get(Setting, "onyva_login_api_base") + if s and s.value: + return s.value.rstrip("/") + issuer = db.get(Setting, "oidc_issuer") + return f"{(issuer.value if issuer and issuer.value else 'https://login.onyva.dev').rstrip('/')}/api/v1" + + +class OnyvaLogin: + """Dünner Client für /api/v1/apps. `http(method, url, headers, body) -> (status, data, headers)` ist in Tests ersetzbar.""" + + def __init__(self, base: str, key: str, http: Callable | None = None, sleep: Callable[[float], None] = time.sleep): + self.base, self.key, self._http, self._sleep = base.rstrip("/"), key, http or self._real, sleep + + @staticmethod + def _real(method, url, headers, body): + r = httpx.request(method, url, headers=headers, json=body, timeout=30) + try: + data = r.json() if r.content else None + except ValueError: + data = None + return r.status_code, data, dict(r.headers) + + def _req(self, method: str, path: str, body: dict | None = None): + for attempt in range(4): + status, data, headers = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}, body) + if status == 429 and attempt < 3: # Rate-Limit: 120 Requests / 60 s pro Key + self._sleep(min(int({k.lower(): v for k, v in headers.items()}.get("retry-after", 5)), 60)) + continue + if status == 401: + raise OnyvaError("API-Key ungültig, widerrufen oder abgelaufen (401).") + return status, data + raise OnyvaError("Rate-Limit des Login-Systems (429) dauerhaft überschritten.") + + def _ok(self, method, path, body=None, ok=(200, 201, 204)): + status, data = self._req(method, path, body) + if status not in ok: + detail = data.get("detail") if isinstance(data, dict) else data + raise OnyvaError(f"{method} {path} → HTTP {status}: {str(detail)[:300]}") + return data + + def list_apps(self) -> list[dict]: + return (self._ok("GET", "/apps") or {}).get("apps", []) + + def get_app(self, cid: str) -> dict | None: + status, data = self._req("GET", f"/apps/{cid}") + if status == 404: + return None + if status != 200: + raise OnyvaError(f"GET /apps/{cid} → HTTP {status}") + return data + + def create_app(self, body: dict) -> dict: + return self._ok("POST", "/apps", body, ok=(201, 200)) + + def patch_app(self, cid: str, body: dict) -> dict: + return self._ok("PATCH", f"/apps/{cid}", body) + + def delete_app(self, cid: str) -> None: + self._ok("DELETE", f"/apps/{cid}", ok=(204, 200)) + + def rotate_secret(self, cid: str) -> dict: + return self._ok("POST", f"/apps/{cid}/rotate-secret") + + def memberships(self, cid: str) -> list[dict]: + return (self._ok("GET", f"/apps/{cid}/memberships") or {}).get("memberships", []) + + def put_membership(self, cid: str, email: str, role: str) -> bool: + status, _ = self._req("PUT", f"/apps/{cid}/memberships", {"user_email": email, "role": role}) + if status == 404: + return False # Nutzer existiert im Login nicht + if status not in (200, 201): + raise OnyvaError(f"PUT memberships → HTTP {status}") + return True + + def delete_membership(self, cid: str, email: str) -> None: + self._req("DELETE", f"/apps/{cid}/memberships/{email}") + + +def client_from_env(db, http=None) -> OnyvaLogin | None: + key = os.getenv("ONYVA_LOGIN_API_KEY", "").strip() + return OnyvaLogin(base_url(db), key, http) if key else None + + +# ── Soll-Zustand ──────────────────────────────────────────────────────────── + +def config_of(t: Tenant) -> dict: + return {**DEFAULTS, **(t.oidc or {})} + + +def validate_config(cfg: dict) -> dict: + out = {**DEFAULTS, **cfg} + if out["mode"] not in MODES: + raise ValueError("mode: off | adopt | managed") + if out["auth_mode"] not in AUTH_MODES: + raise ValueError("auth_mode: required | optional | disabled") + if out["totp_mode"] not in TOTP_MODES: + raise ValueError("totp_mode: disabled | optional | required") + if out["members_mode"] not in ("add", "exact"): + raise ValueError("members_mode: add | exact") + out["allow_registration"] = bool(out["allow_registration"]) + out["backchannel_logout_uri"] = (out["backchannel_logout_uri"] or "").strip() or None + if out["backchannel_logout_uri"] and not out["backchannel_logout_uri"].startswith("https://"): + raise ValueError("Backchannel-Logout-URI muss https:// sein und öffentlich erreichbar") + members, seen = [], set() + for m in out["members"] or []: + email, role = str(m.get("email", "")).strip().lower(), m.get("role", "viewer") + if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + raise ValueError(f"Ungültige E-Mail: {email!r}") + if role not in ROLES: + raise ValueError(f"Rolle von {email}: viewer | editor | admin") + if email not in seen: + seen.add(email) + members.append({"email": email, "role": role}) + out["members"] = members + out.pop("last_sync", None) + return out + + +def desired_redirects(base_domain: str, t: Tenant) -> list[str]: + hosts = [f"{t.subdomain or t.id}.{base_domain}"] + if t.custom_domain: + hosts.append(t.custom_domain) + return [f"https://{h}/auth/callback" for h in hosts] + + +def diff_app(actual: dict, want: dict, mode: str) -> dict: + """Welche PATCH-Felder wären nötig? adopt: nur fehlende URIs ergänzen; managed: alles exakt.""" + changes: dict = {} + have, need = list(actual.get("redirect_uris") or []), want["redirect_uris"] + if mode == "adopt": + missing = [u for u in need if u not in have] + if missing: + changes["redirect_uris"] = have + missing + elif mode == "managed": + if set(have) != set(need): + changes["redirect_uris"] = need + for f in ("auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri"): + if actual.get(f) != want[f]: + changes[f] = want[f] + return changes + + +def _secret(db, tenant_id: str, key: str) -> str | None: + row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key)) + return crypto.decrypt(row.value_enc) if row and row.value_enc else None + + +def _store_credentials(tenant_id: str, client_id: str | None, client_secret: str | None, actor="onyva-login-sync") -> None: + """Sofort und in eigener Transaktion sichern – das Secret ist danach nicht mehr abrufbar.""" + from app.services import tenants as tsvc + with session() as db: + if client_id: + tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_ID", client_id, actor) + if client_secret: + tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_SECRET", client_secret, actor) + + +# ── Abgleich ──────────────────────────────────────────────────────────────── + +def sync_tenant(tenant_id: str, *, apply: bool, log: Callable[[str], None] = lambda m: None, client: OnyvaLogin | None = None, + rotate: bool = False, actor: str = "system") -> dict: + """Gleicht den OIDC-Client des Tenants ab. apply=False → nur lesen und Plan melden. + Rückgabe: {ok, applied, skipped, client_id, actions:[{kind,detail}], warnings:[...], created, error}.""" + rep = {"ok": True, "applied": False, "skipped": None, "client_id": None, "actions": [], "warnings": [], "created": False, "error": None} + act = lambda kind, detail: (rep["actions"].append({"kind": kind, "detail": detail}), log(f"OIDC: {detail}")) # noqa: E731 + warn = lambda msg: (rep["warnings"].append(msg), log(f"OIDC ⚠ {msg}")) # noqa: E731 + + if apply and not writes_allowed(): + rep.update(ok=False, error="Schreibzugriffe auf das Login-System sind gesperrt (OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv).") + return rep + if apply and not guard.live_allowed(tenant_id): + rep.update(ok=False, error=guard.denial(tenant_id)) + return rep + try: + with session() as db: + t = db.get(Tenant, tenant_id) + cfg = config_of(t) + base_domain = (db.get(Setting, "base_domain").value if db.get(Setting, "base_domain") else "example.de") + want = {"name": f"Onyscribe – {t.name}", "redirect_uris": desired_redirects(base_domain, t), "auth_mode": cfg["auth_mode"], + "totp_mode": cfg["totp_mode"], "allow_registration": cfg["allow_registration"], "backchannel_logout_uri": cfg["backchannel_logout_uri"]} + cid, has_secret = _secret(db, tenant_id, "OIDC_CLIENT_ID"), bool(_secret(db, tenant_id, "OIDC_CLIENT_SECRET")) + client = client or client_from_env(db) + mode = cfg["mode"] + if mode == "off": + rep["skipped"] = "OIDC-Verwaltung ist für diesen Tenant aus." + log("OIDC: Verwaltung aus – nichts zu tun") + return rep + if client is None: + rep["skipped"] = "ONYVA_LOGIN_API_KEY nicht gesetzt." + rep["ok"] = not apply # beim echten Deploy ist ein fehlender Key ein Fehler, im Dry-Run nur ein Hinweis + rep["error"] = None if rep["ok"] else rep["skipped"] + log("OIDC: (übersprungen – ONYVA_LOGIN_API_KEY nicht gesetzt)") + return rep + + app = client.get_app(cid) if cid else None + new_app = app is None + verb = "" if apply else "würde " + if app is None: + if cid: + warn(f"Client {cid} existiert im Login nicht mehr – es wird ein neuer angelegt.") + body = {k: want[k] for k in ("name", "redirect_uris", "auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri")} + act("create", f"{verb}App „{want['name']}“ anlegen (Redirect: {', '.join(want['redirect_uris'])}; auth_mode={want['auth_mode']})") + if apply: + created = client.create_app(body) + cid = created["client_id"] + _store_credentials(tenant_id, cid, created.get("client_secret"), actor) # ZUERST sichern + rep["created"], has_secret = True, True + log(f"OIDC: App angelegt → {cid} (Secret verschlüsselt gespeichert)") + app = {"redirect_uris": want["redirect_uris"], "auth_mode": want["auth_mode"]} + else: + changes = diff_app(app, want, mode) + if changes: + act("patch", f"{verb}App {cid} anpassen: " + ", ".join(f"{k}={v}" for k, v in changes.items())) + if apply: + client.patch_app(cid, changes) + else: + log(f"OIDC: App {cid} ist abgeglichen") + rep["client_id"] = cid + + if not has_secret: + warn("Client-Secret ist nicht in der Admin-DB (beim Login nicht abrufbar) – eintragen oder „Secret rotieren“.") + if rotate and cid: + act("rotate", f"{verb}Secret von {cid} rotieren (altes sofort ungültig)") + if apply: + _store_credentials(tenant_id, None, client.rotate_secret(cid)["client_secret"], actor) + + # Freigaben + members = cfg["members"] + effective_auth = want["auth_mode"] if mode == "managed" else (app.get("auth_mode") or want["auth_mode"]) + if (members or cfg["members_mode"] == "exact") and (cid or new_app): + current = {} if new_app else {m["email"].lower(): m["role"] for m in client.memberships(cid)} + for m in members: + if current.get(m["email"]) != m["role"]: + act("member_put", f"{verb}{m['email']} als {m['role']} freigeben") + if apply and not client.put_membership(cid, m["email"], m["role"]): + warn(f"{m['email']} existiert im Login nicht – Nutzer dort anlegen, dann erneut abgleichen.") + if cfg["members_mode"] == "exact": + for email in sorted(set(current) - {m["email"] for m in members}): + act("member_delete", f"{verb}Freigabe von {email} entziehen") + if apply: + client.delete_membership(cid, email) + elif effective_auth == "required" and not members: + log("OIDC: auth_mode=required ohne gepflegte Freigaben – bestehende Freigaben bleiben unverändert") + rep["applied"] = apply and bool(rep["actions"]) + except OnyvaError as exc: + rep.update(ok=False, error=str(exc)) + log(f"OIDC ✘ {exc}") + except Exception as exc: # noqa: BLE001 + rep.update(ok=False, error=f"{type(exc).__name__}: {exc}") + log(f"OIDC ✘ {rep['error']}") + if apply: + _record(tenant_id, rep) + return rep + + +def _record(tenant_id: str, rep: dict) -> None: + with session() as db: + t = db.get(Tenant, tenant_id) + cfg = dict(t.oidc or {}) + cfg["last_sync"] = {"ts": datetime.now(timezone.utc).isoformat(), "ok": rep["ok"], "created": rep["created"], "error": rep["error"], + "actions": [a["detail"] for a in rep["actions"]], "warnings": rep["warnings"]} + t.oidc = cfg + store.audit(db, "system", "oidc.sync", tenant_id, "ok" if rep["ok"] else f"Fehler: {rep['error']}") diff --git a/app/services/preflight.py b/app/services/preflight.py new file mode 100644 index 0000000..99ad281 --- /dev/null +++ b/app/services/preflight.py @@ -0,0 +1,114 @@ +"""Vorflug-Check: ist ein Tenant bereit für einen Live-Deploy? Liest nur (Mittwald-GET, Login-API-GET, eigener Health) und schreibt nichts. + +Jeder Punkt: {id, level: ok|warn|fail|info, label, detail, fix}. Ein Deploy ist „bereit“, wenn es kein `fail` gibt. +""" +from __future__ import annotations + +import socket + +from sqlalchemy import select + +from app.db import session +from app.models import Secret, Tenant +from app.services import contract, deploy, guard, onyva_login, preview, store +from app.services.mittwald import DeployError, DryRunSkip, Mittwald + + +def _item(id_, level, label, detail="", fix=""): + return {"id": id_, "level": level, "label": label, "detail": detail, "fix": fix} + + +def run(tenant_id: str, *, mw_http=None, oidc_client=None, self_get=None) -> dict: + out: list[dict] = [] + add = lambda *a, **k: out.append(_item(*a, **k)) # noqa: E731 + with session() as db: + t = db.get(Tenant, tenant_id) + cfg, _ = store.content(db, t, "current") + draft, source, pin = bool(t.draft), t.source, t.core_pin + base_domain, project, stack = deploy.setting(db, "base_domain"), deploy.setting(db, "mittwald_project_id"), deploy.setting(db, "mittwald_stack_id") + registry, admin_url = deploy.setting(db, "registry"), deploy.setting(db, "admin_public_url") + bundle_ok = deploy.setting(db, "core_supports_bundle", "false") == "true" + tag = deploy.effective_tag(db, t) + host = deploy.host_for(db, t) + missing = deploy.missing_secrets(db, t) + oc = onyva_login.config_of(t) + has_oidc_secrets = all(db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == k, Secret.value_enc.is_not(None))) is not None + for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET")) + stt_provider = (cfg.get("stt") or {}).get("provider") or "mistral" + + # Freigaben / Modus + add("live", "ok" if deploy.live_enabled() else "warn", "DEPLOY_LIVE", "aktiv" if deploy.live_enabled() else "nicht aktiv – nur Dry-Run möglich", + "" if deploy.live_enabled() else "Env DEPLOY_LIVE=true der Admin-Plattform setzen und neu starten (erst nach erfolgreichem Dry-Run).") + add("allow", "ok" if guard.live_allowed(tenant_id) else "warn", "Live-Freigabe für diesen Tenant", f"DEPLOY_ALLOWED_TENANTS = {guard.describe()}", + "" if guard.live_allowed(tenant_id) else f"Env DEPLOY_ALLOWED_TENANTS um „{tenant_id}“ ergänzen.") + # Vertrag / Core + add("contract", "ok" if contract.supported() else "fail", "Core-Vertrag", f"Admin-Core {contract.sha()[:10]}, Vertrag v{contract.contract_version()}", + "" if contract.supported() else "Admin-Plattform aktualisieren.") + hex_tag = deploy._hex_tag(tag) + add("tag", "ok" if hex_tag else "warn", "Core-Image-Tag", f":{tag}" + (" (gepinnt)" if pin else ""), + "" if hex_tag else "Besser den vollen Git-SHA als Pin setzen (Tenant → Übersicht → Core-Version) – dann prüft der Health-Check die Core-Version.") + if source == "admin": + add("bundle", "ok" if bundle_ok else "fail", "Core kann Bundle laden", "Einstellung core_supports_bundle", "" if bundle_ok else "Einstellungen → „Core kann Bundle laden“ aktivieren (erst wenn das Image den Bundle-Loader enthält).") + good_url = admin_url.startswith("https://") and "<" not in admin_url + add("adminurl", "ok" if good_url else "fail", "Öffentliche Admin-URL", admin_url or "nicht gesetzt", + "" if good_url else "Einstellungen → „Öffentliche URL der Admin-Plattform“ (https://…): so holt der Tenant-Container sein Bundle.") + if good_url: + try: + status = (self_get or _get)(f"{admin_url.rstrip('/')}/healthz") + add("adminreach", "ok" if status == 200 else "fail", "Admin von außen erreichbar", f"GET /healthz → {status}", "" if status == 200 else "Domain/Proxy in Coolify prüfen.") + except Exception as exc: # noqa: BLE001 + add("adminreach", "fail", "Admin von außen erreichbar", f"{type(exc).__name__}: {str(exc)[:120]}", "Domain/Proxy in Coolify prüfen.") + # Mittwald + add("domain", "ok" if base_domain else "fail", "Basis-Domain", base_domain or "nicht gesetzt", "" if base_domain else "Einstellungen → Basis-Domain (= Gitea-Variable DOMAIN).") + add("registry", "ok" if registry else "fail", "Registry", registry or "nicht gesetzt", "" if registry else "Einstellungen → Registry (= Gitea-Variable REGISTRY).") + add("mtoken", "ok" if deploy.token_present() else "fail", "MITTWALD_API_TOKEN", "gesetzt" if deploy.token_present() else "nicht gesetzt", + "" if deploy.token_present() else "Env MITTWALD_API_TOKEN der Admin-Plattform setzen (derselbe Token wie in Gitea).") + add("mproject", "ok" if (project or stack) else "fail", "Mittwald PROJECT_ID / STACK_ID", f"{project or '–'} / {stack or '–'}", "" if (project or stack) else "Einstellungen → PROJECT_ID (und optional STACK_ID).") + if deploy.token_present() and (project or stack): + try: + mw = Mittwald(__import__("os").getenv("MITTWALD_API_TOKEN", ""), __import__("os").getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), False, lambda m: None, mw_http) + sid = mw.resolve_stack_id(project, stack) + services = sorted(Mittwald.services_of(mw.api("GET", f"/stacks/{sid}/", write=False))) + mine = f"app-{tenant_id}" + protected = [s for s in services if s != mine] + add("stack", "ok", "Mittwald-Stack lesbar", f"{len(services)} Services; bestehende bleiben unangetastet: {', '.join(protected) or '–'}") + add("svc", "info", "Service dieses Tenants", f"{mine} " + ("existiert bereits (wird aktualisiert)" if mine in services else "existiert noch nicht (wird angelegt)")) + except (DeployError, DryRunSkip) as exc: + add("stack", "fail", "Mittwald-Stack lesbar", str(exc)[:200], "Token-Rechte, PROJECT_ID/STACK_ID prüfen.") + except Exception as exc: # noqa: BLE001 + add("stack", "fail", "Mittwald-Stack lesbar", f"{type(exc).__name__}: {str(exc)[:160]}", "Netzwerk/Token prüfen.") + # Secrets / OIDC / STT + will_create = oc["mode"] == "managed" and onyva_login.key_present() and not has_oidc_secrets + real_missing = [m for m in missing if not (will_create and m in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))] + add("secrets", "ok" if not real_missing else "fail", "Pflicht-Secrets", "vollständig" if not real_missing else "fehlen: " + ", ".join(real_missing), + "" if not real_missing else "Tenant → Secrets (tenantspezifisch) bzw. Einstellungen → Geteilte Secrets (z. B. CORTECS_API_KEY).") + if will_create: + add("oidcnew", "info", "OIDC-Client", "wird beim Deploy automatisch im Login angelegt (Client-ID/-Secret sofort gespeichert)") + elif oc["mode"] != "off" and not onyva_login.key_present(): + add("oidckey", "fail", "ONYVA_LOGIN_API_KEY", "nicht gesetzt – OIDC-Abgleich beim Deploy nicht möglich", "Env ONYVA_LOGIN_API_KEY setzen oder OIDC-Verwaltung auf „off“ stellen und Client manuell eintragen.") + elif oc["mode"] != "off": + add("oidckey", "ok", "Login-API", f"Modus {oc['mode']}") + if oc["mode"] != "off" and oc["auth_mode"] == "required" and not oc["members"]: + add("members", "warn", "Freigaben", "auth_mode „required“, aber keine Nutzer gepflegt – niemand kann sich anmelden", + "Domain & Login → Freigaben: mindestens dich eintragen (Nutzer muss im Login existieren).") + if stt_provider != "cortecs": + add("stt", "info", "STT-Provider", stt_provider + " – braucht MISTRAL_API_KEY") + # Inhalt + with session() as db: + t = db.get(Tenant, tenant_id) + try: + val = preview.run(db, t, "current", "validate") + add("render", "ok" if val.get("ok") else "fail", "Veröffentlichter Stand rendert (echter Core)", "; ".join(val.get("errors", []))[:200] or "Web-UI und PDF ok", + "" if val.get("ok") else "Tenant → Editor korrigieren und neu veröffentlichen.") + except Exception as exc: # noqa: BLE001 + add("render", "fail", "Rendering", str(exc)[-200:]) + if draft: + add("draft", "warn", "Unveröffentlichter Entwurf", "wird NICHT mit deployt", "Erst veröffentlichen, wenn er mit soll.") + add("host", "info", "Ziel-URL", f"https://{host}", "") + ready = not any(i["level"] == "fail" for i in out) + return {"ready": ready, "items": out, "host": host, "tag": tag} + + +def _get(url: str) -> int: + import httpx + return httpx.get(url, timeout=8, follow_redirects=True).status_code diff --git a/app/services/preview.py b/app/services/preview.py new file mode 100644 index 0000000..12ca638 --- /dev/null +++ b/app/services/preview.py @@ -0,0 +1,109 @@ +"""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 | ws: (Live-Arbeitsstand des Agenten) | proposal: (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) diff --git a/app/services/preview_worker.py b/app/services/preview_worker.py new file mode 100644 index 0000000..5f1c07d --- /dev/null +++ b/app/services/preview_worker.py @@ -0,0 +1,65 @@ +"""Läuft in einem isolierten Verzeichnis mit Kopie des Cores + materialisiertem Tenant. + +Aufruf: python preview_worker.py (JSON-Eingabe auf stdin) +Führt den ECHTEN Core aus (auch overrides.py) – deshalb nur als Subprozess ohne Secrets/Netz-Keys. +""" +import json +import os +import sys +import traceback + +sys.path.insert(0, os.getcwd()) + +kind, out = sys.argv[1], sys.argv[2] +args = json.loads(sys.stdin.read() or "{}") + + +def main() -> None: + from core import app as appmod + from core.config import CFG + + if kind == "index": + page = appmod._render_index() + open(out, "w", encoding="utf-8").write(page) + elif kind == "pdf": + pdf = appmod.build_pdf(args.get("title") or "Vorschau", args["summary"], "SPRECHER_0: Beispiel.") + open(out, "wb").write(pdf) + elif kind == "prompt": + base = appmod._summary_prompt() + ctx = {"title": args.get("title", ""), "insurance_type": args.get("terminart", ""), + "customer_2": args.get("projekt", ""), "customer_name": args.get("kunde", ""), "customer_address": ""} + final = appmod.HOOKS.build_prompt(base, ctx) + json.dump({"prompt": final, "chars": len(final)}, open(out, "w")) + elif kind == "validate": + errors, warnings = [], [] + page = appmod._render_index() + if "@@" in page: + errors.append("Web-UI: nicht ersetzte @@PLATZHALTER@@ im Template") + if not CFG.logo_path: + warnings.append("Kein Logo (logo.svg/logo.png) hinterlegt") + if not CFG.favicon_path: + warnings.append("Kein favicon.svg – es wird ein generisches Favicon verwendet") + try: + pdf = appmod.build_pdf("Validierung", args["summary"], "SPRECHER_0: Beispiel.") + if pdf[:5] != b"%PDF-": + errors.append("PDF-Rendering lieferte kein PDF") + except Exception as exc: # noqa: BLE001 + errors.append(f"PDF-Rendering fehlgeschlagen: {exc}") + prompt = CFG.prompt_path.read_text(encoding="utf-8") + if len(prompt.strip()) < 200: + warnings.append("Prompt ist sehr kurz") + json.dump({"ok": not errors, "errors": errors, "warnings": warnings, + "tenant": CFG.id, "prompt_chars": len(prompt)}, open(out, "w")) + + +try: + main() +except SystemExit: + raise +except BaseException: # noqa: BLE001 + tb = traceback.format_exc() + if kind == "validate": + json.dump({"ok": False, "errors": ["Tenant lässt sich nicht laden:\n" + tb[-1500:]], "warnings": []}, open(out, "w")) + else: + sys.stderr.write(tb) + sys.exit(2) diff --git a/app/services/releases.py b/app/services/releases.py new file mode 100644 index 0000000..5258fb1 --- /dev/null +++ b/app/services/releases.py @@ -0,0 +1,254 @@ +"""Core-Releases: Eingang (CI), Kompatibilitätsprüfung aller Tenants, Freigabe, Canary-Rollout, Rollback. + +Ablauf: CI meldet Image-Tag → Admin (läuft auf genau diesem Core) prüft alle Tenants in der Sandbox → Freigabe → +Rollout (Canary zuerst, Abbruch beim ersten Fehler, Health-Check pro Tenant) → aktiver Tag wird der neue Standard. +Gepinnte Tenants nehmen nicht teil. Echte Rollouts brauchen DEPLOY_LIVE=true; sonst nur Dry-Run. +""" +from __future__ import annotations + +import hashlib +import io +import re +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.db import session +from app.models import CoreRelease, Deployment, Setting, Tenant +from app.services import contract, deploy, guard, preview, store + +DATE = re.compile(r"\b\d{1,2}\.\d{1,2}\.\d{2,4}\b") + + +class ReleaseError(RuntimeError): + pass + + +# ── Eingang ───────────────────────────────────────────────────────────────── + +def announce(db, sha: str, tag: str | None, ref: str, image: str, message: str) -> tuple[CoreRelease, bool]: + if not re.fullmatch(r"[0-9a-fA-F]{7,64}", sha or ""): + raise ReleaseError("sha: 7–64 Hex-Zeichen erwartet") + sha = sha.lower() + r = db.scalar(select(CoreRelease).where(CoreRelease.sha == sha)) + created = r is None + if created: + r = CoreRelease(sha=sha, tag=(tag or sha)[:100], ref=ref[:100], image=image[:300], message=message[:500]) + db.add(r) + store.audit(db, "ci", "core.release", None, f"{sha[:10]} ({ref}) {message[:80]}") + else: + r.message, r.ref, r.image = message[:500] or r.message, ref[:100] or r.ref, image[:300] or r.image + db.flush() + return r, created + + +def admin_matches(r: CoreRelease) -> bool: + return contract.same_sha(contract.sha(), r.sha) + + +def view(r: CoreRelease, active_tag: str) -> dict: + return {"id": r.id, "sha": r.sha, "tag": r.tag, "ref": r.ref, "image": r.image, "message": r.message, "status": r.status, + "announced_at": r.announced_at.isoformat() if r.announced_at else None, "admin_matches": admin_matches(r), + "approved_by": r.approved_by, "compat": r.compat, "rollout_batch": r.rollout_batch, "previous_tag": r.previous_tag, + "active": r.tag == active_tag or contract.same_sha(r.sha, active_tag)} + + +# ── Kompatibilitätsprüfung ────────────────────────────────────────────────── + +def _pdf_fingerprint(pdf: bytes) -> tuple[int, str]: + from pypdf import PdfReader + rd = PdfReader(io.BytesIO(pdf)) + text = DATE.sub("", "\n".join((p.extract_text() or "") for p in rd.pages)) # das Tagesdatum steht im PDF + return len(rd.pages), hashlib.sha256(text.encode()).hexdigest() + + +def fingerprint(db, t: Tenant) -> dict: + """Rendert den veröffentlichten Stand mit dem aktuellen Admin-Core und bildet Fingerabdrücke.""" + cfg, files = store.content(db, t, "current") + sample = preview.sample_for(t) + fp: dict = {"sample": hashlib.sha256(sample.encode()).hexdigest()[:12]} + val = preview.run_content(db, t, cfg, files, "validate", sample) + fp["validate"] = {"ok": val.get("ok", False), "errors": val.get("errors", []), "warnings": val.get("warnings", [])} + if val.get("ok"): + pages, text_sha = _pdf_fingerprint(preview.run_content(db, t, cfg, files, "pdf", sample)) + fp.update(pdf_pages=pages, pdf_text=text_sha, + web=hashlib.sha256(preview.run_content(db, t, cfg, files, "index", sample)).hexdigest(), + prompt=hashlib.sha256(preview.run_content(db, t, cfg, files, "prompt", sample, {"title": "x"})["prompt"].encode()).hexdigest()) + fp["schema_problems"] = contract.check_tenant_json(cfg) + return fp + + +def compare(fp: dict, baseline: dict | None, version: int) -> dict: + """→ {status: ok|changed|new|error, changed:[…], errors:[…], warnings:[…]}""" + out = {"status": "ok", "changed": [], "errors": fp["validate"]["errors"], "warnings": list(fp["validate"]["warnings"]) + fp.get("schema_problems", [])} + if not fp["validate"]["ok"]: + out["status"] = "error" + return out + if not baseline or baseline.get("version") != version or baseline["fp"].get("sample") != fp["sample"]: + out["status"] = "new" + return out + labels = {"pdf_pages": "PDF-Seitenzahl", "pdf_text": "PDF-Text", "web": "Web-UI", "prompt": "Finaler Prompt"} + out["changed"] = [labels[k] for k in labels if baseline["fp"].get(k) != fp.get(k)] + if out["changed"]: + out["status"] = "changed" + return out + + +def check(release_id: int, *, force: bool = False, wait: bool = False, workers: int = 3) -> None: + """Prüft ALLE Tenants (außer archivierten) mit dem Core, auf dem die Admin-Plattform läuft. Ergebnis in release.compat.""" + with session() as db: + r = db.get(CoreRelease, release_id) + if r is None: + raise ReleaseError("Release nicht gefunden") + if not force and not admin_matches(r): + raise ReleaseError(f"Die Admin-Plattform läuft noch auf Core {contract.sha()[:10]}, das Release ist {r.sha[:10]}. " + "Erst den Admin neu bauen (FROM neues Core-Image) – sonst würde gegen den falschen Core geprüft.") + ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status != "archived").order_by(Tenant.id))] + r.compat = {"checked_at": None, "core_sha": contract.sha(), "running": True, "tenants": {}, "summary": {}, "forced": force and not admin_matches(r)} + r.status = "new" if r.status in ("new", "checked") else r.status + + def one(tid: str): + with session() as db: + t = db.get(Tenant, tid) + try: + fp = fingerprint(db, t) + res = compare(fp, t.render_baseline, t.current_version) + except Exception as exc: # noqa: BLE001 + fp, res = {}, {"status": "error", "changed": [], "errors": [f"{type(exc).__name__}: {str(exc)[-300:]}"], "warnings": []} + return tid, fp, res + + def work(): + results = {} + with ThreadPoolExecutor(max_workers=workers) as ex: + for tid, fp, res in ex.map(one, ids): + results[tid] = {**res, "fp": fp} + summary = {k: sum(1 for v in results.values() if v["status"] == k) for k in ("ok", "changed", "new", "error")} + with session() as db: + r = db.get(CoreRelease, release_id) + r.compat = {"checked_at": datetime.now(timezone.utc).isoformat(), "core_sha": contract.sha(), "running": False, "tenants": results, + "summary": summary, "forced": bool(r.compat and r.compat.get("forced"))} + if r.status == "new": + r.status = "checked" + store.audit(db, "system", "core.check", None, f"{r.sha[:10]}: {summary}") + + th = threading.Thread(target=work, daemon=True) + th.start() + if wait: + th.join() + + +# ── Freigabe & Rollout ────────────────────────────────────────────────────── + +def approve(db, release_id: int, actor: str, acknowledge: bool = False) -> CoreRelease: + r = db.get(CoreRelease, release_id) + if r is None: + raise ReleaseError("Release nicht gefunden") + c = r.compat + if not c or c.get("running"): + raise ReleaseError("Erst die Kompatibilitätsprüfung abschließen.") + if not c.get("forced") and not contract.same_sha(c.get("core_sha", ""), r.sha): + raise ReleaseError("Die Prüfung stammt von einem anderen Core als dieses Release – bitte erneut prüfen.") + if c["summary"].get("error"): + bad = [t for t, v in c["tenants"].items() if v["status"] == "error"] + raise ReleaseError(f"Nicht freigebbar: {len(bad)} Tenant(s) mit Fehlern ({', '.join(bad)}).") + if c["summary"].get("changed") and not acknowledge: + raise ReleaseError("Bei einigen Tenants hat sich das Rendering geändert – bitte prüfen und bewusst bestätigen (acknowledge).") + r.status, r.approved_by, r.approved_at = "approved", actor, datetime.now(timezone.utc) + store.audit(db, actor, "core.approve", None, r.sha[:10]) + return r + + +def canary_list(db) -> list[str]: + s = db.get(Setting, "canary_tenants") + return [x.strip() for x in (s.value if s and s.value else "demo").split(",") if x.strip()] + + +def _set_setting(db, key: str, value: str) -> None: + s = db.get(Setting, key) + if s is None: + s = Setting(key=key) + db.add(s) + s.value = value + + +def rollout(release_id: int, actor: str, *, dry_run: bool, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]: + live = not dry_run + if live and not deploy.live_enabled(): + raise ReleaseError("Echte Rollouts sind gesperrt (DEPLOY_LIVE != true). Dry-Run ist möglich.") + with session() as db: + r = db.get(CoreRelease, release_id) + if r is None: + raise ReleaseError("Release nicht gefunden") + allowed = {"approved", "active", "failed", "rolled_back"} | ({"new", "checked"} if dry_run else set()) # Dry-Run schon vor der Freigabe + if r.status not in allowed: + raise ReleaseError(f"Release ist im Status „{r.status}“ – erst prüfen und freigeben.") + tag, sha = r.tag, r.sha + ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin] + if live: # Live: nur für Live-Schreibzugriffe freigegebene Tenants (guard.py) + ids = [t for t in ids if guard.live_allowed(t)] + if not ids: + raise ReleaseError(f"Kein Tenant für Live-Rollouts freigegeben (DEPLOY_ALLOWED_TENANTS = {guard.describe()}).") + canary = canary_list(db) + batch = uuid.uuid4().hex[:10] + if live: + r.status, r.previous_tag, r.rollout_batch = "rolling", deploy.setting(db, "core_image_tag", "latest"), batch + store.audit(db, actor, "core.rollout", None, f"{sha[:10]} → {len(ids)} Tenants") + + def done(ok: bool, results: dict): + if not live: + return + with session() as db: + r = db.get(CoreRelease, release_id) + if ok: + r.status = "active" + _set_setting(db, "core_image_tag", tag) + for other in db.scalars(select(CoreRelease).where(CoreRelease.status == "active", CoreRelease.id != release_id)): + other.status = "superseded" + for tid in results: # Baselines für künftige Kompatibilitätsprüfungen + t = db.get(Tenant, tid) + c = ((r.compat or {}).get("tenants") or {}).get(tid) + if t and c and c.get("fp"): + t.render_baseline = {"version": t.current_version, "core": sha, "fp": c["fp"]} + else: + r.status = "failed" + store.audit(db, "system", "core.rollout.done", None, f"{sha[:10]} {'ok' if ok else 'FEHLER'} {results}") + + dep_ids = deploy.run_sequence(ids, "dry-run" if dry_run else "deploy", actor, batch=batch, core_tag=tag, http=http, oidc_client=oidc_client, + health=health, canary=canary, wait=wait, on_done=done) + return batch, dep_ids + + +def rollback_release(release_id: int, actor: str, *, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]: + """Alle (nicht gepinnten) Tenants auf den Tag vor diesem Rollout zurück.""" + if not deploy.live_enabled(): + raise ReleaseError("Echte Rollbacks sind gesperrt (DEPLOY_LIVE != true).") + with session() as db: + r = db.get(CoreRelease, release_id) + if r is None or not r.previous_tag: + raise ReleaseError("Kein vorheriger Tag bekannt – Rollback nicht möglich.") + prev = r.previous_tag + ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin and guard.live_allowed(t.id)] + batch = uuid.uuid4().hex[:10] + store.audit(db, actor, "core.rollback", None, f"{r.sha[:10]} → :{prev}") + + def done(ok, results): + with session() as db: + r = db.get(CoreRelease, release_id) + if ok: + r.status = "rolled_back" + _set_setting(db, "core_image_tag", prev) + store.audit(db, "system", "core.rollback.done", None, f"{'ok' if ok else 'FEHLER'} {results}") + + return batch, deploy.run_sequence(ids, "deploy", actor, batch=batch, core_tag=prev, http=http, oidc_client=oidc_client, health=health, wait=wait, on_done=done) + + +def tenant_rollback_tag(db, t: Tenant) -> str | None: + """Letzter erfolgreich live deployter Tag dieses Tenants, der vom aktuellen abweicht.""" + for d in db.scalars(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "deploy", Deployment.status == "success", + Deployment.simulated.is_(False)).order_by(Deployment.id.desc())): + if d.image_tag and d.image_tag != t.deployed_image: + return d.image_tag + return None diff --git a/app/services/samples.py b/app/services/samples.py new file mode 100644 index 0000000..0656c58 --- /dev/null +++ b/app/services/samples.py @@ -0,0 +1,76 @@ +"""Beispielprotokolle für die PDF-Vorschau (so, wie der LLM sie liefern würde).""" + +DEFAULT_SAMPLE = """===TERMINART=== +Kick-off +===/TERMINART=== +===PROJEKT=== +Muster GmbH +Relaunch 2026 +===/PROJEKT=== +===KONTAKT=== +Max Mustermann - Geschäftsführer +Erika Beispiel - Projektleitung +===/KONTAKT=== +Anlass & Kontext +Erstes Kennenlernen und Abstecken des Projektrahmens. Ziel ist ein gemeinsames Verständnis von Umfang, Zeitplan und Zuständigkeiten. +Besprochene Themen +• Zeitplan und Meilensteine bis zum Go-live im Herbst +• Budgetrahmen und Freigabeprozess +• Anbindung des bestehenden CRM +Entscheidungen +• Projektstart im Juli +• Wöchentlicher Jour fixe, dienstags 10:00 Uhr +Action Items +• Angebot bis Freitag senden [Verantwortlich: Erika Beispiel, Frist: 18.07.] +• Zugänge zum CRM bereitstellen [Verantwortlich: Max Mustermann, Frist: 22.07.] +Offene Punkte +• Finale Designrichtung noch nicht entschieden +• Datenschutzfreigabe der Fachabteilung steht aus +Nächste Schritte +• Folgetermin zum Konzept-Review vereinbaren +""" + +ROTARY_SAMPLE = """===TERMINART=== +Clubmeeting +===/TERMINART=== +===PROJEKT=== +Lieferketten im Wandel +===/PROJEKT=== +Begrüßung +Der Präsident eröffnet das Meeting und begrüßt die anwesenden Freunde sowie zwei Gäste. +Geburtstage +• Anna Beispiel +• Peter Muster +Aktuelle 5 Minuten +Kurzer Bericht zum Stand der Spendenaktion für das Jugendprojekt. Bisher sind 4.200 Euro zusammengekommen. +Happy Euro +• Für die gelungene Veranstaltung am Samstag +• Für das gute Wetter beim Grillfest +Vortrag / Referat +Globalisierung – historische Einordnung +Der Referent ordnet die Entwicklung der globalen Lieferketten seit den 1990er-Jahren ein. +Folgen für die Region +• Abhängigkeit von wenigen Zulieferern +• Chancen durch regionale Wertschöpfung +Dank +Der Präsident dankt dem Referenten für den anregenden Vortrag. +Abschluss +Nächstes Meeting am kommenden Montag. +""" + +SAMPLE_TRANSCRIPT = """SPRECHER_0: Guten Morgen zusammen, schön, dass wir uns heute zum Kick-off treffen. Ich bin Erika Beispiel, Projektleitung auf unserer Seite. +SPRECHER_1: Morgen. Max Mustermann, ich bin Geschäftsführer der Muster GmbH. Wir wollen unsere Website bis zum Herbst neu aufsetzen. +SPRECHER_0: Gut. Dann starten wir im Juli. Ich würde einen wöchentlichen Jour fixe vorschlagen, dienstags um zehn. +SPRECHER_1: Passt. Das Budget liegt bei etwa vierzigtausend Euro, die Freigabe läuft über mich. +SPRECHER_0: Ich schicke Ihnen bis Freitag das Angebot. Und wir brauchen von Ihnen die Zugänge zum CRM, möglichst bis zum zweiundzwanzigsten Juli. +SPRECHER_1: Mache ich. Offen ist noch die Designrichtung und ob die Datenschutzabteilung zustimmt. +SPRECHER_0: Dann vereinbaren wir als Nächstes einen Termin zum Konzept-Review. +""" + +ROTARY_TRANSCRIPT = """SPRECHER_0: Liebe Freunde, ich begrüße euch zum Clubmeeting und freue mich über zwei Gäste heute Abend. +SPRECHER_0: Geburtstag haben diese Woche Anna Beispiel und Peter Muster, herzlichen Glückwunsch. +SPRECHER_1: Zur Spendenaktion für das Jugendprojekt: Wir haben bisher viertausendzweihundert Euro zusammen. +SPRECHER_2: Happy Euro für das schöne Wetter beim Grillfest und für die gelungene Veranstaltung am Samstag. +SPRECHER_3: Mein Vortrag heute: Globalisierung und Lieferketten. Seit den Neunzigern hat sich die Abhängigkeit von wenigen Zulieferern stark erhöht. Für unsere Region bedeutet das Risiken, aber auch Chancen durch regionale Wertschöpfung. +SPRECHER_0: Vielen Dank für den Vortrag. Nächstes Meeting ist am kommenden Montag. +""" diff --git a/app/services/seed.py b/app/services/seed.py new file mode 100644 index 0000000..47c7453 --- /dev/null +++ b/app/services/seed.py @@ -0,0 +1,54 @@ +"""Seed-Export/-Import: Startdaten (importierte Tenants) als Datei im Repo. + +So startet eine frische Instanz (z. B. Coolify) mit den aktuellen Tenants, ohne Zugriff auf das alte Git-Repo. +Secrets-WERTE sind nie enthalten – nur die Schlüsselnamen. +""" +from __future__ import annotations + +import base64 +import gzip +import json +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Blob, Secret, Setting, Tenant, Version + +COLS = ["id", "name", "status", "source", "subdomain", "custom_domain", "domain_status", "cpu", "memory", + "current_version", "deployed_version", "deployed_image", "preview_sample", "oidc", "notes"] + + +def export(db: Session, path: Path) -> None: + tenants = db.scalars(select(Tenant).order_by(Tenant.id)).all() + shas = set() + out = {"tenants": [], "versions": [], "secrets": [], "settings": {}} + for t in tenants: + out["tenants"].append({c: getattr(t, c) for c in COLS}) + for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number)): + out["versions"].append({"tenant_id": t.id, "number": v.number, "message": v.message, "author": v.author, + "config": v.config, "files": v.files}) + shas |= set(v.files.values()) + for s in db.scalars(select(Secret)): + out["secrets"].append({"tenant_id": s.tenant_id, "key": s.key}) + out["settings"] = {s.key: s.value for s in db.scalars(select(Setting))} + out["blobs"] = {sha: {"b64": base64.b64encode(db.get(Blob, sha).content).decode(), "text": db.get(Blob, sha).is_text} for sha in shas} + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(gzip.compress(json.dumps(out).encode(), 9)) + + +def load(db: Session, path: Path) -> int: + data = json.loads(gzip.decompress(path.read_bytes())) + for sha, b in data["blobs"].items(): + raw = base64.b64decode(b["b64"]) + db.add(Blob(sha=sha, content=raw, size=len(raw), is_text=b["text"])) + for t in data["tenants"]: + db.add(Tenant(**t)) + db.flush() + for v in data["versions"]: + db.add(Version(**v)) + for s in data["secrets"]: + db.add(Secret(tenant_id=s["tenant_id"], key=s["key"], value_enc=None, source="sops-import")) + for k, v in data["settings"].items(): + db.add(Setting(key=k, value=v)) + return len(data["tenants"]) diff --git a/app/services/status.py b/app/services/status.py new file mode 100644 index 0000000..ab6248e --- /dev/null +++ b/app/services/status.py @@ -0,0 +1,61 @@ +"""Zustandsabgleich: was läuft wirklich? (Vertrag: GET /health → core.version, bundle.source/version/stale)""" +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor + +from sqlalchemy import select + +from app.db import session +from app.models import Tenant +from app.services import contract, deploy + +_CACHE: dict = {"ts": 0.0, "data": None} +TTL = 30.0 + + +def _http_get(url: str): + import httpx + r = httpx.get(url, timeout=6, follow_redirects=True) + return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {}) + + +def probe(host: str, expect_tag: str, expect_bundle: int | None, get=_http_get) -> dict: + out = {"host": host, "reachable": False, "core": None, "bundle": None, "drift": [], "error": None} + try: + status, body = get(f"https://{host}/health") + except Exception as exc: # noqa: BLE001 + out["error"] = f"{type(exc).__name__}: {str(exc)[:120]}" + out["drift"].append("nicht erreichbar") + return out + out["reachable"] = status == 200 + if status != 200: + out["drift"].append(f"HTTP {status}") + core, bundle = body.get("core"), body.get("bundle") + out["core"], out["bundle"] = core, bundle + if core is None: + out["drift"].append("Core ohne Vertrag (alte Version)") + elif expect_tag and deploy._hex_tag(expect_tag) and not contract.same_sha(core.get("version", ""), expect_tag): + out["drift"].append(f"Core {core.get('version', '')[:10]} statt {expect_tag[:10]}") + if expect_bundle is not None: + if not bundle: + out["drift"].append("meldet keine Bundle-Quelle") + elif bundle.get("source") != "admin" or bundle.get("version") != expect_bundle or bundle.get("stale"): + out["drift"].append(f"Bundle {bundle.get('source')} v{bundle.get('version')}{' (veraltet)' if bundle.get('stale') else ''} statt admin v{expect_bundle}") + elif bundle and bundle.get("source") not in ("git", None): + out["drift"].append(f"Bundle-Quelle {bundle.get('source')}, erwartet git") + return out + + +def probe_all(get=_http_get, refresh: bool = False, workers: int = 6) -> dict: + if not refresh and _CACHE["data"] is not None and time.time() - _CACHE["ts"] < TTL: + return _CACHE["data"] + with session() as db: + items = [] + for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)): + items.append((t.id, deploy.host_for(db, t), deploy.effective_tag(db, t), t.current_version if t.source == "admin" else None)) + with ThreadPoolExecutor(max_workers=workers) as ex: + res = list(ex.map(lambda it: (it[0], probe(it[1], it[2], it[3], get)), items)) + data = {tid: r for tid, r in res} + _CACHE.update(ts=time.time(), data=data) + return data diff --git a/app/services/store.py b/app/services/store.py new file mode 100644 index 0000000..85eea58 --- /dev/null +++ b/app/services/store.py @@ -0,0 +1,225 @@ +"""Inhalte eines Tenants: Blobs, Entwurf, Versionen, Diff, Bundle. + +Ein Tenant besteht aus `config` (Inhalt der tenant.json ohne deploy-Block) und +`files` ({pfad: sha}). Bearbeitet wird immer ein Entwurf (tenant.draft); +„Veröffentlichen“ schreibt daraus eine neue unveränderliche Version. +""" +from __future__ import annotations + +import difflib +import hashlib +import io +import json +import tarfile +from copy import deepcopy + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from app.models import AuditLog, Blob, Tenant, Version + +TEXT_SUFFIXES = {".txt", ".html", ".css", ".py", ".json", ".svg", ".md", ".yaml", ".yml", ".csv"} +RESERVED = {"tenant.json"} # wird aus config erzeugt + + +def is_text_path(path: str) -> bool: + return any(path.lower().endswith(s) for s in TEXT_SUFFIXES) + + +def put_blob(db: Session, data: bytes, path: str = "") -> str: + sha = hashlib.sha256(data).hexdigest() + if db.get(Blob, sha) is None: + db.add(Blob(sha=sha, content=data, size=len(data), is_text=is_text_path(path))) + db.flush() + return sha + + +def get_blob(db: Session, sha: str) -> bytes: + b = db.get(Blob, sha) + if b is None: + raise KeyError(sha) + return b.content + + +def audit(db: Session, actor: str, action: str, tenant_id: str | None = None, detail: str = "") -> None: + db.add(AuditLog(actor=actor, action=action, tenant_id=tenant_id, detail=detail)) + + +def current_version(db: Session, t: Tenant) -> Version | None: + if t.current_version is None: + return None + return db.scalar(select(Version).where(Version.tenant_id == t.id, Version.number == t.current_version)) + + +def get_version(db: Session, tenant_id: str, number: int) -> Version | None: + return db.scalar(select(Version).where(Version.tenant_id == tenant_id, Version.number == number)) + + +def content(db: Session, t: Tenant, source: str = "draft") -> tuple[dict, dict]: + """(config, files) für source = draft | current | v. Draft fällt auf current zurück.""" + if source == "draft" and t.draft: + return deepcopy(t.draft["config"]), dict(t.draft["files"]) + if source.startswith("v") and source[1:].isdigit(): + v = get_version(db, t.id, int(source[1:])) + else: + v = current_version(db, t) + if v is None: + return {}, {} + return deepcopy(v.config), dict(v.files) + + +def ensure_draft(db: Session, t: Tenant) -> dict: + """Arbeitskopie des Entwurfs (nie das am ORM-Objekt hängende Dict mutieren).""" + if t.draft: + return json.loads(json.dumps(t.draft)) + cfg, files = content(db, t, "current") + return {"config": cfg, "files": files} + + +def _save_draft(t: Tenant, draft: dict) -> None: + t.draft = json.loads(json.dumps(draft)) + flag_modified(t, "draft") + + +def set_config(db: Session, t: Tenant, config: dict) -> None: + d = ensure_draft(db, t) + config = {k: v for k, v in config.items() if k != "deploy"} + config["id"] = t.id + d["config"] = config + _save_draft(t, d) + _sync_draft_state(db, t) + + +def set_file(db: Session, t: Tenant, path: str, data: bytes) -> str: + path = clean_path(path) + d = ensure_draft(db, t) + sha = put_blob(db, data, path) + d["files"][path] = sha + _save_draft(t, d) + _sync_draft_state(db, t) + return sha + + +def delete_file(db: Session, t: Tenant, path: str) -> None: + d = ensure_draft(db, t) + d["files"].pop(path, None) + _save_draft(t, d) + _sync_draft_state(db, t) + + +def clean_path(path: str) -> str: + path = path.strip().lstrip("/") + if not path or ".." in path.split("/") or path in RESERVED or "\\" in path: + raise ValueError(f"Ungültiger Dateipfad: {path!r}") + return path + + +def _sync_draft_state(db: Session, t: Tenant) -> None: + """Entwurf, der identisch zur aktuellen Version ist, wieder löschen.""" + if t.draft and not diff_summary(db, t, "current", "draft")["changed"]: + t.draft = None + + +def discard_draft(db: Session, t: Tenant) -> None: + t.draft = None + + +def publish(db: Session, t: Tenant, message: str, author: str) -> Version: + if not t.draft: + raise ValueError("Kein Entwurf zum Veröffentlichen.") + number = (t.current_version or 0) + 1 + last = db.scalar(select(Version.number).where(Version.tenant_id == t.id).order_by(Version.number.desc())) + number = max(number, (last or 0) + 1) + v = Version(tenant_id=t.id, number=number, message=message.strip() or f"Version {number}", author=author, + config=t.draft["config"], files=t.draft["files"]) + db.add(v) + t.current_version = number + t.draft = None + t.name = v.config.get("name", t.name) + audit(db, author, "version.publish", t.id, f"v{number}: {v.message}") + db.flush() + return v + + +def restore_as_draft(db: Session, t: Tenant, number: int) -> None: + v = get_version(db, t.id, number) + if v is None: + raise KeyError(number) + t.draft = {"config": deepcopy(v.config), "files": dict(v.files)} + _sync_draft_state(db, t) + + +# ── Diff ──────────────────────────────────────────────────────────────────── + +def diff_summary(db: Session, t: Tenant, a: str, b: str, with_text: bool = False) -> dict: + return diff_content(db, content(db, t, a), content(db, t, b), with_text) + + +def diff_content(db: Session, left: tuple[dict, dict], right: tuple[dict, dict], with_text: bool = False) -> dict: + ca, fa = left + cb, fb = right + items = [] + if json.dumps(ca, sort_keys=True) != json.dumps(cb, sort_keys=True): + items.append(_item("tenant.json", "geändert", db, json.dumps(ca, indent=2, ensure_ascii=False, sort_keys=True), + json.dumps(cb, indent=2, ensure_ascii=False, sort_keys=True), True, with_text)) + for path in sorted(set(fa) | set(fb)): + sa, sb = fa.get(path), fb.get(path) + if sa == sb: + continue + kind = "neu" if sa is None else "gelöscht" if sb is None else "geändert" + text = is_text_path(path) + ta = get_blob(db, sa).decode("utf-8", "replace") if (sa and text and with_text) else "" + tb = get_blob(db, sb).decode("utf-8", "replace") if (sb and text and with_text) else "" + items.append(_item(path, kind, db, ta, tb, text, with_text)) + return {"changed": bool(items), "items": items} + + +def _item(path, kind, db, ta, tb, text, with_text): + out = {"path": path, "kind": kind, "text": text} + if with_text and text: + lines = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "vorher", "nachher", lineterm="", n=2)) + out["diff"] = lines + out["added"] = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++")) + out["removed"] = sum(1 for l in lines if l.startswith("-") and not l.startswith("---")) + return out + + +# ── Bundle (genau das, was ein Tenant-Container beim Start abholt) ────────── + +def tenant_json_bytes(config: dict) -> bytes: + return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode() + + +def materialize(db: Session, t: Tenant, source: str, target) -> dict: + """Schreibt das Tenant-Verzeichnis (tenant.json + Dateien) nach `target`.""" + cfg, files = content(db, t, source) + write_tenant_dir(db, cfg, files, target) + return cfg + + +def write_tenant_dir(db: Session, cfg: dict, files: dict, target) -> None: + target.mkdir(parents=True, exist_ok=True) + (target / "tenant.json").write_bytes(tenant_json_bytes(cfg)) + for path, sha in files.items(): + p = target / path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(get_blob(db, sha)) + + +def bundle_tar(db: Session, t: Tenant, source: str = "current") -> tuple[bytes, dict]: + cfg, files = content(db, t, source) + manifest = {"tenant": t.id, "version": t.current_version if source != "draft" else None, + "files": {"tenant.json": hashlib.sha256(tenant_json_bytes(cfg)).hexdigest(), **files}} + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + def add(name, data): + ti = tarfile.TarInfo(name) + ti.size = len(data) + ti.mtime = 0 + tar.addfile(ti, io.BytesIO(data)) + add("tenant.json", tenant_json_bytes(cfg)) + for path, sha in sorted(files.items()): + add(path, get_blob(db, sha)) + add("MANIFEST.json", json.dumps(manifest, indent=2).encode()) + return buf.getvalue(), manifest diff --git a/app/services/tenants.py b/app/services/tenants.py new file mode 100644 index 0000000..c4dd6cc --- /dev/null +++ b/app/services/tenants.py @@ -0,0 +1,95 @@ +"""Tenant-Lebenszyklus: anlegen, klonen, Secrets, Cut-over-Check.""" +from __future__ import annotations + +import re +from copy import deepcopy + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Deployment, Secret, Tenant, Version +from app.services import crypto, defaults, store + +SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,38}$") + + +def create(db: Session, tenant_id: str, name: str, clone_from: str | None, actor: str) -> Tenant: + tenant_id = tenant_id.strip().lower() + if not SLUG.match(tenant_id): + raise ValueError("ID: nur a-z, 0-9 und Bindestrich, 2–39 Zeichen.") + if db.get(Tenant, tenant_id): + raise ValueError(f"Tenant „{tenant_id}“ existiert bereits.") + t = Tenant(id=tenant_id, name=name.strip() or tenant_id, subdomain=tenant_id, domain_status="none", source="admin", + oidc={"mode": "managed"}) + db.add(t) + db.flush() + if clone_from: + src = db.get(Tenant, clone_from) + cfg, files = store.content(db, src, "current") + cfg = deepcopy(cfg) + cfg.update({"id": tenant_id, "name": t.name, "title": f"{t.name} – Gesprächsprotokoll", + "description": f"Gesprächsprotokoll-Tool für {t.name}", "ai_detect_org": t.name, "org_short": t.name}) + # Personenbezogene/mandantenspezifische Dateien nicht mitklonen + files = {p: s for p, s in files.items() if not p.startswith("namensliste")} + t.cpu, t.memory = src.cpu, src.memory + else: + cfg, files = defaults.skeleton_config(tenant_id, t.name), {} + db.add(Version(tenant_id=tenant_id, number=1, message="Angelegt" + (f" (Kopie von {clone_from})" if clone_from else ""), + author=actor, config=cfg, files=files)) + t.current_version = 1 + for key, val in (("SESSION_SECRET", defaults.new_token()), ("ADMIN_BUNDLE_TOKEN", defaults.new_token())): + db.add(Secret(tenant_id=tenant_id, key=key, value_enc=crypto.encrypt(val), source="manual")) + for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"): + db.add(Secret(tenant_id=tenant_id, key=key, value_enc=None, source="manual")) + store.audit(db, actor, "tenant.create", tenant_id, f"Kopie von {clone_from}" if clone_from else "leer") + db.flush() + return t + + +def secret_rows(db: Session, tenant_id: str) -> list[dict]: + rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))} + keys = list(dict.fromkeys(defaults.REQUIRED_SECRETS + [k for k in rows if k not in defaults.REQUIRED_SECRETS])) + out = [] + for k in keys: + r = rows.get(k) + out.append({ + "key": k, "set": bool(r and r.value_enc), "source": r.source if r else "", + "required": k in defaults.REQUIRED_SECRETS, "help": defaults.SECRET_HELP.get(k, ""), + "updated_at": r.updated_at.isoformat() if r and r.updated_at else None, + }) + return out + + +def set_secret(db: Session, tenant_id: str, key: str, value: str | None, actor: str) -> None: + if not re.match(r"^[A-Z][A-Z0-9_]{1,60}$", key): + raise ValueError("Schlüssel: GROSSBUCHSTABEN_MIT_UNTERSTRICH") + r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key)) + if r is None: + r = Secret(tenant_id=tenant_id, key=key) + db.add(r) + r.value_enc = crypto.encrypt(value) if value else None + r.source = "manual" + store.audit(db, actor, "secret.set" if value else "secret.clear", tenant_id, key) + + +def check_bundle_token(db: Session, tenant_id: str, token: str) -> bool: + r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "ADMIN_BUNDLE_TOKEN")) + return bool(r and r.value_enc and token and crypto.decrypt(r.value_enc) == token) + + +def cutover_checks(db: Session, t: Tenant, validation: dict | None) -> list[dict]: + secrets_ok = all(s["set"] for s in secret_rows(db, t.id) if s["required"]) + imported = db.scalar(select(Version).where(Version.tenant_id == t.id, Version.author == "import")) is not None + dry = db.scalar(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "dry-run", + Deployment.status == "success").order_by(Deployment.id.desc())) + return [ + {"id": "content", "label": "Inhalt aus Git importiert & Hash-gleich verifiziert" if imported else "Inhalt in der Datenbank", + "ok": True}, + {"id": "validate", "label": "Validierung (Core-Rendering von Web-UI und PDF)", "ok": bool(validation and validation.get("ok"))}, + {"id": "secrets", "label": "Alle Pflicht-Secrets in der Admin-DB gesetzt", "ok": secrets_ok}, + {"id": "oidc", "label": "OIDC-Client abgeglichen (Login-API)" if (t.oidc or {}).get("mode", "adopt") != "off" else "OIDC-Verwaltung aus", + "ok": (t.oidc or {}).get("mode", "adopt") == "off" or bool(((t.oidc or {}).get("last_sync") or {}).get("ok"))}, + {"id": "domain", "label": "Domain verifiziert", "ok": t.domain_status == "verified"}, + {"id": "dryrun", "label": "Dry-Run erfolgreich", "ok": dry is not None}, + {"id": "published", "label": "Keine unveröffentlichten Änderungen", "ok": not t.draft}, + ] diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..2df2595 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,33 @@ +"""Zentrale Konfiguration (Env-getrieben).""" +from __future__ import annotations + +import base64 +import hashlib +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if not os.getenv("ONYSCRIBE_SKIP_DOTENV"): # lokale Entwicklung: .env im Projektordner einlesen (Docker/Coolify setzen die Env direkt) + try: + from dotenv import load_dotenv + load_dotenv(ROOT / ".env", override=False) # echte Umgebungsvariablen haben Vorrang + except ImportError: + pass +DATA = ROOT / ".data" +DATA.mkdir(exist_ok=True) + +DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA / 'onyscribe-admin.db'}") +# Entwicklungs-Fallback: abgeleiteter Key. In Produktion SECRET_KEY setzen! +_raw = os.getenv("SECRET_KEY", "").strip() +SECRET_KEY = _raw or base64.urlsafe_b64encode(hashlib.sha256(b"onyscribe-admin-dev").digest()).decode() +AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true" + +# Vendored Snapshot des Plattform-Cores (Preview führt den echten Core aus) +PLATFORM_DIR = Path(os.getenv("PLATFORM_DIR", ROOT / "vendor" / "platform")) +PREVIEW_CACHE = ROOT / ".preview-cache" +PREVIEW_CACHE.mkdir(exist_ok=True) + +# Pfad zur bestehenden transkript-platform (nur für den Importer) +LEGACY_PLATFORM = Path(os.getenv("LEGACY_PLATFORM", ROOT.parent / "transkript-platform")) + +APP_NAME = "Onyscribe Admin" diff --git a/app/static/admin.css b/app/static/admin.css new file mode 100644 index 0000000..fd4686a --- /dev/null +++ b/app/static/admin.css @@ -0,0 +1,170 @@ +:root{ + --creme:#f4efe8; --dark:#1a1a1a; --cyan:#00cccc; --cyan-d:#009e9e; --white:#fff; --stone:#eceae4; --mist:#f7f4f0; + --border:#ddd8d0; --gray:#888; --mid:#555; --ok:#2a8a2a; --warn:#e0a000; --err:#c0392b; + --font:"Liberation Sans",Arial,Helvetica,sans-serif; --mono:ui-monospace,"JetBrains Mono","Liberation Mono",Menlo,monospace; +} +*{box-sizing:border-box} +html,body{margin:0;height:100%} +body{font-family:var(--font);background:var(--creme);color:#2c2c2c;font-size:13px;line-height:1.45;display:flex} +a{color:inherit} +h1,h2,h3{margin:0;color:var(--dark)} +button,input,select,textarea{font:inherit} +.muted{color:var(--gray)} .mono{font-family:var(--mono);font-size:12px} .grow{flex:1;min-width:0} +.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.nowrap{white-space:nowrap} + +/* ── Sidebar ─────────────────────────────────────────────── */ +.side{width:196px;flex:none;background:var(--dark);color:#d8d4cc;display:flex;flex-direction:column;height:100vh;position:sticky;top:0;border-right:2px solid var(--cyan)} +.brand{padding:18px 18px 12px} +.brand b{color:#fff;font-size:19px;letter-spacing:-.3px} +.brand span{display:block;color:var(--cyan);font-size:9.5px;font-weight:700;letter-spacing:2px;text-transform:uppercase} +.nav{padding:6px 8px;display:flex;flex-direction:column;gap:1px} +.nav a{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;text-decoration:none;color:#bdb8ae;font-weight:600} +.nav a:hover{background:#262626;color:#fff} .nav a.on{background:#262626;color:#fff;box-shadow:inset 3px 0 0 var(--cyan)} +.nav svg{width:15px;height:15px;flex:none} +.side-foot{margin-top:auto;padding:12px 16px;font-size:11px;color:#8a867e;border-top:1px solid #2c2c2c;display:flex;flex-direction:column;gap:6px} +.pill{display:inline-block;border-radius:4px;padding:1px 7px;font-weight:700;letter-spacing:.8px;font-size:9.5px;text-transform:uppercase;width:fit-content} +.pill.lock{background:#3a2f12;color:#f0c55a} .pill.live{background:#4a1d17;color:#ff9a8a} .pill.dev{background:#2c2c2c;color:var(--cyan)} +.side-foot a{color:#bdb8ae} + +/* ── Seiten-Layout ───────────────────────────────────────── */ +.main{flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;overflow:auto} +.page{padding:22px 28px 50px;max-width:1500px;width:100%} +.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:1.8px;color:var(--gray);text-transform:uppercase} +.eyebrow::before{content:"— ";color:var(--cyan)} +.title{font-size:24px;font-weight:700;letter-spacing:-.4px;margin:2px 0 0} +.lead{color:var(--mid);margin:4px 0 0;max-width:760px} +.head{display:flex;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap} + +/* ── Cards ───────────────────────────────────────────────── */ +.card{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:14px 16px} +.card + .card,.card + .grid2,.grid2 + .card{margin-top:12px} +.card h3{font-size:13.5px;margin-bottom:8px} +.card .sub{color:var(--gray);font-size:12px;margin:-4px 0 10px} +.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +.grid2 > .card{margin:0!important} +.kpis{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px} +.kpi{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:7px 13px;display:flex;gap:9px;align-items:baseline;cursor:default} +.kpi b{font-size:18px;color:var(--dark)} .kpi span{color:var(--gray);font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase} +.kpi.click{cursor:pointer} .kpi.click:hover{border-color:var(--cyan)} + +/* ── Badges / Buttons ────────────────────────────────────── */ +.badge{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--stone);color:var(--mid);white-space:nowrap;line-height:1.6} +.badge.ok{background:#e3f3e3;color:var(--ok)} .badge.warn{background:#fbf0cf;color:#8a6200} +.badge.err{background:#f8dcd8;color:var(--err)} .badge.cyan{background:#d6f5f5;color:#007878} .badge.dark{background:var(--dark);color:#fff} +.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--white);color:var(--dark);border-radius:8px;padding:6px 12px;font-weight:700;cursor:pointer;text-decoration:none;font-size:12.5px;white-space:nowrap} +.btn:hover{border-color:var(--dark)} .btn.primary{background:var(--dark);color:#fff;border-color:var(--dark)} +.btn.cyan{background:var(--cyan);border-color:var(--cyan);color:#002b2b} .btn.cyan:hover{background:var(--cyan-d);border-color:var(--cyan-d);color:#fff} +.btn.danger{color:var(--err)} .btn.danger:hover{border-color:var(--err)} .btn.sm{padding:3px 9px;font-size:11.5px;border-radius:6px} +.btn[disabled],.btn.disabled{opacity:.45;pointer-events:none} +.seg{display:inline-flex;background:var(--stone);border-radius:8px;padding:2px;gap:1px} +.seg button{border:0;background:none;padding:4px 10px;border-radius:6px;font-weight:700;color:var(--mid);cursor:pointer;font-size:12px} +.seg button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 2px rgba(0,0,0,.14)} +.chipbar{display:flex;gap:5px;flex-wrap:wrap} +.fchip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:3px 11px;font-size:12px;font-weight:700;color:var(--mid);cursor:pointer} +.fchip.on{background:var(--dark);color:#fff;border-color:var(--dark)} +input.search{border:1px solid var(--border);border-radius:8px;padding:6px 10px;min-width:220px;background:#fff} + +/* ── Tabellen ────────────────────────────────────────────── */ +table.t{width:100%;border-collapse:collapse} +table.t th{text-align:left;font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--gray);padding:6px 9px;border-bottom:1px solid var(--border);white-space:nowrap} +table.t td{padding:7px 9px;border-bottom:1px solid var(--stone);vertical-align:middle} +table.t tr:last-child td{border-bottom:0} +table.t.hover tbody tr{cursor:pointer} table.t.hover tbody tr:hover td{background:var(--mist)} +.logo-box{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--mist);display:grid;place-items:center;flex:none;overflow:hidden} +.logo-box img{max-width:28px;max-height:28px} +.logo-box.lg{width:120px;height:56px} .logo-box.lg img{max-width:104px;max-height:44px} +.swatches{display:inline-flex;gap:2px;vertical-align:middle} +.swatches i{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.14);display:block} +.dotc{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;background:var(--stone)} +.dotc.ok{background:var(--ok)} .dotc.warn{background:var(--warn)} .dotc.err{background:var(--err)} .dotc.cyan{background:var(--cyan)} + +/* ── Tenant-Seite ────────────────────────────────────────── */ +.thead{padding:12px 20px 10px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--creme);position:sticky;top:0;z-index:6} +.thead h1{font-size:17px;letter-spacing:-.2px;line-height:1.2} +.crumb{font-size:11px;color:var(--gray)} .crumb a{text-decoration:none} .crumb a:hover{color:var(--dark)} +.dirty{display:inline-flex;gap:6px;align-items:center;font-size:11.5px;font-weight:700;color:#8a6200;background:#fbf0cf;border-radius:999px;padding:2px 10px} +.dirty::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--warn)} +.mid{min-width:0} +.tl{display:grid;grid-template-columns:176px minmax(0,1fr);flex:1;min-height:0;align-items:start} +.tl.open{grid-template-columns:176px minmax(340px,5fr) minmax(360px,6fr)} +.snav{position:sticky;top:53px;padding:12px 8px 20px 14px;display:flex;flex-direction:column;gap:1px;max-height:calc(100vh - 53px);overflow:auto} +.snav h6{margin:12px 8px 3px;font-size:9.5px;letter-spacing:1.6px;text-transform:uppercase;color:var(--gray)} +.snav h6:first-child{margin-top:0} +.snav button{display:flex;align-items:center;gap:7px;border:0;background:none;text-align:left;padding:6px 9px;border-radius:7px;font-weight:600;color:var(--mid);cursor:pointer;width:100%} +.snav button:hover{background:var(--stone);color:var(--dark)} .snav button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 3px rgba(0,0,0,.1),inset 3px 0 0 var(--cyan)} +.snav .n{margin-left:auto;font-size:10.5px} +.pane{padding:14px 18px 60px;min-width:0;container-type:inline-size} +@container (max-width:780px){.grid2{grid-template-columns:1fr}.fgrid.c3{grid-template-columns:1fr 1fr}} +.panel{position:sticky;top:53px;height:calc(100vh - 53px);padding:10px 20px 14px 4px;display:none;flex-direction:column;gap:8px;min-width:0} +.tl.open .panel{display:flex} +.pbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.pv-frame{flex:1;background:var(--white);border:1px solid var(--border);border-radius:10px;position:relative;overflow:hidden;display:flex;justify-content:center;background-image:radial-gradient(#e6e1d8 1px,transparent 1px);background-size:14px 14px} +.pv-frame iframe{border:0;background:#fff;height:100%;width:100%;transition:width .2s;box-shadow:0 0 0 1px var(--border)} +.pv-frame.tablet iframe{width:768px;max-width:100%} .pv-frame.mobile iframe{width:390px;max-width:100%} +.pv-load{position:absolute;inset:0;background:rgba(244,239,232,.72);display:none;place-items:center;font-weight:700;color:var(--mid)} .pv-load.on{display:grid} +.pv-err{position:absolute;inset:0;background:#fff;padding:16px;overflow:auto;display:none;color:var(--err);font-family:var(--mono);font-size:12px;white-space:pre-wrap} .pv-err.on{display:block} +.hide{display:none!important} + +/* ── Formulare ───────────────────────────────────────────── */ +.fgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} .fgrid.c3{grid-template-columns:repeat(3,minmax(0,1fr))} +.fgrid .full{grid-column:1/-1} +label.f{display:block;font-size:10.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--gray)} +label.f > input,label.f > select,label.f > textarea{display:block;width:100%;margin-top:3px;text-transform:none;letter-spacing:0;font-weight:400;color:#222} +input[type=text],input[type=email],input[type=url],input[type=password],input[type=number],select,textarea{border:1px solid var(--border);border-radius:7px;padding:5px 9px;background:#fff;outline:none;font-size:12.5px} +input:focus,select:focus,textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(0,204,204,.16)} +textarea{width:100%;resize:vertical;line-height:1.5} +textarea.code{font-family:var(--mono);font-size:12px;tab-size:4;min-height:300px;background:#fbfaf8;white-space:pre} +.colors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 14px} +.colorf{display:flex;gap:7px;align-items:center;min-width:0} +.colorf input[type=color]{width:24px;height:24px;border:1px solid var(--border);border-radius:5px;padding:0;background:none;cursor:pointer;flex:none} +.colorf span{font-size:11px;color:var(--mid);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.colorf input[type=text]{width:74px;padding:2px 5px;font-family:var(--mono);font-size:11.5px} +.chk{display:flex;gap:8px;align-items:flex-start;padding:5px 0}.chk input{margin-top:2px;accent-color:var(--dark)} +.hint{font-size:11.5px;color:var(--gray)} +.banner{border-radius:8px;padding:7px 12px;font-size:12.5px;margin-bottom:10px;border:1px solid} +.banner.warn{background:#fdf6df;border-color:#ecd48a;color:#6f5200} .banner.info{background:#e6f8f8;border-color:#a8e6e6;color:#005e5e} +.banner.err{background:#fbe6e3;border-color:#efb5ad;color:#8e2418} .banner.ok{background:#e8f5e8;border-color:#b3dcb3;color:#1e661e} +details.fold{border:1px solid var(--border);border-radius:9px;background:var(--white);margin-top:10px} +details.fold > summary{cursor:pointer;padding:8px 13px;font-weight:700;list-style:none;display:flex;gap:8px;align-items:center} +details.fold > summary::before{content:"▸";color:var(--gray);transition:.15s} details.fold[open] > summary::before{transform:rotate(90deg)} +details.fold > .in{padding:2px 14px 13px} +.kv{display:grid;grid-template-columns:104px 1fr;gap:5px 10px;font-size:12.5px} .kv dt{color:var(--gray);font-weight:700;font-size:10.5px;letter-spacing:.9px;text-transform:uppercase;padding-top:2px} .kv dd{margin:0} +.check{display:flex;gap:8px;padding:4px 0;align-items:center;font-size:12.5px} +.dot{width:17px;height:17px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;flex:none;background:var(--stone);color:var(--gray)} +.dot.ok{background:var(--ok);color:#fff} .dot.no{background:#f2d9d5;color:var(--err)} +.console{background:#141414;color:#d6f5e6;font-family:var(--mono);font-size:12px;border-radius:8px;padding:11px 13px;white-space:pre-wrap;min-height:90px;max-height:340px;overflow:auto} +.console .sim{color:#7fd8d8} +pre.json{background:#141414;color:#e6e6e6;border-radius:8px;padding:11px;font-size:11.5px;overflow:auto;max-height:300px;font-family:var(--mono);margin:6px 0 0} +.diff{font-family:var(--mono);font-size:11.5px;background:#fbfaf8;border:1px solid var(--border);border-radius:7px;overflow:auto;max-height:260px;margin:5px 0 10px} +.diff div{padding:0 9px;white-space:pre} .diff .a{background:#e3f6e3;color:#1c5a1c} .diff .r{background:#fbe3df;color:#8e2418} .diff .h{color:var(--gray);background:var(--stone)} +.empty{padding:22px;text-align:center;color:var(--gray)} +.copy{cursor:pointer;border-bottom:1px dashed var(--gray)} + +/* ── Agent-Chat ──────────────────────────────────────────── */ +.chat{display:flex;flex-direction:column;flex:1;min-height:0} +.agpane{position:sticky;top:53px;height:calc(100vh - 53px);padding:12px 12px 14px 18px} +.agpane.hide{display:none!important} +.msgs{flex:1;overflow:auto;display:flex;flex-direction:column;gap:9px;padding:2px 2px 10px} +.bub{max-width:94%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;font-size:12.5px} +.bub.user{align-self:flex-end;background:var(--dark);color:#fff;border-bottom-right-radius:3px} +.bub.assistant{align-self:flex-start;background:var(--white);border:1px solid var(--border);border-bottom-left-radius:3px} +.steps{display:flex;flex-direction:column;gap:3px;margin:0 0 6px} +.step{display:flex;gap:6px;align-items:baseline;font-size:11.5px;color:var(--mid);background:var(--mist);border:1px solid var(--stone);border-radius:6px;padding:2px 8px;white-space:normal;overflow-wrap:anywhere} +.step b{font-family:var(--mono);color:#007878;flex:none} .step.err{background:#fbe6e3;border-color:#efb5ad} .step.err b{color:var(--err)} +.thought{color:var(--gray);font-style:italic;font-size:12px;margin:0 0 5px} +.prop{margin-top:8px;border:1px dashed var(--cyan);border-radius:8px;padding:8px 10px;background:#f0fbfb} +.chat-in{display:flex;gap:6px;border-top:1px solid var(--border);padding-top:8px} +.chat-in textarea{min-height:38px;max-height:120px} +.chips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:7px} +.chip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:2px 10px;font-size:11.5px;cursor:pointer}.chip:hover{border-color:var(--cyan)} +.typing{align-self:flex-start;color:var(--gray);font-size:11.5px} + +/* ── Modal / Toast ───────────────────────────────────────── */ +.modal-bg{position:fixed;inset:0;background:rgba(20,20,20,.5);display:none;place-items:center;z-index:50;padding:20px}.modal-bg.on{display:grid} +.modal{background:var(--creme);border-radius:12px;width:min(780px,100%);max-height:90vh;overflow:auto;padding:20px 22px;border:1px solid var(--border);box-shadow:0 20px 60px rgba(0,0,0,.35)} +.modal h2{font-size:17px;margin-bottom:10px}.modal .acts{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} +#toast{position:fixed;bottom:18px;right:18px;background:var(--dark);color:#fff;padding:9px 15px;border-radius:9px;font-weight:700;z-index:99;display:none;border-left:4px solid var(--cyan);max-width:420px}#toast.err{border-color:var(--err)} + +@media (max-width:1250px){.grid2{grid-template-columns:1fr}.tl.open{grid-template-columns:150px minmax(300px,1fr) minmax(320px,1fr)}} +@media (max-width:800px){.side{width:56px}.side .brand span,.nav a span,.side-foot{display:none}.tl,.tl.open{grid-template-columns:1fr}.snav{position:static;flex-direction:row;flex-wrap:wrap;max-height:none}.snav h6{display:none}} diff --git a/app/static/tenant.js b/app/static/tenant.js new file mode 100644 index 0000000..9362188 --- /dev/null +++ b/app/static/tenant.js @@ -0,0 +1,598 @@ +/* Tenant-Detailseite: eine Navigation (links) · Formular (Mitte) · Vorschau/Agent (rechts) */ +let S = null; +let SCHEMA = null; +const ui = {sec: 'overview', mode: 'edit', open: true, pvKind: 'web', pvSrc: 'draft', pvDev: '', agentSrc: null}; +const SECTIONS = [ + ['overview', 'Übersicht', null, false], + ['brand', 'Marke & Farben', 'Gestaltung', true], ['texts', 'Absender & PDF-Texte', 'Gestaltung', true], ['pdf', 'PDF-Layout', 'Gestaltung', true], + ['prompt', 'Prompt', 'Inhalt', true], ['files', 'Dateien', 'Inhalt', true], + ['func', 'Funktion & STT', 'Verhalten', true], ['code', 'Hook-Code', 'Verhalten', true], ['adv', 'tenant.json', 'Verhalten', true], + ['domain', 'Domain & Login', 'Betrieb', false], ['secrets', 'Secrets', 'Betrieb', false], ['deploy', 'Deploy', 'Betrieb', false], ['versions', 'Verlauf', 'Betrieb', false], +]; +const A = p => `/api/tenants/${TID}${p}`; +const $ = s => document.querySelector(s); +const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o); +function setPath(o, p, v){ const ks = p.split('.'); let c = o; ks.slice(0,-1).forEach(k => c = (c[k] ??= {})); c[ks.at(-1)] = v; } +function unsetPath(o, p){ const ks = p.split('.'); const chain = [o]; let c = o; for(const k of ks.slice(0,-1)){ c = c?.[k]; if(c == null) return; chain.push(c); } delete c[ks.at(-1)]; for(let i = ks.length - 2; i >= 0; i--){ if(Object.keys(chain[i+1]).length === 0) delete chain[i][ks[i]]; else break; } } +function debounce(fn, ms){ let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; } +function shade(hex, f){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.max(0,Math.min(255,Math.round(x*f)))); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); } +function tint(hex, a){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.round(x+(255-x)*a)); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); } +const copyText = t => navigator.clipboard.writeText(t).then(()=>toast('Kopiert')); +const secDef = k => SECTIONS.find(s => s[0] === k); +const kindLabel = {'dry-run':'Dry-Run','deploy':'Deploy','offboard-dry':'Offboard (Dry-Run)','offboard':'Offboard'}; + +/* ───────── Laden & Rahmen ───────── */ +async function loadSchema(){ try{ const c = await api('GET', '/api/contract'); SCHEMA = c.schema; }catch(e){ SCHEMA = null; } } +async function load(keep){ + S = await api('GET', A('/state')); + renderHead(); renderNav(); + if(!keep) route(); +} +function route(){ + const h = (location.hash||'').slice(1); + if(secDef(h)) ui.sec = h; + renderSection(); +} +window.addEventListener('hashchange', route); + +function renderHead(){ + $('#h-name').textContent = S.name; + const st = S.status !== 'active' ? `${S.status}` : ''; + $('#h-badges').innerHTML = `${S.has_draft?'Entwurf':''}${st}v${S.current_version} + deployt v${S.deployed_version??'–'} + Quelle: ${S.source==='admin'?'Admin':'Git'}`; + $('#h-actions').innerHTML = S.has_draft + ? `` : ''; + document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode)); +} +function renderNav(extra){ + let g = null, out = ''; + const nfiles = Object.keys(S.files).length; + const badges = {files: nfiles ? `${nfiles}` : '', versions: `${S.current_version}`, + secrets: extra?.missing ? `` : '', code: S.files['overrides.py'] ? '' : '', + deploy: S.deployed_version !== S.current_version ? '' : ''}; + for(const [k, l, grp] of SECTIONS){ + if(grp !== g){ g = grp; if(grp) out += `
${grp}
`; } + out += ``; + } + $('#snav').innerHTML = out; +} +function gotoSec(k){ ui.mode = 'edit'; if(location.hash === '#'+k) route(); else location.hash = k; } +/* Layout: Mitte = Formular ODER Agent-Chat; die Live-Vorschau rechts bleibt in beiden Fällen sichtbar (Betriebsbereiche ohne Vorschau). */ +function layout(){ + ui.open = secDef(ui.sec)[3] || ui.mode === 'agent'; + $('#tl').classList.toggle('open', ui.open); + $('#pane').classList.toggle('hide', ui.mode === 'agent'); + $('#ag-wrap').classList.toggle('hide', ui.mode !== 'agent'); + document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode)); + if(ui.open) refreshPreview(); +} +$('#h-panel').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); }); +function renderSection(){ + renderNav(); layout(); + ({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv, + domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec](); +} + +/* ───────── Speichern (Entwurf) ───────── */ +const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; }; +function afterSave(r){ + S.has_draft = r.has_draft; renderHead(); saveState(r.has_draft ? 'Entwurf gespeichert' : 'Entspricht der veröffentlichten Version'); + refreshPreviewSoon(); +} +const saveConfigNow = async () => { try { saveState('Speichere …'); afterSave(await api('PUT', A('/config'), S.config)); } catch(e){ toast(e.message,1); saveState('Fehler'); } }; +const saveConfig = debounce(saveConfigNow, 500); +async function saveFileNow(path, content){ try { saveState('Speichere …'); afterSave(await api('PUT', A('/files/'+path), {content})); } catch(e){ toast(e.message,1); saveState(e.message); } } +const saveFile = debounce(saveFileNow, 600); +async function deleteFile(path){ afterSave(await api('DELETE', A('/files/'+path))); await load(true); renderSection(); } +async function uploadFile(input, path){ + const f = input.files[0]; if(!f) return; + const fd = new FormData(); fd.append('file', f); + const r = await fetch(A('/upload?path='+encodeURIComponent(path||f.name)), {method:'POST', body: fd}); + if(!r.ok){ toast((await r.json()).detail||'Upload fehlgeschlagen',1); return; } + await load(true); renderSection(); refreshPreviewSoon(); toast('Hochgeladen'); headLogo(); renderHead(); +} +function headLogo(){ const n = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; $('#h-logo').innerHTML = n?``:''; } +async function discardDraft(){ + if(!confirm('Alle Änderungen im Entwurf verwerfen?')) return; + await api('POST', A('/draft/discard')); await load(true); renderSection(); headLogo(); refreshPreview(true); toast('Entwurf verworfen'); +} +const saveInd = () => `${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'}`; +const title = (t, sub) => `

${t}${sub?` ${sub}`:''}

${saveInd()}
`; + +/* ───────── Live-Vorschau ───────── */ +let pvSeq = 0; +async function refreshPreview(force){ + if(!ui.open) return; + const seq = ++pvSeq, kind = ui.pvKind; + $('#f-web').style.display = kind==='web'?'':'none'; $('#f-pdf').style.display = kind==='pdf'?'':'none'; + $('#pv-frame').className = 'pv-frame ' + (kind==='web'?ui.pvDev:''); + $('#pv-dev').style.display = kind==='web'?'':'none'; + $('#pv-load').classList.add('on'); $('#pv-err').classList.remove('on'); + const t0 = performance.now(); + try { + const src = ui.pvSrc === 'agent' ? (ui.agentSrc || 'draft') : ui.pvSrc; + const r = await fetch(`/preview/${TID}/${kind}?source=${encodeURIComponent(src)}&_=${force?Date.now():''}`); + if(seq !== pvSeq) return; + if(!r.ok){ $('#pv-err').textContent = await r.text(); $('#pv-err').classList.add('on'); } + else if(kind==='web'){ $('#f-web').srcdoc = await r.text(); } + else { const b = await r.blob(); $('#f-pdf').src = URL.createObjectURL(b) + '#toolbar=0&navpanes=0'; } + $('#pv-info').textContent = `Core ${S.core_version} · ${Math.round(performance.now()-t0)} ms`; + } catch(e){ $('#pv-err').textContent = String(e); $('#pv-err').classList.add('on'); } + $('#pv-load').classList.remove('on'); +} +const refreshPreviewSoon = debounce(() => refreshPreview(), 900); +const refreshPreviewLive = debounce(() => refreshPreview(), 250); +function setPvSrc(src){ + ui.pvSrc = src; $('#pv-agent').classList.toggle('hide', !ui.agentSrc); + document.querySelectorAll('#pv-src button').forEach(x => x.classList.toggle('on', x.dataset.s === src)); +} +document.addEventListener('click', e => { + const b = e.target.closest('#pv-kind button, #pv-dev button, #pv-src button'); if(!b) return; + const g = b.parentElement.id; + b.parentElement.querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b)); + if(g==='pv-kind') ui.pvKind = b.dataset.k; if(g==='pv-dev') ui.pvDev = b.dataset.d; if(g==='pv-src') ui.pvSrc = b.dataset.s; + refreshPreview(); +}); + +/* ───────── Übersicht ───────── */ +async function sOverview(){ + const c = S.config, col = c.colors||{}; + $('#pane').innerHTML = ` +
+

Status

+
+
URL
${esc(S.host)} ${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}
+
Version
v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'veraltet':''}
+
Quelle
${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}
+
Core
:${esc(String(S.core.tag).slice(0,12))} ${S.core.pin?'gepinnt':'folgt Rollouts'} · Admin-Vorschau: ${esc(S.core.sha)} +
+
STT
${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}
+
Dateien
${Object.keys(S.files).map(f=>`${esc(f)}`).join(' ')||'–'}
+
Marke
${Object.entries(col).map(([k,v])=>``).join('')}
+
+ +
+
+

Cut-over auf die Admin-Plattform

+

Bis alles grün ist, bleibt der Tenant auf Git. Umschalten = TENANT_SOURCE=admin beim nächsten Deploy.

+
Prüfe …
+
+
+
+

Betrieb

+
+ +
+
Ressourcen wirken beim nächsten Deploy. „paused/archived“ nimmt den Tenant aus „Alle neu deployen“.
+
+

Notizen

+ +
+
+
+

Core-Version

Neue Core-Releases rollt die Admin-Plattform zentral aus (Menü „Core“). Ein Pin hält diesen Tenant auf einem festen Tag – er nimmt dann an Rollouts nicht teil.

+
+
Gefahrenbereich
+

Offboard entfernt Service, Volume und Ingress aus dem Mittwald-Stack (Dry-Run zeigt vorher, was passiert). Löschen entfernt nur die Daten in dieser Datenbank – und nur für archivierte Tenants.

+
+
`; + const [cut, mode, sec] = await Promise.all([api('GET', A('/cutover')), api('GET', '/api/deploy/mode'), api('GET', A('/secrets'))]); + renderNav({missing: sec.some(s => s.required && !s.set)}); + $('#b-off')?.classList.toggle('disabled', !mode.live); + const ok = cut.checks.every(x=>x.ok); + $('#cut').innerHTML = cut.checks.map(x=>`
${x.ok?'✓':'!'}${esc(x.label)}
`).join('') + + `
${cut.source==='admin' + ? `Quelle: Admin` + : `${ok?'':'noch nicht alle Bedingungen erfüllt'}`}
`; +} +async function savePin(){ try{ await api('PUT', A('/core-pin'), {pin: $('#m-pin').value}); await load(true); toast('Core-Pin gespeichert – wirksam beim nächsten Deploy'); sOverview(); }catch(e){ toast(e.message,1) } } +async function probeMe(){ + $('#run-state').innerHTML = 'prüfe …'; + try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''}` : `${esc(r.drift.join(' · ')||'nicht erreichbar')}`; } + catch(e){ $('#run-state').innerHTML = `${esc(e.message)}`; } +} +async function saveMeta(){ try{ await api('PUT', A('/meta'), {cpu:$('#m-cpu').value, memory:$('#m-mem').value, status:$('#m-status').value, notes:$('#m-notes').value}); await load(true); toast('Gespeichert'); sOverview(); }catch(e){ toast(e.message,1) } } +async function setSource(s){ await api('PUT', A('/source'), {source:s}); await load(true); sOverview(); toast('TENANT_SOURCE='+s+' – wirksam beim nächsten Deploy'); } +async function runValidate(){ $('#val').innerHTML='Validiere mit dem echten Core …'; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); } +const valHtml = r => ``; +async function offboard(dry){ + if(!dry && !confirm('Service, Volume und Ingress dieses Tenants wirklich vom Mittwald-Stack entfernen?')) return; + try{ const r = await api('POST', A('/offboard'), {dry_run:dry}); followDeploy(r.id, $('#con-off')); }catch(e){ toast(e.message,1) } +} +async function deleteOidcApp(){ + const id = prompt('OIDC-Client dieses Tenants im Login-System LÖSCHEN (nicht umkehrbar, beendet alle Logins der App). Zur Bestätigung die Tenant-ID eingeben:'); if(id===null) return; + try{ await api('DELETE', A(`/oidc/app?confirm=${encodeURIComponent(id)}`)); toast('OIDC-Client gelöscht'); await load(true); sOverview(); }catch(e){ toast(e.message,1) } +} +async function deleteTenant(){ + const id = prompt('Alle Daten dieses Tenants in der Datenbank löschen (Versionen, Secrets, Chat). Zur Bestätigung die ID eingeben:'); if(id===null) return; + try{ await api('DELETE', `/api/tenants/${TID}?confirm=${encodeURIComponent(id)}`); location.href='/'; }catch(e){ toast(e.message,1) } +} + +/* ───────── Formular-Helfer ───────── */ +function bindCfg(root){ + root.querySelectorAll('[data-cfg]').forEach(el => { + el.addEventListener('input', () => { + const p = el.dataset.cfg; + const v = el.type==='checkbox' ? el.checked : el.type==='number' ? (el.value === '' ? '' : Number(el.value)) : el.value; + if(v === '' && !['name'].includes(p) && !p.startsWith('office.') && !p.startsWith('colors.')) unsetPath(S.config, p); else setPath(S.config, p, v); + if(el.type==='color'){ const t = root.querySelector(`[data-cfg="${p}"][type=text]`); if(t) t.value = v; } + if(el.type==='text' && el.dataset.color){ const c = root.querySelector(`[data-cfg="${p}"][type=color]`); if(c && /^#[0-9a-f]{6}$/i.test(v)) c.value = v; } + saveConfig(); + }); + }); +} +const fld = (label, path, o={}) => ``; + +/* ───────── Gestaltung ───────── */ +function sBrand(){ + const col = S.config.colors||{}; + const logo = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; + $('#pane').innerHTML = title('Marke & Farben') + ` +

Name & Titel

${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}
+

Logo & Favicon

+
${logo?``:'kein Logo'}
+ +
Word-Export braucht zusätzlich logo.png.
+
${S.files['favicon.svg']?``:'–'}
+
+

Farben

+
${Object.keys(S.color_labels).map(k=>`
${esc(S.color_labels[k])}
`).join('')}
`; + bindCfg($('#pane')); +} +function deriveFromAccent(){ + const a = (S.config.colors||{}).accent; if(!/^#[0-9a-f]{6}$/i.test(a)) return toast('Akzent muss #RRGGBB sein',1); + Object.assign(S.config.colors, {accent_dark:shade(a,.72), accent_mid:shade(a,1.12), accent_light:tint(a,.88), accent_xlight:tint(a,.94)}); + saveConfigNow(); sBrand(); +} +function sTexts(){ + $('#pane').innerHTML = title('Absender & PDF-Texte') + ` +

Absender / Kopf- & Fußzeile

Erscheint im PDF und im Word-Export.

+ ${fld('Name','office.name')}${fld('Zusatz (Rechtsform)','office.subtitle')}${fld('Adresse','office.address',{full:1})}${fld('Telefon','office.tel')}${fld('E-Mail','office.email')}${fld('Website','office.website',{full:1})}
+

PDF-Beschriftungen

${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}
`; + bindCfg($('#pane')); +} + +/* Text-Datei-Editor mit „Standard des Cores“ */ +async function fileEditor(path, o){ + const has = !!S.files[path]; let body = ''; + try { const r = await fetch(A('/files/'+path)+'?source=draft'); if(r.ok) body = await r.text(); } catch(e){} + const w = document.createElement('div'); w.className = 'card'; + w.innerHTML = `

${esc(o.title)}

+ ${has ? `eigene Datei` : `Core-Standard`}
+ ${o.sub?`

${o.sub}

`:''} + `; + const ta = w.querySelector('textarea'); + ta.addEventListener('input', () => saveFile(path, ta.value)); + ta.addEventListener('keydown', e => { if(e.key==='Tab'){ e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); ta.dispatchEvent(new Event('input')); } }); + w.querySelector('[data-act=custom]')?.addEventListener('click', async () => { await saveFileNow(path, body); await load(true); renderSection(); }); + w.querySelector('[data-act=reset]')?.addEventListener('click', async () => { if(confirm('Eigene Datei löschen und den Core-Standard verwenden?')) await deleteFile(path); }); + return w; +} +async function sPdf(){ + $('#pane').innerHTML = title('PDF-Layout') + `
+
Beispielprotokoll für die Vorschau
+
Format wie vom LLM geliefert; gilt nur für die Vorschau.
`; + $('#e1').appendChild(await fileEditor('protocol.html', {title:'protocol.html', sub:'Jinja-Template des PDFs.', h:300})); + $('#e2').appendChild(await fileEditor('protocol.css', {title:'protocol.css', sub:'Farben kommen als Jinja-Variablen aus der Marke.', h:300})); +} +async function saveSample(){ await api('PUT', A('/sample'), {sample: $('#sample').value}); S.sample = $('#sample').value; toast('Beispiel gespeichert'); ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); refreshPreview(true); } + +/* ───────── Inhalt ───────── */ +async function sPrompt(){ + $('#pane').innerHTML = title('Prompt') + ` +

Selbstbeschreibung

Ersetzt @@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@ im Standard-Prompt.

+ ${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}
+
Prompt testen (echter LLM-Lauf) kostet Cortecs-Guthaben
+ ${S.llm_ready ? '' : ''} +
+
+
+
`; + bindCfg($('#pane')); + $('#ed').appendChild(await fileEditor('prompt.txt', {title:'prompt.txt', sub:'Ohne eigene Datei gilt der Standard-Prompt des Cores. Hook-Code kann den Prompt zusätzlich erweitern (siehe „Finalen Prompt anzeigen“).', h:420})); +} +async function promptTest(){ + $('#pt-go').disabled = true; $('#pt-info').textContent = 'LLM läuft … (bis zu 1–2 Minuten)'; $('#pt-out').innerHTML = ''; + try{ + const r = await api('POST', A('/prompt-test'), {transcript:$('#pt-tr').value, terminart:$('#pt-art').value, projekt:$('#pt-proj').value}); + $('#pt-info').textContent = `${r.model} · ${r.input_tokens}→${r.output_tokens} Tokens · Prompt ${r.prompt_chars} Zeichen${r.truncated?' · ⚠ abgeschnitten (max_tokens)':''}`; + window._ptText = r.text; + $('#pt-out').innerHTML = `
${esc(r.text)}
`; + }catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = ``; } + $('#pt-go').disabled = !S.llm_ready; +} +async function usePtAsSample(){ await api('PUT', A('/sample'), {sample: window._ptText}); S.sample = window._ptText; ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); setPvSrc('draft'); refreshPreview(true); toast('Als Beispiel gesetzt'); } +async function showFinalPrompt(){ + try{ const r = await api('GET', A('/final-prompt')); $('#pt-out').innerHTML = `
${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:
${esc(r.prompt)}
`; } + catch(e){ $('#pt-out').innerHTML = ``; } +} +function sFiles(){ + const rows = Object.entries(S.files).map(([p,m]) => `${esc(p)} ${m.personal?'personenbezogen':''}${(m.size/1024).toFixed(1)} KB${m.text?'Text':'Binär'}${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?` `:''}`).join(''); + $('#pane').innerHTML = title('Dateien') + `
${rows||''}
PfadGrößeTyp
Keine Dateien
+
z. B. namensliste.txt – lesbar für Hook-Code im Container. Namenslisten sind für den KI-Agenten automatisch gesperrt.
`; +} +async function openTextFile(p){ const w = await fileEditor(p, {title:p, h:260}); $('#tf').innerHTML=''; $('#tf').appendChild(w); } + +/* ───────── Verhalten ───────── */ +/* Felder, die der Core im Schema deklariert, aber keine eigene Maske haben, erscheinen automatisch hier (neue Core-Optionen ohne Admin-Änderung). */ +const BESPOKE_PATHS = ['name','title','description','pdf_projekt_label','pdf_footer_note','ai_detect_org','org_short','org_profile']; +const isBespoke = p => BESPOKE_PATHS.includes(p) || p.startsWith('colors.') || p.startsWith('office.'); +function schemaField(f){ + const raw = getPath(S.config, f.path), cur = raw === undefined ? f.default : raw; + const help = f.help ? ` ${esc(f.help)}` : ''; + if(f.type === 'bool') return ``; + if(f.type === 'enum') return ``; + if(f.type === 'color') return ``; + if(f.type === 'text') return ``; + return ``; +} +function sFunc(){ + if(!SCHEMA) return sFuncLegacy(); + const groups = {}; + SCHEMA.fields.filter(f => !isBespoke(f.path)).forEach(f => (groups[f.group] ??= []).push(f)); + const order = Object.keys(groups); + const known = ['Funktion', 'Spracherkennung']; + const card = (g) => { const fs = groups[g], basic = fs.filter(f=>!f.advanced), adv = fs.filter(f=>f.advanced); + const grid = list => `
${list.map(schemaField).join('')}
`; + return `

${esc(g)}${known.includes(g)?'':' neu im Core'}

${basic.length?grid(basic):''}${adv.length?`
Erweitert
${grid(adv)}
`:''} + ${g==='Spracherkennung'?'

Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.

':''}
`; }; + $('#pane').innerHTML = title('Funktion & STT') + `

Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.

` + order.map(card).join(''); + bindCfg($('#pane')); +} +function sFuncLegacy(){ + const stt = S.config.stt||{}; + const chk = (path, label, hint, def) => { const v = getPath(S.config,path); return ``; }; + $('#pane').innerHTML = title('Funktion & STT') + ` +

Funktionen (Flags)

+ ${chk('flags.ui_kontakt_field','Feld „Ansprechpartner Kunde“','Aus: Feld in der Web-UI ausgeblendet (z. B. Vereine).',true)} + ${chk('flags.multi_audio','Mehrere Audiodateien (bis 5)','Aus: nur eine Datei pro Protokoll.',true)}
+

Spracherkennung (STT)

+ + ${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}
`; + bindCfg($('#pane')); +} +async function sCode(){ + const has = !!S.files['overrides.py']; + $('#pane').innerHTML = title('Hook-Code') + ` +

Verfügbare Hooks

build_prompt
Prompt vor dem LLM-Aufruf anpassen
transform_transcript
Transkript nachbearbeiten
pdf_context
Daten fürs PDF-Template ändern
extra_routes
zusätzliche FastAPI-Routen
`; + if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460})); + else $('#ed').innerHTML = `
Dieser Tenant nutzt keinen Hook-Code.

`; +} +async function createHook(){ await saveFileNow('overrides.py', `"""Tenant-Hooks für ${S.id}."""\nfrom core.hooks import TenantHooks\n\n\nclass Hooks(TenantHooks):\n def build_prompt(self, base_prompt: str, ctx: dict) -> str:\n return base_prompt\n`); await load(true); renderSection(); } +function sAdv(){ + $('#pane').innerHTML = title('tenant.json') + `

Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der deploy-Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).

+
`; + $('#rawjson').addEventListener('input', e => { try { S.config = JSON.parse(e.target.value); $('#rawerr').textContent=''; saveConfig(); } catch(x){ $('#rawerr').textContent = 'Ungültiges JSON: '+x.message; } }); +} + +/* ───────── Veröffentlichen ───────── */ +async function openPublish(){ + modal('m-pub', true); $('#pub-body').innerHTML = '
Validiere (echter Core) und berechne Änderungen …
'; $('#pub-go').disabled = true; + const [val, diff] = await Promise.all([api('POST', A('/validate?source=draft')), api('GET', A('/diff?a=current&b=draft'))]); + const code = diff.items.some(i => i.path==='overrides.py'); + $('#pub-body').innerHTML = valHtml(val) + `

${diff.items.length} geänderte Datei(en)

` + diff.items.map(diffHtml).join('') + + (code ? `` : '') + + ``; + const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); }; + $('#codeok')?.addEventListener('change', upd); upd(); +} +const diffHtml = i => `
${esc(i.path)}${i.kind}${i.added!=null?`+${i.added} −${i.removed}`:''}
${i.diff?`
${i.diff.map(l=>`
${esc(l)||' '}
`).join('')}
`:'
Binärdatei
'}
`; +async function publish(){ + try { const r = await api('POST', A('/publish'), {message: $('#pubmsg').value}); modal('m-pub', false); await load(true); renderSection(); toast('Version v'+r.version+' veröffentlicht'); } + catch(e){ toast(e.message, 1); } +} + +/* ───────── Agent ───────── */ +const CHIPS = ['Setze die Akzentfarbe auf #0B6E4F','Füge dem Prompt hinzu: Nenne Beträge immer in Euro mit Tausenderpunkt.','Lege ein eigenes PDF-CSS an und mache die Überschriften größer.','Prüfe den Tenant auf Probleme.']; +async function initAgent(){ + $('#ag-chips').innerHTML = CHIPS.map(s=>`${esc(s)}`).join(''); + $('#chat-text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendChat(); } }); + const st = await api('GET', '/api/llm/status?tenant=' + TID); + const src = {shared:'geteiltes Secret', env:'Env/.env', tenant:'Key des Tenants'}[st.source]; + $('#ag-mode').textContent = st.configured ? `${st.model} · ${st.api}` : 'Demo-Modus – kein Key gefunden'; + $('#ag-mode').className = 'badge ' + (st.configured ? 'cyan' : 'warn'); + $('#ag-mode').title = st.configured ? 'Cortecs-Key aus: ' + src : (st.error || 'Key unter Einstellungen → Geteilte Secrets setzen oder Env CORTECS_API_KEY'); + if(st.error) $('#ag-usage').textContent = '⚠ ' + st.error; + const h = await api('GET', A('/chat')); const box = $('#msgs'); + if(!h.length) box.innerHTML = '
Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.
'; + else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9; +} +function stepsHtml(steps){ return (steps||[]).map(s=>`
${esc(s.tool)}${esc(s.arg)} → ${esc(s.result)}
`).join(''); } +function propHtml(m){ + if(!m.proposal) return ''; + const items = (m.proposal.items||[]).map(i=>`${esc(i.path)}`).join(' '); + return `
Vorschlag: ${esc(m.proposal.summary)}
${items}
${m.proposal_status==='open' + ? `` + : `${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'}`}
`; +} +function msgEl(m){ + const d = document.createElement('div'); d.className = 'bub '+m.role; d.id = 'm'+(m.id||''); + const u = m.usage && m.usage.in ? `
${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens
` : ''; + d.innerHTML = (m.steps&&m.steps.length?`
${stepsHtml(m.steps)}
`:'') + esc(m.content) + propHtml(m) + u; return d; +} +async function sendChat(text){ + const ta = $('#chat-text'); text = text || ta.value.trim(); if(!text) return; ta.value = ''; + ui.mode = 'agent'; layout(); + const box = $('#msgs'); box.querySelector('.empty')?.remove(); + box.appendChild(msgEl({role:'user', content:text})); + const live = document.createElement('div'); live.className = 'bub assistant'; live.innerHTML = '
Agent arbeitet …
'; box.appendChild(live); box.scrollTop = 1e9; + $('#chat-send').disabled = true; + try { + const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})}); + if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status); + const rd = r.body.getReader(), dec = new TextDecoder(); let buf = ''; + while(true){ + const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true}); + let i; while((i = buf.indexOf('\n\n')) >= 0){ + const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2); + if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); } + else if(ev.type==='changed'){ refreshPreviewLive(); } + else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev])); + else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `
${esc(ev.text)}
`); + else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text; + else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message)); + if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); } + refreshPreview(true); } + box.scrollTop = 1e9; + } + } + } catch(e){ live.remove(); toast(e.message, 1); } + $('#chat-send').disabled = false; +} +async function decide(id, d){ + const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m)); + ui.agentSrc = null; setPvSrc('draft'); + if(d==='apply'){ await load(true); renderNav(); headLogo(); if(ui.sec==='overview') sOverview(); toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); } + refreshPreview(true); +} +async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '
Keine Unterschiede zum Entwurf.
'; modal('m-diff', true); } +async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = '
Verlauf geleert.
'; } + +/* ───────── Betrieb ───────── */ +async function sDomain(){ + const dnsTarget = `${S.subdomain}.${S.base_domain}`; + const o = await api('GET', A('/oidc')); window._oidc = o; + const logout = `https://${S.host}/`; + const modeHelp = {off:'Nichts im Login anfassen (Client wird manuell gepflegt).', adopt:'Bestehenden Client übernehmen: fehlende Redirect-URIs ergänzen, sonst nichts ändern.', managed:'Client vollständig aus dieser Konfiguration verwalten (anlegen, Redirect-URIs, auth/totp, Freigaben).'}; + $('#pane').innerHTML = `

Domain & Login

+
+

Domain

+
+
+
+ ${S.domain_status==='verified'?'verifiziert':S.domain_status==='pending'?'DNS ausstehend':'nicht eingerichtet'}
+
+ ${S.custom_domain?`
TypNameWert
CNAME${esc(S.custom_domain)}${esc(dnsTarget)}

Alternativ A-Record auf die Projekt-IP aus mStudio. TLS stellt Mittwald automatisch aus.

`:''} +

Redirect-URIs im Login ergeben sich aus Subdomain und eigener Domain und werden beim Deploy mit abgeglichen.

+ +

OIDC-Client · Abgleich über die Login-Developer-API bei jedem Deploy

+
+ API-Key ${o.api_key?'vorhanden':'fehlt'} + Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'} + ${o.client_id?esc(o.client_id):'noch kein Client'} + Secret ${o.secret_set?'gesetzt':'fehlt'}
+
+ +
${modeHelp[o.mode]}
+ + + +
+
Redirect-URIs (Soll):
${o.redirect_uris.map(u=>`
${esc(u)}
`).join('')} +
Logout-Ziel: ${esc(logout)} (Core: OIDC_LOGOUT_REDIRECT_URI)
+
+ + +
+ ${o.last_sync?`
Letzter Abgleich: ${fmt(o.last_sync.ts)} · ${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')}${o.last_sync.created?' · Client neu angelegt':''}
`:''} +
+ +
+

Freigaben

+
+

Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.

+
+
+
+

Manuell eintragen

Falls der Client nicht automatisch verwaltet wird (Verwaltung „off“) oder das Secret aus dem Altbestand übernommen werden soll.

+
+
+
+

Bundle-Abruf durch den Container

So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = ADMIN_BUNDLE_TOKEN. Der Core kann das noch nicht – Schnittstelle steht.

+
curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n  ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz
`; + renderMembers(o.members); + $('#oc-mode').addEventListener('change', e => { $('#oc-modehelp').textContent = modeHelp[e.target.value]; }); +} +function renderMembers(ms){ + window._members = ms.map(m => ({...m})); + $('#oc-members tbody').innerHTML = window._members.length ? window._members.map((m,i)=>` + + `).join('') : 'Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“).'; +} +function addMember(){ window._members.push({email:'', role:'viewer'}); renderMembers(window._members); } +async function saveOidc(toastOk){ + try{ + await api('PUT', A('/oidc'), {mode:$('#oc-mode').value, auth_mode:$('#oc-auth').value, totp_mode:$('#oc-totp').value, allow_registration:$('#oc-reg').checked, + backchannel_logout_uri:$('#oc-bc').value, members:window._members.filter(m=>m.email), members_mode:$('#oc-mm').value}); + if(toastOk) { toast('OIDC-Konfiguration gespeichert'); sDomain(); } return true; + }catch(e){ toast(e.message,1); return false; } +} +async function syncOidc(apply, rotate){ + if(!await saveOidc(false)) return; + if(rotate && !confirm('Secret rotieren? Das alte Secret ist sofort ungültig – der Container braucht danach einen Deploy.')) return; + if(apply && !rotate && !confirm('Jetzt im Login-System schreiben (Client anlegen/anpassen, Freigaben setzen)?')) return; + $('#oc-out').innerHTML = 'Frage das Login-System …'; + try{ + const r = await api('POST', A('/oidc/sync'), {apply, rotate}); + const acts = r.actions.map(a=>`
• ${esc(a.detail)}
`).join('') || (r.skipped?`
${esc(r.skipped)}
`:'
✔ Alles abgeglichen – keine Änderungen nötig.
'); + $('#oc-out').innerHTML = ``; + if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; } + }catch(e){ $('#oc-out').innerHTML = ``; } +} +async function importMembers(){ try{ const o = await api('POST', A('/oidc/import-members')); renderMembers(o.members); toast(o.members.length+' Freigaben übernommen – speichern nicht vergessen'); }catch(e){ toast(e.message,1) } } +async function saveManual(){ const id=$('#o-id').value, sc=$('#o-sec').value; if(id) await api('PUT',A('/secrets/OIDC_CLIENT_ID'),{value:id}); if(sc) await api('PUT',A('/secrets/OIDC_CLIENT_SECRET'),{value:sc}); toast('Gespeichert'); sDomain(); } +async function saveDomain(){ try{ await api('PUT', A('/domain'), {subdomain:$('#d-sub').value, custom_domain:$('#d-cust').value}); await load(true); sDomain(); toast('Domain gespeichert'); }catch(e){ toast(e.message,1) } } +async function checkDomain(){ $('#dns-msg').textContent='Prüfe …'; const r = await api('POST', A('/domain/check')); await load(true); await sDomain(); $('#dns-msg').textContent = r.message; } +async function sSecrets(){ + const sec = await api('GET', A('/secrets')); renderNav({missing: sec.some(s=>s.required&&!s.set)}); + $('#pane').innerHTML = `

Secrets

Verschlüsselt in der Datenbank. Werte werden nie angezeigt und gelangen nie in Agent-Kontext oder Bundle – nur beim Deploy als Env an den Container.

+ ${sec.map(s=>` + + `).join('')}
SchlüsselStatusBedeutung
${esc(s.key)}${s.required?' Pflicht':''}${s.set?'gesetzt':s.source==='sops-import'?'noch in SOPS':'fehlt'}${esc(s.help)} ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?``:''}
+
Bestehende Tenants: python scripts/import_tenants.py --with-secrets liest SOPS und verschlüsselt neu.
`; +} +async function setSecret(k){ const v = prompt('Neuer Wert für '+k+':'); if(v===null) return; await api('PUT', A('/secrets/'+k), {value:v}); toast('Gespeichert'); sSecrets(); } +async function addSecret(){ const k = prompt('Name (z. B. MISTRAL_API_KEY):'); if(!k) return; await setSecret(k.trim().toUpperCase()); } +async function genSecret(k){ if(!confirm(k+' neu erzeugen? Der Container braucht danach einen Deploy.')) return; await api('PUT', A('/secrets/'+k), {generate:true}); toast('Neu erzeugt'); sSecrets(); } + +async function followDeploy(id, con){ + con.classList.remove('hide'); con.style.display = 'block'; + const tick = async () => { const d = await api('GET','/api/deployments/'+id); con.innerHTML = esc(d.log).replace(/\[DRY-RUN\]/g,'[DRY-RUN]'); con.scrollTop = 1e9; + if(d.status==='running'||d.status==='queued') setTimeout(tick,500); else { await load(true); if(ui.sec==='deploy') sDeploy(true); } }; + tick(); +} +async function sDeploy(keepConsole){ + const [plan, hist] = await Promise.all([api('GET', A('/plan')), api('GET', '/api/deployments')]); + const mine = hist.filter(d => d.tenant === TID); const m = plan.mode; + const old = keepConsole ? $('#con')?.innerHTML : null; + $('#pane').innerHTML = `

Deploy

+ ${m.live?``:''} +

Vorflug-Check · ist alles bereit für den Live-Deploy? (liest nur)

Noch nicht ausgeführt.
+

Geplanter Rollout

+
Service
${esc(plan.service)}
Image
${esc(plan.patch.image)}
Version
v${S.current_version} ${S.has_draft?'Entwurf wird nicht deployt':''}
Ingress
${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}
+
Voraussetzungen
API-Token Projekt-ID ${S.source==='admin'?`Core-Bundle`:''} + OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}
+ ${plan.missing_secrets.length?``:''} +
Service-Definition (PATCH-Body, Secrets maskiert)
${esc(JSON.stringify(plan.patch,null,2))}
+
+
+

Verlauf

${mine.length?`${mine.map(d=>``).join('')}
ZeitArtVersionStatus
${fmt(d.created_at)}${kindLabel[d.kind]||d.kind} ${d.simulated?'Dry-Run':'live'}v${d.version}${d.status}
`:'
Noch keine Deployments für diesen Tenant.
'}
`; + if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; } +} +async function runPreflight(){ + $('#pf').innerHTML = 'Prüfe (liest Mittwald-Stack, Login-API, Rendering) …'; + try{ + const r = await api('GET', A('/preflight')); + const dot = {ok:'ok', fail:'no', warn:'no', info:''}; const ic = {ok:'✓', fail:'✕', warn:'!', info:'i'}; + $('#pf').innerHTML = ``+ + r.items.map(i=>`
${ic[i.level]}${esc(i.label)} ${esc(i.detail)}${i.fix?`
→ ${esc(i.fix)}
`:''}
`).join(''); + }catch(e){ $('#pf').innerHTML = ``; } +} +async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } } +async function startDeploy(dry){ + if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return; + try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } +} + +/* ───────── Verlauf ───────── */ +async function sVersions(){ + const v = await api('GET', A('/versions')); + $('#pane').innerHTML = `

Verlauf

Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als Entwurf an – nichts wird überschrieben.

+ ${v.map(x=>` + `).join('')}
VersionNachrichtAutorZeitDateien
v${x.number} ${x.current?'aktuell':''} ${x.deployed?'deployt':''}${esc(x.message)}${esc(x.author)}${fmt(x.created_at)}${x.files}${x.current?'':` `}
`; +} +async function showDiff(a,b,t){ const d = await api('GET', A(`/diff?a=${a}&b=${b}`)); $('#diff-title').textContent = 'Diff '+t; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '
Keine Unterschiede.
'; modal('m-diff', true); } +async function restoreV(n){ if(!confirm('v'+n+' als Entwurf wiederherstellen? Ein bestehender Entwurf wird ersetzt.')) return; await api('POST', A(`/versions/${n}/restore`)); await load(true); location.hash = 'brand'; renderSection(); toast('v'+n+' liegt jetzt im Entwurf'); } + +(async () => { await loadSchema(); await load(); initAgent(); })(); diff --git a/app/templates/audit.html b/app/templates/audit.html new file mode 100644 index 0000000..b6486ce --- /dev/null +++ b/app/templates/audit.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Audit-Log · Onyscribe Admin{% endblock %} +{% block body %} +
+
Nachvollziehbarkeit

Audit-Log.

+

Wer hat wann was geändert, veröffentlicht oder deployt – inklusive Freigaben von Agent-Vorschlägen.

+
+ + {% for r in rows %} + {% else %}{% endfor %} +
ZeitWerAktionTenantDetail
{{ r.ts|dt }}{{ r.actor }}{{ r.action }}{{ r.tenant_id or '–' }}{{ r.detail }}
Noch keine Einträge.
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..983fa5d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,44 @@ + + + + + +{% block title %}Onyscribe Admin{% endblock %} + + + + + +
{% block body %}{% endblock %}
+
+ +{% block scripts %}{% endblock %} + + diff --git a/app/templates/core.html b/app/templates/core.html new file mode 100644 index 0000000..0bc67fb --- /dev/null +++ b/app/templates/core.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}Core · Onyscribe Admin{% endblock %} +{% block body %} +
+
Zusammenspiel mit dem Core

Core-Releases.

+

Die Core-CI meldet neue Images hierher. Die Admin-Plattform prüft damit alle Tenants, gibt nach deiner Freigabe aus und rollt Canary-zuerst aus – mit Health-Check pro Tenant und Rollback.

+
+
+
+

Core der Admin-Plattform

+

Vorschau, Sandbox und Formulare nutzen genau diesen Core. Im Container ist das Admin-Image FROM das Core-Image gebaut – Schriften, Python-Pakete und WeasyPrint sind damit identisch zur Produktion.

+

Rollout-Regeln

+
+

Gepinnte Tenants (Tenant → Übersicht → Core-Version) nehmen nicht teil. Der Rollout bricht beim ersten Fehler ab; Echt-Rollouts brauchen DEPLOY_LIVE=true.

+
+

Releases

+
+ +{% endblock %} +{% block scripts %} +{% raw %}{% endraw %} +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..584369f --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}Tenants · Onyscribe Admin{% endblock %} +{% block body %} +
+
+
Übersicht

Tenants.

+
+ + + + +
+
+ +
+
{{ stats.tenants }}Tenants
+
{{ stats.admin }}Quelle Admin
+
{{ stats.outdated }}Deploy veraltet
+
{{ stats.drafts }}Entwürfe
+
{{ stats.secrets }}Secrets offen
+
:{{ tag }}Core {{ core }}
+
+ +
+
+ +
+ {% for k,l in [('all','Alle'),('draft','Entwurf'),('outdated','Deploy veraltet'),('secrets','Secrets offen'),('git','Git'),('admin','Admin'),('code','mit Hook-Code'),('paused','pausiert / archiviert')] %}{{ l }}{% endfor %} +
+
+ + {% for r in rows %} + + + + + + + + + + + {% endfor %} +
TenantVersionQuelleMarkeAnpassungenRessourcenLäuftHinweise
{% if r.logo %}{% endif %}
+
{{ r.t.name }}{% if r.t.status!='active' %} {{ r.t.status }}{% endif %}
{{ r.host }}
v{{ r.t.current_version }} + {{ 'deployt v'~(r.t.deployed_version or '–') if r.outdated else 'deployt' }}{{ 'Admin' if r.t.source=='admin' else 'Git' }}{% for k in ['accent','accent_dark','accent_light','bg','red'] %}{% endfor %}STT {{ r.stt }}{% if r.custom_prompt %} · Prompt{% endif %}{% if r.custom_pdf %} · PDF{% endif %}{% if r.has_code %} · Code{% endif %}{{ r.t.cpu }} CPU · {{ r.t.memory }}–{% if r.t.draft %}Entwurf {% endif %}{% if r.missing_secrets %}{{ r.missing_secrets }} Secret(s){% endif %}
+
Keine Tenants für diesen Filter.
+
+ +

Letzte Deployments · alle ansehen

+ {% if last %}{% for d in last %} + {% endfor %}
{{ d.created_at|dt }}{{ d.tenant_id or 'alle' }}{{ d.kind }}v{{ d.version }}{{ d.status }}
+ {% else %}
Noch keine Deployments.
{% endif %} +
+
+ + + + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/deployments.html b/app/templates/deployments.html new file mode 100644 index 0000000..7f104a1 --- /dev/null +++ b/app/templates/deployments.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Deployments · Onyscribe Admin{% endblock %} +{% block body %} +
+
+
Rollout

Deployments.

+

Differenzielles PATCH auf den Mittwald-Stack (nie PUT), Namensraum app-, Guard für fremde Container, Abbruch beim ersten Fehler.

+
+
+ {% if not deploy_live() %}{% endif %} +
Core-Image:{{ tag }} + Image-Tag und Registry unter Einstellungen. Nach dem Core-Umbau holen Tenants ihr Bundle zur Laufzeit – dann genügt ein Neustart statt Image-Build.
+

Verlauf

+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..09d15e6 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} +{% block title %}Einstellungen · Onyscribe Admin{% endblock %} +{% block body %} +
+
Plattform

Einstellungen.

+

Geteilte Werte für alle Tenants – bisher Gitea-„Variables“ und secrets/shared.enc.yaml.

+
+ +
+

Hosting & Deploy

+
+ + + + + + + +
+
+
{{ '✓' if mode.token else '!' }}MITTWALD_API_TOKEN {{ 'gesetzt (lesende Dry-Run-Aufrufe möglich)' if mode.token else 'nicht gesetzt (Dry-Run ohne Lesezugriff)' }}
+
{{ '✓' if mode.live else '–' }}DEPLOY_LIVE {{ 'true – echte Deployments erlaubt' if mode.live else 'nicht aktiv – Schreibzugriffe gesperrt' }}
+
{{ '✓' if mode.allowed != 'keiner' else '–' }}DEPLOY_ALLOWED_TENANTS = {{ mode.allowed }} – nur diese Tenants werden live angefasst (Mittwald, Login-API)
+ +
+
+ +

Login-API (OIDC-Clients automatisch verwalten)

+
{{ '✓' if oidc.api_key else '!' }}ONYVA_LOGIN_API_KEY {{ 'gesetzt' if oidc.api_key else 'nicht gesetzt – Abgleich beim Deploy nur als Hinweis (Dry-Run) bzw. Fehler (live)' }}
+
{{ '✓' if oidc.writes_allowed else '–' }}OIDC_SYNC_LIVE / DEPLOY_LIVE {{ 'aktiv – Schreiben im Login erlaubt' if oidc.writes_allowed else 'nicht aktiv – nur Lesen/Plan' }}
+
+
Liest GET /apps mit dem Key. Der Key liegt als Env der Admin-Plattform (wie ein Admin-Passwort), nie in der DB.
+
+ +

Login (OIDC) & Limits

+
+ + + + +
+ +
+ +

LLM & Transkription (Cortecs)

+
+ + + + + +
+

Geteilte Secrets

+ {% for k in ['CORTECS_API_KEY','SMTP_PASSWORD'] %} +
{{ k }} + {{ 'gesetzt' if shared.get(k) else 'extern (SOPS) – noch nicht übernommen' }} +
+ {% endfor %} +
+ +

KI-Agent (Admin-Chat)

+
{{ 'Cortecs-Key vorhanden – echter Agent' if llm_ready else 'kein Key – Demo-Modus' }}
+
+ + +
+ +
Liest GET /v1/models mit dem geteilten Key und markiert Modelle mit Tool-Unterstützung.
+
+
+ +

E-Mail (SMTP)

+
+
+

Kostenerfassung

+
+
+ +

Weitere Core-Einstellungen · aus dem Schema des Cores erzeugt, gelten für alle Tenants

+

Alles, was der Core als geteilte Einstellung deklariert und oben noch keine eigene Maske hat. Neue Core-Variablen erscheinen hier automatisch und gehen beim Deploy ins Container-Env. Leer = Standard des Cores.

+
Lade …
+ +

Rollout

+
+

Der aktive Tag ändert sich durch Core-Releases (Menü „Core“).

+ +

Core

+
Vendored Core
{{ core }}
Bedeutung
Live-Vorschau und Validierung führen genau diesen Stand aus (vendor/platform, aktualisieren mit scripts/sync_core.sh).
+
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/tenant.html b/app/templates/tenant.html new file mode 100644 index 0000000..518665a --- /dev/null +++ b/app/templates/tenant.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ t.name }} · Onyscribe Admin{% endblock %} +{% block body %} +
+ +
+
Tenants / {{ t.id }}
+

{{ t.name }}

+
+
+
+
+ +
+
+
+ +
+
Lade …
+
+
Agent
+
+
+
+
+
+ +
+ + + +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/app/templating.py b/app/templating.py new file mode 100644 index 0000000..aaf085a --- /dev/null +++ b/app/templating.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +from fastapi.templating import Jinja2Templates + +from app.settings import APP_NAME, ROOT + +templates = Jinja2Templates(directory=str(ROOT / "app" / "templates")) +BERLIN = ZoneInfo("Europe/Berlin") + + +def dt(value: datetime | None, fmt: str = "%d.%m.%Y %H:%M") -> str: + if not value: + return "–" + if value.tzinfo is None: + from datetime import timezone + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(BERLIN).strftime(fmt) + + +templates.env.filters["dt"] = dt +templates.env.globals["APP_NAME"] = APP_NAME + + +def _live() -> bool: + from app.services import deploy + return deploy.live_enabled() + + +templates.env.globals["deploy_live"] = _live diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..f2c9e84 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,6 @@ +# Nur lokal (docker compose lädt diese Datei automatisch dazu): Port auf dem Host veröffentlichen. +# Coolify nutzt sie nicht – dort läuft der Zugriff über den Proxy (Domain auf Service „admin“, Port 8300). +services: + admin: + ports: + - "${ADMIN_PORT:-8300}:8300" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7e4c724 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +# Onyscribe Admin – Admin-Oberfläche + Postgres. Für Coolify (Docker-Compose-Ressource) und lokal. +# Pflicht-Env: POSTGRES_PASSWORD, SECRET_KEY +# SECRET_KEY erzeugen: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Backups: Volume `pgdata` (Postgres) sichern – darin liegt ALLES (Tenants, Versionen, verschlüsselte Secrets). +# ACHTUNG: SECRET_KEY ebenfalls sichern – ohne ihn sind die Secrets in der DB nicht entschlüsselbar. +services: + admin: + build: + context: . + # Standard: vendored Core (lokal). Produktion: ADMIN_DOCKERFILE=Dockerfile.fromcore + CORE_IMAGE=/transkript-platform: + dockerfile: ${ADMIN_DOCKERFILE:-Dockerfile} + args: + CORE_IMAGE: ${CORE_IMAGE:-scratch} + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg://onyscribe:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD setzen}@db:5432/onyscribe + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY setzen} + PORT: "8300" + # Login der Admin-Plattform (login.onyva.dev). Mit AUTH_ENABLED=true sind Client + Zugriffsregel Pflicht. + AUTH_ENABLED: ${AUTH_ENABLED:-false} + OIDC_ISSUER: ${OIDC_ISSUER:-https://login.onyva.dev} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} + OIDC_LOGOUT_REDIRECT_URI: ${OIDC_LOGOUT_REDIRECT_URI:-} + APP_COOKIE_SECURE: ${APP_COOKIE_SECURE:-true} + ADMIN_ALLOWED_EMAILS: ${ADMIN_ALLOWED_EMAILS:-} + ADMIN_REQUIRED_ROLE: ${ADMIN_REQUIRED_ROLE:-} + # Deploys: ohne DEPLOY_LIVE=true nur Dry-Run. Token nur setzen, wenn lesende Dry-Run-Aufrufe gewünscht sind. + DEPLOY_LIVE: ${DEPLOY_LIVE:-false} + # Welche Tenants dürfen LIVE angefasst werden (Mittwald + Login-API)? leer = keiner · demo-admin,x = diese · * = alle + DEPLOY_ALLOWED_TENANTS: ${DEPLOY_ALLOWED_TENANTS:-} + MITTWALD_API_TOKEN: ${MITTWALD_API_TOKEN:-} + # Core-CI meldet neue Images hierher (POST /api/core/releases, Bearer-Token) + CORE_RELEASE_TOKEN: ${CORE_RELEASE_TOKEN:-} + # OIDC-Clients automatisch verwalten (Developer-API von login.onyva.dev). Key = Admin-Passwort-Äquivalent! + ONYVA_LOGIN_API_KEY: ${ONYVA_LOGIN_API_KEY:-} + OIDC_SYNC_LIVE: ${OIDC_SYNC_LIVE:-false} # true erlaubt Schreiben im Login-System (auch ohne DEPLOY_LIVE) + # Optional statt Eintrag unter Einstellungen → Geteilte Secrets + CORTECS_API_KEY: ${CORTECS_API_KEY:-} + expose: + - "8300" # Coolify: Domain auf Service „admin“ legen, Port 8300 (https://:8300). Lokal veröffentlicht docker-compose.override.yml den Port. + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: onyscribe + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD setzen} + POSTGRES_DB: onyscribe + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U onyscribe -d onyscribe"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + pgdata: diff --git a/docs/RUNBOOK_PARALLELTEST.md b/docs/RUNBOOK_PARALLELTEST.md new file mode 100644 index 0000000..48549ca --- /dev/null +++ b/docs/RUNBOOK_PARALLELTEST.md @@ -0,0 +1,289 @@ +# Runbook: Admin-Plattform parallel zum Altbetrieb testen (ohne Merge, nur mit Demo-Tenant) + +Ziel: Die neue Admin-Plattform und den neuen Core **parallel** zu den laufenden Tenants betreiben – mit **einem** neuen Test-Tenant +`demo-admin`. Nichts Bestehendes wird angefasst, es wird **nichts gemergt**, und jeder Schritt lässt sich einzeln zurücknehmen. + +--- + +## 0. So läuft das Ganze parallel (und warum der Altbetrieb sicher ist) + +``` +ALTBETRIEB (unverändert) NEUBETRIEB (Test) +──────────────────────── ───────────────── +Gitea: main / cortecs-llm-migration Gitea: Branch admin-platform-integration + └ build-deploy.yml → :latest + deploy-all └ build-branch-image.yml → NUR Image : (kein :latest, kein Deploy) +Mittwald: app-demo, app-rotary, app-sprinz, … Mittwald: app-demo-admin ← neuer, eigener Service + Volume + Ingress + Domain: demo. … Domain: demo-admin. + Daten: ins Image gebacken (tenants/) Daten: Bundle von der Admin-Plattform (TENANT_SOURCE=admin) + OIDC-Client: von Hand angelegt OIDC-Client: automatisch über die Login-API + Admin-Plattform auf Coolify (eigene Domain, eigene Postgres) +``` + +Schutzmechanismen (alle eingebaut und getestet): + +| Schutz | Wirkung | +|---|---| +| Branch-Trennung | `build-deploy.yml` läuft nur auf `main`/`cortecs-llm-migration` (auch nicht per „Run workflow“ vom Test-Branch). Der Test-Branch baut nur ein Image mit Tag = Git-SHA. | +| Eigener Tenant | `demo-admin` hat eigenen Service `app-demo-admin`, eigenes Volume `vol-demo-admin`, eigenen Host und eigenen OIDC-Client. `app-demo` bleibt unberührt. | +| `DEPLOY_ALLOWED_TENANTS=demo-admin` | Die Admin-Plattform darf **nur** diesen Tenant live anfassen (Mittwald **und** Login-API). Auch „Alle neu deployen“ trifft nur ihn. | +| `DEPLOY_LIVE=false` zum Start | Erst nur Dry-Run (liest den Stack, schreibt nichts). | +| Differenzielles PATCH + Guard | Es wird nur `app-demo-admin` geschrieben; danach prüft der Admin, dass alle fremden Services noch da sind. | +| Default `TENANT_SOURCE=git` im Core | Der neue Core verhält sich ohne Env wie der alte. | + +Rückgängig machen: siehe **Abschnitt 11**. + +--- + +## 1. Werte zusammentragen (einmalig) + +| Wert | Woher | Verwendung | +|---|---|---| +| `DOMAIN` | Gitea → Repo `transkriptipt-platform` → Einstellungen → Actions → **Variablen** | Basis-Domain in der Admin-Plattform | +| `PROJECT_ID`, `STACK_ID` | dito | Mittwald-Ziel | +| `REGISTRY` (z. B. `gitea.onyva.dev/onyva`) | dito | Registry in der Admin-Plattform | +| `MITTWALD_API_TOKEN` | Gitea → **Secrets** (du kennst den Wert; derselbe Token) | Env der Admin-Plattform | +| Weitere Variablen: `OIDC_ISSUER`, `AUTH_ENABLED`, `APP_COOKIE_SECURE`, `MAX_FILE_SIZE_MB`, `CORTECS_BASE_URL`, `LLM_MODEL`, `LLM_MAX_TOKENS`, `LLM_TEMPERATURE`, `SMTP_HOST/PORT/USER/FROM`, `MISTRAL_*`, `COST_*` | dito | in **Einstellungen** der Admin-Plattform (Abschnitt 6) | +| `CORTECS_API_KEY`, `SMTP_PASSWORD` | `cd ~/PycharmProjects/transkript-platform && sops -d --extract '["CORTECS_API_KEY"]' secrets/shared.enc.yaml` (analog `SMTP_PASSWORD`) | Einstellungen → Geteilte Secrets | +| `COST_INGEST_KEY` von `demo` | `sops -d --extract '["COST_INGEST_KEY"]' tenants/demo/secrets.enc.yaml` | Secret des Test-Tenants (Abschnitt 7, Punkt 7.4) | +| **``** | du wählst: Domain, unter der Coolify die Admin-Plattform ausliefert, z. B. `admin.deine-domain.de` | muss **öffentlich per HTTPS** erreichbar sein (der Tenant-Container holt von dort sein Bundle) | +| **``** | deine Login-E-Mail bei login.onyva.dev (Konto muss dort existieren) | Admin-Zugriff + Freigabe im Test-Tenant | + +Werte **nicht** in Chats oder Tickets kopieren (Keys, Secrets). + +--- + +## 2. Core-Branch pushen (Image bauen lassen) + +```bash +cd ~/PycharmProjects/transkript-platform +git branch --show-current # → admin-platform-integration +git log --oneline -3 # 13055b8 CI: build-deploy nur von Produktions-Branches … +git rev-parse HEAD # → voller SHA; z. B. 13055b811ca0030841f41ce1f10e4f30cba1a7db ← MERKEN (= CORE_SHA) +git push -u origin admin-platform-integration +``` + +Was jetzt in Gitea passiert (Repo → **Actions**): + +| Workflow | Erwartung | +|---|---| +| `test` | grün (Schema ↔ Code ↔ Tenants, Bundle-Loader) | +| `build-branch-image` | grün; Log-Zeile „Image gebaut: `/transkript-platform:`“ | +| `build-deploy` | **läuft nicht** (Branch nicht gelistet) – wenn doch: sofort abbrechen und melden | + +Prüfen: Gitea → Repo → **Packages** → `transkript-platform` hat einen neuen Tag = voller SHA. Der Tag `latest` ist **unverändert** (Zeitstempel!). + +> ✅ Ergebnis: `CORE_SHA` (voller 40-stelliger SHA). Lege ihn in deine Notizen. + +--- + +## 3. Admin-Plattform ins eigene Repo + +1. Gitea → **Neues Repository**: Organisation `Onyva`, Name `onyscribe-admin`, **privat**, leer (ohne README/.gitignore/Lizenz). +2. Lokal: + +```bash +cd ~/PycharmProjects/onyscribe-admin +git status --short | head # .env und .data dürfen NICHT erscheinen (sind ignoriert) +git ls-files | grep -E '(^|/)\.env$|\.db$' || echo "ok: keine Secrets/DB im Index" +git add -A +git commit -m "Onyscribe Admin: Initialer Stand" +git remote add origin https://gitea.onyva.dev/Onyva/onyscribe-admin.git +git push -u origin main +``` + +Hinweis: `seed/tenants.seed.json.gz` enthält die Tenant-Konfigurationen inkl. Zusatzdateien (z. B. `namensliste.txt` – personenbezogen). Das liegt heute schon im Altrepo; Repo privat halten. + +--- + +## 4. Login-System: Client + API-Key für die Admin-Plattform + +Im Login-Dashboard (login.onyva.dev), als Admin: + +1. **API-Keys → neuer Key** („onyscribe-admin“, ggf. Ablaufdatum). Klartext `onyva_…` **einmalig** kopieren → später `ONYVA_LOGIN_API_KEY`. +2. Client für die Admin-Plattform selbst anlegen (Dashboard **oder** per API): + +```bash +KEY=onyva_… # der Key aus 1. +curl -sX POST https://login.onyva.dev/api/v1/apps -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"name":"Onyscribe Admin","redirect_uris":["https:///auth/callback"],"auth_mode":"required","totp_mode":"disabled"}' +# → client_id + client_secret SOFORT sichern (nur einmal sichtbar) +curl -sX PUT https://login.onyva.dev/api/v1/apps//memberships -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"user_email":"","role":"admin"}' +``` + +Ergebnis: `client_id`, `client_secret` (Admin-Login), `ONYVA_LOGIN_API_KEY`. + +--- + +## 5. Coolify: Admin-Plattform deployen + +**Neue Ressource → Docker Compose (aus Git)**: Repo `Onyva/onyscribe-admin`, Branch `main`, Compose-Datei `docker-compose.yml` +(Zugriff auf das private Repo per Deploy-Key/Token einrichten). + +**Umgebungsvariablen** (Coolify → Ressource → Environment): + +| Variable | Wert | Phase | +|---|---|---| +| `POSTGRES_PASSWORD` | langes Zufallspasswort | immer | +| `SECRET_KEY` | `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` – **sichern! ohne ihn sind die Secrets in der DB unlesbar** | immer | +| `AUTH_ENABLED` | `true` | immer | +| `OIDC_ISSUER` | `https://login.onyva.dev` | immer | +| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | aus Abschnitt 4 (Punkt 2) | immer | +| `OIDC_REDIRECT_URI` | `https:///auth/callback` | immer | +| `OIDC_LOGOUT_REDIRECT_URI` | `https:///` | immer | +| `APP_COOKIE_SECURE` | `true` | immer | +| `ADMIN_ALLOWED_EMAILS` | `` (weitere kommagetrennt) | immer – ohne Regel wird jeder Login abgelehnt | +| `MITTWALD_API_TOKEN` | derselbe Token wie in Gitea | immer | +| `ONYVA_LOGIN_API_KEY` | aus Abschnitt 4 (Punkt 1) | immer | +| `DEPLOY_ALLOWED_TENANTS` | `demo-admin` | immer | +| `DEPLOY_LIVE` | **`false`** | Phase 1 (Dry-Run) → in Abschnitt 8 auf `true` | +| `OIDC_SYNC_LIVE` | `false` | optional `true`, um den Login-Abgleich separat vor dem Deploy zu testen | +| `CORE_RELEASE_TOKEN` | leer | erst in Abschnitt 10 | +| `CORTECS_API_KEY` | leer (wird in der UI als Secret gesetzt) | optional | + +**Domain**: Service `admin` → Domain `https://:8300` (Port 8300 = Container-Port). **Persistente Daten**: Volume `pgdata` (Postgres) – Backup einrichten. + +Deploy starten. Der erste Build nutzt das normale `Dockerfile` (mitgelieferter Core-Snapshot, **keine Registry nötig**). + +Prüfen: + +- [ ] `https:///healthz` → `{"ok":true}` +- [ ] `https:///` → Weiterleitung zu login.onyva.dev → Login → Dashboard mit **7 Tenants** (Seed). +- [ ] Seitenleiste unten: „Deploy: gesperrt · Dry-Run“, „“. +- Probleme: „Kein Zugriff“ → `ADMIN_ALLOWED_EMAILS`; `redirect_uri`-Fehler → Redirect-URI im Login-Client **exakt** wie in `OIDC_REDIRECT_URI`. + +--- + +## 6. Admin-Plattform konfigurieren (Menü „Einstellungen“) + +Felder (Spalte „Gitea“ = dieselbe Variable wie bisher), dann oben **„Alles speichern“**: + +| Feld in der Admin-Plattform | Wert / Gitea | +|---|---| +| Basis-Domain | `DOMAIN` | +| Mittwald PROJECT_ID / STACK_ID | `PROJECT_ID` / `STACK_ID` | +| Registry | `REGISTRY` | +| Image-Name | `transkript-platform` | +| **Öffentliche URL der Admin-Plattform** | `https://` | +| **Core kann Bundle laden** | ✔ aktivieren (betrifft nur Tenants mit Quelle „Admin“, also nur `demo-admin`) | +| Login (OIDC) & Limits | `OIDC_ISSUER`, `AUTH_ENABLED` (true), `APP_COOKIE_SECURE` (true), `MAX_FILE_SIZE_MB` | +| LLM & Transkription | `CORTECS_BASE_URL`, `LLM_MODEL`, `LLM_MAX_TOKENS`, `LLM_TEMPERATURE`, `MISTRAL_*` | +| E-Mail (SMTP) | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_FROM` | +| Kostenerfassung | `COST_API_BASE`, `COST_TRACKING_ENABLED` | +| „Weitere Core-Einstellungen“ (aus dem Schema) | nur ausfüllen, was du in Gitea auch gesetzt hast (`COST_LLM_*`, `COST_STT_PER_MINUTE`, `STT_*` …); leer = Standard des Cores | +| Rollout → Canary-Tenants | `demo-admin` | +| Geteilte Secrets → `CORTECS_API_KEY` | **Wert setzen** (aus `sops -d …`) | +| Geteilte Secrets → `SMTP_PASSWORD` | **Wert setzen** (nur nötig, wenn der Test E-Mail-Versand prüfen soll) | + +Dann: Karte „Login-API“ → **„Verbindung testen“** → `✔ Verbindung ok – N App(s) im Login`. + +--- + +## 7. Test-Tenant `demo-admin` anlegen und konfigurieren + +**7.1 Anlegen**: Dashboard → **+ Neuer Tenant** → Name `Onyva Demo (Admin-Test)`, ID `demo-admin`, Ausgangspunkt „Kopie von Onyva Demo“ → Anlegen. +(Quelle ist automatisch „Admin“, OIDC-Verwaltung „managed“.) + +**7.2 Spracherkennung**: Tenant → *Funktion & STT* → Provider `cortecs`, Modell `voxtral-mini-transcribe-2602` +(dann reicht der geteilte Cortecs-Key; `demo` nutzt sonst Mistral und bräuchte einen eigenen `MISTRAL_API_KEY`). + +**7.3 Optisch unterscheidbar machen**: *Marke & Farben* → Akzent z. B. `#E8590C` → „Töne aus Akzent ableiten“. Oben rechts **Prüfen & veröffentlichen** → Nachricht „Test-Tenant“ → **v2**. + +**7.4 Secrets**: Tenant → *Secrets* → `COST_INGEST_KEY` **Wert setzen** (der Wert von `demo`; Kosten laufen dann unter `demo`, für den Test okay). +`SESSION_SECRET` und `ADMIN_BUNDLE_TOKEN` sind schon erzeugt. `OIDC_CLIENT_ID/SECRET` legt der Deploy automatisch an. + +**7.5 Login**: Tenant → *Domain & Login*: Subdomain `demo-admin`; OIDC-Verwaltung `managed`, Zugriff `required`; **Freigaben**: `` als `editor` (Nutzer muss im Login existieren) → *Speichern*. +**„Gegen Login prüfen“** (nur lesend) → erwartet: „würde App ‚Onyscribe – Onyva Demo (Admin-Test)‘ anlegen (Redirect: https://demo-admin./auth/callback …)“ und „würde als editor freigeben“. + +**7.6 Core pinnen**: Tenant → *Übersicht* → Karte „Core-Version“ → Pin = **voller SHA aus Abschnitt 2** → *Pin setzen*. +(Der Pin sorgt dafür, dass genau das Branch-Image läuft und der Health-Check die Core-Version prüfen kann.) + +**7.7 Vorflug-Check + Dry-Run**: Tenant → *Deploy* → **Vorflug-Check → Prüfen**. +Erwartet in Phase 1: alles grün/gelb, **gelb** nur „DEPLOY_LIVE nicht aktiv“; keine roten Punkte. Typische rote Punkte und Fix stehen direkt dabei +(z. B. Registry/PROJECT_ID/Token fehlen, `CORTECS_API_KEY`/`COST_INGEST_KEY` fehlen, „Core kann Bundle laden“ aus, Admin-URL nicht erreichbar). +Dann **Dry-Run** → Log lesen: „geschützte bestehende Services (bleiben unangetastet): [app-demo, app-rotary, …]“, `[DRY-RUN] PATCH …` für Volume und Service, kein Schreibzugriff. + +--- + +## 8. Live-Deploy des Test-Tenants + +1. Coolify → Admin-Ressource → `DEPLOY_LIVE=true` setzen (`DEPLOY_ALLOWED_TENANTS=demo-admin` bleibt) → neu deployen (Daten bleiben). +2. Admin → `demo-admin` → *Deploy* → Vorflug-Check → muss **„Bereit“** zeigen → **Deployen**. + +Was passiert (Log): OIDC-Client im Login anlegen und Secret speichern → Pflicht-Secrets prüfen → Volume + Service per PATCH → Service neu erzeugen → Ingress +`demo-admin.` → Health-Check (bis ~90 s Wartezeit für Start + Zertifikat). + +Abnahme-Checkliste: + +- [ ] Deploy-Log endet mit `Health-Check … ✔ · Core 13055b811c · Bundle v2` und `✔ Fertig` +- [ ] mStudio: Service `app-demo-admin` läuft; **alle anderen Services unverändert** (Image-Tags wie vorher) +- [ ] `https://demo-admin./health` → `core.version` = dein SHA, `bundle` = `{"source":"admin","version":2,"stale":false}` +- [ ] Seite öffnen → Login → freigegebener Nutzer kommt rein → Oberfläche in der **neuen Farbe** +- [ ] Protokoll erzeugen (kleines Transkript hochladen) → Zusammenfassung → PDF/Word → Aussehen = Vorschau in der Admin-Plattform +- [ ] Admin → Dashboard → **Zustand prüfen**: `demo-admin` grün. Die Altbetrieb-Tenants zeigen „Core ohne Vertrag (alte Version)“ – **erwartet und harmlos** (sie laufen noch auf dem alten Image) +- [ ] Altbetrieb: `demo.` und die anderen Kunden funktionieren unverändert + +--- + +## 9. Änderung ohne Core-Änderung testen + +1. Admin → `demo-admin` → *Marke & Farben*: Akzent ändern → **Prüfen & veröffentlichen** (v3) → *Deploy* → **Deployen** (nur Neustart, kein Image-Build). +2. Erwartung: Health-Check `Bundle v3 ✔`; Seite neu laden → neue Farbe. +3. Agent: *KI-Agent* → „Setze die Akzentfarbe auf #0B6E4F“ → Live-Vorschau beobachten → „In Entwurf übernehmen“ → veröffentlichen → deployen. +4. Ausfallsicherheit (nur `demo-admin`!): Coolify → Admin-Ressource **stoppen**; in mStudio den Container `app-demo-admin` **neu starten** → er startet mit gecachtem Bundle: + `https://demo-admin./health` → `bundle.source = "cache"`, `stale = true`. Admin wieder starten → nächster Container-Neustart holt wieder `admin`. + +--- + +## 10. (Optional) Den kompletten Core-Release-Weg testen + +1. Coolify: `CORE_RELEASE_TOKEN` = langes Zufallstoken, Admin neu deployen. Gitea → Repo → Einstellungen → Actions: **Variable** `ADMIN_URL` = `https://`, **Secret** `ADMIN_RELEASE_TOKEN` = dasselbe Token. + (Wirkt nur auf Workflows des Test-Branches; `main` kennt den `notify-admin`-Job nicht.) +2. Im Core-Repo eine Kleinigkeit committen und auf den Branch pushen → `build-branch-image` baut und meldet das Release. +3. Admin → Menü **Core**: neues Release, Badge **„Admin älter/anders“** (die Admin-Plattform enthält noch den alten Core-Snapshot). +4. Angleichen: `cd ~/PycharmProjects/onyscribe-admin && scripts/sync_core.sh && git add -A && git commit -m "Core-Snapshot aktualisiert" && git push` → Coolify deployt → Badge **„gleicher Core“**. + *(Später ersetzt `Dockerfile.fromcore` diesen Schritt: Admin wird direkt auf dem Core-Image gebaut.)* +5. **Prüfen** → Matrix mit allen 7 Tenants (beim ersten Mal „neu (keine Baseline)“) → **Freigeben** → **Dry-Run** → *Tenant `demo-admin`: Pin aufheben* → **Ausrollen** → nur `demo-admin` wird live ausgerollt (Allowlist), Health-Check prüft die neue Core-SHA. +6. Rollback testen: **Zurückrollen** (stellt den vorherigen Tag wieder her). + +--- + +## 11. Abbruch / Rückbau (jederzeit) + +| Was | Wie | +|---|---| +| Test-Tenant live entfernen | Admin → `demo-admin` → Übersicht → Gefahrenbereich → **Offboard-Dry-Run** prüfen → **Offboard (live)** (entfernt Service, Volume, Ingress) | +| OIDC-Client des Test-Tenants | Gefahrenbereich → **OIDC-Client im Login löschen** | +| Admin-Plattform | Coolify-Ressource stoppen/löschen; Login-Client „Onyscribe Admin“ und API-Key im Login löschen | +| Branch/Image | Gitea: Branch `admin-platform-integration` löschen, Package-Tag (SHA) löschen | +| Alles Live sperren | `DEPLOY_LIVE=false` (und/oder `DEPLOY_ALLOWED_TENANTS` leeren) → Admin neu deployen | + +Der Altbetrieb braucht in keinem dieser Fälle eine Änderung. + +--- + +## 12. Fehlersuche + +| Symptom | Ursache / Fix | +|---|---| +| Vorflug: „Mittwald-Stack lesbar“ rot | Token-Rechte, `PROJECT_ID`/`STACK_ID` in Einstellungen prüfen (dieselben Werte wie Gitea) | +| Deploy: „Pflicht-Secrets ohne Wert: …“ | `CORTECS_API_KEY` (Einstellungen → Geteilte Secrets), `COST_INGEST_KEY` (Tenant → Secrets), ggf. `MISTRAL_API_KEY` (nur bei STT-Provider mistral) | +| Deploy: „… nicht für Live-Schreibzugriffe freigegeben“ | `DEPLOY_ALLOWED_TENANTS` enthält die Tenant-ID nicht | +| Deploy: „Quelle ‚Admin‘ ist noch gesperrt“ | Einstellungen → „Core kann Bundle laden“ aktivieren | +| Deploy: „OIDC-Abgleich fehlgeschlagen: API-Key ungültig (401)“ | `ONYVA_LOGIN_API_KEY` falsch/abgelaufen; Key im Login neu erzeugen | +| Deploy: „… Freigaben: existiert im Login nicht“ (Warnung) | Nutzer im Login anlegen, dann *Domain & Login → Jetzt abgleichen* | +| Health-Check: „Core X statt Y“ | Pin/Tag stimmt nicht mit dem Image überein oder Image fehlt in der Registry (mStudio-Container-Log) | +| Health-Check: „Bundle cache v… (veraltet)“ | Container erreicht die Admin-URL nicht: Domain/HTTPS/Firewall, `admin_public_url`, Bundle-Token | +| Container-Log: „Tenant-Bundle … nicht verfügbar“ | wie oben; zusätzlich `ADMIN_URL`/`ADMIN_BUNDLE_TOKEN` im Plan (Deploy → Service-Definition) prüfen | +| Login der Tenant-Seite: `redirect_uri`-Fehler | *Domain & Login → Gegen Login prüfen → Jetzt abgleichen* (ergänzt die Redirect-URI) | +| Login: `invalid_client` | `OIDC_CLIENT_SECRET` fehlt/ist veraltet → *Jetzt abgleichen* bzw. neu deployen | +| Zertifikat/Ingress fehlt | Token braucht Domain/Ingress-Recht (Log: „Ingress … NICHT angelegt“); Service läuft trotzdem, nach Rechteänderung erneut deployen | +| Admin „Zustand prüfen“: „nicht erreichbar“ | Tenant noch nicht deployt bzw. DNS/Zertifikat noch nicht fertig | + +--- + +## 13. Danach (nicht Teil dieses Tests) + +Erst wenn der Test-Tenant mehrere Tage sauber läuft: Core-Branch reviewen und mergen (→ `build-deploy` baut dann den neuen Core für alle), +Secrets der echten Tenants importieren (`python scripts/import_tenants.py --with-secrets`), pro Tenant die **Cut-over-Checkliste** abarbeiten und +`DEPLOY_ALLOWED_TENANTS` schrittweise erweitern. Die echten Tenants stellst du **einzeln** um – Rückweg: Quelle zurück auf „Git“. diff --git a/requirements-extra.txt b/requirements-extra.txt new file mode 100644 index 0000000..260ab33 --- /dev/null +++ b/requirements-extra.txt @@ -0,0 +1,8 @@ +# Nur die Pakete, die der Core NICHT schon mitbringt (Dockerfile.fromcore baut auf dem Core-Image auf) +sqlalchemy>=2.0 +psycopg[binary]>=3.2 +pyyaml>=6.0 +cryptography>=43 +httpx>=0.27 +anthropic>=0.40 +pypdf>=4.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a75a05 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Admin-Plattform +fastapi==0.135.1 +uvicorn[standard]==0.41.0 +jinja2>=3.1 +sqlalchemy>=2.0 +psycopg[binary]>=3.2 +python-multipart>=0.0.22 +pyyaml>=6.0 +cryptography>=43 +httpx>=0.27 +anthropic>=0.40 +pypdf>=4.0 +# Abhängigkeiten des vendored Cores (Live-Vorschau führt den echten Core aus) +openai==2.46.0 +python-dotenv>=1.0 +requests>=2.31 +weasyprint>=60.0 +python-docx>=1.1.0 diff --git a/scripts/export_seed.py b/scripts/export_seed.py new file mode 100644 index 0000000..0f60259 --- /dev/null +++ b/scripts/export_seed.py @@ -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)") diff --git a/scripts/fake_login_server.py b/scripts/fake_login_server.py new file mode 100644 index 0000000..1371e56 --- /dev/null +++ b/scripts/fake_login_server.py @@ -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() diff --git a/scripts/import_tenants.py b/scripts/import_tenants.py new file mode 100644 index 0000000..6d92377 --- /dev/null +++ b/scripts/import_tenants.py @@ -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) diff --git a/scripts/sync_core.sh b/scripts/sync_core.sh new file mode 100755 index 0000000..2059ea6 --- /dev/null +++ b/scripts/sync_core.sh @@ -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 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)" diff --git a/scripts/verify_import.py b/scripts/verify_import.py new file mode 100644 index 0000000..4ce7fda --- /dev/null +++ b/scripts/verify_import.py @@ -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) diff --git a/seed/tenants.seed.json.gz b/seed/tenants.seed.json.gz new file mode 100644 index 0000000..75781a0 Binary files /dev/null and b/seed/tenants.seed.json.gz differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5fbe58d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Test-Isolation: Tests dürfen NIE echte Keys/Dienste aus der lokalen .env oder der Shell erreichen.""" +import os +import tempfile + +os.environ["ONYSCRIBE_SKIP_DOTENV"] = "1" # .env nicht einlesen +for var in ("CORTECS_API_KEY", "ONYVA_LOGIN_API_KEY", "MITTWALD_API_TOKEN", "MITTWALD_API_BASE", "DEPLOY_LIVE", "OIDC_SYNC_LIVE", + "AUTH_ENABLED", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_LOGOUT_REDIRECT_URI", "OIDC_ISSUER", + "ADMIN_ALLOWED_EMAILS", "DEPLOY_ALLOWED_TENANTS", "CORE_RELEASE_TOKEN", "ADMIN_REQUIRED_ROLE", "SECRET_KEY", "APP_COOKIE_SECURE"): + os.environ.pop(var, None) +os.environ["DATABASE_URL"] = f"sqlite:///{tempfile.mkdtemp()}/test.db" + +import pytest # noqa: E402 + + +@pytest.fixture(scope="module", autouse=True) +def fresh_db(): + """Jedes Testmodul startet mit leerer Datenbank (die Module teilen sich sonst Zustand über die Temp-DB).""" + from app.db import Base, engine, init_db + from app import models # noqa: F401 + Base.metadata.drop_all(engine) + init_db() + yield diff --git a/tests/fakelogin.py b/tests/fakelogin.py new file mode 100644 index 0000000..775e5f1 --- /dev/null +++ b/tests/fakelogin.py @@ -0,0 +1,59 @@ +"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md) für Tests und lokale Entwicklung.""" + + +class FakeLogin: + """In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md).""" + + def __init__(self, users=("anna@x.de", "bob@x.de")): + self.apps, self.members, self.users, self.calls, self.n, self.rate_limit_once = {}, {}, set(users), [], 0, False + + def __call__(self, method, url, headers, body): + path = url.split("/api/v1")[1] + self.calls.append((method, path, body)) + if headers.get("Authorization") != "Bearer onyva_testkey": + return 401, {"detail": "invalid"}, {} + if self.rate_limit_once: + self.rate_limit_once = False + return 429, {"detail": "slow down"}, {"Retry-After": "2"} + parts = path.strip("/").split("/") + if parts == ["apps"]: + if method == "GET": + return 200, {"apps": list(self.apps.values())}, {} + self.n += 1 + cid = f"app_{self.n:04d}" + app = {"client_id": cid, "name": body["name"], "slug": body["name"].lower(), "redirect_uris": body["redirect_uris"], + "auth_mode": body.get("auth_mode", "required"), "totp_mode": body.get("totp_mode", "disabled"), + "allow_registration": body.get("allow_registration", False), "backchannel_logout_uri": body.get("backchannel_logout_uri")} + self.apps[cid], self.members[cid] = app, {} + return 201, {**app, "client_secret": f"secret-{self.n}"}, {} + cid = parts[1] + if cid not in self.apps: + return 404, {"detail": "not found"}, {} + if len(parts) == 2: + if method == "GET": + return 200, self.apps[cid], {} + if method == "PATCH": + self.apps[cid].update(body) + return 200, self.apps[cid], {} + if method == "DELETE": + del self.apps[cid] + return 204, None, {} + if parts[2] == "rotate-secret": + self.n += 1 + return 200, {"client_id": cid, "client_secret": f"rotated-{self.n}"}, {} + if parts[2] == "memberships": + if method == "GET": + return 200, {"memberships": [{"email": e, "display_name": e, "role": r} for e, r in self.members[cid].items()]}, {} + if method == "PUT": + if body["user_email"] not in self.users: + return 404, {"detail": "user"}, {} + self.members[cid][body["user_email"]] = body.get("role", "viewer") + return 200, body, {} + if method == "DELETE": + self.members[cid].pop(parts[3], None) + return 204, None, {} + return 404, {}, {} + + @property + def writes(self): + return [c for c in self.calls if c[0] != "GET"] diff --git a/tests/test_core_contract.py b/tests/test_core_contract.py new file mode 100644 index 0000000..a2f2bc6 --- /dev/null +++ b/tests/test_core_contract.py @@ -0,0 +1,443 @@ +"""Zusammenspiel Admin ↔ Core: Vertrag/Schema, schema-getriebene Env, Release-Eingang, Kompatibilitätsprüfung, Rollout, Rollback, Zustand.""" +import json +import os +import re +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.db import session +from app.main import app +from app.models import CoreRelease, Tenant +from app.services import contract, deploy, releases, status as status_svc +from app.services.importer import import_all +from app.services.mittwald import DeployError +from app.settings import LEGACY_PLATFORM, ROOT + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + with session() as db: + import_all(db, Path(LEGACY_PLATFORM)) + yield c + + +# ── Vertrag / Schema ──────────────────────────────────────────────────────── + +def test_contract_is_loaded_and_supported(): + assert contract.available() and contract.supported() and contract.contract_version() == 1 + assert len(contract.schema()["fields"]) >= 30 and contract.build_info()["sha"] not in ("", "unbekannt") + + +def test_all_imported_tenants_conform_to_schema(client): + for tid in ("demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"): + cfg = client.get(f"/api/tenants/{tid}/state").json()["config"] + assert contract.check_tenant_json(cfg) == [], tid + + +def test_admin_knows_every_color_the_core_declares(): + from app.services.defaults import COLOR_LABELS, DEFAULT_COLORS + core_colors = {p.split(".", 1)[1] for p in contract.field_paths() if p.startswith("colors.")} + assert core_colors == set(COLOR_LABELS) == set(DEFAULT_COLORS), "Farben im Core-Schema weichen von den Admin-Defaults ab" + + +def test_bespoke_form_paths_exist_in_schema(): + js = (ROOT / "app" / "static" / "tenant.js").read_text() + bespoke = re.search(r"BESPOKE_PATHS = \[(.*?)\]", js, re.S).group(1) + for p in re.findall(r"'([a-z_.]+)'", bespoke): + assert p in contract.field_paths(), f"{p} wird von einer festen Admin-Maske genutzt, fehlt aber im Core-Schema" + + +def test_schema_warnings_flow_into_validate(client): + cfg = client.get("/api/tenants/demo/state").json()["config"] + client.put("/api/tenants/demo/config", json={**cfg, "flags": {"ui_kontakt_feld": False}}) # Tippfehler + v = client.post("/api/tenants/demo/validate").json() + client.post("/api/tenants/demo/draft/discard") + assert any("flags.ui_kontakt_feld" in w for w in v["warnings"]) + + +def test_contract_endpoint_and_settings_get(client): + c = client.get("/api/contract").json() + assert c["available"] and c["supported"] and c["schema"]["env"] + assert client.get("/api/settings").json()["base_domain"] == "onyscribe.de" + assert client.get("/core").status_code == 200 + + +# ── schema-getriebene Container-Env ───────────────────────────────────────── + +def test_env_is_built_from_core_schema_and_picks_up_new_variables(client, monkeypatch): + client.put("/api/settings", json={"llm_model": "claude-x", "smtp_host": "smtp.test"}) + with session() as db: + env = deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False) + assert env["LLM_MODEL"] == "claude-x" and env["SMTP_HOST"] == "smtp.test" and env["TENANT"] == "sprinz" and "STT_PROVIDER" not in env + # Der Core deklariert eine neue geteilte Variable → sie landet ohne Admin-Änderung im Env + spec = contract.env_spec() + [{"name": "NEW_FEATURE_LEVEL", "kind": "setting", "scope": "shared", "setting": "new_feature_level", "group": "X", "label": "X"}] + monkeypatch.setattr(contract, "env_spec", lambda: spec) + client.put("/api/settings", json={"new_feature_level": "3"}) + with session() as db: + assert deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)["NEW_FEATURE_LEVEL"] == "3" + + +def test_required_secrets_follow_schema_conditions(): + assert "MISTRAL_API_KEY" not in contract.required_secrets({"stt": {"provider": "cortecs"}}) + assert "MISTRAL_API_KEY" in contract.required_secrets({"stt": {"provider": "mistral"}}) + assert "MISTRAL_API_KEY" in contract.required_secrets({}) + assert {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY", "CORTECS_API_KEY"} <= set(contract.required_secrets({})) + + +# ── Health-Verifikation & Zustandsabgleich ────────────────────────────────── + +def health_body(sha="abcdef1234567", bundle=None, status="ok"): + return {"status": status, "tenant": "t", "core": {"version": sha, "contract": 1, "hook_api": 1}, "bundle": bundle or {"source": "git", "version": None, "stale": False, "error": None}} + + +def test_verify_health_accepts_correct_and_rejects_wrong_versions(): + logs = [] + deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body()), tries=1) + assert any("✔" in l for l in logs) + with pytest.raises(DeployError, match="Core 1234567890 statt abcdef1"): + deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body("1234567890abc")), tries=2, delay=0, sleep=lambda s: None) + with pytest.raises(DeployError, match="Bundle cache v2 \\(veraltet\\) statt admin v3"): + deploy.verify_health("h", None, 3, logs.append, get=lambda u: (200, health_body(bundle={"source": "cache", "version": 2, "stale": True})), tries=1) + with pytest.raises(DeployError, match="nicht erreichbar|Connect"): + def boom(u): + raise OSError("Connect refused") + deploy.verify_health("h", None, None, logs.append, get=boom, tries=2, delay=0, sleep=lambda s: None) + deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, {"status": "ok"}), tries=1) # Image vor dem Vertrag → nur Hinweis + assert any("Image vor dem Vertrag" in l for l in logs) + + +def test_status_probe_detects_drift(): + ok = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("abcdef12349999", {"source": "admin", "version": 5, "stale": False}))) + assert ok["reachable"] and ok["drift"] == [] + bad = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("0000000aaaa", {"source": "baked", "version": None, "stale": True}))) + assert len(bad["drift"]) == 2 + old = status_svc.probe("h", "abcdef1234", None, get=lambda u: (200, {"status": "ok"})) + assert "Core ohne Vertrag" in old["drift"][0] + down = status_svc.probe("h", "x", None, get=lambda u: (_ for _ in ()).throw(OSError("timeout"))) + assert not down["reachable"] and down["drift"] == ["nicht erreichbar"] + + +# ── Release-Eingang ───────────────────────────────────────────────────────── + +SHA_ADMIN = None + + +def _admin_sha(): + return contract.sha() + + +def test_release_intake_requires_token_and_is_idempotent(client, monkeypatch): + body = {"sha": _admin_sha(), "tag": _admin_sha(), "ref": "main", "image": "reg/x", "message": "Neuer Core"} + monkeypatch.delenv("CORE_RELEASE_TOKEN", raising=False) + assert client.post("/api/core/releases", json=body).status_code == 503 + monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim") + assert client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer falsch"}).status_code == 401 + assert client.post("/api/core/releases", json={**body, "sha": "zzz"}, headers={"Authorization": "Bearer geheim"}).status_code == 400 + r = client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json() + assert r["created"] and r["admin_matches"] and r["status"] == "new" + assert not client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()["created"] + other = client.post("/api/core/releases", json={**body, "sha": "f" * 40, "tag": "f" * 40, "message": "Fremder Core"}, headers={"Authorization": "Bearer geheim"}).json() + assert not other["admin_matches"] + + +def test_release_list_needs_session_when_auth_enabled(monkeypatch): + from app import auth + monkeypatch.setattr(auth, "AUTH_ENABLED", True) + with TestClient(app) as c: + assert c.get("/api/core/releases").status_code == 401 + monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim") + assert c.post("/api/core/releases", json={"sha": "a" * 40}, headers={"Authorization": "Bearer geheim"}).status_code == 200 # CI braucht keine Session + assert c.get("/api/tenants/demo/state").status_code == 401 + monkeypatch.setattr(auth, "AUTH_ENABLED", False) + + +# ── Kompatibilitätsprüfung & Freigabe ─────────────────────────────────────── + +def _rid(sha): + with session() as db: + return db.query(CoreRelease).filter_by(sha=sha).one().id + + +def test_check_requires_matching_admin_core(client): + rid = _rid("f" * 40) + r = client.post(f"/api/core/releases/{rid}/check", json={}) + assert r.status_code == 400 and "Admin" in r.json()["detail"] and "neu bauen" in r.json()["detail"] + + +def test_check_matrix_baseline_changes_and_approval_rules(client): + rid = _rid(_admin_sha()) + releases.check(rid, wait=True) + with session() as db: + c = db.get(CoreRelease, rid).compat + assert set(c["tenants"]) >= {"demo", "rotary-wil", "sprinz"} and c["summary"]["error"] == 0 + assert all(v["status"] == "new" for v in c["tenants"].values()) # noch keine Baseline + assert c["tenants"]["rotary-wil"]["fp"]["pdf_pages"] >= 1 + # Freigabe ohne Änderungen möglich + assert client.post(f"/api/core/releases/{rid}/approve", json={}).status_code == 200 + # Baseline setzen (wie nach einem erfolgreichen Rollout) und Core-Änderung simulieren + with session() as db: + for tid, v in c["tenants"].items(): + t = db.get(Tenant, tid) + t.render_baseline = {"version": t.current_version, "core": "alt", "fp": {**v["fp"], "pdf_text": "0" * 64}} + releases.check(rid, wait=True) + with session() as db: + c2 = db.get(CoreRelease, rid).compat + assert c2["tenants"]["sprinz"]["status"] == "changed" and "PDF-Text" in c2["tenants"]["sprinz"]["changed"] + r = client.post(f"/api/core/releases/{rid}/approve", json={}) + assert r.status_code == 400 and "bestätigen" in r.json()["detail"] + assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200 + # unveränderte Baseline → ok + with session() as db: + for tid, v in c2["tenants"].items(): + t = db.get(Tenant, tid) + t.render_baseline = {"version": t.current_version, "core": "x", "fp": v["fp"]} + releases.check(rid, wait=True) + with session() as db: + assert db.get(CoreRelease, rid).compat["summary"]["ok"] >= 7 + + +def test_broken_tenant_blocks_approval(client): + rid = _rid(_admin_sha()) + client.put("/api/tenants/revier/files/protocol.html", json={"content": "{% for %}"}) + client.post("/api/tenants/revier/publish", json={"message": "kaputt"}) + releases.check(rid, wait=True) + with session() as db: + c = db.get(CoreRelease, rid).compat + assert c["tenants"]["revier"]["status"] == "error" and c["summary"]["error"] == 1 + r = client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}) + assert r.status_code == 400 and "revier" in r.json()["detail"] + client.post("/api/tenants/revier/versions/1/restore") # reparieren + client.post("/api/tenants/revier/publish", json={"message": "repariert"}) + releases.check(rid, wait=True) + assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200 + + +# ── Rollout / Rollback ────────────────────────────────────────────────────── + +def _ready(client, tid): + """Tenant mit allen Pflicht-Secrets, Quelle git, OIDC-Verwaltung aus – bereit für einen (gefakten) Live-Deploy.""" + from app.services import tenants as tsvc + client.post("/api/tenants", json={"id": tid, "name": f"Tenant {tid}"}) # existiert er schon, bleibt er und wird nur reaktiviert + client.put(f"/api/tenants/{tid}/meta", json={"status": "active"}) + client.put(f"/api/tenants/{tid}/core-pin", json={"pin": ""}) + client.put(f"/api/tenants/{tid}/source", json={"source": "git"}) + client.put(f"/api/tenants/{tid}/oidc", json={"mode": "off"}) + with session() as db: + for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"): + tsvc.set_secret(db, tid, k, "x", "t") + + +class FakeMW: + def __init__(self): + self.calls, self.images = [], {} + + def __call__(self, method, url, headers, body): + path = url.split("/v2")[1] + self.calls.append((method, path)) + if method == "GET" and path.endswith("/stacks/s-1/"): + return 200, {"services": {"legacy": {"id": "svc-0"}, **{n: {"id": f"id-{n}"} for n in self.images}}}, "" + if method == "GET" and "ingresses" in path: + return 200, [], "" + if method == "GET" and "/projects/p-1/" in path: + return 200, {"id": "uuid"}, "" + if method == "PATCH": + for name, svc in ((body or {}).get("services") or {}).items(): + if svc: + self.images[name] = svc["image"] + return 200, {}, "" + + +@pytest.fixture() +def live_env(client, monkeypatch): + monkeypatch.setenv("DEPLOY_LIVE", "true") + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*") + monkeypatch.setenv("MITTWALD_API_TOKEN", "tok") + client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o", "core_image_tag": "alt1234", "canary_tenants": "ro2"}) + client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"}) + with session() as db: # alle bestehenden Tenants aus dem Rollout nehmen (andere Tests laufen weiter gegen sie) + paused = [t.id for t in db.query(Tenant).filter(Tenant.status == "active")] + for t in db.query(Tenant): + t.status = "paused" + yield + with session() as db: + for t in db.query(Tenant): + t.status = "active" if t.id in paused else t.status + for t in db.query(Tenant).filter(Tenant.id.like("ro%")): + t.status = "archived" + + +def _approved_release(client, sha="c" * 40): + with session() as db: + r, _ = releases.announce(db, sha, sha, "main", "reg.test/o", "Rollout-Test") + r.status = "approved" + r.compat = {"tenants": {}, "summary": {}, "core_sha": sha, "running": False} + rid = r.id + return rid + + +def test_dry_run_rollout_changes_nothing(client, live_env, monkeypatch): + monkeypatch.delenv("DEPLOY_LIVE") + _ready(client, "ro1") + rid = _approved_release(client, "d" * 40) + mw = FakeMW() + batch, ids = releases.rollout(rid, "t", dry_run=True, http=mw, wait=True) + assert not [c for c in mw.calls if c[0] != "GET"] # Dry-Run liest höchstens + rows = client.get(f"/api/deployments?batch={batch}").json() + assert rows and all(r["status"] == "success" and r["kind"] == "dry-run" and r["image"] == "d" * 40 for r in rows) + with session() as db: + assert db.get(CoreRelease, rid).status == "approved" + assert deploy.setting(db, "core_image_tag") == "alt1234" + with pytest.raises(releases.ReleaseError, match="gesperrt"): + releases.rollout(rid, "t", dry_run=False) + + +def test_live_rollout_canary_first_pin_baseline_and_rollback(client, live_env): + for t in ("ro1", "ro2", "ro3"): + _ready(client, t) + client.put("/api/tenants/ro3/core-pin", json={"pin": "fest99"}) + rid = _approved_release(client, "e" * 40) + with session() as db: + db.get(CoreRelease, rid).compat = {"tenants": {"ro1": {"fp": {"x": 1}}, "ro2": {"fp": {"x": 2}}}, "summary": {}, "core_sha": "e" * 40, "running": False} + mw, seen = FakeMW(), [] + health = lambda host, tag, bundle, log: seen.append((host, tag)) # noqa: E731 + batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True) + rows = client.get(f"/api/deployments?batch={batch}").json() + assert [r["tenant"] for r in rows][0] == "ro2" and {r["tenant"] for r in rows} == {"ro1", "ro2"} # Canary zuerst, ro3 gepinnt → nicht dabei + assert all(r["status"] == "success" and not r["simulated"] for r in rows) + assert mw.images == {"app-ro1": f"reg.test/o/transkript-platform:{'e' * 40}", "app-ro2": f"reg.test/o/transkript-platform:{'e' * 40}"} + assert [t for _, t in seen] == ["e" * 40, "e" * 40] + with session() as db: + r = db.get(CoreRelease, rid) + assert r.status == "active" and r.previous_tag == "alt1234" and deploy.setting(db, "core_image_tag") == "e" * 40 + assert db.get(Tenant, "ro1").render_baseline["fp"] == {"x": 1} and db.get(Tenant, "ro1").deployed_image == "e" * 40 + assert deploy.image_ref(db, db.get(Tenant, "ro3")).endswith(":fest99") # Pin gewinnt + # Rollback aller Tenants auf den vorherigen Tag + mw2 = FakeMW() + releases.rollback_release(rid, "t", http=mw2, health=health, wait=True) + assert set(mw2.images.values()) == {"reg.test/o/transkript-platform:alt1234"} + with session() as db: + assert db.get(CoreRelease, rid).status == "rolled_back" and deploy.setting(db, "core_image_tag") == "alt1234" + + +def test_failed_canary_aborts_rollout_and_marks_release_failed(client, live_env): + for t in ("ro1", "ro2"): + _ready(client, t) + rid = _approved_release(client, "9" * 40) + mw = FakeMW() + + def health(host, tag, bundle, log): + if host.startswith("ro2."): + raise DeployError("Health-Check fehlgeschlagen: Core falsch") + + batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True) + rows = {r["tenant"]: r for r in client.get(f"/api/deployments?batch={batch}").json()} + assert rows["ro2"]["status"] == "failed" and rows["ro1"]["status"] == "cancelled" and "übersprungen" in rows["ro1"]["log"] + assert "app-ro1" not in mw.images # nichts ausgerollt + with session() as db: + assert db.get(CoreRelease, rid).status == "failed" and deploy.setting(db, "core_image_tag") == "alt1234" + + +def test_tenant_rollback_uses_previous_successful_live_tag(client, live_env, monkeypatch): + from app.services.mittwald import Mittwald + _ready(client, "ro4") + mw, health = FakeMW(), (lambda *a: None) + monkeypatch.setattr(deploy, "_client", lambda live, log, http=None: Mittwald("tok", "https://api.test/v2", live, log, mw)) + monkeypatch.setattr(deploy, "verify_health", lambda *a, **k: None) + deploy.start("ro4", "deploy", "t", http=mw, core_tag="1111111", health=health, wait=True) + deploy.start("ro4", "deploy", "t", http=mw, core_tag="2222222", health=health, wait=True) + with session() as db: + assert db.get(Tenant, "ro4").deployed_image == "2222222" and releases.tenant_rollback_tag(db, db.get(Tenant, "ro4")) == "1111111" + r = client.post("/api/tenants/ro4/rollback").json() + assert r["tag"] == "1111111" + time.sleep(1.5) + assert mw.images["app-ro4"].endswith(":1111111") + + +def test_rollout_blocked_for_unapproved_and_unsupported(client, live_env): + with session() as db: + r, _ = releases.announce(db, "8" * 40, "8" * 40, "main", "", "neu") + rid = r.id + with pytest.raises(releases.ReleaseError, match="erst prüfen und freigeben"): + releases.rollout(rid, "t", dry_run=False) + with pytest.raises(releases.ReleaseError): + releases.approve(session().__enter__(), rid, "t") + + +# ── Schreib-Freigabe pro Tenant (DEPLOY_ALLOWED_TENANTS) ─────────────────── + +def test_allowlist_parsing(monkeypatch): + from app.services import guard + monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS", raising=False) + assert guard.allowed_tenants() == set() and not guard.live_allowed("demo") and guard.describe() == "keiner" + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", " demo-admin , x ") + assert guard.live_allowed("demo-admin") and guard.live_allowed("x") and not guard.live_allowed("demo") + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*") + assert guard.allowed_tenants() is None and guard.live_allowed("irgendwer") + + +def test_live_deploy_oidc_and_rollout_respect_allowlist(client, live_env, monkeypatch): + for t in ("ro1", "ro2"): + _ready(client, t) + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "ro2") + # API: nicht freigegebener Tenant → 403, freigegebener → ok + assert client.post("/api/tenants/ro1/deploy", json={"dry_run": False}).status_code == 403 + assert client.post("/api/tenants/ro1/offboard", json={"dry_run": False}).status_code == 403 + assert client.post("/api/tenants/ro1/rollback").status_code == 403 + assert client.post("/api/tenants/ro1/oidc/sync", json={"apply": True}).status_code == 403 + dry = client.post("/api/tenants/ro1/deploy", json={"dry_run": True}) # Dry-Run immer erlaubt + assert dry.status_code == 200 + for _ in range(40): + if client.get(f"/api/deployments/{dry.json()['id']}").json()["status"] != "running": + break + time.sleep(0.25) + assert client.get("/api/deploy/mode").json()["allowed"] == "ro2" + # Engine blockiert auch direkt + d = client.get(f"/api/deployments/{deploy.start('ro1', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json() + assert d["status"] == "failed" and "nicht freigegeben" in d["log"] + # Rollout: nur ro2 wird live ausgerollt, ro1 bleibt unberührt + rid = _approved_release(client, "7" * 40) + mw = FakeMW() + batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=lambda *a: None, wait=True) + assert [r["tenant"] for r in client.get(f"/api/deployments?batch={batch}").json()] == ["ro2"] and list(mw.images) == ["app-ro2"] + # niemand freigegeben → Rollout wird abgelehnt + monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS") + with pytest.raises(releases.ReleaseError, match="Kein Tenant für Live-Rollouts freigegeben"): + releases.rollout(rid, "t", dry_run=False) + + +# ── Vorflug-Check ─────────────────────────────────────────────────────────── + +def test_preflight_reports_problems_and_readiness(client, live_env, monkeypatch): + from tests.test_flow import FakeLogin # noqa: F401 + _ready(client, "pf1") + client.put("/api/settings", json={"admin_public_url": "", "core_supports_bundle": "false", "base_domain": "onyscribe.de"}) + client.put("/api/tenants/pf1/source", json={"source": "admin"}) + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "") + mw = FakeMW() + from app.services import preflight + r = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200) + lv = {i["id"]: i["level"] for i in r["items"]} + assert not r["ready"] and lv["bundle"] == "fail" and lv["adminurl"] == "fail" and lv["allow"] == "warn" and lv["stack"] == "ok" and lv["tag"] == "warn" + assert "legacy" in next(i for i in r["items"] if i["id"] == "stack")["detail"] # bestehende Services werden als „bleiben unangetastet“ gezeigt + assert [c for c in mw.calls if c[0] != "GET"] == [] # schreibt nie + # alles in Ordnung bringen + client.put("/api/settings", json={"admin_public_url": "https://admin.example.de", "core_supports_bundle": "true"}) + client.put("/api/tenants/pf1/core-pin", json={"pin": "a" * 40}) + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "pf1") + r2 = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200) + assert r2["ready"], [i for i in r2["items"] if i["level"] == "fail"] + bad = preflight.run("pf1", mw_http=mw, self_get=lambda u: 502) + assert not bad["ready"] and any(i["id"] == "adminreach" and i["level"] == "fail" for i in bad["items"]) + + +def test_live_deploy_requires_admin_public_url_for_admin_source(client, live_env): + _ready(client, "pf2") + client.put("/api/tenants/pf2/source", json={"source": "admin"}) + client.put("/api/settings", json={"core_supports_bundle": "true", "admin_public_url": ""}) + d = client.get(f"/api/deployments/{deploy.start('pf2', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json() + assert d["status"] == "failed" and "Öffentliche URL der Admin-Plattform" in d["log"] + assert client.get("/api/tenants/pf2/preflight").status_code == 200 diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..8a7cf95 --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,529 @@ +"""End-to-End-Test des Kernablaufs gegen eine temporäre SQLite-DB (echter Core-Render in der Sandbox).""" +import json +import os +import tarfile +import io +import tempfile +import time +from pathlib import Path + +_tmp = tempfile.mkdtemp() +os.environ["DATABASE_URL"] = f"sqlite:///{_tmp}/t.db" + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.db import session # noqa: E402 +from app.main import app # noqa: E402 +from app.services.importer import import_all # noqa: E402 +from app.settings import LEGACY_PLATFORM # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + with session() as db: + import_all(db, Path(LEGACY_PLATFORM)) + yield c + + +def test_import_all_tenants(client): + for tid in ["demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"]: + s = client.get(f"/api/tenants/{tid}/state").json() + assert s["current_version"] == 1 and s["config"]["id"] == tid and "deploy" not in s["config"] + + +def test_previews_render_with_real_core(client): + for tid in ("rotary-wil", "sprinz"): + assert client.get(f"/preview/{tid}/pdf").content[:5] == b"%PDF-" + html = client.get(f"/preview/{tid}/web").text + assert "@@" not in html and "= 7 and all(x["status"] == "success" for x in rows) + + +def test_mittwald_client_live_sequence_with_guard(): + """Gegen einen Fake-HTTP: richtige Reihenfolge, PATCH statt PUT, Guard gegen verschwundene Services.""" + from app.services.mittwald import DeployError, Mittwald + calls, lines = [], [] + stack = {"services": {"legacy": {"id": "svc-0"}, "app-x": {"id": "svc-1"}}} + + def http(method, url, headers, body): + calls.append((method, url.split("/v2")[1], body)) + if method == "GET" and url.endswith("/stacks/s-1/"): + return 200, stack, "" + if method == "GET" and "ingresses" in url: + return 200, [], "" + if method == "GET" and "/projects/p-1/" in url: + return 200, {"id": "uuid-1"}, "" + return 200, {}, "" + + mw = Mittwald("tok", "https://api.test/v2", True, lines.append, http) + mw.deploy_service(stack_id="s-1", service="app-x", volume="vol-x", definition={"image": "i"}, container_port="8276", + project_id="p-1", hostname="x.example.de") + verbs = [(m, p) for m, p, _ in calls] + assert ("PUT", "/stacks/s-1/") not in verbs + assert verbs.index(("PATCH", "/stacks/s-1/")) < verbs.index(("POST", "/stacks/s-1/services/svc-1/actions/recreate/")) + assert any(p == "/ingresses" and m == "POST" for m, p, _ in calls) + assert any("Guard" in l for l in lines) + # Guard schlägt an, wenn ein fremder Service verschwindet + state = {"n": 0} + + def http2(method, url, headers, body): + if method == "GET" and url.endswith("/stacks/s-1/"): + state["n"] += 1 + return 200, ({"services": {"legacy": {}}} if state["n"] == 1 else {"services": {"app-x": {"id": "svc-1"}}}), "" + return 200, {}, "" + + try: + Mittwald("tok", "https://api.test/v2", True, lambda m: None, http2).deploy_service( + stack_id="s-1", service="app-x", volume="vol-x", definition={}, container_port="8276", project_id="p-1", hostname=None) + raise AssertionError("Guard hat nicht angeschlagen") + except DeployError as exc: + assert "verschwunden" in str(exc) + + +def test_dry_run_client_never_writes(): + from app.services.mittwald import Mittwald + calls = [] + mw = Mittwald("tok", "https://api.test/v2", False, lambda m: None, lambda *a: calls.append(a) or (200, {}, "")) + mw.deploy_service(stack_id="s", service="app-x", volume="v", definition={"environment": {"X_SECRET": "geheim"}}, container_port="1", project_id="p", hostname="h.de") + assert all(c[0] == "GET" for c in calls) + + +def _sse(resp): + return [json.loads(l[6:]) for l in resp.text.split("\n\n") if l.startswith("data: ")] + + +def test_agent_mock_stream_and_apply(client): + r = client.post("/api/tenants/revier/chat", json={"message": "Setze die Akzentfarbe auf #0B6E4F"}) + ev = _sse(r) + assert [e["type"] for e in ev][0] == "mode" and ev[0]["backend"] == "mock" + assert any(e["type"] == "step" and e["tool"] == "validate" for e in ev) + msg = ev[-1]["message"] + assert msg["proposal_status"] == "open" + assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] != "#0B6E4F" # Entwurf unberührt + d = client.get(f"/api/tenants/revier/chat/{msg['id']}/diff").json() + assert d["changed"] and "#0B6E4F" in json.dumps(d) + client.post(f"/api/tenants/revier/chat/{msg['id']}/apply") + assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] == "#0B6E4F" + client.post("/api/tenants/revier/draft/discard") + + +def test_agent_tools_with_scripted_backend(client): + """Echter Loop + echte Tools + Core-Rendering, nur das LLM ist geskriptet.""" + from app.agent import loop + from app.agent.backends import Turn + + class Scripted: + name, model = "scripted", "x" + + def __init__(self): + self.i = 0 + + def step(self, system, messages, tools): + self.i += 1 + if self.i == 1: + return Turn([{"type": "tool_use", "id": "1", "name": "read_file", "input": {"path": "namensliste.txt"}}, + {"type": "tool_use", "id": "2", "name": "write_file", "input": {"path": "namensliste.txt", "content": "x"}}, + {"type": "tool_use", "id": "3", "name": "write_file", "input": {"path": "protocol.css", "content": "body{color:red}"}}, + {"type": "tool_use", "id": "4", "name": "write_file", "input": {"path": "overrides.py", "content": "def x(:"}}, + {"type": "tool_use", "id": "5", "name": "render_pdf", "input": {}}]) + return Turn([{"type": "text", "text": "Fertig."}]) + + with session() as db: + from app.models import Tenant + t = db.get(Tenant, "rotary-wil") + ev = list(loop.run(db, t, "teste", backend=Scripted())) + steps = [e for e in ev if e["type"] == "step"] + assert "gesperrt" in steps[0]["result"] # Namensliste für den Agenten unlesbar + assert steps[1]["error"] and "personenbezogen" in steps[1]["result"] # … und unveränderbar + assert not steps[2]["error"] + assert steps[3]["error"] # Syntaxfehler in overrides.py abgefangen + assert "Seite" in steps[4]["result"] + prop = [e for e in ev if e["type"] == "proposal"][0]["proposal"] + assert [i["path"] for i in prop["items"]] == ["protocol.css"] + with session() as db: # Entwurf bleibt unberührt, bis freigegeben + assert not db.get(Tenant, "rotary-wil").draft + + +def test_prompt_test_endpoint(client, monkeypatch): + from app.services import llm + monkeypatch.setattr(llm, "summarize", lambda db, system, user, model=None, tenant_id=None: {"text": "===TERMINART===\nKick-off\n===/TERMINART===", "model": "m", "input_tokens": 10, "output_tokens": 5, "truncated": False}) + r = client.post("/api/tenants/rotary-wil/prompt-test", json={}).json() + assert r["text"].startswith("===TERMINART") and r["prompt_chars"] > 1000 + # Namensregister landet über den build_prompt-Hook im finalen Prompt + from app.services import preview + with session() as db: + from app.models import Tenant + fin = preview.run(db, db.get(Tenant, "rotary-wil"), "draft", "prompt", args={"title": "x"})["prompt"] + assert "Namensregister des Clubs" in fin + + +def test_prompt_test_without_key_is_clean_error(client, monkeypatch): + monkeypatch.delenv("CORTECS_API_KEY", raising=False) + r = client.post("/api/tenants/sprinz/prompt-test", json={}) + assert r.status_code == 400 and "CORTECS_API_KEY" in r.json()["detail"] + + +def test_meta_archive_delete(client): + client.post("/api/tenants", json={"id": "wegdamit", "name": "Weg damit"}) + assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 99}).status_code == 400 + assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 1, "memory": "1G", "notes": "n"}).status_code == 200 + assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 400 # erst archivieren + client.put("/api/tenants/wegdamit/meta", json={"status": "archived"}) + assert client.delete("/api/tenants/wegdamit?confirm=falsch").status_code == 400 + assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 200 + assert client.get("/api/tenants/wegdamit/state").status_code == 404 + + +def test_oidc_login_flow_and_access_rules(monkeypatch): + from app import auth + monkeypatch.setattr(auth, "AUTH_ENABLED", True) + monkeypatch.setenv("OIDC_CLIENT_ID", "cid") + monkeypatch.setenv("OIDC_CLIENT_SECRET", "sec") + import base64 as b64 + idt = lambda claims: "h." + b64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + ".s" # noqa: E731 + with TestClient(app, follow_redirects=False) as c: + assert c.get("/").status_code == 302 and c.get("/").headers["location"] == "/auth/login" + assert c.get("/api/tenants/demo/state").status_code == 401 + assert c.get("/healthz").status_code == 200 + r = c.get("/auth/login") + assert r.status_code == 302 and "code_challenge_method=S256" in r.headers["location"] + state = dict(p.split("=") for p in r.headers["location"].split("?")[1].split("&"))["state"] + # Default-Deny: keine Zugriffsregel konfiguriert + monkeypatch.setattr(auth, "exchange_code", lambda code, cv: {"id_token": idt({"sub": "1", "email": "chef@onyva.de", "app_roles": ["admin"]})}) + assert c.get(f"/auth/callback?code=x&state={state}").status_code == 403 + # Mit Allowlist + monkeypatch.setenv("ADMIN_ALLOWED_EMAILS", "chef@onyva.de") + state2 = dict(p.split("=") for p in c.get("/auth/login").headers["location"].split("?")[1].split("&"))["state"] + ok = c.get(f"/auth/callback?code=x&state={state2}") + assert ok.status_code == 303 + assert c.get("/api/tenants/demo/state").status_code == 200 + assert c.get("/auth/me").json()["user"]["email"] == "chef@onyva.de" + # falscher State → neu starten, keine Session + with TestClient(app, follow_redirects=False) as c2: + c2.get("/auth/login") + assert c2.get("/auth/callback?code=x&state=falsch").headers["location"] == "/auth/login" + # Bundle bleibt tokenbasiert erreichbar (nicht über Cookie) + assert c2.get("/api/tenants/demo/bundle").status_code == 401 + monkeypatch.setattr(auth, "AUTH_ENABLED", False) + + +def test_messages_backend_talks_to_cortecs_endpoint(): + """Anthropic-SDK gegen Cortecs: richtige URL (/v1/messages), Bearer-Auth, Tool-Schema, Antwort-Parsing.""" + import anthropic + try: + import httpx2 as httpx # neuere Anthropic-SDKs bringen einen eigenen HTTP-Client mit + except ImportError: + import httpx + from app.agent.backends import MessagesBackend + from app.agent.tools import TOOLS + seen = {} + + def handler(req: httpx.Request): + seen["url"], seen["auth"], seen["body"] = str(req.url), req.headers.get("authorization"), json.loads(req.content) + return httpx.Response(200, json={"id": "m", "type": "message", "role": "assistant", "model": "x", "stop_reason": "tool_use", + "content": [{"type": "text", "text": "ok"}, {"type": "tool_use", "id": "t1", "name": "validate", "input": {}}], + "usage": {"input_tokens": 11, "output_tokens": 7}}) + + b = MessagesBackend("KEY", "https://api.cortecs.ai/v1", "claude-4-6-sonnet") + b.client = anthropic.Anthropic(api_key="KEY", auth_token="KEY", base_url="https://api.cortecs.ai", http_client=httpx.Client(transport=httpx.MockTransport(handler))) + turn = b.step("sys", [{"role": "user", "content": "hi"}], TOOLS) + assert seen["url"] == "https://api.cortecs.ai/v1/messages" and seen["auth"] == "Bearer KEY" + assert seen["body"]["model"] == "claude-4-6-sonnet" and {t["name"] for t in seen["body"]["tools"]} >= {"edit_file", "validate"} + assert turn.blocks[1] == {"type": "tool_use", "id": "t1", "name": "validate", "input": {}} and turn.usage == {"in": 11, "out": 7} + + +def test_chat_backend_message_conversion(): + from app.agent.backends import ChatBackend + conv = ChatBackend._convert("sys", [ + {"role": "user", "content": "los"}, + {"role": "assistant", "content": [{"type": "text", "text": "ich lese"}, {"type": "tool_use", "id": "a", "name": "read_file", "input": {"path": "x"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "inhalt"}]}]) + assert [m["role"] for m in conv] == ["system", "user", "assistant", "tool"] + assert conv[2]["tool_calls"][0]["function"]["name"] == "read_file" and conv[3]["tool_call_id"] == "a" + + +def test_plan_masks_secrets_but_not_tokens_limit(client): + p = client.get("/api/tenants/sprinz/plan").json()["patch"]["environment"] + assert p["LLM_MAX_TOKENS"] == "16000" and p["TENANT"] == "sprinz" and "TENANT_SOURCE" not in p + + +def test_final_prompt_and_files_privacy(client): + r = client.get("/api/tenants/rotary-wil/final-prompt").json() + assert "Namensregister des Clubs" in r["prompt"] + st = client.get("/api/tenants/rotary-wil/state").json() + assert st["files"]["namensliste.txt"]["personal"] is True + + +# ── OIDC-Abgleich gegen die Developer-API (Fake-Login-Server) ─────────────── + +from tests.fakelogin import FakeLogin # noqa: E402 + + +@pytest.fixture() +def login(monkeypatch): + from app.services import onyva_login + fake = FakeLogin() + monkeypatch.setenv("ONYVA_LOGIN_API_KEY", "onyva_testkey") + monkeypatch.setenv("OIDC_SYNC_LIVE", "true") + monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*") + monkeypatch.setattr(onyva_login, "client_from_env", + lambda db, http=None: onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=lambda s: None)) + return fake + + +def _secret_value(tenant, key): + from app.models import Secret + from app.services import crypto + from sqlalchemy import select + with session() as db: + r = db.scalar(select(Secret).where(Secret.tenant_id == tenant, Secret.key == key)) + return crypto.decrypt(r.value_enc) if r and r.value_enc else None + + +def test_oidc_creates_app_stores_secret_and_is_idempotent(client, login): + client.post("/api/tenants", json={"id": "klinik1", "name": "Klinik Eins"}) + client.put("/api/tenants/klinik1/oidc", json={"members": [{"email": "anna@x.de", "role": "editor"}, {"email": "ghost@x.de", "role": "viewer"}]}) + plan = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": False}).json() + assert plan["ok"] and [a["kind"] for a in plan["actions"]][0] == "create" and not login.writes # Dry-Run schreibt nichts + rep = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json() + assert rep["created"] and rep["client_id"] == "app_0001" + app = login.apps["app_0001"] + assert app["redirect_uris"] == ["https://klinik1.onyscribe.de/auth/callback"] and app["auth_mode"] == "required" + assert _secret_value("klinik1", "OIDC_CLIENT_ID") == "app_0001" and _secret_value("klinik1", "OIDC_CLIENT_SECRET") == "secret-1" + assert login.members["app_0001"] == {"anna@x.de": "editor"} and any("ghost@x.de" in w for w in rep["warnings"]) + login.calls.clear() + again = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json() + assert [a["kind"] for a in again["actions"]] == ["member_put"] and not any(c[0] in ("POST",) for c in login.writes) # nur der (weiter fehlende) Geist-Nutzer + assert client.get("/api/tenants/klinik1/oidc").json()["last_sync"]["ok"] + + +def test_oidc_adopt_only_adds_missing_redirect(client, login): + from app.services import tenants as tsvc + login.apps["app_legacy"] = {"client_id": "app_legacy", "name": "Alt", "redirect_uris": ["https://wil-rotary.onyscribe.de/auth/callback", "https://alt.example/cb"], + "auth_mode": "optional", "totp_mode": "required", "allow_registration": True, "backchannel_logout_uri": None} + with session() as db: + tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_ID", "app_legacy", "t") + tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_SECRET", "legacy-secret", "t") + client.put("/api/tenants/rotary-wil/meta", json={}) + client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"}) + rep = client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True}).json() + assert rep["ok"] and not rep["created"] and not rep["warnings"] + assert login.apps["app_legacy"]["redirect_uris"][-1:] == ["https://alt.example/cb"] and login.apps["app_legacy"]["auth_mode"] == "optional" # nichts Fremdes verändert + assert _secret_value("rotary-wil", "OIDC_CLIENT_SECRET") == "legacy-secret" + # Subdomain ändern → adopt ergänzt nur die neue URI + client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "neu"}) + client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True}) + assert "https://neu.onyscribe.de/auth/callback" in login.apps["app_legacy"]["redirect_uris"] and len(login.apps["app_legacy"]["redirect_uris"]) == 3 + client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"}) + + +def test_oidc_managed_corrects_drift_and_exact_members(client, login): + client.post("/api/tenants", json={"id": "klinik2", "name": "Klinik Zwei"}) + client.put("/api/tenants/klinik2/oidc", json={"members": [{"email": "anna@x.de", "role": "viewer"}], "members_mode": "exact", "totp_mode": "optional"}) + client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True}) + cid = _secret_value("klinik2", "OIDC_CLIENT_ID") + assert login.apps[cid]["totp_mode"] == "optional" + login.apps[cid].update(auth_mode="disabled", redirect_uris=["https://evil.example/cb"]) # Drift + login.members[cid]["bob@x.de"] = "admin" # fremde Freigabe + rep = client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True}).json() + assert login.apps[cid]["auth_mode"] == "required" and login.apps[cid]["redirect_uris"] == ["https://klinik2.onyscribe.de/auth/callback"] + assert "bob@x.de" not in login.members[cid] and {a["kind"] for a in rep["actions"]} >= {"patch", "member_delete"} + + +def test_oidc_stale_client_id_recreates_and_rotate(client, login): + from app.services import tenants as tsvc + client.post("/api/tenants", json={"id": "klinik3", "name": "Klinik Drei"}) + with session() as db: + tsvc.set_secret(db, "klinik3", "OIDC_CLIENT_ID", "app_gone", "t") + rep = client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True}).json() + assert rep["created"] and any("existiert im Login nicht mehr" in w for w in rep["warnings"]) + assert _secret_value("klinik3", "OIDC_CLIENT_ID") != "app_gone" + old = _secret_value("klinik3", "OIDC_CLIENT_SECRET") + client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True, "rotate": True}) + assert _secret_value("klinik3", "OIDC_CLIENT_SECRET") not in (None, old) + + +def test_oidc_write_gate_and_missing_key(client, monkeypatch): + monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False) + monkeypatch.delenv("DEPLOY_LIVE", raising=False) + assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403 + monkeypatch.delenv("ONYVA_LOGIN_API_KEY", raising=False) + r = client.post("/api/tenants/sprinz/oidc/sync", json={"apply": False}).json() + assert r["ok"] and "ONYVA_LOGIN_API_KEY" in r["skipped"] + assert client.delete("/api/tenants/sprinz/oidc/app?confirm=sprinz").status_code == 403 + assert client.put("/api/tenants/sprinz/oidc", json={"mode": "kaputt"}).status_code == 400 + assert client.put("/api/tenants/sprinz/oidc", json={"members": [{"email": "keine-mail"}]}).status_code == 400 + + +def test_oidc_rate_limit_retry_and_bad_key(): + from app.services import onyva_login + fake = FakeLogin() + waits = [] + c = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=waits.append) + fake.rate_limit_once = True + assert c.list_apps() == [] and waits == [2] + try: + onyva_login.OnyvaLogin("https://login.test/api/v1", "falsch", fake, sleep=lambda s: None).list_apps() + raise AssertionError + except onyva_login.OnyvaError as exc: + assert "401" in str(exc) + + +def test_oidc_import_members_and_delete_app(client, login): + client.post("/api/tenants", json={"id": "klinik4", "name": "Klinik Vier"}) + client.post("/api/tenants/klinik4/oidc/sync", json={"apply": True}) + cid = _secret_value("klinik4", "OIDC_CLIENT_ID") + login.members[cid]["bob@x.de"] = "admin" + got = client.post("/api/tenants/klinik4/oidc/import-members").json() + assert got["members"] == [{"email": "bob@x.de", "role": "admin"}] + assert client.delete("/api/tenants/klinik4/oidc/app?confirm=nein").status_code == 400 + assert client.delete("/api/tenants/klinik4/oidc/app?confirm=klinik4").status_code == 200 + assert cid not in login.apps and _secret_value("klinik4", "OIDC_CLIENT_ID") is None + + +def test_deploy_runs_oidc_sync_before_service_patch(client, login, monkeypatch): + """Live-Deploy (alles gefakt): OIDC-Client wird VOR dem Service-PATCH angelegt und landet in dessen Env; Fehler bricht ab.""" + from app.services import deploy + client.post("/api/tenants", json={"id": "klinik5", "name": "Klinik Fünf"}) + client.put("/api/tenants/klinik5/source", json={"source": "git"}) + client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o"}) + monkeypatch.setenv("DEPLOY_LIVE", "true") + monkeypatch.setenv("MITTWALD_API_TOKEN", "tok") + # Pflicht-Secrets laut Core-Schema: erst fehlen sie → Deploy wird VOR dem Service-Eingriff blockiert + blocked = deploy.start("klinik5", "deploy", "test", http=lambda *a: (200, {}, ""), oidc_client=None, wait=True, health=lambda *a: None) + d0 = client.get(f"/api/deployments/{blocked}").json() + assert d0["status"] == "failed" and ("COST_INGEST_KEY" in d0["log"] or "OIDC-Abgleich" in d0["log"] or "ONYVA_LOGIN_API_KEY" in d0["log"]) + client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"}) + client.put("/api/tenants/klinik5/secrets/COST_INGEST_KEY", json={"value": "ik"}) + order = [] + stack = {"services": {"legacy": {"id": "svc-0"}, "app-klinik5": {"id": "svc-5"}}} + + def mw_http(method, url, headers, body): + order.append(("mittwald", method, url.split("/v2")[1], body)) + if method == "GET" and url.endswith("/stacks/s-1/"): + return 200, stack, "" + if method == "GET" and "ingresses" in url: + return 200, [], "" + if method == "GET" and "/projects/p-1/" in url: + return 200, {"id": "uuid-1"}, "" + return 200, {}, "" + + class Spy(FakeLogin): + def __call__(self, method, url, headers, body): + order.append(("login", method, url.split("/api/v1")[1], body)) + return super().__call__(method, url, headers, body) + + spy = Spy() + from app.services import onyva_login + oc = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", spy, sleep=lambda s: None) + dep = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=oc, wait=True, health=lambda *a: None) + d = client.get(f"/api/deployments/{dep}").json() + assert d["status"] == "success", d["log"] + first_login_write = next(i for i, o in enumerate(order) if o[0] == "login" and o[1] == "POST") + first_patch = next(i for i, o in enumerate(order) if o[0] == "mittwald" and o[1] == "PATCH") + assert first_login_write < first_patch + env = next(o[3]["services"]["app-klinik5"]["environment"] for o in order if o[0] == "mittwald" and o[1] == "PATCH" and o[3].get("services")) + assert env["OIDC_CLIENT_ID"] == "app_0001" and env["OIDC_CLIENT_SECRET"] == "secret-1" and env["OIDC_REDIRECT_URI"].startswith("https://klinik5.") + # Login-API kaputt → Deploy bricht VOR dem Service-PATCH ab + order.clear() + client.put("/api/tenants/klinik5/oidc", json={"auth_mode": "optional"}) + bad = onyva_login.OnyvaLogin("https://login.test/api/v1", "falscher-key", spy, sleep=lambda s: None) + dep2 = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=bad, wait=True, health=lambda *a: None) + d2 = client.get(f"/api/deployments/{dep2}").json() + assert d2["status"] == "failed" and "OIDC-Abgleich fehlgeschlagen" in d2["log"] + assert not any(o[0] == "mittwald" and o[1] == "PATCH" for o in order) + + +def test_dry_run_deploy_shows_oidc_plan_without_writes(client, login, monkeypatch): + monkeypatch.delenv("DEPLOY_LIVE", raising=False) + monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False) + from app.services import deploy + client.post("/api/tenants", json={"id": "klinik6", "name": "Klinik Sechs"}) + dep = deploy.start("klinik6", "dry-run", "test", wait=True) + d = client.get(f"/api/deployments/{dep}").json() + assert d["status"] == "success" and "würde App" in d["log"] and not login.writes diff --git a/vendor/platform/CORE_VERSION b/vendor/platform/CORE_VERSION new file mode 100644 index 0000000..ca68dcd --- /dev/null +++ b/vendor/platform/CORE_VERSION @@ -0,0 +1 @@ +13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration diff --git a/vendor/platform/core/BUILD_INFO.json b/vendor/platform/core/BUILD_INFO.json new file mode 100644 index 0000000..65fb146 --- /dev/null +++ b/vendor/platform/core/BUILD_INFO.json @@ -0,0 +1 @@ +{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"} diff --git a/vendor/platform/core/__init__.py b/vendor/platform/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vendor/platform/core/app.py b/vendor/platform/core/app.py new file mode 100644 index 0000000..e04640e --- /dev/null +++ b/vendor/platform/core/app.py @@ -0,0 +1,1637 @@ +"""Tenant-aware FastAPI application. + +The transcription / summarisation / delivery logic is identical for every +customer (the "core"). Everything brand- or customer-specific is pulled from +the active tenant config (core.config.CFG) and optional code hooks +(core.hooks). The tenant is selected once per process via the TENANT env var. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import html +import io +import json +import os +import re +import secrets +import smtplib +import subprocess +import tempfile +import threading +import time +import uuid +import warnings +from contextlib import asynccontextmanager +from datetime import datetime +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from pathlib import Path +from urllib import error as urllib_error +from urllib.parse import quote, urlencode +from urllib import request as urllib_request + +import requests +from dotenv import load_dotenv + +load_dotenv() + +from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response + +from core import contract, cost, retry +from core.config import CFG, CORE_TEMPLATES +from core.hooks import load_hooks +from core.docx_export import render_docx +from core.pdf import build_pdf_context, render_pdf +from core.protocol import ( + normalize_protocol_text, + parse_protocol_sections, + project_label, + safe_filename, +) + +warnings.filterwarnings("ignore", category=UserWarning) + +HOOKS = load_hooks(CFG.dir) + +MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE_MB", "300")) * 1024 * 1024 +ALLOWED_CONTENT_TYPES = { + "audio/mpeg", "audio/mp3", "audio/webm", "audio/ogg", "audio/wav", + "audio/flac", "audio/x-flac", "audio/aac", "audio/x-m4a", "audio/mp4", + "video/mp4", "video/webm", "video/quicktime", "video/x-m4v", +} +# Tuples, not sets: the order is user-visible in the "Erlaubt sind: ..." error +# messages and in the accept-attributes rendered into the frontend. +ALLOWED_UPLOAD_SUFFIXES = ( + ".mp3", ".webm", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".mp4", ".m4v", ".mov", +) +ALLOWED_TRANSCRIPT_SUFFIXES = (".txt", ".vtt", ".docx", ".md") +ALLOWED_VOCABULARY_SUFFIXES = (".txt",) +# Several recordings of one meeting (e.g. split into parts) can be uploaded +# together; they are joined in the given order and transcribed as one file. +# On by default, a tenant switches it off with "flags": {"multi_audio": false}. +MAX_AUDIO_FILES = 5 if CFG.flag("multi_audio", True) else 1 +# Voxtral Mini Transcribe V2 takes up to 3 h of audio per request (Mistral +# docs). Longer recordings are rejected before the STT call with a clear +# message instead of a provider error after the upload. +MAX_AUDIO_SECONDS = int(os.getenv("STT_MAX_AUDIO_MINUTES", "180")) * 60 + + +def _unsupported_format_detail(kind: str, suffix: str, allowed: tuple[str, ...]) -> str: + shown = suffix or "(ohne Dateiendung)" + return f"Nicht unterstütztes {kind}: {shown}. Erlaubt sind: {', '.join(allowed)}" + + +SEND_JOBS: dict[str, dict] = {} +SEND_JOBS_LOCK = threading.Lock() +# Background transcription jobs. The browser uploads, gets a job_id back +# immediately and then polls /transcribe-status — so no single HTTP request +# sits idle long enough to hit the ~10s reverse-proxy timeout. This in-memory +# store only works with a single worker process (see CMD in the Dockerfile). +TRANSCRIBE_JOBS: dict[str, dict] = {} +TRANSCRIBE_JOBS_LOCK = threading.Lock() +# Background summarisation jobs — same rationale as TRANSCRIBE_JOBS: the LLM call +# can run well past the ~10s reverse-proxy idle timeout (long protocols), so the +# browser uploads, gets a job_id and polls /summarize-status. Single worker only. +SUMMARIZE_JOBS: dict[str, dict] = {} +SUMMARIZE_JOBS_LOCK = threading.Lock() +DATA_DIR = Path(os.getenv("DATA_DIR", str(CFG.dir))) +VOCAB_FILE = DATA_DIR / "vocabulary.json" +VOCAB_LOCK = threading.Lock() +TEMP_DIR = Path(os.getenv("TEMP_DIR", tempfile.gettempdir())) / "meeting-transcript" +TEMP_DIR.mkdir(parents=True, exist_ok=True) + +AUTH_COOKIE = f"{CFG.id}_session" +AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() == "true" +AUTH_COOKIE_SECURE = os.getenv("APP_COOKIE_SECURE", "false").lower() == "true" +SESSION_SECRET = os.getenv("SESSION_SECRET", os.getenv("APP_SESSION_SECRET", "")).strip() +OIDC_ISSUER = os.getenv("OIDC_ISSUER", "https://login.onyva.dev").strip() +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "").strip() +OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "").strip() +OIDC_REDIRECT_URI = os.getenv("OIDC_REDIRECT_URI", "http://localhost:8000/auth/callback").strip() +OIDC_LOGOUT_REDIRECT_URI = os.getenv("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8000/").strip() +# The pending login (state + PKCE verifier) lives in a signed, short-lived cookie +# on the client — NOT in process memory, so a container restart or redeploy in the +# middle of a login no longer invalidates it. +OAUTH_STATE_COOKIE = f"{CFG.id}_oauth" +OAUTH_STATE_TTL = 10 * 60 +# Marker appended to the state of an automatically retried login. The IdP echoes +# the state back verbatim, so it survives the round trip even when the browser +# keeps no cookie at all — which is what makes it a reliable loop breaker. +OAUTH_RETRY_MARKER = "~r" + + +class NoSpeechDetectedError(RuntimeError): + pass + + +class AudioTooLongError(RuntimeError): + pass + + +@asynccontextmanager +async def lifespan(app: FastAPI): + _stt = _health_stt() + print(f"Server startet – Tenant: {CFG.id} ({CFG.name}) – " + f"STT: Voxtral via {_stt['provider']} ({_stt.get('model', '?')})") + cost.start_worker() + yield + cost.flush_now() + print("Server fährt herunter.") + + +app = FastAPI(lifespan=lifespan) + + +# ── auth / session ─────────────────────────────────────────────────────────── + + +def _decode_jwt_claims(token: str) -> dict: + try: + payload_b64 = token.split(".")[1] + payload_b64 += "=" * (4 - len(payload_b64) % 4) + return json.loads(base64.urlsafe_b64decode(payload_b64).decode()) + except Exception: + return {} + + +def _sign_payload(data: dict) -> str: + payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=") + sig = hmac.new(SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + return f"{payload}.{sig}" + + +def _verify_payload(token: str) -> dict | None: + try: + payload, _, sig = token.partition(".") + if not sig or not SESSION_SECRET: + return None + expected = hmac.new(SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + return None + payload += "=" * (4 - len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload).decode()) + except Exception: + return None + + +def _get_session_user(request: Request) -> dict | None: + if not AUTH_ENABLED: + return {"sub": "user:0", "email": "dev@local", "name": "Dev", "user_type": "employee", "app_roles": ["admin"]} + token = request.cookies.get(AUTH_COOKIE, "") + return _verify_payload(token) if token else None + + +def require_auth(request: Request) -> dict: + user = _get_session_user(request) + if user is None: + raise HTTPException(status_code=401, detail="Nicht angemeldet") + return user + + +def _set_session(response: Response, user: dict) -> None: + response.set_cookie( + AUTH_COOKIE, _sign_payload(user), max_age=12 * 60 * 60, + httponly=True, secure=AUTH_COOKIE_SECURE, samesite="lax", + ) + + +def _set_pending_login(response: Response, state: str, code_verifier: str) -> None: + # SameSite=lax is required: the IdP sends the browser back via a top-level GET, + # which is exactly the case lax still allows. + payload = {"state": state, "cv": code_verifier, "exp": int(time.time()) + OAUTH_STATE_TTL} + response.set_cookie( + OAUTH_STATE_COOKIE, _sign_payload(payload), max_age=OAUTH_STATE_TTL, + httponly=True, secure=AUTH_COOKIE_SECURE, samesite="lax", + ) + + +def _pending_login_verifier(request: Request, state: str) -> str | None: + """PKCE verifier for `state`, or None if the cookie is missing, expired, + tampered with or belongs to a different login attempt.""" + token = request.cookies.get(OAUTH_STATE_COOKIE, "") + data = _verify_payload(token) if token else None + if not data or int(data.get("exp", 0)) < time.time(): + return None + if not hmac.compare_digest(str(data.get("state", "")), state): + return None + return data.get("cv") or None + + +# ── audio helpers ──────────────────────────────────────────────────────────── + + +def _ffmpeg_executable() -> str: + import shutil + found = shutil.which("ffmpeg") + if found: + return found + winget_root = Path(os.environ.get("LOCALAPPDATA", "C:/Users")) / "Microsoft/WinGet/Packages" + for candidate in sorted(winget_root.glob("Gyan.FFmpeg_*/ffmpeg-*/bin/ffmpeg.exe"), reverse=True): + if candidate.exists(): + return str(candidate) + raise RuntimeError("ffmpeg nicht gefunden. Bitte installieren: winget install Gyan.FFmpeg") + + +def _audio_duration_seconds(path: Path) -> float: + """Media duration in seconds via ffprobe (best effort; 0.0 on failure). + + Used for STT cost which is billed per audio minute — Mistral's response + carries no usage figure.""" + ffprobe = (_ffmpeg_executable().replace("ffmpeg", "ffprobe") + if "ffmpeg" in _ffmpeg_executable() else "ffprobe") + try: + r = subprocess.run( + [ffprobe, "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + capture_output=True, text=True, + ) + return float(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else 0.0 + except Exception: + return 0.0 + + +# Video containers whose audio track must be extracted before transcription. +_VIDEO_SUFFIXES = {".mp4", ".m4v", ".mov", ".webm"} + + +def extract_audio_if_video(input_path: Path) -> tuple[Path, bool]: + """Mistral transcribes audio files directly. For video containers we extract + the audio track to mp3 first. Returns (path, created_temp).""" + if input_path.suffix.lower() not in _VIDEO_SUFFIXES: + return input_path, False + output_path = input_path.with_suffix(".extracted.mp3") + r = subprocess.run( + [_ffmpeg_executable(), "-y", "-i", str(input_path), "-vn", "-ac", "1", + "-ar", "16000", "-b:a", "96k", str(output_path)], + capture_output=True, text=True, + ) + if r.returncode != 0: + raise RuntimeError(f"ffmpeg fehlgeschlagen:\n{r.stderr}") + return output_path, True + + +def concat_audio(paths: list[Path]) -> Path: + """Join several audio/video files, in the given order, into one mono mp3. + + The STT call then sees a single recording, so diarization keeps the same + speaker ids across all parts (transcribing the parts separately would + restart at SPRECHER_0 in every file). Every input is resampled to the same + format first because the concat filter needs matching streams.""" + output_path = TEMP_DIR / f"{uuid.uuid4()}.merged.mp3" + cmd = [_ffmpeg_executable(), "-y"] + for p in paths: + cmd += ["-i", str(p)] + chains = "".join( + f"[{i}:a:0]aresample=16000,aformat=channel_layouts=mono[a{i}];" + for i in range(len(paths)) + ) + joined = "".join(f"[a{i}]" for i in range(len(paths))) + cmd += ["-filter_complex", f"{chains}{joined}concat=n={len(paths)}:v=0:a=1[out]", + "-map", "[out]", "-b:a", "96k", str(output_path)] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + output_path.unlink(missing_ok=True) + raise RuntimeError(f"Zusammenfügen der Audiodateien fehlgeschlagen:\n{r.stderr[-1500:]}") + return output_path + + +def normalize_language(language: str) -> str: + lang = (language or "de").strip().lower() + return "en" if lang.startswith("en") else "de" + + +# ── Voxtral transcription (Mistral direkt oder via Cortecs) ────────────────── +# +# Beide Anbieter sprechen dieselbe Wire-API (Mistral-Format: multipart-Request +# mit diarize/timestamp_granularities/context_bias, Antwort mit segments + +# speaker_id) — Cortecs reicht die Voxtral-Aufrufe durch. Deshalb unterscheiden +# sich nur Endpunkt, API-Key und Modell-ID; Request-Bau und Parser sind geteilt. +# +# Auswahl pro Tenant über tenants//tenant.json: +# "stt": { "provider": "cortecs", "model": "voxtral-mini-transcribe-2602" } +# Ohne Eintrag bleibt es bei Mistral direkt. Env-Variablen (STT_PROVIDER, +# STT_MODEL bzw. MISTRAL_TRANSCRIBE_MODEL) überschreiben die tenant.json. + +_STT_PROVIDERS = { + "mistral": { + "url": "https://api.mistral.ai/v1/audio/transcriptions", + "key_env": "MISTRAL_API_KEY", + "model": "voxtral-mini-latest", + # $0.003/min → €0.002625/min (1 USD ≈ €0.875) + "cost_per_minute": 0.002625, + }, + "cortecs": { + "url": None, # aus CORTECS_BASE_URL abgeleitet + "key_env": "CORTECS_API_KEY", + "model": "voxtral-mini-transcribe-2602", + # Cortecs rechnet Voxtral nativ in EUR ab (Stand 12.08.2026). + "cost_per_minute": 0.003, + }, +} + + +def _stt_config() -> dict: + """Resolve the STT provider for this tenant. + + Precedence: env > tenant.json "stt" > provider default. + """ + tenant_stt = CFG.stt or {} + provider = (os.getenv("STT_PROVIDER") or tenant_stt.get("provider") or "mistral").strip().lower() + spec = _STT_PROVIDERS.get(provider) + if spec is None: + raise RuntimeError( + f"Unbekannter STT-Provider '{provider}'. Erlaubt: {', '.join(sorted(_STT_PROVIDERS))}" + ) + + url = spec["url"] + if provider == "cortecs": + base = os.getenv("CORTECS_BASE_URL", "https://api.cortecs.ai/v1").strip().rstrip("/") + url = f"{base}/audio/transcriptions" + + # MISTRAL_TRANSCRIBE_MODEL wird global an alle Container verteilt (SHARED_ENV) + # und gilt daher nur für den Mistral-Provider — sonst würde es die Modell-ID + # eines Cortecs-Tenants überschreiben. STT_MODEL wirkt providerübergreifend. + legacy_model = os.getenv("MISTRAL_TRANSCRIBE_MODEL") if provider == "mistral" else None + model = (os.getenv("STT_MODEL") or legacy_model + or tenant_stt.get("model") or spec["model"]).strip() + cost_per_minute = float(tenant_stt.get("cost_per_minute") or spec["cost_per_minute"]) + + return { + "provider": provider, + "url": url, + "key_env": spec["key_env"], + "model": model, + "diarize": os.getenv("STT_DIARIZE", os.getenv("MISTRAL_DIARIZE", "true")).lower() == "true", + "timeout": int(os.getenv("STT_TIMEOUT", os.getenv("MISTRAL_TIMEOUT", "600"))), + "cost_per_minute": cost_per_minute, + } + + +def _stt_api_key(cfg: dict) -> str: + key = os.getenv(cfg["key_env"], "").strip() + if not key: + raise RuntimeError(f"{cfg['key_env']} nicht gesetzt") + return key + + +def _load_vocabulary() -> list[str]: + with VOCAB_LOCK: + if not VOCAB_FILE.exists(): + return [] + try: + data = json.loads(VOCAB_FILE.read_text(encoding="utf-8")) + return [str(t).strip() for t in (data if isinstance(data, list) else []) if str(t).strip()] + except Exception: + return [] + + +def _save_vocabulary(terms: list[str]) -> None: + with VOCAB_LOCK: + DATA_DIR.mkdir(parents=True, exist_ok=True) + VOCAB_FILE.write_text(json.dumps(sorted(set(terms)), ensure_ascii=False, indent=2), encoding="utf-8") + + +def _transcribe_with_voxtral(audio_path: Path, cfg: dict) -> dict: + """Call the Voxtral transcription API (multipart) of the configured provider. + The language is auto-detected (no forced language); diarization + custom + vocabulary (context_bias) are enabled by default. The app's audio is + German/English, so auto-detection resolves to de/en.""" + base_parts: list = [("model", (None, cfg["model"]))] + if cfg["diarize"]: + base_parts.append(("diarize", (None, "true"))) + base_parts.append(("timestamp_granularities", (None, "segment"))) + for term in _load_vocabulary()[:100]: # custom vocabulary → context_bias + base_parts.append(("context_bias", (None, term))) + + def do_request(): + # Re-open the file on every attempt: a retried multipart POST needs a + # fresh handle, otherwise the body would be empty (handle at EOF). + with audio_path.open("rb") as f: + parts = base_parts + [("file", (audio_path.name, f, "application/octet-stream"))] + return requests.post( + cfg["url"], + headers={"Authorization": f"Bearer {_stt_api_key(cfg)}"}, + files=parts, + timeout=cfg["timeout"], + ) + + resp = retry.request_with_retry(do_request, label=f"{cfg['provider']}-stt") + if resp.status_code >= 400: + raise RuntimeError( + f"Transkription ({cfg['provider']}) HTTP {resp.status_code}: {resp.text[:500]}" + ) + return resp.json() + + +def _format_voxtral_transcript(result: dict) -> str: + # Speaker label follows the auto-detected language (de → SPRECHER, en → SPEAKER). + lang = normalize_language(result.get("language") or "de") + speaker_label = "SPEAKER" if lang == "en" else "SPRECHER" + empty_label = "No speech detected" if lang == "en" else "Keine Sprache erkannt" + + segments = result.get("segments") or [] + lines: list[str] = [] + last_speaker = object() # sentinel so the first segment never "merges" + for seg in segments: + text = (seg.get("text") or "").strip() + if not text: + continue + speaker = seg.get("speaker_id") + if lines and speaker == last_speaker: + lines[-1] += " " + text # merge consecutive same-speaker segments + continue + label = f"{speaker_label}_{speaker}" if speaker is not None else speaker_label + lines.append(f"{label}: {text}") + last_speaker = speaker + if lines: + return "\n".join(lines) + + full_text = (result.get("text") or "").strip() + if full_text: + return f"{speaker_label}: {full_text}" + return f"{speaker_label}: {empty_label}" + + +def _format_duration(seconds: float) -> str: + minutes = int(round(seconds / 60)) + return f"{minutes // 60}:{minutes % 60:02d} h" + + +def transcribe_audio(file_path: Path, duration_seconds: float = 0.0) -> str: + """Transcribe an audio/video file via Voxtral (auto language detection), + Provider laut Tenant-Konfiguration. Returns the speaker-labelled transcript. + + The request timeout grows with the audio length (STT_TIMEOUT + 5 min per + audio hour), so a long but valid recording is not cut off and retried.""" + cfg = _stt_config() + cfg["timeout"] += int(300 * duration_seconds / 3600) + audio_path, created = extract_audio_if_video(file_path) + try: + result = _transcribe_with_voxtral(audio_path, cfg) + return _format_voxtral_transcript(result) + except Exception as exc: + print(f"Transkriptionsfehler ({cfg['provider']}): {exc}") + raise + finally: + if created: + audio_path.unlink(missing_ok=True) + + +def _track_stt_usage(seconds: float, *, account: str | None = None, + idempotency_key: str | None = None) -> None: + """Report the EUR cost of an STT call from audio duration (billed per minute). + Best effort: tracking failures must never break the transcription flow.""" + try: + if seconds <= 0: + return + minutes = seconds / 60.0 + stt = _stt_config() + cost.track_usage( + "stt_audio", + amount_eur=cost.stt_cost_eur(minutes, per_minute=stt["cost_per_minute"]), + account=account, + quantity=round(minutes, 2), + idempotency_key=f"{idempotency_key}:stt" if idempotency_key else None, + metadata={"model": stt["model"], "provider": stt["provider"], + "seconds": round(seconds, 1)}, + ) + except Exception as exc: + print(f"[cost] stt usage tracking skipped: {exc}") + + +# ── transcript upload parsing (docx / vtt / txt) ───────────────────────────── + + +def _normalize_speaker_name(name: str) -> str: + """Canonical speaker label so the same person never splits into several. + + Word/Teams/Zoom exports frequently mix non-breaking spaces, double spaces + or trailing punctuation in the same name across utterances; without this + the dedup downstream would show "Ruben", "Ruben", "Ruben" as 3 speakers. + """ + name = re.sub(r"\s+", " ", name).strip() + return name.rstrip(":").strip() + + +def _extract_docx_text(content: bytes) -> str: + import zipfile + import xml.etree.ElementTree as ET + try: + with zipfile.ZipFile(io.BytesIO(content)) as zf: + xml_bytes = zf.read("word/document.xml") + except (zipfile.BadZipFile, KeyError) as exc: + raise HTTPException(status_code=415, detail=f"Ungültige .docx-Datei: {exc}") + ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + root = ET.fromstring(xml_bytes) + paragraphs: list[str] = [] + for paragraph in root.iter(f"{ns}p"): + text = "".join((t.text or "") for t in paragraph.iter(f"{ns}t")) + paragraphs.append(text.strip()) + return "\n".join(paragraphs) + + +def _parse_vtt_transcript(raw: str) -> str: + lines = raw.replace("\r\n", "\n").split("\n") + output: list[str] = [] + current_speaker: str | None = None + current_text: list[str] = [] + tag_pattern = re.compile(r"<[^>]+>") + voice_pattern = re.compile(r"^]+)>(.*?)(?:)?\s*$", re.IGNORECASE) + cue_id_pattern = re.compile(r"^(\d+|[0-9a-f\-]{16,})$", re.IGNORECASE) + + def flush(): + nonlocal current_speaker, current_text + if current_text: + text = " ".join(current_text).strip() + if text: + output.append(f"{current_speaker or 'SPRECHER'}: {text}") + current_text = [] + current_speaker = None + + for line in lines: + stripped = line.strip() + if not stripped: + flush() + continue + if stripped.upper().startswith("WEBVTT") or stripped.startswith("NOTE"): + continue + if "-->" in stripped: + flush() + continue + if cue_id_pattern.match(stripped): + continue + match = voice_pattern.match(stripped) + if match: + current_speaker = _normalize_speaker_name(match.group(1)) + current_text.append(tag_pattern.sub("", match.group(2)).strip()) + else: + current_text.append(tag_pattern.sub("", stripped).strip()) + flush() + return "\n".join(line for line in output if line.strip()) + + +def _normalize_text_transcript(raw: str) -> str: + lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n") + output: list[str] = [] + current_speaker: str | None = None + current_text: list[str] = [] + timestamp_pattern = re.compile(r"^(.+?)\s+\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\s*$") + # Teams/Zoom .docx export: "Name M:SS". + # The timestamp is followed immediately (no space) by the utterance, so the + # minute must not bleed into the speaker name (the "Olivia Kibele 0" bug). + teams_pattern = re.compile(r"^(.+?)\s+\d{1,2}:\d{2}(?::\d{2})?(\S.*)$") + inline_pattern = re.compile(r"^([^:\n]{1,80}):\s*(.+)$") + + def looks_like_name(candidate: str) -> bool: + return bool( + candidate + and len(candidate.split()) <= 6 + and not re.search(r"[.!?]", candidate) + and not candidate.lower().startswith("http") + ) + + def flush(): + nonlocal current_text + if current_speaker is not None and current_text: + text = " ".join(current_text).strip() + if text: + output.append(f"{current_speaker}: {text}") + current_text = [] + + for raw_line in lines: + line = raw_line.strip() + if not line: + flush() + continue + timestamp_match = timestamp_pattern.match(line) + if timestamp_match and not timestamp_match.group(1).lower().startswith("http"): + flush() + current_speaker = _normalize_speaker_name(timestamp_match.group(1)) + continue + teams_match = teams_pattern.match(line) + if teams_match and looks_like_name(teams_match.group(1).strip()): + flush() + current_speaker = _normalize_speaker_name(teams_match.group(1)) + current_text.append(teams_match.group(2).strip()) + continue + inline_match = inline_pattern.match(line) + if inline_match: + candidate = inline_match.group(1).strip() + if looks_like_name(candidate): + flush() + current_speaker = _normalize_speaker_name(candidate) + current_text.append(inline_match.group(2).strip()) + continue + if current_speaker is None: + current_speaker = "SPRECHER" + current_text.append(line) + flush() + # Drop the leading header block (title/date/duration/"Transkription gestartet") + # that precedes the first real speaker — but keep SPRECHER if the whole + # transcript is unlabelled (no named speaker was ever found). + if any(not line.startswith("SPRECHER:") for line in output): + while output and output[0].startswith("SPRECHER:"): + output.pop(0) + if not output and raw.strip(): + output.append(f"SPRECHER: {raw.strip()}") + return "\n".join(output) + + +def parse_transcript_upload(content: bytes, filename: str | None) -> str: + suffix = Path(filename or "").suffix.lower() + if suffix == ".docx": + text = _extract_docx_text(content) + else: + text = content.decode("utf-8", errors="replace") + if text.startswith(""): + text = text[1:] + if suffix == ".vtt": + return _parse_vtt_transcript(text) + return _normalize_text_transcript(text) + + +def _upload_suffix(content_type: str, filename: str | None) -> str: + ext_map = { + "audio/mpeg": ".mp3", "audio/mp3": ".mp3", "audio/webm": ".webm", "video/webm": ".webm", + "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/flac": ".flac", "audio/x-flac": ".flac", + "audio/aac": ".aac", "audio/x-m4a": ".m4a", "audio/mp4": ".mp4", "video/mp4": ".mp4", + "video/quicktime": ".mov", "video/x-m4v": ".m4v", + } + # The filename comes from the client, so only a plain, short extension is + # carried over into the temp path — everything else falls back to the + # content type (ffmpeg/STT pick the decoder by extension). + guessed = Path(filename or "").suffix.strip().lower().lstrip(".") + if guessed.isalnum() and 1 <= len(guessed) <= 8: + return f".{guessed}" + return ext_map.get(content_type, ".audio") + + +async def _persist_upload_temporarily(file: UploadFile) -> tuple[Path, int]: + content_type = (file.content_type or "").split(";")[0].strip().lower() + suffix = Path(file.filename or "").suffix.lower() + supported_type = content_type in ALLOWED_CONTENT_TYPES or content_type.startswith("audio/") + supported_suffix = suffix in ALLOWED_UPLOAD_SUFFIXES + if not supported_type and not supported_suffix: + raise HTTPException( + status_code=415, + detail=_unsupported_format_detail("Dateiformat", suffix, ALLOWED_UPLOAD_SUFFIXES), + ) + content = await file.read() + if len(content) > MAX_FILE_SIZE: + max_mb = MAX_FILE_SIZE // 1024 // 1024 + raise HTTPException(status_code=413, detail=f"Datei zu groß ({len(content) // 1024 // 1024} MB). Max {max_mb} MB") + if len(content) == 0: + raise HTTPException(status_code=400, detail="Leere Datei") + temp_path = TEMP_DIR / f"{uuid.uuid4()}{_upload_suffix(content_type, file.filename)}" + temp_path.write_bytes(content) + return temp_path, len(content) + + +# ── AI summarisation ───────────────────────────────────────────────────────── + + +def _llm_client(): + try: + from openai import OpenAI + except ModuleNotFoundError as exc: + raise RuntimeError( + "Python-Paket 'openai' nicht installiert. Bitte Abhaengigkeiten neu installieren." + ) from exc + api_key = os.getenv("CORTECS_API_KEY", "").strip() + if not api_key: + raise RuntimeError("CORTECS_API_KEY nicht gesetzt") + # Cortecs speaks the OpenAI wire format; the SDK appends /chat/completions to + # the base URL, so it must include the /v1 prefix. + # The SDK retries connection errors + 408/409/429/5xx with exponential + # backoff; bump the default (2) so a brief provider blip doesn't fail the job. + return OpenAI( + api_key=api_key, + base_url=os.getenv("CORTECS_BASE_URL", "https://api.cortecs.ai/v1").strip(), + max_retries=int(os.getenv("LLM_MAX_RETRIES", "4")), + timeout=float(os.getenv("LLM_TIMEOUT", "600")), + ) + + +def _summary_prompt() -> str: + """The summary prompt with the tenant's identity filled in. + + The shared default prompt is written with @@ORG@@/@@ORG_SHORT@@/@@ORG_PROFILE@@ + placeholders so every tenant gets a protocol that names *its own* company — + a tenant-specific prompt.txt simply contains no placeholders. + """ + path = CFG.prompt_path + if not path.exists(): + raise RuntimeError(f"{path} nicht gefunden") + prompt = path.read_text(encoding="utf-8").strip() + if not prompt: + raise RuntimeError(f"{path} ist leer") + for token, value in ( + ("@@ORG@@", CFG.ai_detect_org), + ("@@ORG_SHORT@@", CFG.org_short), + ("@@ORG_PROFILE@@", CFG.org_profile), + ): + prompt = prompt.replace(token, value) + # An empty @@ORG_PROFILE@@ must not leave a dangling space behind. + return re.sub(r"[ \t]+(?=\n)", "", prompt).strip() + + +def _llm_text(response) -> str: + parts = [] + for choice in getattr(response, "choices", []) or []: + text = getattr(getattr(choice, "message", None), "content", None) + if text: + parts.append(text) + return "\n".join(parts).strip() + + +def _track_llm_usage(cost_point: str, model: str, response, *, account: str | None = None, + idempotency_key: str | None = None) -> None: + """Report the EUR cost of a Cortecs/Claude call from its token usage. + Best effort: tracking failures must never break the summarisation flow.""" + try: + usage = getattr(response, "usage", None) + if usage is None: + return + in_tok = int(getattr(usage, "prompt_tokens", 0) or 0) + out_tok = int(getattr(usage, "completion_tokens", 0) or 0) + cost.track_usage( + cost_point, + amount_eur=cost.llm_cost_eur(model, in_tok, out_tok), + account=account, + quantity=in_tok + out_tok, + idempotency_key=idempotency_key, + metadata={"model": model, "input_tokens": in_tok, "output_tokens": out_tok}, + ) + except Exception as exc: + print(f"[cost] llm usage tracking skipped: {exc}") + + +def _set_send_job(job_id: str, **updates): + with SEND_JOBS_LOCK: + job = SEND_JOBS.setdefault(job_id, {}) + job.update(updates) + + +def _set_summarize_job(job_id: str, **updates): + with SUMMARIZE_JOBS_LOCK: + SUMMARIZE_JOBS.setdefault(job_id, {}).update(updates) + + +def _prune_summarize_jobs(keep: int = 50): + with SUMMARIZE_JOBS_LOCK: + overflow = len(SUMMARIZE_JOBS) - keep + if overflow <= 0: + return + stale = [ + jid for jid, d in SUMMARIZE_JOBS.items() + if d.get("status") in ("completed", "error") + ][:overflow] + for jid in stale: + SUMMARIZE_JOBS.pop(jid, None) + + +def _run_summarize_job(job_id: str, transcript: str, title: str, insurance_type: str, + customer_2: str, customer_name: str, customer_address: str, + account: str | None = None): + started = time.monotonic() + try: + _set_summarize_job(job_id, status="running") + print(f"[summarize {job_id}] Start") + summary = summarize_with_ai( + transcript, title, insurance_type, customer_2, customer_name, customer_address, + account=account, idempotency_key=job_id, + ) + _set_summarize_job(job_id, status="completed", summary=summary, + completed_at=datetime.now().isoformat()) + print(f"[summarize {job_id}] Fertig nach {time.monotonic() - started:.1f}s") + except Exception as exc: + _set_summarize_job(job_id, status="error", error=str(exc), + completed_at=datetime.now().isoformat()) + print(f"[summarize {job_id}] Fehlgeschlagen nach {time.monotonic() - started:.1f}s: {exc}") + + +def _set_transcribe_job(job_id: str, **updates): + with TRANSCRIBE_JOBS_LOCK: + TRANSCRIBE_JOBS.setdefault(job_id, {}).update(updates) + + +def _prune_transcribe_jobs(keep: int = 50): + # Finished/failed jobs hold the full transcript, so cap how many we retain + # to avoid unbounded memory growth on a long-running process. + with TRANSCRIBE_JOBS_LOCK: + overflow = len(TRANSCRIBE_JOBS) - keep + if overflow <= 0: + return + stale = [ + jid for jid, d in TRANSCRIBE_JOBS.items() + if d.get("status") in ("completed", "error") + ][:overflow] + for jid in stale: + TRANSCRIBE_JOBS.pop(jid, None) + + +def _run_transcribe_job(job_id: str, temp_paths: list[Path], filename: str, size: int, + account: str | None = None): + started = time.monotonic() + merged: Path | None = None + try: + _set_transcribe_job(job_id, status="running") + print(f"[transcribe {job_id}] Start ({filename}, {len(temp_paths)} Datei(en), {size} Bytes)") + if len(temp_paths) > 1: + merged = concat_audio(temp_paths) + temp_path = merged or temp_paths[0] + seconds = _audio_duration_seconds(temp_path) + # 0.0 = ffprobe could not read the duration → let the provider decide. + if seconds > MAX_AUDIO_SECONDS: + raise AudioTooLongError( + f"Die Aufnahme ist insgesamt {_format_duration(seconds)} lang – möglich sind " + f"höchstens {_format_duration(MAX_AUDIO_SECONDS)} pro Transkription. " + "Bitte kürzen oder auf mehrere Protokolle aufteilen." + ) + transcript = transcribe_audio(temp_path, seconds) + _track_stt_usage(seconds, account=account, idempotency_key=job_id) + _set_transcribe_job( + job_id, status="completed", transcription=transcript, + filename=filename, size=size, completed_at=datetime.now().isoformat(), + ) + print(f"[transcribe {job_id}] Fertig nach {time.monotonic() - started:.1f}s") + except NoSpeechDetectedError as exc: + _set_transcribe_job(job_id, status="error", error=str(exc), + completed_at=datetime.now().isoformat()) + print(f"[transcribe {job_id}] Keine Sprache erkannt nach {time.monotonic() - started:.1f}s") + except AudioTooLongError as exc: + _set_transcribe_job(job_id, status="error", error=str(exc), + completed_at=datetime.now().isoformat()) + print(f"[transcribe {job_id}] Zu lang ({exc})") + except Exception as exc: + _set_transcribe_job( + job_id, status="error", + error=f"Transkription fehlgeschlagen: {exc}", + completed_at=datetime.now().isoformat(), + ) + print(f"[transcribe {job_id}] Fehlgeschlagen nach {time.monotonic() - started:.1f}s: {exc}") + finally: + for p in (*temp_paths, merged): + if p is not None: + p.unlink(missing_ok=True) + + +def summarize_with_ai( + transcript: str, title: str, insurance_type: str = "", customer_2: str = "", + customer_name: str = "", customer_address: str = "", + account: str | None = None, idempotency_key: str | None = None, +) -> str: + client = _llm_client() + base_prompt = _summary_prompt() + ctx = { + "title": title, "insurance_type": insurance_type, "customer_2": customer_2, + "customer_name": customer_name, "customer_address": customer_address, + } + prompt = HOOKS.build_prompt(base_prompt, ctx) + model = os.getenv("LLM_MODEL", "claude-4-6-sonnet") + + terminart_val = insurance_type.strip() or "(aus Gespräch erkennen)" + projekt_val = customer_2.strip() or "(keiner)" + kunde_val = customer_name.strip() or "(aus Gespräch erkennen)" + kontakt_val = customer_address.strip() or "(aus Gespräch erkennen)" + title_val = title.strip() or "(ohne Titel)" + + user_content = ( + f"Termin-Art: {terminart_val}\n" + f"Projektname: {projekt_val}\n" + f"Kunde / Unternehmen: {kunde_val}\n" + f"Ansprechpartner Kunde: {kontakt_val}\n" + f"Meeting-Titel: {title_val}\n\n" + f"Gesprächsprotokoll:\n{transcript}" + ) + def complete(system_prompt: str, cost_suffix: str): + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ], + max_tokens=int(os.getenv("LLM_MAX_TOKENS", "16000")), + temperature=float(os.getenv("LLM_TEMPERATURE", "0.2")), + stream=False, + ) + _track_llm_usage("llm_summarize", model, response, account=account, + idempotency_key=f"{idempotency_key}:{cost_suffix}" if idempotency_key else None) + return _llm_text(response), _hit_token_limit(response) + + content, truncated = complete(prompt, "summarize") + if truncated: + # The protocol ran into max_tokens and stops mid-sentence. Instead of + # an error, silently ask once more for a shorter version that keeps + # the tenant's structure (the tenant prompt itself stays unchanged). + print(f"[summarize] max_tokens erreicht ({len(content)} Zeichen) – zweiter Versuch, gekürzt") + content, truncated = complete(prompt + _SHORTER_RETRY_INSTRUCTION, "summarize-short") + if truncated: + print("[summarize] auch der gekürzte Versuch hat max_tokens erreicht") + content = _drop_unfinished_tail(content) + if not content: + raise RuntimeError("Claude hat keine Dokumentation zurückgegeben") + return normalize_protocol_text(content) + + +_SHORTER_RETRY_INSTRUCTION = """ + +Wichtiger Zusatz zur Länge: +Ein erster Versuch mit diesen Anweisungen ist zu lang geworden und wurde abgeschnitten. Erstelle das Dokument deshalb in einer deutlich kürzeren Fassung, etwa halb so lang wie üblich. +- Behalte den Aufbau exakt bei: dieselben Metadatenblöcke, dieselben Abschnitte und Überschriften in derselben Reihenfolge, dieselben Formatregeln und dieselbe Tonalität. +- Kein Abschnitt darf fehlen, auch die letzten Abschnitte müssen vollständig sein. +- Kürze durch Verdichten: weniger Details, Beispiele und Nebenaspekte, kürzere Absätze, weniger Zwischenüberschriften. Zahlen, Termine, Namen und Entscheidungen bleiben erhalten. +- Alle übrigen Regeln, insbesondere "erfinde nichts", gelten unverändert.""" + + +def _hit_token_limit(response) -> bool: + """True if the model stopped because max_tokens was reached (the text is + then cut off mid-sentence). OpenAI-compatible: finish_reason "length".""" + for choice in getattr(response, "choices", []) or []: + if getattr(choice, "finish_reason", None) in ("length", "max_tokens"): + return True + return False + + +def _drop_unfinished_tail(text: str) -> str: + """Last resort for a still truncated protocol: drop the half-written last + line, which the PDF parser would otherwise render as a heading, and any + heading left without content above it.""" + lines = text.rstrip().split("\n") + unfinished = lambda ln: not re.search(r"[.!?:…\"“”»)\]]$", ln.strip()) + if len(lines) > 1 and unfinished(lines[-1]): + lines.pop() + while len(lines) > 1 and (not lines[-1].strip() or unfinished(lines[-1])): + lines.pop() + return "\n".join(lines).rstrip() + + +# ── e-mail / delivery ──────────────────────────────────────────────────────── + + +def build_email_html(title: str, summary: str) -> str: + escaped_title = html.escape(title) + c = CFG.colors + org = CFG.office.get("name", CFG.name) + subtitle = CFG.office.get("subtitle", "") + org_line = f"{org} {subtitle}".strip() + return f"""\ + + + +
+
+
{html.escape(CFG.name)}
+
{escaped_title}
+
+
+

Im Anhang finden Sie das Gesprächsprotokoll als PDF.

+

Freundliche Grüße
{html.escape(org_line)}

+
+
+ + +""" + + +def build_pdf( + title: str, summary: str, transcript: str, insurance_type: str = "", customer_2: str = "", + customer_name: str = "", customer_address: str = "", +) -> bytes: + context = build_pdf_context( + CFG, title, summary, insurance_type, customer_2, customer_name, customer_address + ) + context = HOOKS.pdf_context(context) + return render_pdf(context, CFG) + + +def build_docx( + title: str, summary: str, transcript: str, insurance_type: str = "", customer_2: str = "", + customer_name: str = "", customer_address: str = "", +) -> bytes: + # Reuses the exact same structured context as the PDF, so the Word document + # carries identical content for every tenant. + context = build_pdf_context( + CFG, title, summary, insurance_type, customer_2, customer_name, customer_address + ) + context = HOOKS.pdf_context(context) + return render_docx(context, CFG) + + +def deliver( + recipients: list[str], title: str, summary: str, transcript: str, insurance_type: str = "", + customer_2: str = "", customer_name: str = "", customer_address: str = "", +) -> bool: + clean_summary = normalize_protocol_text(summary) + pdf_bytes = build_pdf(title, clean_summary, transcript, insurance_type, customer_2, customer_name, customer_address) + + parsed = parse_protocol_sections(clean_summary) + projekt_lines = parsed["projekt"] + if customer_name.strip(): + projekt_lines = [customer_name.strip()] + ([customer_2.strip()] if customer_2.strip() else []) + project_lbl = project_label(projekt_lines) if projekt_lines else "Projekt" + terminart = insurance_type.strip() or parsed["terminart"] or "Kundengespräch" + date_str = datetime.now().strftime("%Y-%m-%d") + pdf_name = f"{date_str}_{safe_filename(terminart)}_{safe_filename(project_lbl)}.pdf" + subject_title = f"Gesprächsprotokoll {terminart} – {project_lbl}" + + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", 587)) + smtp_user = os.getenv("SMTP_USER") + smtp_password = os.getenv("SMTP_PASSWORD") + smtp_from_raw = os.getenv("SMTP_FROM", "").strip() + if smtp_from_raw and "@" not in smtp_from_raw and smtp_user: + smtp_from = f"{smtp_from_raw} <{smtp_user}>" + elif smtp_from_raw: + smtp_from = smtp_from_raw + else: + smtp_from = smtp_user or "" + + if not all([smtp_host, smtp_user, smtp_password]): + raise RuntimeError("SMTP nicht konfiguriert. Das PDF wurde nicht gespeichert und nicht versendet.") + + html_body = build_email_html(subject_title, clean_summary) + body = ( + f"Termin-Art: {terminart}\n" + f"Projekt: {project_lbl}\n" + f"Datum: {datetime.now().strftime('%d.%m.%Y')}\n\n" + f"Im Anhang finden Sie das Gesprächsprotokoll als PDF." + ) + msg = MIMEMultipart("mixed") + msg["Subject"] = subject_title + msg["From"] = smtp_from + msg["To"] = ", ".join(recipients) + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(body, "plain", "utf-8")) + alt.attach(MIMEText(html_body, "html", "utf-8")) + msg.attach(alt) + attachment = MIMEApplication(pdf_bytes, _subtype="pdf") + attachment.add_header("Content-Disposition", "attachment", filename=pdf_name) + msg.attach(attachment) + + print(f"Sende E-Mail an {recipients} via {smtp_host}:{smtp_port} ...") + + def _send(): + with smtplib.SMTP(str(smtp_host), smtp_port, timeout=30) as server: + server.ehlo() + server.starttls() + server.ehlo() + auth_str = f"\x00{smtp_user}\x00{smtp_password}" + auth_b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii") + code, resp = server.docmd("AUTH PLAIN", auth_b64) + if code != 235: + raise smtplib.SMTPAuthenticationError(code, resp) + server.send_message(msg, from_addr=str(smtp_from), to_addrs=recipients) + + try: + # Retry only transient connection failures (OSError covers connection + # reset / timeout / DNS). Auth and recipient errors are not OSError, so + # they fail fast without pointless retries. + retry.call_with_retry( + _send, + retry_on=(smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected, OSError), + label="smtp-send", + ) + print("E-Mail erfolgreich gesendet.") + except Exception as e: + print(f"SMTP-Fehler: {e}") + raise RuntimeError(f"E-Mail konnte nicht gesendet werden: {e}") from e + return True + + +# ── frontend templating ────────────────────────────────────────────────────── + + +def _favicon_data_uri() -> str: + # A tenant can ship its own brand favicon (favicon.svg). Otherwise we + # generate a compact mark from the tenant's accent + red colours. + fav = CFG.favicon_path + if fav: + svg = re.sub(r"<\?xml.*?\?>", "", fav.read_text(encoding="utf-8"), flags=re.DOTALL).strip() + return "data:image/svg+xml," + quote(svg, safe="") + c = CFG.colors + accent = c["accent"].replace("#", "%23") + red = c["red"].replace("#", "%23") + return ( + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E" + "%3Crect width='64' height='64' rx='14' fill='%23FFFFFF'/%3E" + f"%3Ccircle cx='39' cy='15' r='6.5' fill='{accent}'/%3E" + f"%3Cpath d='M17 23 L30 35 L17 47' fill='none' stroke='{accent}' stroke-width='6.5' stroke-linecap='round' stroke-linejoin='round'/%3E" + f"%3Cpath d='M31 29 L44 41 L31 53' fill='none' stroke='{red}' stroke-width='6.5' stroke-linecap='round' stroke-linejoin='round'/%3E" + "%3C/svg%3E" + ) + + +def _topbar_logo_markup() -> str: + p = CFG.logo_path + label = html.escape(CFG.name) + if p and p.suffix.lower() == ".svg": + svg = p.read_text(encoding="utf-8") + svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.DOTALL) + svg = re.sub(r"", "", svg, flags=re.DOTALL) + svg = re.sub(r"