Skip to content

Tenant-Box Phase 6 — Update-Pipeline

Stand 2026-05-08. Stufen 1–4 live. pg_upgrade Major-Tooling (Stufe 5) ist weiter ausstehend, weil das eigenes Datenrisiko trägt und einen separaten Spike braucht.

Warum Phase 6 schwer ist

Im Gegensatz zur Provisionierung (Phase 1-4) ist die Update-Pipeline online. Tenants haben echte Daten, schreiben gerade in den Chat, sind im DMS unterwegs. Ein fehlerhafter Update-Lauf kann:

  • Den Synapse-Container bricken (Chat ist tot bis Rollback).
  • Eine Postgres-Major-Migration zerschießen, die nicht rückwärts kompatibel ist.
  • Asynchron eine Datei-Migration starten, die mid-flight abbricht und die DB in einem inkonsistenten Zustand zurücklässt.

Deshalb: kein Update ohne Pre-Snapshot. Kein breit-Rollout ohne Canary. Kein Rollback ohne getesteten Restore-Pfad.

Stufenplan

Stufe 1 ─ Drift-Detection (read-only)         ✓ live (2026-05-08)
Stufe 2 ─ State-Machine als Modell             ✓ live (2026-05-08, 15 Tests grün)
Stufe 3 ─ CVE-Hotpath-Validierung + Endpoint   ✓ live (2026-05-08, 10 Tests grün)
Stufe 4 ─ Scharfer Roll-out (Agent + Engine + UI)  ✓ live (2026-05-08)
Stufe 5 ─ pg_upgrade Major-Tooling (dual-volume)   → Folge-PR (eigener Spike)

Stufe 1–3 lieferten Werkzeuge. Stufe 4 verbindet sie zu einem ausführbaren Pfad: Pre-Snapshot → Agent-Update → Health-Probe → Auto-Rollback bei Fail. Trigger ausschließlich manuell über Admin-UI — kein Cron startet Updates.

Stufe 1: Drift-Detection (live)

Was: Stündlicher Cron tenant-box-drift-detection (Minute 07) liest tenant_box_registry, vergleicht installierte Versionen mit dem kanonischen Manifest, schreibt Diff in drift_report (JSONB) + last_drift_check_at.

Versions-Manifest: src/services/tenant-box-versions.ts — Single-Source-of-Truth, in TypeScript versioniert. Bumps gehen über PR gegen diese Datei. Pro-Tier sind die Strukturen separat, auch wenn heute alle Tiers identische Werte haben.

Severity-Logik:

KomponenteDiff-TypSeverity
migrationsHeadbeliebigmajor
postgresMajor-Bump (16 → 17)major
postgresMinor/Patchminor
synapseMajor-Bump (v1 → v2)major
synapseMinor/Patchminor
miniobeliebigminor
anyunbekannt / latestmajor

Aggregation pro Tenant: worst-of über alle Items.

Was Drift-Detection NICHT macht:

  • Kein Live-Scan auf dem Host. Wir vertrauen den Versions-Spalten in tenant_box_registry. Wenn die lügen, ist das ein Bug der Provisionierung, nicht der Drift-Detection.
  • Kein Auto-Update. Wir markieren nur. Trigger zum Update ist ein separater Schritt (Stufe 4).

Admin-UI: /tenant-drift listet alle Tenant-Boxes mit Severity-Badge, sortiert nach Schwere. Klick öffnet Detail-Drawer mit Soll/Ist + Items. Button "Sofort-Scan auslösen" triggert runDriftScan() ad-hoc.

Stufe 2: State-Machine (live)

Was: Reine TS-Funktion transition(state, action) in src/services/tenant-box-rollout.service.ts. Wirft InvalidTransition bei nicht-erlaubten Übergängen, damit der Caller bewusst handeln muss.

Status-Übergänge (staged):

queued
  └── start ──> canary
                  └── advance ──> free_5
                                    └── advance ──> free_100
                                                      └── advance ──> pro
                                                                        └── advance ──> enterprise
                                                                                          └── advance ──> done

  beliebig laufender Status:
    ── pause(reason) ──> paused
    paused ── resume() ──> previousStatus
    ── fail(error) ──> failed
    ── rollback(reason) ──> rolled_back

Status-Übergänge (emergency_cve):

queued ── start ──> pro ──> enterprise ──> free_100 ──> done

Ratio: bei CVE haben Pro/Enterprise SLA-Druck und sind first-priority. Free als letztes, weil dort das Schadens-Volumen pro Schule kleiner ist und wir bei einem Bug Zeit haben zu pausieren.

Persistenz: update_rollouts (Migration 0064) speichert pro Rollout-Lauf den State + JSONB Audit-Log. update_rollout_actions speichert pro Tenant pro Lauf das Ergebnis (snapshot ok? updated? health ok? rolled_back?). Der pure-Function-Layer berührt die DB nicht — Persistenz liegt beim Caller (typisch in einer Transaktion).

Tests: 15 Vitest-Cases gegen die pure Transition-Funktion. Happy-Paths, Pause/Resume, Reject-bei-Terminal, Emergency-CVE-Reihenfolge.

Stufe 3: CVE-Hotpath (live, ohne Trigger)

Was: validateCveHotpathRequest prüft Approval-Token (sha256 gegen CVE_APPROVAL_TOKEN_HASH env), Pflicht-Felder (reason mit CVE-ID, to-Versionen, triggeredBy) und Backup-Konfiguration. Wirft strukturierte Fehler mit Code (AUTH | CONFIG | VALIDATION | INTERNAL).

Endpoint: POST /admin/v1/tenant-boxes/cve-hotpath mit Body { reason, toSynapse, toPostgres, toMinio, toMigrationsHead, approvalToken, triggeredBy }. Schreibt einen update_rollouts-Eintrag mit status='queued' und kind='emergency_cve'. Trigger zum Start ist Stufe 4.

Sicherheits-Modell:

  • Approval-Token-Klartext liegt nirgends im Repo. Lee verwaltet das in einem Passwort-Manager. Hash-only in env.
  • crypto.timingSafeEqual für Vergleich, kein String-Compare.
  • Pre-Snapshot-Pflicht: ohne BACKUP_MASTER_KEY+S3-Creds verweigert der Endpoint mit Code CONFIG.
  • Rollout-ID hat das Format cve-YYYYMMDD-<random8> für Audit-Lesbarkeit.

Tests: 10 Vitest-Cases, inkl. AUTH-Reject mit falschem Token, CONFIG-Reject ohne env, VALIDATION-Reject bei zu kurzer reason / fehlenden Versionen.

Stufe 4 (live): Scharfe Ausführung

Komponenten:

  1. Agent-Commands (prilog-agent/src/handlers/tenant-box.ts):

    • tenant-box.report_versionsdocker compose ps --format json, extrahiert echte Image-Tags pro Service. Read-only.
    • tenant-box.healthcheck — 4-Komponenten-Probe (synapse_api, synapse_sync, postgres pg_isready, minio /minio/health/live) mit Response-Time. Read-only.
    • tenant-box.update — Image-Tag-Bump im docker-compose.yml, compose pull + compose up -d der geänderten Services. Schreibt ein Backup-File docker-compose.yml.before-update.<ts>. Bei pull-Fehler: Compose wird zurückgeschrieben.
  2. Backend Versions-Sync (tenant-box-version-sync.service.ts):

    • runVersionSync() iteriert Registry, ruft Agent, schreibt Versions zurück in tenant_box_registry.
    • Cron tenant-box-version-sync täglich 03:30 UTC.
    • Damit hört Drift-Detection auf "unbekannt" zu sagen.
  3. Backend Health-Probes (tenant-box-health.service.ts):

    • probeOnce(slug, hostId, ports) — eine Pruefung
    • probeWithRetry(...) — Retry mit exponential backoff
    • probeStable(slug, hostId, ports, { requiredStableProbes: 3, intervalMs: 60s, timeoutMs: 30min }) — wartet bis 3 Probes in Folge ok. Standard für Rollout-Tier-Advance.
  4. Rollout-Engine (tenant-box-rollout-engine.service.ts):

    • executeRollout(rolloutId) orchestriert alle Phasen.
    • Pro Tenant: Pre-Snapshot via createBackup(tier='pre-update')tenant-box.update via Agent → probeStable → Erfolg eintragen.
    • Bei Update-Fail oder Health-Fail: automatisches restoreBackup aus dem Pre-Snapshot. update_rollout_actions.status = 'rolled_back'.
    • Concurrency-Limit 3 parallel pro Phase.
    • Cancellation: pollt jede 5s update_rollouts.status. Bei paused oder rolled_back beendet sich die Engine nach der aktuellen Tenant-Aktion.
    • Bei Failures in einer Phase: pause(reason) statt advance.
  5. Admin-Endpoints (shared-hosts.router.ts):

    • POST /admin/tenant-boxes/rollouts — neuer Rollout (nimmt Manifest als Default, optional targetSlugs[] für Pilot-Updates)
    • GET /admin/tenant-boxes/rollouts — Liste mit Action-Counts
    • GET /admin/tenant-boxes/rollouts/:id — Detail mit allen Actions
    • POST .../start — startet executeRollout als Background-Promise
    • POST .../pause — setzt status='paused', Engine beendet sich beim nächsten Poll
    • POST .../resume — setzt status zurück auf vorherige Phase + ruft executeRollout erneut (idempotent — picked die Phase auf)
    • POST .../abort — terminiert mit status='failed'
  6. Admin-UI (/tenant-rollouts):

    • Liste aller Rollouts mit Status-Badge, Action-Fortschritt (success/fail/total), Target-Slugs
    • "Neuer Rollout"-Form mit Target-Slugs-Input (komma-getrennt)
    • Detail-Page /tenant-rollouts/[id]: Lifecycle-Buttons (Start/Pause/Resume/Abort), pro-Tenant-Action-Tabelle mit Status, Snapshot-ID, Dauer, Fehler. Auto-Refresh alle 5s während Running.

Was Stufe 4 absichtlich NICHT hat (Folge-Stufen):

  • Tier-Wartezeiten zwischen Phasen. Heute schaltet die Engine sofort weiter, sobald eine Phase ohne Failures durch ist. Wenn Lee bewusst zwischen canary und free_5 24h beobachten will: Pause/Resume manuell. Cron-basierte Wartezeiten kommen mit Stufe 6 (Telemetrie + Alerts).

  • Frontend-Tier-Wartezeit-Konfig pro Rollout. Stufe 4 hat einen globalen Default — kein per-Rollout Override.

  • Pre-Snapshot Restore-Drill. Wir vertrauen createBackup und restoreBackup heute. Eine wöchentliche Drill (Phase 5 Backup-Pipeline Item) ist offen.

Stufe 5 (offen): Postgres-Major-Upgrade-Tooling

Major-Bumps (z.B. 16 → 17) sind nicht in-place möglich. Plan:

  1. Tenant-Box wird gestoppt.
  2. Neues Volume mit Postgres 17 wird daneben gemountet.
  3. pg_upgrade --link migriert Schema + Daten (Inode-Sharing, sehr schnell).
  4. Health-Check gegen das neue Volume.
  5. Nur bei Erfolg: alte Volume entfernt.
  6. Bei Fehler: alte Volume bleibt, neues wird gelöscht.

Kritisch: das Tooling muss idempotent sein (Upgrade kann mehrfach gestartet werden ohne Datenverlust) und einen klaren Restore-Pfad über das Pre-Snapshot haben. Das wird ein Folge-Spike.

Telemetrie + Alerts

Geplant für Stufe 4:

  • Drift-Severity-Histogramm (wie viele Tenants im Major/Minor) als Prilog-Events auf admin-Dashboard
  • Rollout-Status SSE-Stream zur Admin-UI (Live-Verfolgung)
  • Alerts: Health-Probe-Fail in einer Phase, Rollback-Trigger, CVE-Hotpath-Aktivierung. Ziel: Telegram an Lee.

Was NICHT in Phase 6 gehört

  • Module-Updates (z.B. Update eines installierten App-Plugins). Eigene Pipeline mit Lifecycle-Hooks pro Modul. Gehört in Marketplace-Phase (separat).
  • Schema-Migrations für Tenant-spezifische Daten. Heute laufen alle Schema-Migrations zentral im Backend (gegen die geteilte Backend-DB). Tenant-Box hat eigenes Postgres, aber dort liegt nur Synapse-State.
  • Synapse-Konfigurations-Bumps (z.B. neue Module aktivieren). Eigener Pfad über tenant-box.update-Args, kein Versions-Bump.