"""Inhalte eines Tenants: Blobs, Entwurf, Versionen, Diff, Bundle. Ein Tenant besteht aus `config` (Inhalt der tenant.json ohne deploy-Block) und `files` ({pfad: sha}). Bearbeitet wird immer ein Entwurf (tenant.draft); „Veröffentlichen“ schreibt daraus eine neue unveränderliche Version. """ from __future__ import annotations import difflib import hashlib import io import json import tarfile from copy import deepcopy from sqlalchemy import select from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified from app.models import AuditLog, Blob, Tenant, Version TEXT_SUFFIXES = {".txt", ".html", ".css", ".py", ".json", ".svg", ".md", ".yaml", ".yml", ".csv"} RESERVED = {"tenant.json"} # wird aus config erzeugt def is_text_path(path: str) -> bool: return any(path.lower().endswith(s) for s in TEXT_SUFFIXES) def put_blob(db: Session, data: bytes, path: str = "") -> str: sha = hashlib.sha256(data).hexdigest() if db.get(Blob, sha) is None: db.add(Blob(sha=sha, content=data, size=len(data), is_text=is_text_path(path))) db.flush() return sha def get_blob(db: Session, sha: str) -> bytes: b = db.get(Blob, sha) if b is None: raise KeyError(sha) return b.content def audit(db: Session, actor: str, action: str, tenant_id: str | None = None, detail: str = "") -> None: db.add(AuditLog(actor=actor, action=action, tenant_id=tenant_id, detail=detail)) def current_version(db: Session, t: Tenant) -> Version | None: if t.current_version is None: return None return db.scalar(select(Version).where(Version.tenant_id == t.id, Version.number == t.current_version)) def get_version(db: Session, tenant_id: str, number: int) -> Version | None: return db.scalar(select(Version).where(Version.tenant_id == tenant_id, Version.number == number)) def content(db: Session, t: Tenant, source: str = "draft") -> tuple[dict, dict]: """(config, files) für source = draft | current | v. Draft fällt auf current zurück.""" if source == "draft" and t.draft: return deepcopy(t.draft["config"]), dict(t.draft["files"]) if source.startswith("v") and source[1:].isdigit(): v = get_version(db, t.id, int(source[1:])) else: v = current_version(db, t) if v is None: return {}, {} return deepcopy(v.config), dict(v.files) def ensure_draft(db: Session, t: Tenant) -> dict: """Arbeitskopie des Entwurfs (nie das am ORM-Objekt hängende Dict mutieren).""" if t.draft: return json.loads(json.dumps(t.draft)) cfg, files = content(db, t, "current") return {"config": cfg, "files": files} def _save_draft(t: Tenant, draft: dict) -> None: t.draft = json.loads(json.dumps(draft)) flag_modified(t, "draft") def set_config(db: Session, t: Tenant, config: dict) -> None: d = ensure_draft(db, t) config = {k: v for k, v in config.items() if k != "deploy"} config["id"] = t.id d["config"] = config _save_draft(t, d) _sync_draft_state(db, t) def set_file(db: Session, t: Tenant, path: str, data: bytes) -> str: path = clean_path(path) d = ensure_draft(db, t) sha = put_blob(db, data, path) d["files"][path] = sha _save_draft(t, d) _sync_draft_state(db, t) return sha def delete_file(db: Session, t: Tenant, path: str) -> None: d = ensure_draft(db, t) d["files"].pop(path, None) _save_draft(t, d) _sync_draft_state(db, t) def clean_path(path: str) -> str: path = path.strip().lstrip("/") if not path or ".." in path.split("/") or path in RESERVED or "\\" in path: raise ValueError(f"Ungültiger Dateipfad: {path!r}") return path def _sync_draft_state(db: Session, t: Tenant) -> None: """Entwurf, der identisch zur aktuellen Version ist, wieder löschen.""" if t.draft and not diff_summary(db, t, "current", "draft")["changed"]: t.draft = None def discard_draft(db: Session, t: Tenant) -> None: t.draft = None def publish(db: Session, t: Tenant, message: str, author: str) -> Version: if not t.draft: raise ValueError("Kein Entwurf zum Veröffentlichen.") number = (t.current_version or 0) + 1 last = db.scalar(select(Version.number).where(Version.tenant_id == t.id).order_by(Version.number.desc())) number = max(number, (last or 0) + 1) v = Version(tenant_id=t.id, number=number, message=message.strip() or f"Version {number}", author=author, config=t.draft["config"], files=t.draft["files"]) db.add(v) t.current_version = number t.draft = None t.name = v.config.get("name", t.name) audit(db, author, "version.publish", t.id, f"v{number}: {v.message}") db.flush() return v def restore_as_draft(db: Session, t: Tenant, number: int) -> None: v = get_version(db, t.id, number) if v is None: raise KeyError(number) t.draft = {"config": deepcopy(v.config), "files": dict(v.files)} _sync_draft_state(db, t) # ── Diff ──────────────────────────────────────────────────────────────────── def diff_summary(db: Session, t: Tenant, a: str, b: str, with_text: bool = False) -> dict: return diff_content(db, content(db, t, a), content(db, t, b), with_text) def diff_content(db: Session, left: tuple[dict, dict], right: tuple[dict, dict], with_text: bool = False) -> dict: ca, fa = left cb, fb = right items = [] if json.dumps(ca, sort_keys=True) != json.dumps(cb, sort_keys=True): items.append(_item("tenant.json", "geändert", db, json.dumps(ca, indent=2, ensure_ascii=False, sort_keys=True), json.dumps(cb, indent=2, ensure_ascii=False, sort_keys=True), True, with_text)) for path in sorted(set(fa) | set(fb)): sa, sb = fa.get(path), fb.get(path) if sa == sb: continue kind = "neu" if sa is None else "gelöscht" if sb is None else "geändert" text = is_text_path(path) ta = get_blob(db, sa).decode("utf-8", "replace") if (sa and text and with_text) else "" tb = get_blob(db, sb).decode("utf-8", "replace") if (sb and text and with_text) else "" items.append(_item(path, kind, db, ta, tb, text, with_text)) return {"changed": bool(items), "items": items} def _item(path, kind, db, ta, tb, text, with_text): out = {"path": path, "kind": kind, "text": text} if with_text and text: lines = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "vorher", "nachher", lineterm="", n=2)) out["diff"] = lines out["added"] = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++")) out["removed"] = sum(1 for l in lines if l.startswith("-") and not l.startswith("---")) return out # ── Bundle (genau das, was ein Tenant-Container beim Start abholt) ────────── def tenant_json_bytes(config: dict) -> bytes: return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode() def materialize(db: Session, t: Tenant, source: str, target) -> dict: """Schreibt das Tenant-Verzeichnis (tenant.json + Dateien) nach `target`.""" cfg, files = content(db, t, source) write_tenant_dir(db, cfg, files, target) return cfg def write_tenant_dir(db: Session, cfg: dict, files: dict, target) -> None: target.mkdir(parents=True, exist_ok=True) (target / "tenant.json").write_bytes(tenant_json_bytes(cfg)) for path, sha in files.items(): p = target / path p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(get_blob(db, sha)) def bundle_tar(db: Session, t: Tenant, source: str = "current") -> tuple[bytes, dict]: cfg, files = content(db, t, source) manifest = {"tenant": t.id, "version": t.current_version if source != "draft" else None, "files": {"tenant.json": hashlib.sha256(tenant_json_bytes(cfg)).hexdigest(), **files}} buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: def add(name, data): ti = tarfile.TarInfo(name) ti.size = len(data) ti.mtime = 0 tar.addfile(ti, io.BytesIO(data)) add("tenant.json", tenant_json_bytes(cfg)) for path, sha in sorted(files.items()): add(path, get_blob(db, sha)) add("MANIFEST.json", json.dumps(manifest, indent=2).encode()) return buf.getvalue(), manifest