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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

115 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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