Session-Recap 2026-05-02 — Tenant-Lifecycle, Backup, Auto-Scaling
Status: Alles produktiv. Beschreibt was am 2026-05-02 implementiert wurde.
Heute wurde die komplette Tenant-Box-Architektur ausgebaut — von 4 migrierten Live-Tenants über vollständigen Backup/Restore mit Hetzner Object Storage bis zu performance-basiertem Auto-Scaling mit aktivem Rebalance.
Übersicht
| Block | Was | Doku |
|---|---|---|
| A | Old shared-PG/MinIO Deprovision + cloud-init Cleanup | hier |
| B | Tenant-Lifecycle: Delete + Stripe + Orphans | hier |
| C | Backup-Pipeline scharf (Hetzner Object Storage, AES-256-CBC) | hier |
| D | Inter-Host Migration via Backup/Restore-Pipeline | hier |
| E | Auto-Scaling: Performance-based + Active Rebalance | hier |
| F | Admin-UI: Backups-Page + Restore-Button + Migration-Badge | hier |
| G | CI: deprecated-pattern check | hier |
| H | MailPace Health-Check + Sidebar-Indikator | hier |
10+ echte Bugs gefunden + permanent gefixt. Alles Source-fest.
Block A — Old Shared-Infrastructure Deprovision
Nachdem alle 4 Tenants migriert waren (demo3, demo2, demo, leander → Tenant-in-a-Box), wurde die alte Architektur abgerissen.
Was deprovisioniert wurde
shared-1 (49.12.108.38):
- 7 alte synapse_*-DBs gedropped
- 3 tenant-*-Buckets entfernt
- /opt/prilog/tenants/* dirs cleaned
- postgresql + minio Services stopped + disabled
- 1 orphan synapse-e2e-test Container weg
shared-2 (91.99.190.150 — neue IP nach Re-Provisioning):
- 1 verbliebene synapse_demo3 DB
- 1 tenant-demo3 Bucket
- 1 dir gecleant
- Services gestoppt+disabled
- DNS-Record
shared-2.prilog.teamkorrigiert
Source-Fixes
shared-hosting.service.ts:buildSharedHostCloudInit — cloud-init für künftige Hosts installiert KEIN PostgreSQL und KEIN MinIO mehr (~50 Zeilen entfernt). mc-Client bleibt installiert (für Per-Tenant-MinIO-Operationen), aber kein fixer 'local' Alias mehr — Aliase werden pro Tenant dynamisch gesetzt.
tenant-box.service.ts:createTenantBox — schreibt jetzt automatisch TenantRuntime.s3Endpoint / s3Bucket / s3AccessKeyId / s3SecretAccessKeyEncrypted (HKDF-encrypted) pro neuem Tenant. Vorher mussten diese Felder manuell gesetzt werden.
Block B — Tenant-Lifecycle (Delete-Flow)
Vorher gab's KEINEN sauberen Tenant-Delete-Pfad. prisma.tenant.delete ließ Container/DB/Bucket/nginx als Zombies liegen. Stripe-Subscriptions wurden nicht gecancelt → Kunde wäre weiter belastet worden.
Neue Komponente: services/tenant-lifecycle.service.ts
Universeller deleteTenant({ orderId, actor, reason }):
- Stripe-Cancel —
stripe.subscriptions.cancel(...)wenn Subscription existiert (KRITISCH — verhindert weiterlaufende Belastung) - Tenant-Box destroy via Agent (
tenant-box.destroy purge=true) → Container + Verzeichnis weg - Legacy-Cleanup via Agent (
tenant-box.legacy_cleanup) → räumt residuale shared-PG-DB, shared-MinIO-Bucket, /opt/prilog/tenants/-dir auf - DNS-Records bei Bunny löschen (teamRecordId + chatRecordId aus TenantSetting)
- Orphan-Tabellen-Cleanup — 12 Tabellen mit
tenant_idals String (kein FK-Cascade): tenant_settings, process_templates, smoke_test_runs, module_events, tenant_migrations, tenant_marketplace_subscriptions, version_drift, tenant_box_rollout u.a. - DB-Cascade-Delete — ServerOrder.deleteMany (FK-Restrict zu tenant!) + Transaction für verwaiste Tabellen + tenant.delete
- Audit-Log mit
action: 'GDPR_ART17_FULL_DELETE'
Cron-Hookup freemium-wipe.service.ts
Wenn Kunde "Konto schließen" macht (30d-Frist) → Daily-Cron 04:30 ruft wipeTenant(tenantId) → das ruft jetzt tenant-lifecycle.deleteTenant für jede zugehörige Order. Damit räumt das System bei Self-Service-Schließung auch die Infrastruktur mit weg, nicht nur die DB.
Admin-UI
Tenant-Board Card:
- Hover → Trash-Icon → "Vollständig löschen"-Dialog mit Subdomain-Eingabe als Bestätigung
- "Kundengelöscht"-Badge bei
subscriptionStatus='cancelled'+scheduledDeletionAtgesetzt → roter Border + Wipe-Datum
Order-Detail Page — /orders/:id/purge-Endpoint detektiert hostingType='shared' → ruft tenant-lifecycle (statt nur Hetzner-Server-Delete + DB-Cascade). Dedicated-Pfad bleibt unverändert.
Was bleibt erhalten (Compliance)
tenant_id als String-Reference — diese Tabellen bleiben für 7-10y Retention:
- invoices + invoice_items (Buchhaltung)
- impersonation_logs (Audit)
- email_logs (Stalwart)
- child_protection_cases, avv_consent_logs, postfach_audit_logs, crisis_audit_entries
- user_directory_history
- tenant_backup + tenant_backup_audit (DSGVO-Beleg dass Backup gelöscht wurde)
Block C — Backup-Pipeline (Phase B scharf)
Vor heute war Backup nur Scaffolding (Master-Key fehlt, S3-Creds fehlen, Agent-Handler war Stub). Heute scharf geschaltet.
Setup
- Hetzner Object Storage Bucket
prilog-l1-daily-prodin Frankfurt - BACKUP_MASTER_KEY generiert (32 Bytes, hex) und in
.envpersistiert (chmod 600) - S3-Credentials scoped auf den einen Bucket
- Encryption AES-256-CBC mit per-tenant-Key via HKDF-SHA256(master_key, tenant_slug + version)
Agent: tenant-box.backup
1. docker compose stop postgres (atomares DB-State)
2. tar -cf - /srv/tenants/<slug>/ | pigz > /var/lib/prilog/snapshots/<slug>-<ts>.tar.gz
3. docker compose start postgres (Tenant wieder schreibfähig — ~10s Downtime total)
4. SHA256 vom plaintext-Tarball
5. openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 → .tar.gz.enc
6. mc cp <enc> hetzner-alias/bucket/key
7. lokales File + alias cleanupAgent: tenant-box.restore
1. (in-place) docker compose down -v + rm -rf /srv/tenants/<slug>/
2. mc cp hetzner-alias/bucket/key /tmp/staging/
3. openssl enc -d → .tar.gz
4. SHA256 verify gegen expected (Backup-DB-Eintrag)
5. tar -xzf → /srv/tenants/<slug>/
6. chown postgres-data (UID 70), media_store + signing.key (UID 991)
7. docker compose up -d postgres minio (warten healthy)
8. docker compose up -d synapse
9. Verify-Loop 18×10s = 3min (Synapse-Schema-Init nach Restore kann dauern)
10. nginx-Block aus manifest.json neu schreiben + reloadBackend: services/backup.service.ts
createBackup({ tenantSlug, hostId, tier, actor })→ Pre-Insert in tenant_backup mit status=pending → Agent-Call → Update auf status=verified mit size/sha256 → Audit-LogrestoreBackup({ backupId, targetHostId, inPlace, actor, reason })→ Lookup Backup → Agent-Call mit S3-Creds + Encryption-Key + expected SHA256 → DB-Update (serverOrder.synapsePort + sharedHostId)listBackups()+listAuditLog()für Admin-UIcleanupExpiredBackups()für Retention-Cron
Cron-Schedules
| Cron | Schedule | Was |
|---|---|---|
tenant-box-daily-backup | täglich 02:00 UTC, 30s gestaffelt | Backup pro Tenant |
tenant-backup-cleanup | täglich 04:45 UTC | Retention-Enforcement (30d) |
Admin-UI
Neue /backups-Page (Sidebar: "Backups"):
- Tab "Snapshots": Tabelle Tenant/Tier/Status/Erstellt/Größe/Retention
- Tab "Audit-Log": wer/wann/was
- Filter (Tenant-Name oder Aktion)
- ⚡ "Wiederherstellen"-Button pro verified Snapshot
Tenant-Board Card kriegt Backup-Status:
- Status-Dot rechts oben (grün <26h, gelb 26-50h, rot >50h)
- Footer zeigt Backup-Alter ("0h", "5h" etc.)
- Hover → ⚡-Button → Confirm-Dialog → Restore in-place
Notfall-Workflow (panic-proof getestet):
- Tenant hat Probleme → Tenant-Board öffnen
- Hover → ⚡ klicken
- Dialog erklärt was passiert + geschätzte Dauer
- "JETZT WIEDERHERSTELLEN" → 30s später Tenant wieder online
Verifiziert mit demo3-Restore: 29 Sekunden gesamt, alle Daten 1:1 erhalten.
Block D — Inter-Host Migration via Tenant-Box-Pipeline
Drag-and-Drop im Tenant-Board nutzte vorher die alte pg_dump-Pipeline (tenant.snapshot → tenant.transfer → tenant.restore). Beides war broken seit Deprovisioning der shared-PG/MinIO. Komplett umgestellt auf Tenant-Box-Pipeline.
Neue Implementierung in tenant-migration.service.ts
runMigrationViaTenantBox() — 8 Steps:
- preflight — Target-Status='active', Backup-Pipeline configured
- freeze — TenantSetting
maintenance.frozen=migration - snapshot —
createBackup→ Pre-Update-Tarball auf S3 (~2s) - restore —
restoreBackupmittargetHostId→ neue Box auf Target-Host (~30s) - cutover — Bunny DNS-Update auf Target-IP (delete+create → neue Record-IDs persistieren!)
- cleanup —
tenant-box.destroy purge=trueauf Source-Host - verify —
fetch https://<domain>/_matrix/client/versionsmit 12×10s Retry-Loop (DNS-TTL ist 300s) - unfreeze — TenantSetting löschen, status=completed
Old code path
runMigration umbenannt zu runMigrationLegacy mit eslint-disable @typescript-eslint/no-unused-vars Marker. Bleibt im Repo für Rollback-Zwecke, wird nicht mehr aufgerufen. CI-Check verhindert dass es versehentlich wieder benutzt wird.
Block E — Auto-Scaling
Performance-basierte Host-Auswahl
findAvailableSharedHost() berechnet pro Host einen Score = max(cpu_pct, ram_pct, disk_pct) im sliding 10-Minuten-Window aus host_metrics. Wählt den Host mit niedrigstem Score, der unter HOST_FULL_THRESHOLD_PCT = 75 liegt.
Vorher: count-basiert (tenant count < maxTenants). maxTenants=15 war eine grobe Schätzung; tatsächliche Kapazität hängt von Performance-Auslastung ab.
Proaktiver Buffer (5min Cron)
shared-host-auto-scale Cron alle 5 Minuten:
- Wenn alle Hosts ≥75% UND kein Host gerade in Provisioning →
createSharedHost()(Hetzner CCX23, ~10min) - Damit gibt's IMMER einen Host mit Platz, kein neuer Tenant wartet
Active Rebalance (15min Cron)
shared-host-rebalance Cron alle 15 Minuten:
- In-flight check: Skipt wenn schon eine Migration läuft
- Source: Host mit höchstem Score über
HOST_OVERLOAD_THRESHOLD_PCT = 85 - Target: Host mit niedrigstem Score, Score < 75% UND Source-Score - Target-Score ≥
REBALANCE_DELTA_PCT = 30 - Tenant-Wahl: jüngster Tenant auf Source (= least Daten = günstigste Migration), mit
REBALANCE_COOLDOWN_HOURS = 6Hysterese (verhindert Bounce-Loop) - Triggert
startMigration({ initiatedBy: 'cron-rebalance' })→ läuft durch die normale Migration-Pipeline
Schwellenwerte (in shared-hosting.service.ts)
| Konstante | Wert | Bedeutung |
|---|---|---|
HOST_FULL_THRESHOLD_PCT | 75 | Über diesem Wert keine neuen Tenants |
HOST_OVERLOAD_THRESHOLD_PCT | 85 | Triggert Active Rebalance |
REBALANCE_DELTA_PCT | 30 | Min Score-Differenz Source/Target |
REBALANCE_COOLDOWN_HOURS | 6 | Pro Tenant max alle 6h |
METRICS_WINDOW_MIN | 10 | Sliding-Window für Score-Berechnung |
Block F — Admin-UI: Audit-Spuren
Migration-Badge auf Tenant-Card
Wenn lastMigrationAt gesetzt → Badge zwischen Header und Footer:
- BLAU bei Cron: "Auto-Rebalanced"
- GRAU bei manuell: "Migriert"
- Footer: "vor 25min" / "vor 3h" / "vor 2d"
- Tooltip:
shared-1 → shared-2 · 02.05.2026 12:34 · Auto-Rebalance (Cron)
"Kundengelöscht"-Badge
Wenn subscriptionStatus='cancelled' + scheduledDeletionAt → roter Border + Badge mit Wipe-Datum.
Tenant-Card kompletter Indikator-Stack
┌─────────────────────────────┐
│ ● Tenant Name ● │ ← Status + Backup-Status (2 Dots)
│ @subdomain │
├─────────────────────────────┤
│ → Auto-Rebalanced vor 25min│ ← (optional, Migration-Badge)
├─────────────────────────────┤
│ 🗑 Kundengelöscht → 02.06.26│ ← (optional, Self-Service-Schließung)
├─────────────────────────────┤
│ 👤 5 · :8101 · 📁 3h │ ← Users · Port · Backup-Alter
└─────────────────────────────┘Block G — CI: Deprecated-Pattern Check
Neues Script scripts/check-deprecated-patterns.sh läuft in CI (.github/workflows/test.yml) — fail-fast bei Match.
Aktuell gechecked
sendCommand('tenant.snapshot|transfer|restore')— alte pg_dump-PipelinesendCommand('shared_tenant.create')— alte PG/MinIO-ProvisioningsubscribeToCommandResultdirekt (stattcallAgentForHost) — Race-Riskprisma.tenant.deleteohne tenant-lifecycle — Zombie-Riskhost.docker.internal:5432— shared-PG ist deprovisioniert
Pfad-Excludes
runMigrationLegacyfür Rollback erhaltenorders.router.tsdedicated-Pfad ist OK (kein shared-host-Agent zum Cleanup)- 8 bekannte
subscribeToCommandResult-Stellen als TODO incremental migrate
Sobald jemand neuen Code schreibt der ein verbotenes Pattern nutzt → CI rot.
Block H — MailPace Health-Check + Sidebar-Indikator
Vorher tauchten ungültige MAILPACE_API_KEY erst beim ersten fehlgeschlagenen Welcome-Mail auf. Operator wusste nicht warum kein Tenant Mails bekam (siehe andreas-Bug 2026-05-02).
Backend: services/mailpace.service.ts
checkMailpaceHealth() macht POST mit Empty-Body:
- MailPace antwortet 401/403 wenn Token ungültig
- 422 oder 2xx wenn Token gültig (validation error wegen Empty-Body, KEINE E-Mail verbraucht)
getMailpaceHealth() cached den letzten Check.
Trigger
- Backend-Startup ruft
checkMailpaceHealth()automatisch auf — bei FehlerERROR-Log: "Token ungültig, KEINE Mails werden versendet" GET /admin/mailpace/healthfür Admin-UI-PollingPOST /admin/mailpace/health/recheckfür Re-Check ohne Backend-Restart (z.B. nach Token-Update in.env)
Admin-UI: Sidebar-Indikator
Sidebar pollt alle 5min /admin/mailpace/health. Bei ok=false:
- Rotes Banner direkt über dem Theme-Toggle: "Mail-Pipeline kaputt — Welcome-Mails kommen nicht an"
- "Re-Check"-Button → triggert sofortigen Re-Check (für nach Token-Update)
- Tooltip mit Detail-Error
Wenn ok=true ist das Banner unsichtbar.
Block I — Shared-Provisioning Finalize-Helper
Vorher lag die ganze Post-Provisioning-Logik (Status='active', Admin-User-Eintrag, Default-Rollen, Process-Engine-Seeds, Welcome-Mail) als ~80 Zeilen inline in agent-ws.ts und wurde durch payload.sharedTenantOrderId getriggert. Beim Wechsel auf tenant-box.create (sendet das Feld nicht) lief NICHTS davon → andreas blieb 'pending', keine Mail.
Fix
services/shared-provisioning-finalize.service.ts extrahiert. Idempotent (prüft "exists" bevor write):
- status='active' + installationStatus='complete'
- UserDirectoryEntry + PrilogMembership für Admin
- Default-UserTypes (Schule: Mitarbeiter/Eltern/Schueler · sonst: Mitglied)
- Process-Engine Default-Templates
- Welcome-Mail (fail-soft)
Wird von BEIDEN Pfaden gerufen:
agent-ws.ts(legacysharedTenantOrderIdTrigger) — inline-Code gelöscht, ersetzt durch 1-Zeilen-Callshared-hosting.service.ts:autoProvisionSharedTenant(neuer tenant-box-Pfad) — explizit gerufen nachcreateTenantBox
CI-Pattern-Checks ergänzt:
userDirectoryEntry.create admin: trueaußerhalb des finalize-Helpers → CI failseedProcessEngineDefaultsinline (außer im Service oder finalize) → CI fail
Bugs gefunden + permanent gefixt (heute)
- Web-Client missing on shared-2 → cloud-init zieht jetzt
/api/artifacts/latest, agent-on-connect Fallback - stale
synapse-tailscale.confauf shared-1 (Port 8101 von alter demo) — manuell entfernt - minioRootUser min(8) zu strikt → reduziert auf min(3) (matches MinIO requirement, sonst tb-demo = 7 chars failed)
- ServerOrder FK Restrict — tenant.delete warf "Foreign key constraint violated" → tenant-lifecycle löscht jetzt ServerOrder VOR Tenant
- Stripe-Subscriptions liefen nach Tenant-Delete weiter → tenant-lifecycle cancelt jetzt zuerst Stripe (Step 0)
- 12 Orphan-Tabellen mit String-tenantId blieben liegen (process_templates, tenant_settings etc.) → tenant-lifecycle cleant generisch
- Race-Condition in sendAgentCommand — Listener wurde NACH sendCommand registriert, schnelle Agent-Antworten (1-2s) gingen verloren → neue
lib/agent-command.tsmit Listener-First-Pattern, alle 4 Services nutzen den shared Helper - restoreBackup ignorierte targetHostId bei inPlace=true → Inter-Host-Migration schickte restore an SOURCE statt TARGET → Cleanup zerstörte den frisch restored Container → fix: targetHostId hat Vorrang
- Bunny DNS POST 405 für update — sporadischer API-Quirk → updateDnsRecord macht jetzt intern delete+create (gibt neue recordId zurück, Caller persistiert)
- shared-hosts DELETE löschte Hetzner-Server nicht → Orphan-Server liefen weiter und kosteten Geld → DELETE ruft jetzt zuerst hetzner.deleteServer (fail-soft mit Warnung)
- Tenant-Board zeigte "X / 15" — count-basierte Auslastung war nicht aussagekräftig → entfernt (Nenner weg, nur Tenant-Count)
- Quick-Signup hardcoded 20-Nutzer-Limit (Light-Relikt) → maxUsersShared=null, maxUsers=99999, DB-Update der 3 bestehenden Tenants. Web-Client zeigt User-Limit nicht mehr an
- MailPace Token-Probleme bemerkten erst beim ersten Send → Startup-Health-Check + Sidebar-Indikator + Re-Check-Endpoint
- Post-Provisioning-Logik nur an alte sharedTenantOrderId gekoppelt → andreas bekam keinen 'active'-Status, keine Welcome-Mail → finalize-Helper extrahiert, beide Pfade rufen ihn
Architektur-Status nach 2026-05-02
Vollständig produktiv:
| Capability | Status |
|---|---|
| Tenant-in-a-Box pro Tenant | ✅ live, 4 Tenants migriert |
| Auto-Provision neue Hosts | ✅ via Hetzner API |
| Backup nach Hetzner Object Storage | ✅ täglich 02:00, 30d Retention |
| Restore in <30s | ✅ verifiziert |
| Inter-Host-Migration | ✅ via Tenant-Box-Pipeline |
| Auto-Scaling (Buffer + Rebalance) | ✅ 5min/15min Crons |
| Tenant-Lifecycle Delete | ✅ inkl. Stripe-Cancel + Orphan-Cleanup |
| Admin-UI vollständig | ✅ Backups-Page + Restore-Button + Badges |
| CI deprecated-pattern check | ✅ |
| MailPace Health-Check | ✅ Startup + Sidebar-Indikator + Re-Check-Endpoint |
| Shared-Provisioning Finalize | ✅ Helper extrahiert, beide Pfade nutzen ihn |
Offen (für später, siehe project_tenant_box_open_items.md Memory):
- Backup L2 (monthly) + L3 (yearly) mit Object-Lock — aktuell nur L1 daily
- Restore-Drill-Cron mit Sandbox-Host (proactive Backup-Korruption finden)
- Update-Pipeline scharf (version-registry + staged rollout) — aktuell nur Scaffolding
- Enterprise-Onboarding-UI (DNS-Anweisung + Cert-Provisioning) — aktuell nur cert-service.ts Stub
- 8 incremental
subscribeToCommandResult-Migrationen
Crons-Übersicht (alle live)
| Cron | Schedule | Zweck |
|---|---|---|
host-metrics-collect | jede Minute | Hetzner CPU/Disk/Network → host_metrics |
host-metrics-cleanup | täglich 04:00 | host_metrics >30d löschen |
shared-host-auto-scale | alle 5min | Buffer-Host provisionieren wenn alle >75% |
shared-host-rebalance | alle 15min | Tenant von >85% auf <55% migrieren |
tenant-box-daily-backup | täglich 02:00 | Backup pro Tenant → Hetzner Object Storage |
tenant-backup-cleanup | täglich 04:45 | Backup-Retention enforcement |
freemium-tenant-wipe | täglich 04:30 | 30d Konto-Schließung → tenant-lifecycle |
freemium-monthly-billing | 1. um 06:00 | Per-Active-User Billing |
marketplace-monthly-billing | 1. um 06:30 | Marketplace Usage-Records |
module-data-cleanup | täglich 03:00 | Modul-Daten 30d nach Deactivate |
file-cleanup | täglich 03:30 | Soft-deleted Files in S3 |
storage-rotation | täglich 04:00 | MinIO SvcAcct Rotation 90d |
Alle sichtbar im Admin unter /cron-jobs mit Run-History + Status.