Skip to content

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:

  1. prilog-backend-api — Service tenant-migration.service.ts orchestriert die 8 Steps. Service shared-hosting.service.ts macht Auto-Provisioning ueber Hetzner-API + cloud-init.
  2. prilog-agent — laeuft auf jedem Shared-Host, empfaengt Commands ueber WebSocket. Handler in migration.ts machen die echte Arbeit (pg_dump, mc, rsync, pg_restore, docker compose).
  3. 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 aufheben

Bei 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

ts
// 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():

StufeWas
packagescurl, wget, ufw, fail2ban, git, docker.io, docker-compose, nginx, certbot, python3-pip
Tailscaleinstall + tailscale up --auth-key=<TS_KEY>
PostgreSQL 16apt-Repo, listen_addresses auf Tailscale-IP, pg_hba.conf fuer 100.64.0.0/10
MinIObinary 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
FirewallUFW: 22, 80, 443, 8448, tailscale0
Wildcard-Certcertbot-dns-bunny mit BUNNY_API_KEY → *.prilog.team
Cluster-SSH-Key/root/.ssh/id_ed25519 aus Backend-Setting (fuer Inter-Host-rsync)
prilog-agentNode.js 20 + git clone + install.sh (npm install, npm build, systemd)
mcmc-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.ts cloud-init-Template
  • prilog-agent/install.sh Default-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-2SHARED-HOST-2. Bei Loeschung+Neuanlage stimmt das nicht mehr. Fix in Source: prilog-backend-api/src/services/tenant-migration.service.tshost.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-FixSource-DateiWirkt beim
YAML-Parseshared-hosting.service.tsAuto-Provisioning
/api-Praefixshared-hosting.service.ts + agent/install.shAuto + Manuell
Cluster-SSH-Keyshared-hosting.service.ts + shared-host.shAuto + Manuell
host.id-Mappingtenant-migration.service.tsBackend permanent
mc alias 'local'shared-hosting.service.ts + shared-host.shAuto + Manuell
compose-Snapshotagent/migration.tsBeim git pull jedes Hosts
pg_dump-Pipeagent/migration.tsBeim git pull jedes Hosts
Auto-activeagent-ws.tsBackend permanent

Tester-Workflow

Neuen Shared-Host bestellen (~10 Min)

  1. Admin-Portal → Sidebar → Shared Hosts
  2. Button 🟦 Auto-provisionieren
  3. Server-Typ + Location + Max-Tenants waehlen
  4. Confirm-Dialog mit Preis-Hinweis bestaetigen
  5. Karte erscheint mit status='provisioning', IP wird ~30 Sek. spaeter eingetragen
  6. Nach ~10 Min → status='active'. Agent connected.

Tenant von shared-N auf shared-M migrieren

  1. Admin-Portal → Sidebar → Tenant-Board
  2. Karte des Tenants per Drag-Drop in die Spalte des Ziel-Hosts ziehen
  3. Confirm-Dialog mit Downtime-Hinweis
  4. Live-Progress-Panel zeigt 8 Steps mit Status-Punkten
  5. Bei Erfolg: Tenant ist auf neuem Host, alte Daten bleiben 24h+ als Backup
  6. Bei Fehler: Source unveraendert, Error-Details im Panel

Leeren Host loeschen

  1. Tenant-Board oder Shared-Hosts-Page: Trash-Button auf der Karte (nur sichtbar wenn 0 Tenants)
  2. Bestaetigen → DB-Eintrag weg
  3. 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 eine shared_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)