Tenant-Migration & Auto-Provisioning (Ist-Stand)
Status: Live-getestet 2026-05-01. Erste echte Migration leander shared-1 → shared-2 erfolgreich End-to-End. Auto-Provisioning ueber das Admin-Portal startet Hetzner-Server inkl. cloud-init in ~10 Minuten.
Anwender-Sicht: admin-tenant-board.md. Dieses Dokument deckt die technische Architektur, alle Bug-Fixes vom 2026-05-01 und die persistente Quelle jedes Fixes.
Architektur
api.prilog.chat Hetzner Cloud
┌─────────────────┐ ┌─────────────┐
│ prilog-admin │ /shared-hosts │ │
│ /tenant-board │ ─── auto- →│ shared-1 │
└─────────────────┘ provision │ shared-2 │
│ │ shared-N │
│ Migration-Engine └──────┬──────┘
▼ (sendCommand WS) │
┌─────────────────┐ │
│ backend-api │ ←── Agent-WS ────────┘
│ agent-ws.ts │
│ tenant- │
│ migration.ts │
└─────────────────┘Drei zusammenarbeitende Komponenten:
- prilog-backend-api — Service
tenant-migration.service.tsorchestriert die 8 Steps. Serviceshared-hosting.service.tsmacht Auto-Provisioning ueber Hetzner-API + cloud-init. - prilog-agent — laeuft auf jedem Shared-Host, empfaengt Commands ueber WebSocket. Handler in
migration.tsmachen die echte Arbeit (pg_dump, mc, rsync, pg_restore, docker compose). - prilog-admin — Frontend mit Tenant-Board (Drag-Drop) + Shared-Hosts-Page (Auto-Provision-Modal).
Migration: 8 Steps
preflight → Target-Kapazitaet, Status, Tenant-Stillstand-Checks
freeze → Tenant-Schreibzugriff sperren (TenantSetting maintenance.frozen)
snapshot → pg_dump (custom format) + mc mirror MinIO + tar synapse-data + tar compose-dir
transfer → rsync ueber Tailscale source → target
restore → pg_restore + mc mb + tar extract + docker compose up -d
cutover → DNS via Bunny + ServerOrder.sharedHostId/Port + Source-Container stop
verify → curl http://localhost:<port>/_matrix/client/versions
cleanup → Tarball auf Target loeschen, freeze aufhebenBei Fehler: rollback (DB unveraendert, Source bleibt funktional). Source-Daten bleiben 24h+ als Sicherheitsnetz auf altem Host.
Code: prilog-backend-api/src/services/tenant-migration.service.ts Agent-Handler: prilog-agent/src/handlers/migration.ts
Auto-Provisioning eines neuen Shared-Hosts
Flow
Admin click "Auto-provisionieren"
→ POST /admin/shared-hosts/auto-provision { serverType, location, maxTenants }
→ createSharedHost() in shared-hosting.service.ts:
1. agentToken generieren (random 32-byte hex)
2. DB-Eintrag in shared_hosts mit status='provisioning'
3. Cloud-init template rendern (mit agentToken + bunnyApiKey + clusterSshKey)
4. hetzner.createServer({ serverType, location, userData: cloudInit, sshKeys: ['prilog'] })
5. ipAddress + hetznerServerId nachtragen
→ Cloud-init laeuft auf neuem Server (~10 Min):
- apt update, Tailscale, PostgreSQL 16, MinIO, Nginx
- Wildcard-Cert via certbot-dns-bunny
- /root/.ssh/id_ed25519 = Cluster-SSH-Key (fuer Inter-Host-Migration)
- mc alias 'local' fuer MinIO
- prilog-agent installieren + systemd-Unit
→ Agent connectet zu wss://api.prilog.chat/api/agent/ws mit AGENT_TOKEN
→ agent-ws.ts: sharedHost.status='provisioning' → 'active' (auto)Server-Typ-Optionen
// shared-hosting.service.ts:22 SHARED_HOST_OPTIONS
serverTypes: [
{ id: 'ccx13', label: 'CCX13 — 2 vCPU, 8 GB', recommendedMaxTenants: 15 },
{ id: 'ccx23', label: 'CCX23 — 4 vCPU, 16 GB', recommendedMaxTenants: 30 },
{ id: 'ccx33', label: 'CCX33 — 8 vCPU, 32 GB', recommendedMaxTenants: 60 },
{ id: 'ccx43', label: 'CCX43 — 16 vCPU, 64 GB', recommendedMaxTenants: 120 },
]
locations: [ 'fsn1', 'nbg1', 'hel1', 'ash' ]Hardgekodet — Hetzner-Typen aendern sich kaum, neue dazu = git commit + Backend-Deploy.
Buffer-Strategie
ensureSharedHostCapacity() laeuft im Hintergrund: wenn alle Hosts voll, wird automatisch ein neuer auto-provisioniert. Tenants warten bis er bereit ist, werden dann ueber assignPendingSharedTenants() zugewiesen.
Cloud-Init: was passiert auf dem neuen Server
Vollstaendiges Template in prilog-backend-api/src/services/shared-hosting.service.ts buildSharedHostCloudInit():
| Stufe | Was |
|---|---|
| packages | curl, wget, ufw, fail2ban, git, docker.io, docker-compose, nginx, certbot, python3-pip |
| Tailscale | install + tailscale up --auth-key=<TS_KEY> |
| PostgreSQL 16 | apt-Repo, listen_addresses auf Tailscale-IP, pg_hba.conf fuer 100.64.0.0/10 |
| MinIO | binary download, systemd-Unit, MINIO_ROOT_USER=prilog-shared, alias local setzen |
| Nginx | /etc/nginx/prilog-tenants/ Include-Dir |
| Prilog-Verzeichnisse | /opt/prilog/tenants/, /etc/prilog/port-registry.json, /etc/prilog/host.json |
| Firewall | UFW: 22, 80, 443, 8448, tailscale0 |
| Wildcard-Cert | certbot-dns-bunny mit BUNNY_API_KEY → *.prilog.team |
| Cluster-SSH-Key | /root/.ssh/id_ed25519 aus Backend-Setting (fuer Inter-Host-rsync) |
| prilog-agent | Node.js 20 + git clone + install.sh (npm install, npm build, systemd) |
| mc | mc-Client + mc alias set local http://localhost:9000 prilog-shared <pw> |
Existierende Hosts (manuell aufgesetzt vor Auto-Provisioning) bekommen das gleiche per prilog-infra/ops/scripts/setup/shared-host.sh.
Bug-Fix-Historie 2026-05-01
Erste End-to-End-Migration brachte 6 Bugs zutage. Alle Fixes sind in den Source-Repos verankert — Reinstallation des Admin-Portals oder neuer Hosts kann sie nicht regenerieren.
1. Cloud-init YAML-Parser-Crash
Symptom: Cloud-init liefert status=done aber errors: 'empty cloud config' — kein einziger runcmd-Schritt lief. Ursache: Eine Zeile - echo '{"next_port": 8101, ...}' > /etc/prilog/port-registry.json — der : im JSON wurde von YAML als Mapping interpretiert. Fix: Heredoc-Block (- |) statt Inline-echo in shared-hosting.service.ts buildSharedHostCloudInit().
2. BACKEND_WS_URL ohne /api-Praefix
Symptom: Agent connectet, bekommt 404 Loop, 30+ Reconnect-Attempts. Ursache: Cloud-init schrieb BACKEND_WS_URL=wss://api.prilog.chat/agent/ws. Backend ist aber unter /api/agent/ws registriert. Fix in Source:
prilog-backend-api/src/services/shared-hosting.service.tscloud-init-Templateprilog-agent/install.shDefault-Fallback
3. Cluster-SSH-Key fehlt fuer Inter-Host-Migration
Symptom: transfer-Step Permission denied (publickey) beim rsync source → target. Ursache: Hetzner injiziert nur den prilog-Public-Key (via sshKeys: ['prilog']). Der Private-Key fuer Inter-Host-rsync fehlte. Fix: Cloud-init liest den Cluster-Private-Key aus ENV SHARED_HOST_SSH_PRIVATE_KEY (oder Default-File ~/.ssh/prilog auf api-Server) und schreibt ihn nach /root/.ssh/id_ed25519. Plus shared-host.sh akzeptiert CLUSTER_SSH_KEY-env fuer manuelle Setups.
4. sendAgentCommand parste Host-ID aus Name
Symptom: Migration-Service sendet tenant.snapshot an SHARED-HOST-2, aber Agent ist als SHARED-HOST-3 registriert (DB-id, nicht Position) → Agent nicht verbunden. Ursache: Funktion sendAgentCommand parste shared-2 → SHARED-HOST-2. Bei Loeschung+Neuanlage stimmt das nicht mehr. Fix in Source: prilog-backend-api/src/services/tenant-migration.service.ts — host.id direkt durchreichen.
5. mc alias falsch benannt
Symptom: restore-Step mc: <ERROR> Unable to make bucket: Access Denied. Ursache: Cloud-init erstellte alias prilog, Migration-Handler erwartet local. Fix in Source: Beide cloud-init + shared-host.sh nutzen jetzt local.
6. Compose-Config nicht mit-snapshottet
Symptom: verify-Step Synapse-Endpoint reagiert nicht. Ursache: Snapshot bundelte nur DB + MinIO + Synapse-Media, aber nicht /opt/prilog/tenants/<slug>/ (homeserver.yaml + docker-compose.yml). Auf Target-Host startete docker compose up -d nichts, weil das compose-File fehlte. Fix in Source: Snapshot bundlet jetzt compose.tar.gz, Restore extrahiert und startet Container. Plus Volume-Permission-Fix (chown 991:991 media_store) wie bei initialer Provisionierung.
7. pg_dump Permission Denied (vor 5. erkannt)
Symptom: pg_dump: error: could not open output file ...: Permission denied. Ursache: Agent erstellt Dir mit root, dann sudo -u postgres pg_dump -f <dir>/db.dump — postgres-User darf nicht schreiben. Fix in Source: stdout-Pipe statt -f-Flag: sudo -u postgres pg_dump -Fc <db> > <dir>/db.dump — bash (root) schreibt Datei.
8. Agent-Status-Auto-Active fehlte
Symptom: Neuer Host bleibt provisioning selbst nachdem Agent connected. Ursache: agent-ws.ts setzte nur den localSockets-Eintrag, nicht den sharedHost.status. Fix in Source: Beim ersten Connect mit status='provisioning' → DB-Update auf 'active'.
Code-Persistenz-Map
| Bug-Fix | Source-Datei | Wirkt beim |
|---|---|---|
| YAML-Parse | shared-hosting.service.ts | Auto-Provisioning |
| /api-Praefix | shared-hosting.service.ts + agent/install.sh | Auto + Manuell |
| Cluster-SSH-Key | shared-hosting.service.ts + shared-host.sh | Auto + Manuell |
| host.id-Mapping | tenant-migration.service.ts | Backend permanent |
| mc alias 'local' | shared-hosting.service.ts + shared-host.sh | Auto + Manuell |
| compose-Snapshot | agent/migration.ts | Beim git pull jedes Hosts |
| pg_dump-Pipe | agent/migration.ts | Beim git pull jedes Hosts |
| Auto-active | agent-ws.ts | Backend permanent |
Tester-Workflow
Neuen Shared-Host bestellen (~10 Min)
- Admin-Portal → Sidebar → Shared Hosts
- Button 🟦 Auto-provisionieren
- Server-Typ + Location + Max-Tenants waehlen
- Confirm-Dialog mit Preis-Hinweis bestaetigen
- Karte erscheint mit
status='provisioning', IP wird ~30 Sek. spaeter eingetragen - Nach ~10 Min →
status='active'. Agent connected.
Tenant von shared-N auf shared-M migrieren
- Admin-Portal → Sidebar → Tenant-Board
- Karte des Tenants per Drag-Drop in die Spalte des Ziel-Hosts ziehen
- Confirm-Dialog mit Downtime-Hinweis
- Live-Progress-Panel zeigt 8 Steps mit Status-Punkten
- Bei Erfolg: Tenant ist auf neuem Host, alte Daten bleiben 24h+ als Backup
- Bei Fehler: Source unveraendert, Error-Details im Panel
Leeren Host loeschen
- Tenant-Board oder Shared-Hosts-Page: Trash-Button auf der Karte (nur sichtbar wenn 0 Tenants)
- Bestaetigen → DB-Eintrag weg
- Hetzner-Konsole: Server selbst loeschen (Backend macht das nicht automatisch — bewusste Sicherung)
Was bewusst (noch) nicht geht
- Pro-Upgrade (shared → dedicated): HTTP 501. Braucht erst Hetzner-VPS-Provisioning + Komplett-Migration.
- Source-Cleanup-Cron: alte Daten auf Source-Host muss heute manuell aufgeraeumt werden. Geplant: 24h-Sicherheitsfenster, dann auto-cleanup.
- Hetzner-Server-Auto-Delete: Trash-Button entfernt nur DB-Eintrag, nicht den Hetzner-Server selbst (bewusste Sicherung gegen versehentliches Loeschen).
- Server-Typen DB-getrieben: Hardgekodet in
SHARED_HOST_OPTIONS. Aenderung = git commit. Bei haeufigem Bedarf koennte man eineshared_host_types-Tabelle bauen. - Anderer Cluster-SSH-Key als prilog: heute der prilog-Key, der auch User-SSH-Zugang gibt. Bei Compromise eines Hosts kann Angreifer SSH zu allen anderen Hosts. Eigener Cluster-Key (separates Keypair) waere sauberer — defer'd.
Anschlussfaehig an
- admin-tenant-board.md — Anwender-Doku
prilog-infra/ops/scripts/setup/shared-host.sh— manuelles Setup-Skript fuer existierende Hosts (Repository: prilog-infra)