Files
Onyscribe-Admin/app/routers/api.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

771 lines
35 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.
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}"'})