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

255 lines
13 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.
"""Core-Releases: Eingang (CI), Kompatibilitätsprüfung aller Tenants, Freigabe, Canary-Rollout, Rollback.
Ablauf: CI meldet Image-Tag → Admin (läuft auf genau diesem Core) prüft alle Tenants in der Sandbox → Freigabe →
Rollout (Canary zuerst, Abbruch beim ersten Fehler, Health-Check pro Tenant) → aktiver Tag wird der neue Standard.
Gepinnte Tenants nehmen nicht teil. Echte Rollouts brauchen DEPLOY_LIVE=true; sonst nur Dry-Run.
"""
from __future__ import annotations
import hashlib
import io
import re
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import session
from app.models import CoreRelease, Deployment, Setting, Tenant
from app.services import contract, deploy, guard, preview, store
DATE = re.compile(r"\b\d{1,2}\.\d{1,2}\.\d{2,4}\b")
class ReleaseError(RuntimeError):
pass
# ── Eingang ─────────────────────────────────────────────────────────────────
def announce(db, sha: str, tag: str | None, ref: str, image: str, message: str) -> tuple[CoreRelease, bool]:
if not re.fullmatch(r"[0-9a-fA-F]{7,64}", sha or ""):
raise ReleaseError("sha: 7–64 Hex-Zeichen erwartet")
sha = sha.lower()
r = db.scalar(select(CoreRelease).where(CoreRelease.sha == sha))
created = r is None
if created:
r = CoreRelease(sha=sha, tag=(tag or sha)[:100], ref=ref[:100], image=image[:300], message=message[:500])
db.add(r)
store.audit(db, "ci", "core.release", None, f"{sha[:10]} ({ref}) {message[:80]}")
else:
r.message, r.ref, r.image = message[:500] or r.message, ref[:100] or r.ref, image[:300] or r.image
db.flush()
return r, created
def admin_matches(r: CoreRelease) -> bool:
return contract.same_sha(contract.sha(), r.sha)
def view(r: CoreRelease, active_tag: str) -> dict:
return {"id": r.id, "sha": r.sha, "tag": r.tag, "ref": r.ref, "image": r.image, "message": r.message, "status": r.status,
"announced_at": r.announced_at.isoformat() if r.announced_at else None, "admin_matches": admin_matches(r),
"approved_by": r.approved_by, "compat": r.compat, "rollout_batch": r.rollout_batch, "previous_tag": r.previous_tag,
"active": r.tag == active_tag or contract.same_sha(r.sha, active_tag)}
# ── Kompatibilitätsprüfung ──────────────────────────────────────────────────
def _pdf_fingerprint(pdf: bytes) -> tuple[int, str]:
from pypdf import PdfReader
rd = PdfReader(io.BytesIO(pdf))
text = DATE.sub("<datum>", "\n".join((p.extract_text() or "") for p in rd.pages)) # das Tagesdatum steht im PDF
return len(rd.pages), hashlib.sha256(text.encode()).hexdigest()
def fingerprint(db, t: Tenant) -> dict:
"""Rendert den veröffentlichten Stand mit dem aktuellen Admin-Core und bildet Fingerabdrücke."""
cfg, files = store.content(db, t, "current")
sample = preview.sample_for(t)
fp: dict = {"sample": hashlib.sha256(sample.encode()).hexdigest()[:12]}
val = preview.run_content(db, t, cfg, files, "validate", sample)
fp["validate"] = {"ok": val.get("ok", False), "errors": val.get("errors", []), "warnings": val.get("warnings", [])}
if val.get("ok"):
pages, text_sha = _pdf_fingerprint(preview.run_content(db, t, cfg, files, "pdf", sample))
fp.update(pdf_pages=pages, pdf_text=text_sha,
web=hashlib.sha256(preview.run_content(db, t, cfg, files, "index", sample)).hexdigest(),
prompt=hashlib.sha256(preview.run_content(db, t, cfg, files, "prompt", sample, {"title": "x"})["prompt"].encode()).hexdigest())
fp["schema_problems"] = contract.check_tenant_json(cfg)
return fp
def compare(fp: dict, baseline: dict | None, version: int) -> dict:
"""→ {status: ok|changed|new|error, changed:[…], errors:[…], warnings:[…]}"""
out = {"status": "ok", "changed": [], "errors": fp["validate"]["errors"], "warnings": list(fp["validate"]["warnings"]) + fp.get("schema_problems", [])}
if not fp["validate"]["ok"]:
out["status"] = "error"
return out
if not baseline or baseline.get("version") != version or baseline["fp"].get("sample") != fp["sample"]:
out["status"] = "new"
return out
labels = {"pdf_pages": "PDF-Seitenzahl", "pdf_text": "PDF-Text", "web": "Web-UI", "prompt": "Finaler Prompt"}
out["changed"] = [labels[k] for k in labels if baseline["fp"].get(k) != fp.get(k)]
if out["changed"]:
out["status"] = "changed"
return out
def check(release_id: int, *, force: bool = False, wait: bool = False, workers: int = 3) -> None:
"""Prüft ALLE Tenants (außer archivierten) mit dem Core, auf dem die Admin-Plattform läuft. Ergebnis in release.compat."""
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
if not force and not admin_matches(r):
raise ReleaseError(f"Die Admin-Plattform läuft noch auf Core {contract.sha()[:10]}, das Release ist {r.sha[:10]}. "
"Erst den Admin neu bauen (FROM neues Core-Image) – sonst würde gegen den falschen Core geprüft.")
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status != "archived").order_by(Tenant.id))]
r.compat = {"checked_at": None, "core_sha": contract.sha(), "running": True, "tenants": {}, "summary": {}, "forced": force and not admin_matches(r)}
r.status = "new" if r.status in ("new", "checked") else r.status
def one(tid: str):
with session() as db:
t = db.get(Tenant, tid)
try:
fp = fingerprint(db, t)
res = compare(fp, t.render_baseline, t.current_version)
except Exception as exc: # noqa: BLE001
fp, res = {}, {"status": "error", "changed": [], "errors": [f"{type(exc).__name__}: {str(exc)[-300:]}"], "warnings": []}
return tid, fp, res
def work():
results = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
for tid, fp, res in ex.map(one, ids):
results[tid] = {**res, "fp": fp}
summary = {k: sum(1 for v in results.values() if v["status"] == k) for k in ("ok", "changed", "new", "error")}
with session() as db:
r = db.get(CoreRelease, release_id)
r.compat = {"checked_at": datetime.now(timezone.utc).isoformat(), "core_sha": contract.sha(), "running": False, "tenants": results,
"summary": summary, "forced": bool(r.compat and r.compat.get("forced"))}
if r.status == "new":
r.status = "checked"
store.audit(db, "system", "core.check", None, f"{r.sha[:10]}: {summary}")
th = threading.Thread(target=work, daemon=True)
th.start()
if wait:
th.join()
# ── Freigabe & Rollout ──────────────────────────────────────────────────────
def approve(db, release_id: int, actor: str, acknowledge: bool = False) -> CoreRelease:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
c = r.compat
if not c or c.get("running"):
raise ReleaseError("Erst die Kompatibilitätsprüfung abschließen.")
if not c.get("forced") and not contract.same_sha(c.get("core_sha", ""), r.sha):
raise ReleaseError("Die Prüfung stammt von einem anderen Core als dieses Release – bitte erneut prüfen.")
if c["summary"].get("error"):
bad = [t for t, v in c["tenants"].items() if v["status"] == "error"]
raise ReleaseError(f"Nicht freigebbar: {len(bad)} Tenant(s) mit Fehlern ({', '.join(bad)}).")
if c["summary"].get("changed") and not acknowledge:
raise ReleaseError("Bei einigen Tenants hat sich das Rendering geändert – bitte prüfen und bewusst bestätigen (acknowledge).")
r.status, r.approved_by, r.approved_at = "approved", actor, datetime.now(timezone.utc)
store.audit(db, actor, "core.approve", None, r.sha[:10])
return r
def canary_list(db) -> list[str]:
s = db.get(Setting, "canary_tenants")
return [x.strip() for x in (s.value if s and s.value else "demo").split(",") if x.strip()]
def _set_setting(db, key: str, value: str) -> None:
s = db.get(Setting, key)
if s is None:
s = Setting(key=key)
db.add(s)
s.value = value
def rollout(release_id: int, actor: str, *, dry_run: bool, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
live = not dry_run
if live and not deploy.live_enabled():
raise ReleaseError("Echte Rollouts sind gesperrt (DEPLOY_LIVE != true). Dry-Run ist möglich.")
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
allowed = {"approved", "active", "failed", "rolled_back"} | ({"new", "checked"} if dry_run else set()) # Dry-Run schon vor der Freigabe
if r.status not in allowed:
raise ReleaseError(f"Release ist im Status „{r.status}“ – erst prüfen und freigeben.")
tag, sha = r.tag, r.sha
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin]
if live: # Live: nur für Live-Schreibzugriffe freigegebene Tenants (guard.py)
ids = [t for t in ids if guard.live_allowed(t)]
if not ids:
raise ReleaseError(f"Kein Tenant für Live-Rollouts freigegeben (DEPLOY_ALLOWED_TENANTS = {guard.describe()}).")
canary = canary_list(db)
batch = uuid.uuid4().hex[:10]
if live:
r.status, r.previous_tag, r.rollout_batch = "rolling", deploy.setting(db, "core_image_tag", "latest"), batch
store.audit(db, actor, "core.rollout", None, f"{sha[:10]} → {len(ids)} Tenants")
def done(ok: bool, results: dict):
if not live:
return
with session() as db:
r = db.get(CoreRelease, release_id)
if ok:
r.status = "active"
_set_setting(db, "core_image_tag", tag)
for other in db.scalars(select(CoreRelease).where(CoreRelease.status == "active", CoreRelease.id != release_id)):
other.status = "superseded"
for tid in results: # Baselines für künftige Kompatibilitätsprüfungen
t = db.get(Tenant, tid)
c = ((r.compat or {}).get("tenants") or {}).get(tid)
if t and c and c.get("fp"):
t.render_baseline = {"version": t.current_version, "core": sha, "fp": c["fp"]}
else:
r.status = "failed"
store.audit(db, "system", "core.rollout.done", None, f"{sha[:10]} {'ok' if ok else 'FEHLER'} {results}")
dep_ids = deploy.run_sequence(ids, "dry-run" if dry_run else "deploy", actor, batch=batch, core_tag=tag, http=http, oidc_client=oidc_client,
health=health, canary=canary, wait=wait, on_done=done)
return batch, dep_ids
def rollback_release(release_id: int, actor: str, *, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
"""Alle (nicht gepinnten) Tenants auf den Tag vor diesem Rollout zurück."""
if not deploy.live_enabled():
raise ReleaseError("Echte Rollbacks sind gesperrt (DEPLOY_LIVE != true).")
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None or not r.previous_tag:
raise ReleaseError("Kein vorheriger Tag bekannt – Rollback nicht möglich.")
prev = r.previous_tag
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin and guard.live_allowed(t.id)]
batch = uuid.uuid4().hex[:10]
store.audit(db, actor, "core.rollback", None, f"{r.sha[:10]} → :{prev}")
def done(ok, results):
with session() as db:
r = db.get(CoreRelease, release_id)
if ok:
r.status = "rolled_back"
_set_setting(db, "core_image_tag", prev)
store.audit(db, "system", "core.rollback.done", None, f"{'ok' if ok else 'FEHLER'} {results}")
return batch, deploy.run_sequence(ids, "deploy", actor, batch=batch, core_tag=prev, http=http, oidc_client=oidc_client, health=health, wait=wait, on_done=done)
def tenant_rollback_tag(db, t: Tenant) -> str | None:
"""Letzter erfolgreich live deployter Tag dieses Tenants, der vom aktuellen abweicht."""
for d in db.scalars(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "deploy", Deployment.status == "success",
Deployment.simulated.is_(False)).order_by(Deployment.id.desc())):
if d.image_tag and d.image_tag != t.deployed_image:
return d.image_tag
return None