"""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("", "\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