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>
This commit is contained in:
178
vendor/platform/core/bundle.py
vendored
Normal file
178
vendor/platform/core/bundle.py
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Tenant-Bundle-Loader: holt die Tenant-Daten zur Laufzeit von der Admin-Plattform (TENANT_SOURCE=admin).
|
||||
|
||||
git (Standard, wie bisher) Tenant-Ordner aus dem Image: tenants/<id>/
|
||||
admin Bundle von {ADMIN_URL}/api/tenants/<id>/bundle (Bearer ADMIN_BUNDLE_TOKEN), geprüft und lokal gecacht
|
||||
|
||||
Ausfallsicherheit bei admin: Admin nicht erreichbar → letzter gecachter Stand (/data/bundle) → eingebackener Tenant
|
||||
(nur solange BUNDLE_FALLBACK_BAKED nicht „false“ ist) → sonst Fehler beim Start. Die Admin-Plattform ist damit nur
|
||||
Kontrollebene, nicht Teil des Laufzeitpfads.
|
||||
|
||||
Sicherheit: Hashes aller Dateien werden gegen MANIFEST.json geprüft, nur normale Dateien, keine Pfad-Tricks, Größenlimits.
|
||||
Format siehe docs/CONTRACT.md. Nur Standardbibliothek – wird beim Import von core.config genutzt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
SUPPORTED_BUNDLE_FORMAT = 1
|
||||
MAX_FILES = 200
|
||||
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
||||
KEEP_VERSIONS = 2
|
||||
|
||||
|
||||
class BundleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _truthy(v: str | None, default: bool) -> bool:
|
||||
return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _http_fetch(url: str, token: str, etag: str | None, timeout: float = 20.0):
|
||||
"""→ (status, headers, body). 304 bei unverändertem ETag. Wirft OSError bei Netzwerkfehlern."""
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", **({"If-None-Match": etag} if etag else {})})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, dict(r.headers), r.read(MAX_TOTAL_BYTES + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, dict(exc.headers or {}), b""
|
||||
|
||||
|
||||
def parse_bundle(data: bytes, tenant_id: str) -> tuple[dict, dict[str, bytes]]:
|
||||
"""Prüft ein Bundle und gibt (manifest, {pfad: inhalt}) zurück. Wirft BundleError bei jeder Auffälligkeit."""
|
||||
if len(data) > MAX_TOTAL_BYTES:
|
||||
raise BundleError("Bundle zu groß")
|
||||
files: dict[str, bytes] = {}
|
||||
total = 0
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
||||
members = tar.getmembers()
|
||||
if len(members) > MAX_FILES:
|
||||
raise BundleError(f"Bundle enthält mehr als {MAX_FILES} Dateien")
|
||||
for m in members:
|
||||
if not m.isreg():
|
||||
raise BundleError(f"Nicht erlaubter Eintrag im Bundle: {m.name!r}")
|
||||
p = PurePosixPath(m.name)
|
||||
if p.is_absolute() or ".." in p.parts or not p.parts or "\\" in m.name:
|
||||
raise BundleError(f"Unzulässiger Pfad im Bundle: {m.name!r}")
|
||||
total += m.size
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise BundleError("Bundle entpackt zu groß")
|
||||
files[str(p)] = tar.extractfile(m).read()
|
||||
except (tarfile.TarError, EOFError, OSError) as exc:
|
||||
raise BundleError(f"Bundle nicht lesbar: {exc}") from exc
|
||||
if "MANIFEST.json" not in files or "tenant.json" not in files:
|
||||
raise BundleError("MANIFEST.json oder tenant.json fehlt im Bundle")
|
||||
try:
|
||||
manifest = json.loads(files.pop("MANIFEST.json"))
|
||||
cfg = json.loads(files["tenant.json"])
|
||||
except ValueError as exc:
|
||||
raise BundleError(f"MANIFEST.json/tenant.json ungültig: {exc}") from exc
|
||||
if int(manifest.get("bundle_format", 1)) > SUPPORTED_BUNDLE_FORMAT:
|
||||
raise BundleError(f"Bundle-Format {manifest.get('bundle_format')} wird von diesem Core nicht unterstützt")
|
||||
if manifest.get("tenant") != tenant_id or cfg.get("id") != tenant_id:
|
||||
raise BundleError(f"Bundle gehört nicht zu Tenant {tenant_id!r}")
|
||||
expected = manifest.get("files") or {}
|
||||
if set(expected) != set(files):
|
||||
raise BundleError("Dateiliste weicht vom MANIFEST ab")
|
||||
for path, sha in expected.items():
|
||||
if hashlib.sha256(files[path]).hexdigest() != sha:
|
||||
raise BundleError(f"Hash-Abweichung: {path}")
|
||||
return manifest, files
|
||||
|
||||
|
||||
def _install(cache: Path, version: int, manifest: dict, files: dict[str, bytes], etag: str | None) -> Path:
|
||||
"""Schreibt das Bundle in ein neues Versionsverzeichnis und schaltet `current` atomar um."""
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
target = cache / f"v{version}-{hashlib.sha256(json.dumps(manifest.get('files', {}), sort_keys=True).encode()).hexdigest()[:8]}"
|
||||
if not target.exists():
|
||||
tmp = cache / f".tmp-{os.getpid()}-{int(time.time() * 1000)}"
|
||||
for path, content in files.items():
|
||||
dest = tmp / path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(content)
|
||||
(tmp / ".bundle.json").write_text(json.dumps({"version": version, "etag": etag, "fetched_at": time.time()}))
|
||||
os.replace(tmp, target)
|
||||
link = cache / "current"
|
||||
new_link = cache / f".current-{os.getpid()}"
|
||||
if new_link.is_symlink() or new_link.exists():
|
||||
new_link.unlink()
|
||||
os.symlink(target.name, new_link)
|
||||
os.replace(new_link, link)
|
||||
keep = {target.name}
|
||||
for d in sorted((p for p in cache.glob("v*") if p.is_dir()), key=lambda p: p.stat().st_mtime, reverse=True)[:KEEP_VERSIONS]:
|
||||
keep.add(d.name)
|
||||
for d in cache.glob("v*"):
|
||||
if d.is_dir() and d.name not in keep:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
return target
|
||||
|
||||
|
||||
def _cached(cache: Path) -> tuple[Path, dict] | None:
|
||||
link = cache / "current"
|
||||
if link.is_symlink() and (link / "tenant.json").exists():
|
||||
try:
|
||||
meta = json.loads((link / ".bundle.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
return link, meta
|
||||
return None
|
||||
|
||||
|
||||
def resolve(tenant_id: str, tenants_dir: Path, env: dict | None = None, fetch=_http_fetch) -> tuple[Path, dict]:
|
||||
"""Liefert (Tenant-Verzeichnis, Info). Info: {source: git|admin|cache|baked, version, stale, error, fetched_at}."""
|
||||
env = os.environ if env is None else env
|
||||
source = (env.get("TENANT_SOURCE") or "git").strip().lower()
|
||||
baked = tenants_dir / tenant_id
|
||||
if source == "git":
|
||||
return baked, {"source": "git", "version": None, "stale": False, "error": None, "fetched_at": None}
|
||||
if source != "admin":
|
||||
raise BundleError(f"Unbekannte TENANT_SOURCE {source!r} (erlaubt: git, admin)")
|
||||
|
||||
base = (env.get("ADMIN_URL") or "").rstrip("/")
|
||||
token = env.get("ADMIN_BUNDLE_TOKEN") or ""
|
||||
cache = Path(env.get("BUNDLE_CACHE_DIR") or Path(env.get("DATA_DIR") or "/data") / "bundle") / tenant_id
|
||||
have = _cached(cache)
|
||||
error: str | None = None
|
||||
if not base or not token:
|
||||
error = "ADMIN_URL oder ADMIN_BUNDLE_TOKEN nicht gesetzt"
|
||||
else:
|
||||
for attempt in range(3): # kurze Wiederholung: Container und Admin starten oft gleichzeitig
|
||||
try:
|
||||
status, headers, body = fetch(f"{base}/api/tenants/{tenant_id}/bundle", token, (have[1].get("etag") if have else None))
|
||||
except OSError as exc:
|
||||
error, status = f"Admin nicht erreichbar: {exc}", None
|
||||
else:
|
||||
if status == 200:
|
||||
try:
|
||||
manifest, files = parse_bundle(body, tenant_id)
|
||||
version = int(manifest.get("version") or headers.get("X-Bundle-Version") or 0)
|
||||
etag = {k.lower(): v for k, v in headers.items()}.get("etag")
|
||||
d = _install(cache, version, manifest, files, etag)
|
||||
return d, {"source": "admin", "version": version, "stale": False, "error": None, "fetched_at": time.time()}
|
||||
except BundleError as exc:
|
||||
error = f"Ungültiges Bundle: {exc}"
|
||||
break # kein Retry: Inhalt ist kaputt, nicht das Netz
|
||||
elif status == 304 and have:
|
||||
return have[0], {"source": "admin", "version": have[1].get("version"), "stale": False, "error": None, "fetched_at": have[1].get("fetched_at")}
|
||||
elif status in (401, 403, 404):
|
||||
error = f"Admin verweigert das Bundle (HTTP {status})"
|
||||
break
|
||||
else:
|
||||
error = f"Admin-Antwort HTTP {status}"
|
||||
if attempt < 2:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
if have:
|
||||
return have[0], {"source": "cache", "version": have[1].get("version"), "stale": True, "error": error, "fetched_at": have[1].get("fetched_at")}
|
||||
if _truthy(env.get("BUNDLE_FALLBACK_BAKED"), True) and (baked / "tenant.json").exists():
|
||||
return baked, {"source": "baked", "version": None, "stale": True, "error": error, "fetched_at": None}
|
||||
raise BundleError(f"Tenant-Bundle für {tenant_id!r} nicht verfügbar und kein Cache/Rückfall vorhanden: {error}")
|
||||
Reference in New Issue
Block a user