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:
| Schicht | Schützt vor | Wann ausgeführt |
|---|---|---|
| Spec | Code-Drift (Provisioning-Templates verteilt) | Compile-Zeit / Spec-Apply |
| Reconcile | Manual-Drift (vergessene Migrationen, manuelle Hacks) | Täglich + on demand |
| Smoke-Test | Runtime-Drift (Container läuft, Modul tot) | Post-Provision + monatlich |
Datenmodell
Vier neue Prisma-Modelle:
TenantBoxSpec — versionierte deklarative Spezifikation
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.
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
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
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:
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.
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-connectorReconcile-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-Typ | Auto-Fix möglich? | Operation |
|---|---|---|
missing_component | ✅ ja | Komponente nachinstallieren (Tarball ziehen, Volume-Mount in compose, restart) |
wrong_version (Connector-Code-Hash) | ✅ ja | Reinstall aus Artifact + Synapse-Restart |
wrong_version (Container-Image) | ⚠️ nur bei pinned: false | Image-Bump + Container-Restart |
extra_component | ❌ nein | Nur loggen, manueller Eingriff (könnte User-Daten enthalten) |
config_diff | ⚠️ nur bei expliziter auto_fix: true Markierung | homeserver.yaml-Patch + Restart |
Connector-Code-Hash-Erkennung (seit 2026-05-10)
Damit Reconcile auch Inhalt-Drift eines Synapse-Moduls erkennt, nicht nur Existenz:
- Inspector liest den
sha256der ausgeliefertenmodule.py(sieheinspector.ts, Feldconnector_module_sha256). connector-artifact.tsextrahiert 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.detectDriftist pure (keine I/O); der Caller reicht den Soll-Hash viaDriftDetectorContext.expectedConnectorModuleSha256. Bei Mismatch →wrong_version-Finding gegen Komponentematrix-connector.- Reconcile-Pipeline reagiert mit derselben
installMatrixConnector-Operation wie beimissing_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 pertar xf. - Dry-Run-Mode:
--dry-runzeigt was getan würde, ohne zu tun. - Severity-Gating: Nur
severity=criticalwird auto-gefixt.warningnur logged.
CLI
# 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 --statusSmoke-Test-Service
Probes
Eine Probe = ein Boolean-Ergebnis + Latenz + Fehlertext. Eine Smoke-Test-Suite ist eine Liste von Probes.
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
| Probe | Was wird getestet |
|---|---|
synapse_health | GET <synapse>/_matrix/client/versions → 200 |
synapse_admin_login | Admin-User-Login mit gespeichertem Token |
postgres_reachable | SELECT 1 über Tenant-DB |
minio_reachable | mc ls auf Tenant-Bucket |
connector_module_loaded | Synapse-Logs grep auf prilog_matrix_connector loaded (last 1h) |
send_test_audio_and_expect_transcript | End-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
npx tsx scripts/smoke-test-tenant.ts --tenant=leander
npx tsx scripts/smoke-test-tenant.ts --allCron-Integration
Zwei neue Cron-Jobs in core/cron/jobs.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
- Spec v3 in DB laden →
tenant_box_specs(version=3, ...). - Pro Bestandstenant ein Reconcile-Run mit
--migrate-to-spec=3:- Erkennt:
matrix-connectorfehlt → Drift-Report → Auto-Fix - Tarball nach
/srv/tenants/<slug>/connectors/ homeserver.yamlummodules:-Block erweitern (mit Backup)docker-compose.ymlum Volume-Mount erweitern (mit Backup)- Synapse-Container restart
- Erkennt:
- Smoke-Test nach jedem Reconcile — Tenant darf nur als „ok" markiert werden, wenn End-to-End-Test grün ist.
- Reihenfolge: leander → demo → demo3 → weser. (leander zuerst weil aktiv getestet wird; weser zuletzt weil größter Bestand und somit höchstes Risiko.)
- Rollback-Pfad: Pre-Reconcile-Tarball-Snapshot von
/srv/tenants/<slug>/für 7 Tage aufbewahrt.
Bestandstenant-spezifische Eigenheiten
| Tenant | Besonderheit | Risiko |
|---|---|---|
| leander | Aktiv genutzt von Lee + Andreas, kleines Volumen | Niedrig |
| weser | Größter Tenant, viele User | Mittel — Reconcile außerhalb Schul-Stoßzeit |
| demo | Reine Demo, Daten egal | Niedrig — Test-Sandbox |
| demo3 | Brand-neuer Test-Tenant | Niedrig |
Phasenplan
| # | Phase | Aufwand | Abhängigkeit | Liefert |
|---|---|---|---|---|
| 1 | DB-Migration für 4 neue Tabellen + 3 Tenant-Felder | ~1h | — | Schema bereit |
| 2 | Spec-Loader + initiale tenant-box-v3.yaml | ~2h | 1 | DB enthält Spec v3 |
| 3 | Reconcile-Service (Kern) + CLI | ~6h | 1, 2 | npx tsx scripts/reconcile-tenant.ts --tenant=… |
| 4 | Smoke-Test-Framework + 6 Standard-Probes | ~4h | 1 | npx tsx scripts/smoke-test-tenant.ts … |
| 5 | Cron-Integration (2 Jobs) | ~30min | 3, 4 | tägliche Auto-Reconcile |
| 6 | Bestandstenants-Migration (leander → demo → demo3 → weser) | ~2h Live-Run | 3, 4 | alle 4 Tenants auf Spec v3 |
| 7 | Provisioning-Code an Spec koppeln | ~3h | 2, 3 | neue Tenants kommen spec-konform |
| 8 | Phasen-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.