Onyscribe Admin: Initialer Stand

Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

114
app/services/preflight.py Normal file
View File

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