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>
150 lines
8.1 KiB
Python
150 lines
8.1 KiB
Python
"""Datenmodell. Inhalte sind content-addressed (Blob) und versioniert (Version)."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db import Base
|
|
|
|
|
|
def now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
class Tenant(Base):
|
|
__tablename__ = "tenants"
|
|
|
|
id: Mapped[str] = mapped_column(String(64), primary_key=True) # Slug, == TENANT-Env im Container
|
|
name: Mapped[str] = mapped_column(String(200))
|
|
status: Mapped[str] = mapped_column(String(20), default="active") # active | paused | archived
|
|
source: Mapped[str] = mapped_column(String(10), default="git") # TENANT_SOURCE im Container: git | admin
|
|
subdomain: Mapped[str] = mapped_column(String(100), default="")
|
|
custom_domain: Mapped[str] = mapped_column(String(200), default="")
|
|
domain_status: Mapped[str] = mapped_column(String(20), default="none") # none | pending | verified
|
|
cpu: Mapped[float] = mapped_column(Float, default=0.5)
|
|
memory: Mapped[str] = mapped_column(String(20), default="512M")
|
|
current_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
deployed_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
deployed_image: Mapped[str] = mapped_column(String(100), default="")
|
|
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
draft: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"config": {...}, "files": {path: sha}}
|
|
preview_sample: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
oidc: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Soll-Zustand des OIDC-Clients + letzter Abgleich
|
|
core_pin: Mapped[str | None] = mapped_column(String(100), nullable=True) # fester Core-Tag (nimmt an Rollouts nicht teil)
|
|
render_baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {version, core, fp:{…}} Referenz für die Kompatibilitätsprüfung
|
|
notes: Mapped[str] = mapped_column(Text, default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
|
|
|
versions: Mapped[list["Version"]] = relationship(back_populates="tenant", order_by="Version.number.desc()")
|
|
|
|
|
|
class Blob(Base):
|
|
"""Dateiinhalt, adressiert über SHA-256. Später: große Binärdateien nach S3 auslagern."""
|
|
__tablename__ = "blobs"
|
|
|
|
sha: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
content: Mapped[bytes] = mapped_column(LargeBinary)
|
|
size: Mapped[int] = mapped_column(Integer)
|
|
is_text: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
|
|
class Version(Base):
|
|
"""Unveränderlicher Snapshot eines Tenants."""
|
|
__tablename__ = "versions"
|
|
__table_args__ = (UniqueConstraint("tenant_id", "number"),)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
|
|
number: Mapped[int] = mapped_column(Integer)
|
|
message: Mapped[str] = mapped_column(String(500), default="")
|
|
author: Mapped[str] = mapped_column(String(100), default="") # "ruben" | "agent:<run>" | "import"
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
|
config: Mapped[dict] = mapped_column(JSON) # Inhalt der tenant.json (ohne deploy-Block)
|
|
files: Mapped[dict] = mapped_column(JSON) # {pfad: sha}
|
|
|
|
tenant: Mapped[Tenant] = relationship(back_populates="versions")
|
|
|
|
|
|
class Secret(Base):
|
|
"""Verschlüsselter Secret-Wert. tenant_id NULL = geteilt für alle Tenants."""
|
|
__tablename__ = "secrets"
|
|
__table_args__ = (UniqueConstraint("tenant_id", "key"),)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
|
key: Mapped[str] = mapped_column(String(100))
|
|
value_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
source: Mapped[str] = mapped_column(String(30), default="manual") # manual | sops-import
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
|
|
|
|
|
class Setting(Base):
|
|
__tablename__ = "settings"
|
|
|
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
|
value: Mapped[str] = mapped_column(Text, default="")
|
|
|
|
|
|
class Deployment(Base):
|
|
__tablename__ = "deployments"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
batch_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
|
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
kind: Mapped[str] = mapped_column(String(20), default="deploy") # deploy | dry-run | offboard
|
|
version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
image_tag: Mapped[str] = mapped_column(String(100), default="")
|
|
status: Mapped[str] = mapped_column(String(20), default="running") # running | success | failed
|
|
simulated: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
log: Mapped[str] = mapped_column(Text, default="")
|
|
actor: Mapped[str] = mapped_column(String(100), default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
class AuditLog(Base):
|
|
__tablename__ = "audit_log"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
|
actor: Mapped[str] = mapped_column(String(100), default="")
|
|
action: Mapped[str] = mapped_column(String(60))
|
|
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
detail: Mapped[str] = mapped_column(Text, default="")
|
|
|
|
|
|
class ChatMessage(Base):
|
|
__tablename__ = "chat_messages"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
|
|
role: Mapped[str] = mapped_column(String(20)) # user | assistant
|
|
content: Mapped[str] = mapped_column(Text, default="")
|
|
steps: Mapped[list | None] = mapped_column(JSON, nullable=True) # Tool-Aufrufe (Anzeige)
|
|
proposal: Mapped[dict | None] = mapped_column(JSON, nullable=True) # vorgeschlagene Änderung
|
|
proposal_status: Mapped[str] = mapped_column(String(20), default="") # "" | open | applied | rejected
|
|
usage: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"in":…, "out":…, "backend":…, "model":…}
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
|
|
|
|
|
class CoreRelease(Base):
|
|
"""Vom Core-CI gemeldetes Release (Image-Tag). Wird geprüft, freigegeben und ausgerollt."""
|
|
__tablename__ = "core_releases"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
sha: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
tag: Mapped[str] = mapped_column(String(100))
|
|
ref: Mapped[str] = mapped_column(String(100), default="")
|
|
image: Mapped[str] = mapped_column(String(300), default="")
|
|
message: Mapped[str] = mapped_column(String(500), default="")
|
|
announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
|
# new → checked → approved → rolling → active (rejected / failed / superseded)
|
|
status: Mapped[str] = mapped_column(String(20), default="new")
|
|
compat: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {checked_at, core_sha, tenants:{id:{status,…}}, summary:{…}}
|
|
approved_by: Mapped[str] = mapped_column(String(100), default="")
|
|
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
previous_tag: Mapped[str] = mapped_column(String(100), default="") # aktiver Tag vor dem Rollout (für Rollback)
|
|
rollout_batch: Mapped[str] = mapped_column(String(40), default="")
|