Skip to content

Tenant-Spec, Reconcile & Smoke-Test — Konzept

Single Source of Truth für „was ein vollständiger Prilog-Tenant ist" + automatische Drift-Korrektur + End-to-End-Verifikation. Schließt die Lücke, die uns bei der Tenant-in-a-Box-Migration den Matrix-Connector verloren hat.

Das Problem

Was ein vollständiger Tenant ist, war nie zentral definiert — die Information lag verteilt in:

  • prilog-backend-api/src/services/shared-hosting.service.ts (Provisioning-Templates)
  • prilog-agent/src/provision/runner.ts (Provision-Steps)
  • Manuellen Schritten („auf Kunden-Server connector via SSH+tar ausrollen")
  • Memory-Files / Konzept-Dokumenten

Bei der Tenant-in-a-Box-Migration (2026-05-01) wurde der Matrix-Connector vergessen. Niemand merkte es bis ein User Flurfunk auf leander testen wollte (2026-05-10) und nichts passierte.

Das ist Provisioning-Drift: Code-Drift (alte Provisioning-Pfade vs. neue), Manual-Drift (manuelle SSH-Schritte fehlen) und Runtime-Drift (Container läuft, aber Modul ist verschwunden).

Lösung in drei Schichten

┌────────────────────────┐  Single Source of Truth
│   Tenant-Box-Spec      │  YAML in /opt/prilog/specs/tenant-box-v<n>.yaml
│   (deklarativ)         │  versioniert, in DB synchronisiert
└────────────┬───────────┘

       ┌─────┴──────┐
       ▼            ▼
┌──────────────┐ ┌──────────────────┐
│ Provisioning │ │ Reconcile-Cron   │  Drift-Detection + Auto-Fix
│ (neue Boxen) │ │ (taeglich)       │  loescht keine Daten, fixt fehlende Komponenten
└──────────────┘ └──────────────────┘
       │              │
       └──────┬───────┘

    ┌─────────────────────┐
    │   Smoke-Test        │  End-to-End-Verifikation
    │   (post-provision   │  Synapse-Login + Test-Audio + Transcript
    │    + monatlich)     │  Fehlschlag → Alert + Status=degraded
    └─────────────────────┘

Drei Schichten, jede schützt vor einem anderen Drift-Typ:

SchichtSchützt vorWann ausgeführt
SpecCode-Drift (Provisioning-Templates verteilt)Compile-Zeit / Spec-Apply
ReconcileManual-Drift (vergessene Migrationen, manuelle Hacks)Täglich + on demand
Smoke-TestRuntime-Drift (Container läuft, Modul tot)Post-Provision + monatlich

Datenmodell

Vier neue Prisma-Modelle:

TenantBoxSpec — versionierte deklarative Spezifikation

prisma
model TenantBoxSpec {
  id          String   @id @default(cuid()) @db.VarChar(50)
  /// Schema-Version, monoton steigend (1, 2, 3, ...). Pro Version ein Eintrag.
  version     Int      @unique
  /// Vollstaendige Spec als JSON (parsed YAML). Single Source of Truth.
  spec        Json
  /// Hash des Spec-Inhalts für Drift-Detection (SHA-256 hex).
  contentHash String   @map("content_hash") @db.VarChar(64)
  /// Wann die Spec aktiviert wurde
  activatedAt DateTime @default(now()) @map("activated_at")
  activatedBy String?  @map("activated_by") @db.VarChar(255)
  /// Beschreibung — was hat sich gegenueber der vorigen Version geaendert
  notes       String?  @db.Text

  manifests   TenantBoxManifest[]
  driftReports TenantDriftReport[]

  @@map("tenant_box_specs")
}

TenantBoxManifest — was tatsächlich auf dem Tenant liegt

Pro Tenant ein Eintrag, regelmäßig durch Reconcile aktualisiert.

prisma
model TenantBoxManifest {
  id           String   @id @default(cuid()) @db.VarChar(50)
  tenantId     String   @unique @map("tenant_id") @db.VarChar(64)
  /// Welche Spec-Version wurde zuletzt erfolgreich angewendet
  appliedSpecVersion Int @map("applied_spec_version")
  /// Hash der angewendeten Spec — zum Drift-Detection-Vergleich
  appliedSpecHash    String @map("applied_spec_hash") @db.VarChar(64)
  /// IST-Zustand der Komponenten (Snapshot, JSON-Array)
  components   Json
  /// Letzter erfolgreicher Reconcile
  lastReconciledAt DateTime? @map("last_reconciled_at")
  /// Letzter Smoke-Test
  lastSmokeAt      DateTime? @map("last_smoke_at")
  lastSmokeStatus  String?   @map("last_smoke_status") @db.VarChar(20) // 'ok' | 'degraded' | 'failed'
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @updatedAt @map("updated_at")

  spec         TenantBoxSpec @relation(fields: [appliedSpecVersion], references: [version])
  tenant       Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@map("tenant_box_manifests")
}

TenantDriftReport — Abweichungen zwischen Spec und Wirklichkeit

prisma
model TenantDriftReport {
  id          String   @id @default(cuid()) @db.VarChar(50)
  tenantId    String   @map("tenant_id") @db.VarChar(64)
  specVersion Int      @map("spec_version")
  /// 'missing_component' | 'wrong_version' | 'extra_component' | 'config_diff'
  driftType   String   @map("drift_type") @db.VarChar(50)
  /// Komponenten-Name aus der Spec
  component   String   @db.VarChar(100)
  /// Erwarteter Zustand (aus Spec)
  expected    Json?
  /// Tatsaechlicher Zustand (vom Tenant)
  actual      Json?
  /// Severity: 'critical' (Smoke wuerde failen) | 'warning' (cosmetic)
  severity    String   @default("warning") @db.VarChar(20)
  /// Wurde Drift automatisch gefixt? Wann?
  fixedAt     DateTime? @map("fixed_at")
  fixedBy     String?   @map("fixed_by") @db.VarChar(255)
  fixError    String?   @map("fix_error") @db.Text
  detectedAt  DateTime @default(now()) @map("detected_at")

  tenant      Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  spec        TenantBoxSpec @relation(fields: [specVersion], references: [version])

  @@index([tenantId, detectedAt])
  @@index([detectedAt, fixedAt])
  @@map("tenant_drift_reports")
}

TenantSmokeTestRun — End-to-End-Verifikation

prisma
model TenantSmokeTestRun {
  id          String   @id @default(cuid()) @db.VarChar(50)
  tenantId    String   @map("tenant_id") @db.VarChar(64)
  /// 'post_provision' | 'monthly_routine' | 'manual'
  trigger     String   @db.VarChar(40)
  /// 'ok' | 'degraded' | 'failed'
  status      String   @db.VarChar(20)
  /// JSON: pro Probe (synapse_login, test_audio, ...) result + duration
  probes      Json
  /// Gesamtdauer
  durationMs  Int      @map("duration_ms")
  /// Erste Fehlermeldung (fuer Alert-Mail)
  firstError  String?  @map("first_error") @db.Text
  startedAt   DateTime @default(now()) @map("started_at")
  finishedAt  DateTime? @map("finished_at")

  tenant      Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@index([tenantId, startedAt])
  @@index([status, startedAt])
  @@map("tenant_smoke_test_runs")
}

Zusätzlich 3 neue Felder auf bestehender Tenant-Tabelle:

prisma
model Tenant {
  ...
  /// Aggregat-Status fuer den Tenant — gesetzt vom letzten Smoke-Test.
  /// 'ok' | 'degraded' (eine Drift, aber nutzbar) | 'broken' (Smoke-Test rot) | 'pending' (nie getestet)
  boxStatus       String  @default("pending") @map("box_status") @db.VarChar(20)
  boxStatusReason String? @map("box_status_reason") @db.Text
  boxStatusAt     DateTime? @map("box_status_at")

  manifest        TenantBoxManifest?
  driftReports    TenantDriftReport[]
  smokeTestRuns   TenantSmokeTestRun[]
}

Spec-Format (YAML)

Pfad: /opt/prilog/specs/tenant-box-v<n>.yaml. Wird per CLI in die tenant_box_specs-Tabelle geladen.

yaml
schema_version: 3
description: |
  Drittes Tenant-Box-Schema — fügt prilog-matrix-connector als Pflicht-
  Komponente hinzu. Vorher (v2) lebte er ausserhalb der Box auf
  /opt/prilog/connectors/, was bei Tenant-in-a-Box-Migration verloren ging.

# Storage-Layout — wo die Tenant-Daten liegen
storage:
  base_path: /srv/tenants/{slug}
  required_dirs:
    - postgres
    - minio
    - synapse/media_store
    - connectors/prilog-matrix-connector

# Container-Komponenten (docker-compose-Stack)
components:
  - name: postgres
    image: postgres:16-alpine
    container_name: pg-{slug}
    pinned: true                         # bei Update keine automatische Image-Bump
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      type: pg_isready
      interval: 10s
      retries: 5
    resources:
      shared_buffers: 32MB
      max_connections: 50

  - name: minio
    image: minio/minio:RELEASE.2024-12-13T22-19-12Z
    container_name: minio-{slug}
    pinned: true
    volumes:
      - ./minio:/data
    healthcheck:
      type: http
      url: http://127.0.0.1:9000/minio/health/live

  - name: synapse
    image: matrixdotorg/synapse:v1.124.0
    container_name: synapse-{slug}
    pinned: false                        # bei Sicherheits-Update Auto-Bump erlaubt
    volumes:
      - ./homeserver.yaml:/data/homeserver.yaml:ro
      - ./signing.key:/data/signing.key:ro
      - ./log.config:/data/log.config:ro
      - ./synapse/media_store:/data/media_store
      - ./connectors/prilog-matrix-connector:/opt/prilog/connectors/prilog-matrix-connector:ro
    homeserver_yaml_modules:             # ← Pflicht-Eintraege im modules:-Block
      - module: prilog_matrix_connector.PolicyClient
        config_template: matrix-connector-config.yaml.j2
    healthcheck:
      type: http
      url: http://127.0.0.1:8008/_matrix/client/versions
    depends_on:
      - postgres
      - minio

  - name: matrix-connector
    type: synapse_module                 # eingebunden im synapse-Container
    artifact:
      source: prilog-artifacts/matrix-connector/prilog-matrix-connector-latest.tar.gz
      version_field: prilog-matrix-connector/VERSION
    extract_to: ./connectors/prilog-matrix-connector
    config:
      backend_url: ${BACKEND_URL}
      shared_secret_env: MATRIX_CONNECTOR_SECRET
      whisper_enabled: true              # Flurfunk-Hook aktivieren
      transcribe_endpoint: /api/matrix-connector/transcribe-voice

# Post-Provision-Schritte (laufen nach jedem Spec-Apply)
post_provision:
  - smoke_test: synapse_health
  - smoke_test: synapse_admin_login
  - smoke_test: send_test_audio_and_expect_transcript

# Pflicht-Komponenten — Reconcile darf hier keine Drift dulden
required_components:
  - postgres
  - minio
  - synapse
  - matrix-connector

Reconcile-Service

Aufgabe

Pro Tenant: vergleicht IST gegen die in TenantBoxManifest.appliedSpecVersion gepinnte Spec. Bei Drift: erzeugt TenantDriftReport-Eintrag UND fixt automatisch wenn möglich.

Operationen pro Drift-Typ

Drift-TypAuto-Fix möglich?Operation
missing_component✅ jaKomponente nachinstallieren (Tarball ziehen, Volume-Mount in compose, restart)
wrong_version (Connector-Code-Hash)✅ jaReinstall aus Artifact + Synapse-Restart
wrong_version (Container-Image)⚠️ nur bei pinned: falseImage-Bump + Container-Restart
extra_component❌ neinNur loggen, manueller Eingriff (könnte User-Daten enthalten)
config_diff⚠️ nur bei expliziter auto_fix: true Markierunghomeserver.yaml-Patch + Restart

Connector-Code-Hash-Erkennung (seit 2026-05-10)

Damit Reconcile auch Inhalt-Drift eines Synapse-Moduls erkennt, nicht nur Existenz:

  1. Inspector liest den sha256 der ausgelieferten module.py (siehe inspector.ts, Feld connector_module_sha256).
  2. connector-artifact.ts extrahiert den Soll-Hash aus dem Tarball unter /var/www/prilog-artifacts/matrix-connector/prilog-matrix-connector-latest.tar.gz. Eigener kleiner USTAR-Parser, kein Tar-Library-Dependency. Ergebnis ist 60 s gecached.
  3. detectDrift ist pure (keine I/O); der Caller reicht den Soll-Hash via DriftDetectorContext.expectedConnectorModuleSha256. Bei Mismatch → wrong_version-Finding gegen Komponente matrix-connector.
  4. Reconcile-Pipeline reagiert mit derselben installMatrixConnector-Operation wie bei missing_component (atomic rename, alter Stand wird ersetzt) plus erzwungenem Synapse-Restart — letzteres ist nötig, weil der Container den Connector-Code beim Start in den Speicher liest und ein bloßes Datei-Replace nichts bewirkt.

Hintergrund: Bis 2026-05-10 lieferten alle drei Tenant-Boxen einen alten Connector-Stand vom 21.3. aus. Importierbar war er, aktuell nicht — der Audio-Hook fehlte, Flurfunk-Aufnahmen liefen ins Client-Timeout. Der bisherige Drift-Detector prüfte nur Existenz des Verzeichnisses. Der Hash-Vergleich schließt diese Lücke.

Konsequenz für den Workflow: Connector-Repo-Änderung → python3 scripts/build_artifact.py → Artifact in prilog-artifacts/matrix-connector/ ablegen → next Reconcile-Cron (03:30 UTC) rollt aus. Manuell sofort: reconcile-tenant.ts --all.

Idempotenz + Safety

  • Locked Reconcile: Pro Tenant nur ein Reconcile-Run gleichzeitig (Advisory-Lock auf tenantId).
  • Pre-Snapshot: Vor jedem Fix wird ein Tarball-Snapshot von /srv/tenants/<slug>/ erzeugt (rotated, 7 Tage). Bei Failure: Auto-Rollback per tar xf.
  • Dry-Run-Mode: --dry-run zeigt was getan würde, ohne zu tun.
  • Severity-Gating: Nur severity=critical wird auto-gefixt. warning nur logged.

CLI

bash
# Eine Spec laden + aktivieren
npx tsx prisma/load-tenant-box-spec.ts /opt/prilog/specs/tenant-box-v3.yaml

# Manueller Reconcile pro Tenant
npx tsx scripts/reconcile-tenant.ts --tenant=leander
npx tsx scripts/reconcile-tenant.ts --tenant=leander --dry-run

# Alle Tenants
npx tsx scripts/reconcile-tenant.ts --all

# Status-Übersicht
npx tsx scripts/reconcile-tenant.ts --status

Smoke-Test-Service

Probes

Eine Probe = ein Boolean-Ergebnis + Latenz + Fehlertext. Eine Smoke-Test-Suite ist eine Liste von Probes.

ts
interface SmokeProbe {
  name: string;
  required: boolean;          // failed required → status='failed'
  fn: (ctx: SmokeContext) => Promise<ProbeResult>;
}

interface ProbeResult {
  ok: boolean;
  durationMs: number;
  message?: string;
  details?: Record<string, unknown>;
}

Standard-Probes für Tenant-Box-v3

ProbeWas wird getestet
synapse_healthGET <synapse>/_matrix/client/versions → 200
synapse_admin_loginAdmin-User-Login mit gespeichertem Token
postgres_reachableSELECT 1 über Tenant-DB
minio_reachablemc ls auf Tenant-Bucket
connector_module_loadedSynapse-Logs grep auf prilog_matrix_connector loaded (last 1h)
send_test_audio_and_expect_transcriptEnd-to-End: Test-User schickt 5s-Audio in Test-Raum, erwartet Transcript-Reply binnen 60s

Auswertung

  • Alle Probes ok → status='ok'
  • Ein required-Probe fehlgeschlagen → status='failed'
  • Optionaler Probe fehlgeschlagen → status='degraded'

Tenant.boxStatus wird nach jedem Smoke-Test entsprechend gesetzt.

CLI

bash
npx tsx scripts/smoke-test-tenant.ts --tenant=leander
npx tsx scripts/smoke-test-tenant.ts --all

Cron-Integration

Zwei neue Cron-Jobs in core/cron/jobs.ts:

ts
{
  key: 'tenant-reconcile-daily',
  schedule: '30 3 * * *',      // 03:30 UTC, nach Backup-Cron
  scheduleLabel: 'Täglich um 03:30 UTC',
  handler: () => runReconcileForAllTenants(),
}

{
  key: 'tenant-smoke-monthly',
  schedule: '0 4 1 * *',        // 1. des Monats, 04:00 UTC
  scheduleLabel: 'Monatlich (1. des Monats, 04:00)',
  handler: () => runSmokeForAllTenants(),
}

Plus: nach jedem Tenant-Provisioning automatisch ein Smoke-Test-Run.


Migration für Bestandstenants

Stand 2026-05-10: 4 aktive Tenants — leander, weser, demo, demo3.

Plan

  1. Spec v3 in DB ladentenant_box_specs(version=3, ...).
  2. Pro Bestandstenant ein Reconcile-Run mit --migrate-to-spec=3:
    • Erkennt: matrix-connector fehlt → Drift-Report → Auto-Fix
    • Tarball nach /srv/tenants/<slug>/connectors/
    • homeserver.yaml um modules:-Block erweitern (mit Backup)
    • docker-compose.yml um Volume-Mount erweitern (mit Backup)
    • Synapse-Container restart
  3. Smoke-Test nach jedem Reconcile — Tenant darf nur als „ok" markiert werden, wenn End-to-End-Test grün ist.
  4. Reihenfolge: leander → demo → demo3 → weser. (leander zuerst weil aktiv getestet wird; weser zuletzt weil größter Bestand und somit höchstes Risiko.)
  5. Rollback-Pfad: Pre-Reconcile-Tarball-Snapshot von /srv/tenants/<slug>/ für 7 Tage aufbewahrt.

Bestandstenant-spezifische Eigenheiten

TenantBesonderheitRisiko
leanderAktiv genutzt von Lee + Andreas, kleines VolumenNiedrig
weserGrößter Tenant, viele UserMittel — Reconcile außerhalb Schul-Stoßzeit
demoReine Demo, Daten egalNiedrig — Test-Sandbox
demo3Brand-neuer Test-TenantNiedrig

Phasenplan

#PhaseAufwandAbhängigkeitLiefert
1DB-Migration für 4 neue Tabellen + 3 Tenant-Felder~1hSchema bereit
2Spec-Loader + initiale tenant-box-v3.yaml~2h1DB enthält Spec v3
3Reconcile-Service (Kern) + CLI~6h1, 2npx tsx scripts/reconcile-tenant.ts --tenant=…
4Smoke-Test-Framework + 6 Standard-Probes~4h1npx tsx scripts/smoke-test-tenant.ts …
5Cron-Integration (2 Jobs)~30min3, 4tägliche Auto-Reconcile
6Bestandstenants-Migration (leander → demo → demo3 → weser)~2h Live-Run3, 4alle 4 Tenants auf Spec v3
7Provisioning-Code an Spec koppeln~3h2, 3neue Tenants kommen spec-konform
8Phasen-Backlog in leander/Prilog-Space importieren~15min— (Import-API steht)Lee sieht Aufgaben mobil

Gesamt: ~18h fokussierte Arbeit. Realistisch eine Nacht-Session + Vormittag.


Garantien nach Abschluss

  • Jeder neue Tenant wird aus der Spec provisioniert — keine Code-Drift mehr möglich.
  • Jeder bestehende Tenant wird täglich gegen die Spec geprüft — Manual-Drift und vergessene Migrationen werden binnen 24h erkannt + auto-gefixt.
  • Jeder Tenant hat einen monatlichen End-to-End-Smoke-Test — Runtime-Drift wird sichtbar.
  • Bei Drift-Auto-Fix: Pre-Snapshot + Rollback schützen vor Folgeschäden.
  • Operator (Lee) kriegt Mail-Alert bei Smoke-Failure oder unfixable Drift.

Bewusst weggelassen (für später)

  • Spec-Versionierung-Migrationen: bei v3 → v4 muss noch entschieden werden ob Auto-Migrate für alle Tenants oder gestaged. Aktuell: ein Spec, alle drauf.
  • Multi-Tier-Specs: Free vs. Pro vs. Enterprise könnten je eigene Specs haben. Heute eine Spec für alle.
  • GUI im Admin-Portal: Status der Tenants, Drift-Reports einsehbar. Aktuell nur CLI + DB.
  • Rolling-Updates: Spec-Version-Bump triggert nicht automatisch Rollout an alle Tenants. Reconcile fixt nur Komponenten gegen die aktuell gepinnte Spec der Tenant — Bump muss manuell durch Spec-Apply pro Tenant erfolgen.

Diese Punkte landen im Backlog (Aufgaben in leander/Prilog-Space) und können später ergänzt werden.