Skip to content

Tenant-Boxes sicher updaten — ein Tutorial

Dieses Tutorial erklärt von Grund auf, wie Updates auf den Prilog-Tenant-Boxes funktionieren — und vor allem warum sie so funktionieren, wie sie es tun. Es richtet sich an Operatoren (= dich), die Synapse, Postgres oder MinIO auf einem oder mehreren Tenants aktualisieren wollen, ohne nachts wach zu liegen.

Wenn du nach diesem Tutorial einen Pilot-Rollout selbstständig fahren kannst und im Kopf hast, was bei einem Fehler passiert, ist das Ziel erreicht.

Warum nicht einfach docker compose pull && up -d?

Weil ein Tenant kein Lab ist. In jeder Tenant-Box läuft echter Schul-Alltag:

  • Eltern schreiben Nachrichten in Klassen-Räume.
  • Lehrkräfte legen gerade Aufgaben an.
  • Im DMS werden Dateien hochgeladen.
  • Der Synapse-Sync-Loop hat offene Long-Polls aus 50 Browser-Tabs.

Ein "einfaches" Update kann da viele Arten schief gehen:

  • Synapse v1.124 startet nicht wegen eines neuen Configs-Defaults — Chat ist tot, niemand kann sich anmelden.
  • Postgres-Major-Bump transformiert das Daten-Format, neue Synapse-Version hat eine fehlerhafte Migration — Daten korrupt, Restore aus Backup nötig.
  • MinIO läuft, aber eine geänderte Auth-Header macht S3-Uploads broken — niemand kann mehr Anhänge schicken.

In jedem dieser Fälle willst du nicht "manuell debuggen während die Schule gerade Pause macht". Du willst:

  1. Vor dem Update einen Stand zum Zurückspringen.
  2. Während dem Update sehen, ob es funktioniert.
  3. Nach dem Update automatisch zurückrollen, wenn die Box nicht antwortet.

Genau das macht die Update-Pipeline.

Die zwei Schichten

Die Pipeline hat zwei klar getrennte Teile:

┌─────────────────┐      ┌─────────────────┐
│  ERKENNEN       │      │  AUSFÜHREN      │
│  Drift-         │ ───▶ │  Update-        │
│  Detection      │      │  Pipeline       │
│                 │      │                 │
│  Was ist Soll?  │      │  Wie kommen     │
│  Was ist Ist?   │      │  wir vom Ist    │
│  Wo sind wir?   │      │  zum Soll?      │
└─────────────────┘      └─────────────────┘
   passiv, read-only       aktiv, manuell
   stündlich Cron           gestartet

Drift-Detection läuft permanent im Hintergrund und beantwortet die Frage: "Welche Tenant-Box hat welche Versionen, und was ist die Differenz zum Soll-Zustand?"

Die Update-Pipeline ist getrennt und läuft nur, wenn du sie explizit startest. Kein Cron sagt "hey, da ist Drift, ich update mal eben".

Das ist Absicht: Erkennen ≠ Ausführen. Du siehst die Fakten, du entscheidest, du startest. Die Pipeline macht dann den Rest.

Single Source of Truth: das Versions-Manifest

Bevor wir über Drift reden können, brauchen wir einen Soll-Zustand. Den liefert das Manifest in prilog-backend-api/src/services/tenant-box-versions.ts:

typescript
export const TENANT_BOX_MANIFEST = {
  manifestVersion: 1,
  generatedAt: '2026-05-08T00:00:00Z',
  tiers: {
    free: {
      synapse: 'v1.124.0',
      postgres: '16-alpine',
      minio: 'RELEASE.2024-12-13T22-19-12Z',
      migrationsHead: '0065_rollout_target_slugs',
      ...
    },
    pro: { ... },        // Heute identisch zu free
    enterprise: { ... }, // Heute identisch zu free
  },
};

Das Manifest ist TypeScript-Code, kein YAML in der DB. Jede Änderung geht durch Pull-Request, Code-Review, Git-History. Ein Versions-Bump ist ein Commit.

Warum nicht in der DB? Wenn Versionen in der DB stünden, könnte sie jeder Admin ohne Audit ändern. Bei Synapse oder Postgres geht's um CVE-Tracking und Migrations-Verträglichkeit — da willst du, dass Änderungen denselben Review-Prozess durchlaufen wie der Code, nicht der schnelle "Klick in der UI".

Pro Tier gibt es einen separaten Eintrag. Heute laufen alle Tiers identisch, aber die Struktur ermöglicht später, Free-Kunden auf einer neueren Version laufen zu lassen während Enterprise-Kunden noch eine Phase warten.

Wie funktioniert Drift-Detection?

Stündlich läuft der Cron tenant-box-drift-detection. Er macht zwei Dinge:

  1. Liest aus tenant_box_registry die installed Versionen pro Tenant. Diese werden vom anderen Cron (tenant-box-version-sync, täglich) per Agent-Call tenant-box.report_versions befüllt — also ein docker compose ps --format json direkt auf dem Tenant-Host.
  2. Vergleicht mit dem Manifest und schreibt das Diff in tenant_box_registry.drift_report (JSONB).

Das Diff hat eine Severity:

KomponenteDiff-ArtSeverity
migrationsHeadbeliebige Abweichungmajor
synapseMajor-Version-Bump (v1.x → v2.x)major
synapseMinor/Patchminor
synapseinstalled = latest oder leermajor (unbekannt = unsicher)
postgresMajor-Bump (16 → 17)major
postgresMinor/Patchminor
miniobeliebigminor

Pro Tenant wird das schwerste Item als Tenant-Severity genommen.

Das landet alles im Admin-UI unter /tenant-drift:

  • Liste aller Tenants mit Severity-Badge (none/minor/major/critical/unknown)
  • Klick auf eine Zeile öffnet ein Detail-Drawer mit der Soll/Ist-Tabelle
  • "Sofort-Scan auslösen"-Button für eine Ad-hoc-Aktualisierung

Wichtig zu verstehen: Drift-Detection berührt nichts. Sie liest, sie vergleicht, sie schreibt einen Report. Mehr nicht.

Anatomie eines Updates

So sieht ein Tenant-Update von außen aus, wenn die Pipeline läuft:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  1. Pre-Snapshot      (createBackup, tier='pre-update')      │
│     ├─ Postgres stop                                         │
│     ├─ tar /srv/tenants/<slug>/                              │
│     ├─ AES-256 encrypt mit per-Tenant-Key (HKDF)             │
│     ├─ Upload zu Hetzner-S3                                  │
│     └─ Postgres start                                        │
│                                                              │
│  2. Update            (Agent: tenant-box.update)             │
│     ├─ docker-compose.yml: Image-Tags ersetzen               │
│     ├─ Backup-Datei docker-compose.yml.before-update.<ts>    │
│     ├─ docker compose pull <changed-services>                │
│     └─ docker compose up -d <changed-services>               │
│                                                              │
│  3. Health-Probe      (Agent: tenant-box.healthcheck)        │
│     ├─ HTTP /_matrix/client/versions  → 200                  │
│     ├─ HTTP /_matrix/client/v3/sync   → 2xx-4xx              │
│     ├─ pg_isready                     → exit 0               │
│     └─ HTTP /minio/health/live        → 200                  │
│     ↻ wiederholen alle 60s, bis 3× in Folge ok               │
│                                                              │
│  4. Action.status = 'health_ok'                              │
│  5. Registry: synapse_version, postgres_version aktualisiert │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Wenn irgendwo dazwischen etwas schief geht, springt die Engine in den Catch-Pfad:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  AUTO-ROLLBACK                                               │
│                                                              │
│  1. Action.status = 'failed' + error_message                 │
│  2. restoreBackup({ backupId: <pre-update-snapshot> })       │
│     ├─ docker compose down -v                                │
│     ├─ Download .enc + Decrypt                               │
│     ├─ SHA256 verify                                         │
│     ├─ tar -xzf nach /srv/tenants/<slug>/                    │
│     └─ docker compose up -d                                  │
│  3. Action.status = 'rolled_back'                            │
│  4. Tenant ist im Vor-Update-Zustand. Keine Daten verloren.  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Der Pre-Snapshot ist nicht optional. Wenn die Backup-Pipeline nicht konfiguriert ist (BACKUP_MASTER_KEY fehlt), verweigert die Engine den Start. Ohne Restore-Pfad kein Update.

Praxis: ein Pilot-Update Schritt für Schritt

Sagen wir, das Manifest wurde gerade auf synapse: v1.124.1 gebumpt (Patch-Release mit Sicherheits-Fix). Du willst das Update auf deinem Test-Tenant demo ausprobieren, bevor du es auf leander (echte Schule) fährst.

Schritt 1: Drift sehen

Im Admin-UI: /tenant-drift"Sofort-Scan auslösen".

Du siehst eine Tabelle:

SlugTierSeverityDrift-Items
demoprominorsynapse
leanderprominorsynapse
andreasprominorsynapse

Klick auf demo öffnet das Detail-Drawer:

Erwartete Versionen (Manifest)
  synapse:        v1.124.1
  postgres:       16-alpine
  minio:          RELEASE.2024-12-13...
  migrationsHead: 0065_rollout_target_slugs

Installiert
  synapse:        v1.124.0    ← drifted
  postgres:       16-alpine
  minio:          RELEASE.2024-12-13...
  migrationsHead: 0065_rollout_target_slugs

Drift-Items
  synapse: installed=v1.124.0, expected=v1.124.1, severity=minor

Jetzt weißt du genau, was sich ändern wird.

Schritt 2: Pilot-Rollout anlegen

Im Admin-UI: /tenant-rollouts"Neuer Rollout".

  • Versionen werden automatisch aus dem Manifest gezogen.
  • Im Feld Target-Slugs: demo (genau dieses eine Wort).

Klick "Rollout queuen". Du landest in der Liste mit einem neuen Eintrag, Status queued. Es passiert noch nichts.

Warum target_slugs? Ohne Target-Slugs würde die Engine den klassischen Tier-Rollout fahren (canary → free_5 → free_100 → pro → enterprise). Mit Target-Slugs sagst du: "Genau diese Tenants, einmal, fertig." Das ist der Pilot-Modus. Die Action-Stage in der Tabelle heißt dann pilot statt canary, damit klar ist, was passiert ist.

Schritt 3: Detail-Page öffnen, "Start" klicken

Klick auf die Rollout-ID. Du siehst:

  • Lifecycle-Buttons: Start, Pause, Resume, Abort
  • Aktions-Tabelle (leer, weil noch nicht gestartet)
  • Audit-Log + Lifecycle-Zeitstempel

"Start" klicken. Die Engine läuft im Hintergrund (asynchron — Browser hängt nicht).

Auto-Refresh alle 5s zeigt dir, was passiert:

Tenant   Stage  Status        Snapshot  Dauer  Fehler
─────────────────────────────────────────────────────────
demo     pilot  pending       —         —      —

demo     pilot  snapshot_ok   #47       —      —

demo     pilot  updated       #47       —      —
                  ↓ (3-5 Min Health-Probe-Loop)
demo     pilot  health_ok     #47       4.2 Min —

Wenn health_ok erreicht ist, springt der Rollout-Status auf done.

Schritt 4: Verifizieren

Zurück zu /tenant-drift → "Sofort-Scan auslösen". demo sollte jetzt als synchron (severity none) angezeigt werden.

In der Registry:

sql
SELECT slug, synapse_version FROM tenant_box_registry WHERE slug = 'demo';
-- demo | v1.124.1

Pre-Snapshot ist im S3-Bucket gelandet als pre-update-Backup. Du kannst ihn unter /admin/backups sehen.

Schritt 5: Ausrollen auf die anderen

Wenn der Pilot eine Stunde stabil läuft (oder über Nacht), kannst du den zweiten Rollout starten:

  • Target-Slugs: leander, andreas (komma-getrennt)
  • Start

Concurrency-Limit ist 3 — beide laufen parallel, Pipeline ist nach ~5 Min durch.

Oder du fährst ohne Target-Slugs einen klassischen Tier-Rollout. Dann geht es durch alle Phasen mit Wartezeit-Möglichkeit dazwischen.

Wenn etwas schiefgeht — eine Geschichte aus dem echten Leben

Am 8. Mai 2026 war die Engine zum ersten Mal scharf. Der erste Pilot auf demo durchlief sauber: Pre-Snapshot ✓, Update ✓, Health-Probe 3× ok ✓.

Dann brach die Action mit einem Postgres-Fehler ab:

ERROR: column "health_check_result" is of type jsonb
       but expression is of type text

Im Code wurde das JSONB-Feld als String gebunden, ohne expliziten Cast. Das war mein Bug, nicht ein Synapse-Problem.

Was die Engine getan hat:

  1. Sie sah einen Exception in der Update-Loop.
  2. Sie wusste nicht, ob der Bug in Synapse, im Update-Pfad, oder im Schreib-Pfad lag.
  3. Sie machte deshalb das Sicherste: restoreBackup aus dem Pre-Snapshot.
  4. demo war ~5 Minuten auf der neuen Version, und ist dann sauber zurück auf die alte.

Status nach dem Vorfall:

  • Action: rolled_back
  • Tenant: vollständig im Vor-Update-Zustand (Synapse-Container, DB, Bucket, alles)
  • Daten: nichts verloren — Postgres und MinIO wurden während des Updates gar nicht angefasst, nur der Synapse-Container wurde getauscht
  • Pre-Snapshot: bleibt im S3, kann manuell gelöscht oder zur Forensik behalten werden

Das ist der Wert der zwei-stufigen Architektur: es ist egal warum etwas schiefgeht. Code-Bug, Synapse-Crash, Disk voll, Netz weg. Die Engine setzt zurück, dokumentiert, ruft uns. Sie versucht nicht zu raten, sie versucht nicht weiterzumachen, sie versucht nicht "halb-richtig" zu sein.

Der Bug wurde dann sauber gefixt (JSONB-Cast in der Schreib-Funktion), das nächste Update lief komplett durch.

Sicherheits-Garantien — was die Pipeline garantiert

  1. Kein Auto-Start. Die Engine läuft nur, wenn ein Mensch POST /admin/tenant-boxes/rollouts/:id/start triggert. Kein Cron, kein Webhook, kein Drift-Trigger.

  2. Pre-Snapshot ist Pflicht. Ohne BACKUP_MASTER_KEY und konfigurierte S3-Credentials startet die Engine nicht. Die Idee, "ohne Backup mal schnell" ist konstruktiv ausgeschlossen.

  3. Auto-Rollback bei jedem Fail. Update-Fehler, Health-Probe-Fail nach 30 Min, Schreib-Fehler in der DB — alle führen zum gleichen Pfad: restoreBackup aus dem Pre-Snapshot.

  4. Concurrency-Limit von 3. Innerhalb einer Phase werden maximal 3 Tenants parallel angefasst. Bei 50 Pro-Tenants auf demselben Host wird der Host nicht durch parallele compose pull-Operationen erstickt.

  5. Cancellation-aware. Jede 5 Sekunden pollt die Engine den Rollout-Status. Wenn du auf "Pause" klickst, beendet sie sich nach der gerade laufenden Tenant-Aktion (saubere Zwischenfreigabe).

  6. Phase pausiert bei Failure. Wenn ≥1 Tenant in einer Phase fehlschlägt, advanced die Engine nicht zur nächsten Phase. Sie pausiert mit dem Reason Phase X: N Failures. Du musst manuell entscheiden — Resume (versuchen) oder Abort (terminieren).

CVE-Hotpath für Notfälle

Sagen wir, eine kritische CVE in Synapse wird bekannt — Remote Code Execution, alle Versionen vor v1.124.5 betroffen. Du kannst nicht 5 Tage Canary-Beobachtung leisten. Du brauchst alle Tenants auf der Patch-Version, sofort.

Der CVE-Hotpath ist der Override:

bash
curl -X POST https://api.prilog.chat/api/admin/tenant-boxes/cve-hotpath \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "CVE-2026-0001 RCE in Synapse media-repo",
    "toSynapse": "v1.124.5",
    "toPostgres": "16-alpine",
    "toMinio": "RELEASE.2024-12-13T22-19-12Z",
    "toMigrationsHead": "0065_rollout_target_slugs",
    "approvalToken": "<KLARTEXT>",
    "triggeredBy": "lee@prilog.team"
  }'

Der Hotpath validiert:

  • Approval-Token wird gegen CVE_APPROVAL_TOKEN_HASH env (sha256) in konstanter Zeit verglichen. Ohne den Token: 401.
  • Reason muss die CVE-ID enthalten (≥ 8 Zeichen).
  • Backup-Pipeline muss konfiguriert sein — Pre-Snapshot ist auch im Notfall Pflicht.

Reihenfolge im Hotpath: pro → enterprise → free_100. Pro/Enterprise zuerst, weil dort SLA-Druck. Free zuletzt, weil Schadensvolumen pro Schule kleiner und mehr Beobachtungs-Zeit.

Warum nicht UI-Button? Der CVE-Hotpath bewusst keine UI. Wer ihn ausführt, soll wissen was er tut, und der Approval-Token soll nirgends im UI-State liegen. Eine UI-Button wäre genau die Backdoor, durch die im Ernstfall die kaputten Dinge rausgehen.

Was die Pipeline NICHT macht

Wichtig zu wissen, damit du nicht falsche Erwartungen hast:

  • Postgres-Major-Upgrades (16 → 17) sind nicht idempotent über compose up machbar. pg_upgrade-Tooling mit dual-volume und Schema- Migration ist eigene Stufe (Stufe 5 in der Phase-6-Roadmap), Spike noch offen. Bis dahin: Postgres bleibt auf 16, Manifest sagt 16, Drift würde Major-Bump als major markieren — du musst dann vorsichtig manuell migrieren.

  • Module-Updates (z.B. ein installiertes Plugin auf Version X.Y bumpen) laufen nicht durch diese Pipeline. Modul-Lifecycle ist separater Pfad über die Marketplace-Phase.

  • Synapse-Konfigurations-Bumps (neue Module aktivieren, Federation anders konfigurieren) gehen über tenant-box.update mit anderen Args, nicht über einen Versions-Bump. Eigener Pfad.

  • Backend-API-Updates (das hier beschriebene Backend selbst). Das ist ein zentraler Service, kein Tenant-spezifischer Container. Backend- Deploys laufen über git pull && pm2 restart auf dem zentralen Host, nicht durch diese Pipeline.

  • Tier-Wartezeiten zwischen Phasen. Heute schaltet die Engine sofort weiter, wenn eine Phase ohne Failures durch ist. Wenn du 24h zwischen Canary und Free 5% beobachten willst: Pause, beobachten, Resume. Automatische Wartezeiten kommen mit Stufe 6 (Telemetrie).

Quick Reference

Cron-Jobs (im Admin unter /admin/crons)

KeyScheduleWas
tenant-box-version-sync30 3 * * *Holt Live-Versionen pro Tenant per Agent, schreibt in Registry
tenant-box-drift-detection7 * * * *Vergleicht Registry mit Manifest, schreibt drift_report
tenant-box-daily-backup0 2 * * *Tägliche Backups, gestaffelt 30s zwischen Tenants

Wichtige Tabellen

  • tenant_box_registry — eine Zeile pro Tenant. Spalten synapse_version, postgres_version, minio_version (von Versions-Sync gefüllt), drift_report (JSONB, von Drift-Cron gefüllt).
  • update_rollouts — eine Zeile pro Rollout-Versuch. Status, kind, to-Versionen, target_slugs, audit_log (JSONB).
  • update_rollout_actions — eine Zeile pro Tenant pro Rollout. Pre- Snapshot-Backup-ID, Status (pending/snapshot_ok/updated/health_ok/ failed/rolled_back), Health-Check-Result.

Code-Pfade

  • src/services/tenant-box-versions.ts — Manifest
  • src/services/tenant-box-drift.service.ts — Drift-Detection-Service
  • src/services/tenant-box-version-sync.service.ts — Live-Scan
  • src/services/tenant-box-health.service.ts — Health-Probe-Wrapper
  • src/services/tenant-box-rollout.service.ts — State-Machine (pure)
  • src/services/tenant-box-rollout-engine.service.ts — Orchestrator
  • src/services/tenant-box-cve-hotpath.service.ts — CVE-Hotpath-Validierung

Häufige Aufgaben

Wo läuft welche Synapse-Version?/tenant-drift öffnen, Tabelle ist sortiert nach Severity. Falls installed=NULL: erst /admin/cronstenant-box-version-sync → Trigger.

Manifest auf neue Synapse-Version bumpen?prilog-backend-api/src/services/tenant-box-versions.ts editieren, die drei Tier-Einträge auf den neuen Tag setzen, publishedAt hochziehen, Commit. Nach Backend-Deploy ist die Drift-Detection automatisch über die Änderung informiert.

Pilot-Update? Siehe Schritt 1-5 oben. target_slugs = der eine Tenant, fertig nach ~5 Min.

Notfall-Update wegen CVE? CVE-Hotpath-Endpoint mit Approval-Token. Siehe Abschnitt CVE-Hotpath oben.

Rollout pausieren? Detail-Page → "Pause"-Button. Engine beendet sich nach aktueller Tenant-Aktion. Resume oder Abort wenn du soweit bist.

Update läuft fest? Der Health-Probe wartet bis zu 30 Minuten auf 3× stable in Folge. Wenn ein Tenant nicht reagiert: nach 30 Min schlägt die Probe fehl, Auto- Rollback wird ausgelöst, Tenant ist zurück. Du kannst auch vorher manuell Abort klicken, dann läuft der gerade laufende Tenant aus, weitere werden übersprungen.

Weiterführend

  • Konzept Phase 6: tenant-box-phase-6-update.md — die Architektur-Entscheidungen im Detail, inkl. State-Diagramm und Roadmap zu Stufe 5 (pg_upgrade) und Stufe 6 (Telemetrie).
  • Tenant-in-a-Box-Konzept: tenant-in-a-box-konzept.md — wie eine Tenant-Box von der Provisionierung bis zur Löschung funktioniert, was die Backup-Pipeline (Phase 5) ist, wie die Identität (server_name) invariant über den Lifecycle bleibt.
  • Disaster-Recovery-Konzept: disaster-recovery-konzept.md — was passiert, wenn die ganze Backend-DB stirbt, nicht nur ein Tenant-Container.