feat(cluster): Logical Replication wird beim Join automatisch eingerichtet

Bisher war der zweite Node nach dem Join zwar im Cluster registriert und
in der UI sichtbar, replizierte aber keine einzige geteilte Tabelle —
dafuer musste jemand manuell `edgeguard-ctl cluster-setup-standby`
ausfuehren. Wer das uebersah, merkte es erst beim Failover: der neue
Primary stand ohne Domains, Backends, Firewall-Regeln und WireGuard-Keys
da. Das ist jetzt Teil des Join-Vorgangs.

Beide Seiten muessen dafuer vorbereitet sein:

1) Primary, beim Erzeugen des Join-Tokens: ein frisch installierter
   Single-Node hat weder Replikations-Rolle noch PUBLICATION noch
   wal_level=logical. Ohne das liefe das spaetere CREATE SUBSCRIPTION in
   ein 404. Der Token wird deshalb erst ausgegeben, nachdem die
   Publisher-Seite steht — inklusive des einmaligen PG-Restarts
   (wal_level ist ein postmaster-Parameter), der bewusst hier passiert,
   solange der Admin danebensteht und noch kein Peer Traffic erwartet.

   WICHTIG dabei: setupReplicationPrimary rotiert bei jedem Lauf das
   Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
   bereits angebundenem Subscriber wuerde ein zweiter Token-Klick dessen
   Connection-String ungueltig machen und die Replikation still
   anhalten. Deshalb laeuft die Initialisierung nur, wenn PUBLICATION
   und Secret nicht bereits existieren.

2) Neuer Node, nach erfolgreichem Join: cluster-setup-standby laeuft
   detached (die Initialkopie dauert je nach Datenmenge Minuten), der
   Wizard pollt GET /setup/replication-status und zeigt running/done/
   failed an. Schlaegt es fehl, steht das manuelle Kommando inkl.
   Primary-Host direkt daneben statt nur einer Fehlermeldung.

Beides braucht root (psql als postgres, pg_hba, PG-Restart), die API
laeuft als unprivilegierter edgeguard → Aufruf via sudo mit gepinnten
Regeln. Das einzige variable Argument (Primary-Host) wird vorher gegen
Hostname/IP-Syntax geprueft; der Aufruf laeuft ohne Shell. Test dafuer
liegt bei.

Ausserdem zwei Doku-Korrekturen: architecture.md behauptete,
cluster-join richte die Replikation gleich mit ein (tut es nicht,
clusterjoin.Join macht nur Cert + Registrierung), und der Hinweistext
von cluster-join verwies noch auf "PG-Basebackup + KeyDB, Phase 3.5" —
beides laut Doku laengst verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-11 11:40:44 +02:00
parent bb19562bc1
commit 808f6fc055
10 changed files with 429 additions and 11 deletions

View File

@@ -376,7 +376,13 @@
"joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)",
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
"nodeRestartTitle": "Neustart erforderlich",
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:"
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:",
"replRunningTitle": "Cluster-Replikation wird eingerichtet…",
"replRunningDesc": "Die geteilte Konfiguration (Domains, Backends, Firewall-Regeln, WireGuard, DNS, Zertifikate, Benutzer) wird vom Primary kopiert. Das kann je nach Datenmenge einige Minuten dauern — dieses Fenster offen lassen.",
"replDoneTitle": "Cluster-Replikation aktiv",
"replDoneDesc": "Der Knoten ist Logical-Replication-Subscriber. Änderungen am Primary erscheinen ab jetzt automatisch hier.",
"replFailedTitle": "Cluster-Replikation fehlgeschlagen",
"replFailedDesc": "Der Knoten ist im Cluster registriert, repliziert aber noch keine Konfiguration. Auf diesem Knoten manuell nachholen:"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -376,7 +376,13 @@
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
"nodeSuccessDesc": "Cluster certs have been written. One last step:",
"nodeRestartTitle": "Restart required",
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:"
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:",
"replRunningTitle": "Setting up cluster replication…",
"replRunningDesc": "Shared configuration (domains, backends, firewall rules, WireGuard, DNS, certificates, users) is being copied from the primary. Depending on the amount of data this can take a few minutes — keep this window open.",
"replDoneTitle": "Cluster replication active",
"replDoneDesc": "This node is a logical replication subscriber. Changes on the primary now appear here automatically.",
"replFailedTitle": "Cluster replication failed",
"replFailedDesc": "The node is registered in the cluster but is not replicating configuration yet. Run this manually on this node:"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -1,10 +1,10 @@
import { useState } from 'react'
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { Alert, Button, Card, Form, Input, Space, Spin, Typography, message } from 'antd'
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client'
import apiClient, { isEnvelope } from '../../api/client'
import type { SessionUser } from '../../stores/auth'
interface Props {
@@ -26,6 +26,13 @@ interface JoinValues {
token: string
}
interface ReplState {
phase: 'idle' | 'running' | 'done' | 'failed'
primary?: string
error?: string
log?: string
}
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
type Mode = 'standalone' | 'node'
@@ -69,6 +76,38 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
}
}
// Die Logical-Replication-Einrichtung laeuft server-seitig detached
// weiter, nachdem /setup/join-cluster geantwortet hat (die Initialkopie
// der geteilten Tabellen dauert je nach Datenmenge). Hier nur pollen und
// anzeigen — der Wizard ist an dieser Stelle noch pre-auth.
const [repl, setRepl] = useState<ReplState | null>(null)
const replTimer = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!joinDone) return
let stopped = false
const poll = async () => {
try {
const r = await apiClient.get('/setup/replication-status')
const st = isEnvelope(r.data) ? (r.data.data as ReplState) : null
if (stopped || !st) return
setRepl(st)
if (st.phase === 'done' || st.phase === 'failed') {
if (replTimer.current) { clearInterval(replTimer.current); replTimer.current = null }
}
} catch {
// Waehrend des abschliessenden API-Neustarts ist der Endpoint kurz
// weg — weiterpollen statt einen Fehler anzuzeigen.
}
}
void poll()
replTimer.current = setInterval(poll, 3000)
return () => {
stopped = true
if (replTimer.current) { clearInterval(replTimer.current); replTimer.current = null }
}
}, [joinDone])
const onJoin = async (vals: JoinValues) => {
setLoading(true)
try {
@@ -299,6 +338,34 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</div>
</Space>
{repl && repl.phase !== 'idle' && (
<Alert
type={repl.phase === 'done' ? 'success' : repl.phase === 'failed' ? 'error' : 'info'}
showIcon={repl.phase !== 'running'}
icon={repl.phase === 'running' ? <Spin size="small" /> : undefined}
message={
repl.phase === 'running' ? t('setup.replRunningTitle')
: repl.phase === 'done' ? t('setup.replDoneTitle')
: t('setup.replFailedTitle')
}
description={
<Space direction="vertical" size={6} style={{ width: '100%', marginTop: 4 }}>
<Typography.Text type="secondary">
{repl.phase === 'running' ? t('setup.replRunningDesc')
: repl.phase === 'done' ? t('setup.replDoneDesc')
: t('setup.replFailedDesc')}
</Typography.Text>
{repl.phase === 'failed' && (
<>
{repl.error && <Typography.Text code>{repl.error}</Typography.Text>}
<CopyCode value={`sudo edgeguard-ctl cluster-setup-standby ${repl.primary ?? ''}`} />
</>
)}
</Space>
}
/>
)}
<Alert
type="warning"
showIcon