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>
115 lines
8.5 KiB
Python
115 lines
8.5 KiB
Python
"""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
|