Files
Onyscribe-Admin/app/services/mittwald.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

160 lines
7.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.
"""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 "<STACK_ID>"
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 "<STACK_ID>"
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 = "<service-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")