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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user