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:
| Komponente | Diff-Typ | Severity |
|---|---|---|
| migrationsHead | beliebig | major |
| postgres | Major-Bump (16 → 17) | major |
| postgres | Minor/Patch | minor |
| synapse | Major-Bump (v1 → v2) | major |
| synapse | Minor/Patch | minor |
| minio | beliebig | minor |
| any | unbekannt / latest | major |
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_backStatus-Übergänge (emergency_cve):
queued ── start ──> pro ──> enterprise ──> free_100 ──> doneRatio: 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.timingSafeEqualfür Vergleich, kein String-Compare.- Pre-Snapshot-Pflicht: ohne
BACKUP_MASTER_KEY+S3-Creds verweigert der Endpoint mit CodeCONFIG. - 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:
Agent-Commands (prilog-agent/src/handlers/tenant-box.ts):
tenant-box.report_versions—docker 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 -dder geänderten Services. Schreibt ein Backup-Filedocker-compose.yml.before-update.<ts>. Beipull-Fehler: Compose wird zurückgeschrieben.
Backend Versions-Sync (
tenant-box-version-sync.service.ts):runVersionSync()iteriert Registry, ruft Agent, schreibt Versions zurück intenant_box_registry.- Cron
tenant-box-version-synctäglich 03:30 UTC. - Damit hört Drift-Detection auf "unbekannt" zu sagen.
Backend Health-Probes (
tenant-box-health.service.ts):probeOnce(slug, hostId, ports)— eine PruefungprobeWithRetry(...)— Retry mit exponential backoffprobeStable(slug, hostId, ports, { requiredStableProbes: 3, intervalMs: 60s, timeoutMs: 30min })— wartet bis 3 Probes in Folge ok. Standard für Rollout-Tier-Advance.
Rollout-Engine (
tenant-box-rollout-engine.service.ts):executeRollout(rolloutId)orchestriert alle Phasen.- Pro Tenant: Pre-Snapshot via
createBackup(tier='pre-update')→tenant-box.updatevia Agent →probeStable→ Erfolg eintragen. - Bei Update-Fail oder Health-Fail: automatisches
restoreBackupaus dem Pre-Snapshot.update_rollout_actions.status = 'rolled_back'. - Concurrency-Limit 3 parallel pro Phase.
- Cancellation: pollt jede 5s
update_rollouts.status. Beipausedoderrolled_backbeendet sich die Engine nach der aktuellen Tenant-Aktion. - Bei Failures in einer Phase:
pause(reason)stattadvance.
Admin-Endpoints (
shared-hosts.router.ts):POST /admin/tenant-boxes/rollouts— neuer Rollout (nimmt Manifest als Default, optionaltargetSlugs[]für Pilot-Updates)GET /admin/tenant-boxes/rollouts— Liste mit Action-CountsGET /admin/tenant-boxes/rollouts/:id— Detail mit allen ActionsPOST .../start— startetexecuteRolloutals Background-PromisePOST .../pause— setzt status='paused', Engine beendet sich beim nächsten PollPOST .../resume— setzt status zurück auf vorherige Phase + ruftexecuteRollouterneut (idempotent — picked die Phase auf)POST .../abort— terminiert mit status='failed'
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
createBackupundrestoreBackupheute. 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:
- Tenant-Box wird gestoppt.
- Neues Volume mit Postgres 17 wird daneben gemountet.
pg_upgrade --linkmigriert Schema + Daten (Inode-Sharing, sehr schnell).- Health-Check gegen das neue Volume.
- Nur bei Erfolg: alte Volume entfernt.
- 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.