From 808f6fc055d76f3828c62249399de7eec53c80ad Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 11 Sep 2026 11:40:44 +0200 Subject: [PATCH] feat(cluster): Logical Replication wird beim Join automatisch eingerichtet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/edgeguard-ctl/cluster_join.go | 10 +- docs/architecture.md | 9 +- internal/handlers/cluster.go | 65 +++++- internal/handlers/setup.go | 8 + internal/handlers/setup_replication.go | 195 ++++++++++++++++++ internal/handlers/setup_replication_test.go | 53 +++++ management-ui/src/i18n/locales/de/common.json | 8 +- management-ui/src/i18n/locales/en/common.json | 8 +- management-ui/src/pages/Setup/index.tsx | 73 ++++++- .../debian/edgeguard-api/DEBIAN/postinst | 11 + 10 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 internal/handlers/setup_replication.go create mode 100644 internal/handlers/setup_replication_test.go diff --git a/cmd/edgeguard-ctl/cluster_join.go b/cmd/edgeguard-ctl/cluster_join.go index bafa9d6..1f905bf 100644 --- a/cmd/edgeguard-ctl/cluster_join.go +++ b/cmd/edgeguard-ctl/cluster_join.go @@ -52,8 +52,12 @@ func cmdClusterJoin(args []string) int { fmt.Printf(" CN: %s\n", commonName) fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir) fmt.Printf("\nNächste Schritte:\n") - fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n") - fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n") - fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n") + fmt.Printf(" 1) sudo edgeguard-ctl cluster-setup-standby %s\n", primary) + fmt.Printf(" → richtet die Logical Replication ein. OHNE diesen Schritt ist der\n") + fmt.Printf(" Node zwar im Cluster, bekommt aber KEINE geteilte Config.\n") + fmt.Printf(" 2) sudo systemctl restart edgeguard-api # lädt das neue Cert in den mTLS-Agent-Listener\n") + fmt.Printf(" 3) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer auftaucht\n") + fmt.Printf("\nHinweis: Beim Join über den Setup-Wizard passiert Schritt 1 automatisch;\n") + fmt.Printf("dieser CLI-Pfad ist der manuelle Weg und braucht ihn explizit.\n") return 0 } diff --git a/docs/architecture.md b/docs/architecture.md index 8e8cc8e..19e9654 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -339,7 +339,14 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \ --token ``` -`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_ +`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert) und Node-Registrierung in `ha_nodes` (`autoRegister`) — **mehr nicht**. Die Logical Replication ist ein eigener Schritt (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen, Master-Key-Sync, Config-Regeneration). _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_ + +**Join über den Setup-Wizard (empfohlener Weg) macht beides automatisch:** + +1. Auf dem Primary erzeugt `POST /cluster/join-tokens` den Token — und stellt dabei vorher via `cluster-init-replication` sicher, dass die Publisher-Seite steht (Replikations-Rolle + Secret, `wal_level=logical`, `pg_hba`, PUBLICATION). Ein frisch installierter Single-Node hat das alles noch nicht; ohne diesen Schritt liefe das spätere `CREATE SUBSCRIPTION` in ein 404. Idempotent; der einmalige PG-Restart (`wal_level` ist ein postmaster-Parameter) passiert bewusst hier, solange noch kein zweiter Node Traffic erwartet. +2. Auf dem neuen Node startet `POST /setup/join-cluster` nach erfolgreichem Join `cluster-setup-standby` detached (via `sudo`, da root nötig). Fortschritt pollbar über `GET /setup/replication-status` (`running`/`done`/`failed`); der Wizard zeigt ihn an und gibt bei Fehlschlag das manuelle Kommando aus. + +Der reine CLI-Pfad (`cluster-join`) bleibt der manuelle Weg und erfordert `cluster-setup-standby` weiterhin explizit. --- diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 300573e..cd3e0da 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -53,6 +53,14 @@ type ClusterHandler struct { NodeID string } +const ( + // pgPublicationName + pgReplicationSecretPath spiegeln die Werte aus + // cmd/edgeguard-ctl (egPubName / egReplSecret) — beide Seiten muessen + // dasselbe meinen. + pgPublicationName = "edgeguard_shared" + pgReplicationSecretPath = "/var/lib/edgeguard/pg-replication-secret" +) + func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler { return &ClusterHandler{Store: store, LocalID: localID} } @@ -284,8 +292,7 @@ func (h *ClusterHandler) AgentIdentity(c *gin.Context) { // aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die // Datei fehlt (cluster-init-replication noch nicht ausgeführt). func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) { - const secretPath = "/var/lib/edgeguard/pg-replication-secret" - pass, err := readFileString(secretPath) + pass, err := readFileString(pgReplicationSecretPath) if err != nil { response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen")) return @@ -518,6 +525,20 @@ func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) { // Body optional — wenn leer, läuft der Flow ohne Pre-Register. _ = c.ShouldBindJSON(&req) + // Publisher-Seite sicherstellen, BEVOR ein Token rausgeht. Ein frisch + // installierter Single-Node hat weder Replikations-Rolle noch + // PUBLICATION noch wal_level=logical — der beitretende Node bekaeme + // beim CREATE SUBSCRIPTION nur ein 404 ("pg-replication-secret nicht + // gefunden") und stuende ohne replizierte Config da. Idempotent; der + // PG-Restart (nur beim allerersten Mal noetig, wal_level ist ein + // postmaster-Parameter) passiert hier bewusst, solange der Admin + // danebensteht und noch kein zweiter Node Traffic erwartet. + if err := h.ensureReplicationPublisher(c.Request.Context()); err != nil { + slog.Error("cluster: publisher setup before join-token failed", "error", err) + response.Internal(c, err) + return + } + token, exp, err := h.Tokens.Generate() if err != nil { response.Internal(c, err) @@ -1128,3 +1149,43 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) { "client_cn", cn, "remote", c.ClientIP()) response.OK(c, out) } + +// ensureReplicationPublisher richtet die lokale PG-Instanz als Logical- +// Replication-Publisher ein (Rolle + Secret, wal_level=logical, pg_hba, +// Grants, PUBLICATION). Idempotent — auf einem bereits eingerichteten +// Primary ist es ein No-Op. +// +// Braucht root (psql als postgres, pg_hba schreiben, ggf. PG-Restart), die +// API laeuft als unprivilegierter `edgeguard` → Aufruf via sudo mit +// gepinnter Regel, wie bei den uebrigen privilegierten Operationen. +func (h *ClusterHandler) ensureReplicationPublisher(ctx context.Context) error { + // WICHTIG: nur ausfuehren wenn die Publisher-Seite noch NICHT steht. + // setupReplicationPrimary generiert bei JEDEM Lauf ein neues + // Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit + // bereits angebundenem Subscriber wuerde dessen gespeicherter + // Connection-String damit ungueltig und die Replikation bliebe still + // stehen — ein zweiter Token-Klick duerfte das niemals ausloesen. + // Das Passwort laesst sich nicht wiederverwenden (in PG nur gehasht), + // deshalb ist "schon eingerichtet" hier ein hartes Abbruchkriterium. + if h.Store != nil { + var hasPub bool + if err := h.Store.Pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, + pgPublicationName).Scan(&hasPub); err == nil && hasPub { + if _, err := os.Stat(pgReplicationSecretPath); err == nil { + slog.Info("cluster: replication publisher already set up — skipping init") + return nil + } + } + } + + cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // System-Setup, darf nicht am Request-Context haengen + "cluster-init-replication") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cluster-init-replication: %w: %s", + err, strings.TrimSpace(string(out))) + } + slog.Info("cluster: replication publisher ensured") + return nil +} diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 975cc62..3cd71da 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -64,6 +64,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) { g.POST("/complete", h.Complete) g.POST("/complete-node", h.CompleteAsNode) g.POST("/join-cluster", h.JoinCluster) + g.GET("/replication-status", h.ReplicationStatus) } // RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup @@ -206,6 +207,13 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) { go h.preRegisterPrimary(body.PrimaryFQDN) } + // Logical Replication automatisch einrichten. Ohne diesen Schritt waere + // der Node zwar im Cluster registriert, wuerde aber keinerlei geteilte + // Config (Domains, Backends, Firewall-Rules, WireGuard, …) bekommen — + // was frueher erst beim Failover auffiel. Laeuft detached, der Wizard + // pollt /setup/replication-status. + h.startReplicationSetup(body.PrimaryFQDN) + response.OK(c, gin.H{ "completed": st.Completed, "is_cluster_node": st.IsClusterNode, diff --git a/internal/handlers/setup_replication.go b/internal/handlers/setup_replication.go new file mode 100644 index 0000000..91da094 --- /dev/null +++ b/internal/handlers/setup_replication.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" +) + +// Automatische Logical-Replication-Einrichtung beim Cluster-Join. +// +// Früher war das ein manueller Schritt: nach dem Join musste der Operator +// auf dem neuen Node `edgeguard-ctl cluster-setup-standby ` +// ausführen. Wer das übersah, hatte einen Node, der im Cluster sichtbar +// war, aber KEINE geteilte Config replizierte — und merkte es erst beim +// Failover. Deshalb läuft es jetzt direkt aus dem Join heraus. +// +// Der eigentliche Ablauf bleibt im CLI (`cluster-setup-standby`): er +// braucht root (psql als postgres-User, pg_hba, render-config), die API +// läuft als unprivilegierter `edgeguard`. Aufruf daher via sudo mit +// gepinnter Regel — gleiches Muster wie bei apt-get/systemctl/tee. +// +// Weil die Initialkopie der geteilten Tabellen Minuten dauern kann, läuft +// das detached; der Setup-Wizard pollt GET /setup/replication-status. + +const replicationStateFile = "/var/lib/edgeguard/replication-setup-state.json" + +const ( + replPhaseIdle = "idle" + replPhaseRunning = "running" + replPhaseDone = "done" + replPhaseFailed = "failed" +) + +// replStateMu serialisiert Lesen/Schreiben der State-Datei (HTTP-Handler +// + Hintergrund-Goroutine greifen gleichzeitig zu). +var replStateMu sync.Mutex + +// ReplicationSetupState hält den Fortschritt der Standby-Einrichtung. +// Persistiert, damit der Status einen API-Neustart übersteht — der ist +// der letzte Schritt des Setups und würde den Zustand sonst verlieren. +type ReplicationSetupState struct { + Phase string `json:"phase"` + Primary string `json:"primary,omitempty"` + Error string `json:"error,omitempty"` + Log string `json:"log,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +func readReplicationState() ReplicationSetupState { + replStateMu.Lock() + defer replStateMu.Unlock() + raw, err := os.ReadFile(replicationStateFile) + if err != nil { + return ReplicationSetupState{Phase: replPhaseIdle} + } + var st ReplicationSetupState + if err := json.Unmarshal(raw, &st); err != nil { + return ReplicationSetupState{Phase: replPhaseIdle} + } + if st.Phase == "" { + st.Phase = replPhaseIdle + } + // Ein "running", das älter als das CLI-Timeout ist, kann nur von einem + // gestorbenen Prozess stammen (z. B. OOM-Kill). Sonst haengt der Wizard + // ewig im Spinner. + if st.Phase == replPhaseRunning && !st.StartedAt.IsZero() && + time.Since(st.StartedAt) > 15*time.Minute { + st.Phase = replPhaseFailed + st.Error = "Zeitüberschreitung — Einrichtung lief länger als 15 Minuten. " + + "Manuell nachholen: sudo edgeguard-ctl cluster-setup-standby " + st.Primary + } + return st +} + +func writeReplicationState(st ReplicationSetupState) { + st.UpdatedAt = time.Now() + raw, err := json.Marshal(st) + if err != nil { + return + } + replStateMu.Lock() + defer replStateMu.Unlock() + if err := configgen.AtomicWrite(replicationStateFile, raw, 0o640); err != nil { + slog.Warn("setup: replication state write failed", "error", err) + } +} + +// validPrimaryHost laesst nur das durch, was ein Hostname oder eine IP +// sein kann. exec.Command startet keine Shell, Metazeichen koennen also +// ohnehin nichts ausloesen — die Pruefung haelt aber Unsinn von der +// sudo-Regel fern und liefert dem Operator einen klaren Fehler statt +// eines kryptischen CLI-Abbruchs. +func validPrimaryHost(h string) bool { + h = strings.TrimSpace(h) + if h == "" || len(h) > 253 { + return false + } + if net.ParseIP(h) != nil { + return true + } + for _, label := range strings.Split(h, ".") { + if label == "" { + return false + } + for _, r := range label { + isAlnum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') + if !isAlnum && r != '-' { + return false + } + } + } + return true +} + +// startReplicationSetup richtet diesen Node im Hintergrund als Logical- +// Replication-Subscriber ein. Nicht-blockierend: der Join-Request +// antwortet sofort, der Wizard pollt den Status. +func (h *SetupHandler) startReplicationSetup(primary string) { + primary = strings.ToLower(strings.TrimSpace(primary)) + if !validPrimaryHost(primary) { + writeReplicationState(ReplicationSetupState{ + Phase: replPhaseFailed, + Error: "ungültiger Primary-Host: " + primary, + }) + return + } + + writeReplicationState(ReplicationSetupState{ + Phase: replPhaseRunning, + Primary: primary, + StartedAt: time.Now(), + }) + + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("setup: replication setup panic", "panic", r) + writeReplicationState(ReplicationSetupState{ + Phase: replPhaseFailed, Primary: primary, + Error: "interner Fehler bei der Replikations-Einrichtung", + }) + } + }() + + slog.Info("setup: starting logical replication setup", "primary", primary) + // Kein Request-Context: der Join-Request ist längst beantwortet, + // und ein Abbruch mitten im CREATE SUBSCRIPTION wäre schlimmer + // als ein Weiterlaufen. + cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // detached by design — darf nicht am Request haengen + "cluster-setup-standby", primary) + out, err := cmd.CombinedOutput() + logTail := tailString(string(out), 4000) + + if err != nil { + slog.Warn("setup: logical replication setup failed", + "primary", primary, "error", err, "output", logTail) + writeReplicationState(ReplicationSetupState{ + Phase: replPhaseFailed, Primary: primary, + Error: err.Error(), Log: logTail, + }) + return + } + slog.Info("setup: logical replication setup finished", "primary", primary) + writeReplicationState(ReplicationSetupState{ + Phase: replPhaseDone, Primary: primary, Log: logTail, + }) + }() +} + +// tailString kuerzt lange CLI-Ausgaben auf die letzten n Bytes — der +// interessante Teil (Fehler, Abschlussmeldung) steht am Ende. +func tailString(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +// ReplicationStatus liefert den Fortschritt der automatischen Standby- +// Einrichtung. Liegt bewusst auf der Setup-Gruppe (pre-auth): der Wizard +// pollt es, bevor auf dem neuen Node ueberhaupt ein Login moeglich ist. +func (h *SetupHandler) ReplicationStatus(c *gin.Context) { + response.OK(c, readReplicationState()) +} diff --git a/internal/handlers/setup_replication_test.go b/internal/handlers/setup_replication_test.go new file mode 100644 index 0000000..607297c --- /dev/null +++ b/internal/handlers/setup_replication_test.go @@ -0,0 +1,53 @@ +package handlers + +import "testing" + +// validPrimaryHost bewacht das einzige variable Argument einer sudo-Regel +// (`edgeguard-ctl cluster-setup-standby *`). Der Aufruf laeuft zwar ohne +// Shell, aber die Pruefung soll trotzdem halten was sie verspricht. +func TestValidPrimaryHost(t *testing.T) { + valid := []string{ + "utm-1.netcell-it.de", + "primary", + "10.0.5.1", + "89.163.205.6", + "2001:db8::1", + "a-b-c.example.com", + } + for _, h := range valid { + if !validPrimaryHost(h) { + t.Errorf("validPrimaryHost(%q) = false, erwartet true", h) + } + } + + invalid := []string{ + "", + " ", + "host; rm -rf /", + "host && reboot", + "host|tee", + "host$(id)", + "host`id`", + "--tls-dir=/tmp/evil", + "host with space", + "host\nsecond-line", + "..", + "host..example.com", + "/etc/passwd", + } + for _, h := range invalid { + if validPrimaryHost(h) { + t.Errorf("validPrimaryHost(%q) = true, erwartet false", h) + } + } +} + +func TestValidPrimaryHostRejectsOverlongName(t *testing.T) { + long := make([]byte, 254) + for i := range long { + long[i] = 'a' + } + if validPrimaryHost(string(long)) { + t.Error("Hostname > 253 Zeichen muss abgelehnt werden") + } +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 76db7a5..9ae7987 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index cff7bc5..f564d4e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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", diff --git a/management-ui/src/pages/Setup/index.tsx b/management-ui/src/pages/Setup/index.tsx index 408cccb..5a5f1d1 100644 --- a/management-ui/src/pages/Setup/index.tsx +++ b/management-ui/src/pages/Setup/index.tsx @@ -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(null) + const replTimer = useRef | 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) { + {repl && repl.phase !== 'idle' && ( + : undefined} + message={ + repl.phase === 'running' ? t('setup.replRunningTitle') + : repl.phase === 'done' ? t('setup.replDoneTitle') + : t('setup.replFailedTitle') + } + description={ + + + {repl.phase === 'running' ? t('setup.replRunningDesc') + : repl.phase === 'done' ? t('setup.replDoneDesc') + : t('setup.replFailedDesc')} + + {repl.phase === 'failed' && ( + <> + {repl.error && {repl.error}} + + + )} + + } + /> + )} +