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>
112 lines
4.6 KiB
Python
112 lines
4.6 KiB
Python
"""Vertrag zwischen Core und Admin-Plattform (siehe docs/CONTRACT.md).
|
||
|
||
Der Core veröffentlicht bei jedem Release: Image, Manifest, Schema (`core/schema/tenant_schema.json`), Bundle-Format und
|
||
`/health`. Die Admin-Plattform liest das – sie hält keine eigene Kopie der Tenant-Optionen.
|
||
|
||
python -m core.contract # Manifest als JSON auf stdout
|
||
python -m core.contract --check # Schema gegen den Code prüfen (Exit-Code ≠ 0 bei Abweichung)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
CONTRACT_VERSION = 1 # Version dieses Vertrags (Schema-Format, Bundle-Format, Health-Felder)
|
||
HOOK_API = 1 # Version der Hook-Schnittstelle (core/hooks.py); overrides.py kann `HOOK_API = 1` deklarieren
|
||
BUNDLE_FORMAT = 1 # Format des Tenant-Bundles (tar.gz mit tenant.json + Dateien + MANIFEST.json)
|
||
|
||
CORE_DIR = Path(__file__).resolve().parent
|
||
ROOT = CORE_DIR.parent
|
||
SCHEMA_PATH = CORE_DIR / "schema" / "tenant_schema.json"
|
||
BUILD_INFO_PATH = CORE_DIR / "BUILD_INFO.json" # schreibt das Dockerfile; lokal fehlt die Datei
|
||
|
||
# Env-Variablen, die der Code liest, die aber bewusst nicht im Schema stehen (interne/Legacy/Plattformvariablen)
|
||
INTERNAL_ENV = {"TENANTS_DIR", "TEMP_DIR", "LOCALAPPDATA", "APP_SESSION_SECRET", "CORE_SHA", "CORE_BUILT_AT", "PYTHONPATH", "PATH", "HOME"}
|
||
|
||
|
||
def build_info() -> dict:
|
||
"""{sha, built_at, source}: BUILD_INFO.json (Image) > Env CORE_SHA > git > 'dev'."""
|
||
try:
|
||
data = json.loads(BUILD_INFO_PATH.read_text(encoding="utf-8"))
|
||
if data.get("sha"):
|
||
return {"sha": data["sha"], "built_at": data.get("built_at"), "source": "image"}
|
||
except (OSError, ValueError):
|
||
pass
|
||
if os.getenv("CORE_SHA"):
|
||
return {"sha": os.environ["CORE_SHA"], "built_at": os.getenv("CORE_BUILT_AT"), "source": "env"}
|
||
try:
|
||
sha = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
|
||
if sha:
|
||
return {"sha": sha, "built_at": None, "source": "git"}
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return {"sha": "dev", "built_at": None, "source": "dev"}
|
||
|
||
|
||
def load_schema() -> dict:
|
||
return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _sha256(path: Path) -> str | None:
|
||
try:
|
||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def build_manifest() -> dict:
|
||
schema = load_schema()
|
||
return {
|
||
"contract": CONTRACT_VERSION, "hook_api": HOOK_API, "bundle_format": BUNDLE_FORMAT,
|
||
"core": build_info(),
|
||
"requirements_sha256": _sha256(ROOT / "requirements.txt"),
|
||
"schema_sha256": _sha256(SCHEMA_PATH),
|
||
"fields": len(schema["fields"]), "env": len(schema["env"]),
|
||
}
|
||
|
||
|
||
def schema_paths(schema: dict | None = None) -> set[str]:
|
||
return {f["path"] for f in (schema or load_schema())["fields"]}
|
||
|
||
|
||
def check_tenant_json(raw: dict, schema: dict | None = None) -> list[str]:
|
||
"""Meldet unbekannte Schlüssel und falsche Typen in einer tenant.json (Tippfehler fallen sonst still durch)."""
|
||
schema = schema or load_schema()
|
||
fields = {f["path"]: f for f in schema["fields"]}
|
||
ignored = set(schema.get("ignored_keys", []))
|
||
problems: list[str] = []
|
||
|
||
def walk(prefix: str, obj: dict) -> None:
|
||
for k, v in obj.items():
|
||
path = f"{prefix}{k}"
|
||
if not prefix and k in ignored or (not prefix and k == "id"):
|
||
continue
|
||
if path in fields:
|
||
t = fields[path]["type"]
|
||
ok = {"bool": isinstance(v, bool), "number": isinstance(v, (int, float)) and not isinstance(v, bool),
|
||
"enum": v in (fields[path].get("options") or []) or v in (None, ""),
|
||
"color": isinstance(v, str) and len(v) in (4, 7) and v.startswith("#")}.get(t, isinstance(v, str) or v is None)
|
||
if not ok:
|
||
problems.append(f"{path}: ungültiger Wert {v!r} (erwartet {t})")
|
||
elif isinstance(v, dict) and any(p.startswith(path + ".") for p in fields):
|
||
walk(path + ".", v)
|
||
else:
|
||
problems.append(f"{path}: unbekannte Option (nicht im Schema)")
|
||
|
||
walk("", raw)
|
||
return problems
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if "--check" in sys.argv:
|
||
sys.path.insert(0, str(ROOT))
|
||
from tests.test_contract import drift_report # noqa: E402
|
||
rep = drift_report()
|
||
print("\n".join(rep) or "Schema und Code stimmen überein.")
|
||
sys.exit(1 if rep else 0)
|
||
print(json.dumps(build_manifest(), indent=2, ensure_ascii=False))
|