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