From 66c71c5fa8e96b3c373dc873ca3dcce121554950 Mon Sep 17 00:00:00 2001 From: Debian Date: Thu, 4 Jun 2026 13:03:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(cluster):=20Repair-Button-Gating=20auf=20ro?= =?UTF-8?q?le=20statt=20pg=5Frole=20=E2=80=94=20v1.2.87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg_role bleibt nach cluster-setup-standby auf 'standalone' (nur 'promote' setzt 'primary'), daher erschien der Button auf dem Primary (role=primary, pg_role=standalone) nicht. Gating + Dispatch + Status nutzen jetzt isPrimaryNode = role=='primary' || pg_role=='primary' (wie keepalived); Resync-Ziel = Nicht-Primary-Peer. Backend (cluster_repair.go) + UI (Cluster/index.tsx). Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- internal/handlers/cluster_repair.go | 53 ++++++++++++++++++----- management-ui/src/pages/Cluster/index.tsx | 14 +++--- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/VERSION b/VERSION index 3bacf4a..fba473b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.86 \ No newline at end of file +1.2.87 \ No newline at end of file diff --git a/internal/handlers/cluster_repair.go b/internal/handlers/cluster_repair.go index 94cbc73..0c8b30a 100644 --- a/internal/handlers/cluster_repair.go +++ b/internal/handlers/cluster_repair.go @@ -63,16 +63,16 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) { return } local := findNode(all, h.LocalID) - standby := findByPGRole(all, "standby") - // Primary → an den Standby delegieren. - if local != nil && local.PGRole == "primary" { + // Primary → an den Subscriber-Peer (Nicht-Primary) delegieren. + if isPrimaryNode(local) { + standby := findSubscriberPeer(all, h.LocalID) if h.Aggregator == nil { response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar")) return } if standby == nil { - response.BadRequest(c, errors.New("kein Standby-Node gefunden, an den der Resync delegiert werden könnte")) + response.BadRequest(c, errors.New("kein Standby-/Subscriber-Node gefunden, an den der Resync delegiert werden könnte")) return } res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath) @@ -131,13 +131,13 @@ func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) { // Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück. func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) { local := findNode(all, h.LocalID) - primary := findByPGRole(all, "primary") + primary := findPrimary(all) - if local != nil && local.PGRole == "primary" { - return "", errors.New("dieser Node ist der PostgreSQL-Primary — Resync läuft nur auf einem Standby/Subscriber") + if isPrimaryNode(local) { + return "", errors.New("dieser Node ist der Cluster-Primary — Resync läuft nur auf einem Standby/Subscriber") } if primary == nil { - return "", errors.New("kein PostgreSQL-Primary im Cluster gefunden — Resync-Quelle unbekannt") + return "", errors.New("kein Cluster-Primary gefunden — Resync-Quelle unbekannt") } if primary.ID == h.LocalID { return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich") @@ -200,8 +200,8 @@ func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) { if h.Store != nil { if all, err := h.Store.List(c.Request.Context()); err == nil { local := findNode(all, h.LocalID) - standby := findByPGRole(all, "standby") - if local != nil && local.PGRole == "primary" && h.Aggregator != nil && standby != nil { + standby := findSubscriberPeer(all, h.LocalID) + if isPrimaryNode(local) && h.Aggregator != nil && standby != nil { results := h.Aggregator.FanOut(c.Request.Context(), []models.HANode{*standby}, repairAgentPath+"/status", h.LocalID) if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 { @@ -305,15 +305,44 @@ func findNode(nodes []models.HANode, id string) *models.HANode { return nil } -func findByPGRole(nodes []models.HANode, role string) *models.HANode { +// isPrimaryNode: ein Node gilt als Primary (Publication-Quelle), wenn +// role ODER pg_role "primary" ist. pg_role bleibt nach cluster-setup- +// standby auf "standalone" (nur `promote` setzt es), daher ist role das +// verlässliche Signal — analog zur keepalived-Logik. +func isPrimaryNode(n *models.HANode) bool { + return n != nil && (n.Role == "primary" || n.PGRole == "primary") +} + +// findPrimary liefert den Primary-Node (Resync-Quelle). +func findPrimary(nodes []models.HANode) *models.HANode { for i := range nodes { - if nodes[i].PGRole == role { + if isPrimaryNode(&nodes[i]) { return &nodes[i] } } return nil } +// findSubscriberPeer liefert den Resync-Ziel-Peer: ein anderer Node, der +// NICHT der Primary ist (in einem 2-Node-Cluster der Standby/Subscriber). +// Bevorzugt einen online erreichbaren Peer. +func findSubscriberPeer(nodes []models.HANode, localID string) *models.HANode { + var fallback *models.HANode + for i := range nodes { + n := &nodes[i] + if n.ID == localID || isPrimaryNode(n) { + continue + } + if n.Status == "online" { + return n + } + if fallback == nil { + fallback = n + } + } + return fallback +} + // pickPrimaryHost wählt die beste erreichbare Adresse des Primary: // Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige // CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern). diff --git a/management-ui/src/pages/Cluster/index.tsx b/management-ui/src/pages/Cluster/index.tsx index a703917..c940c67 100644 --- a/management-ui/src/pages/Cluster/index.tsx +++ b/management-ui/src/pages/Cluster/index.tsx @@ -394,12 +394,15 @@ export default function ClusterPage() { // Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel // existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert - // an den Standby-Peer). - const localRole = data?.local_node?.pg_role + // an den Subscriber-Peer). Primary = role ODER pg_role 'primary' + // (pg_role bleibt nach setup-standby 'standalone', role ist verlässlich). + const isPrimaryNode = (n?: HANode | null) => !!n && (n.pg_role === 'primary' || n.role === 'primary') + const localIsPrimary = isPrimaryNode(data?.local_node) + const hasSubscriberPeer = data?.peers?.some(p => !isPrimaryNode(p)) ?? false + const hasPrimary = localIsPrimary || (data?.peers?.some(isPrimaryNode) ?? false) const canRepair = !isViewer && !!data?.drift_found - && (localRole === 'standby' - || (localRole === 'primary' && (data?.peers?.some(p => p.pg_role === 'standby') ?? false))) + && (localIsPrimary ? hasSubscriberPeer : hasPrimary) const peerColumns: ColumnsType = [ { @@ -540,8 +543,7 @@ export default function ClusterPage() { description={ <> {t('cluster.driftBannerDesc')} - {data.local_node?.pg_role === 'primary' - && !(data.peers?.some(p => p.pg_role === 'standby')) + {localIsPrimary && !hasSubscriberPeer && {t('cluster.repair.noStandbyHint')}} }