"""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))