fix(cluster): Repair-Button-Gating auf role statt pg_role — v1.2.87

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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-04 13:03:17 +02:00
parent f7dd7a3a4b
commit 66c71c5fa8
3 changed files with 50 additions and 19 deletions

View File

@@ -1 +1 @@
1.2.86 1.2.87

View File

@@ -63,16 +63,16 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
return return
} }
local := findNode(all, h.LocalID) local := findNode(all, h.LocalID)
standby := findByPGRole(all, "standby")
// Primary → an den Standby delegieren. // Primary → an den Subscriber-Peer (Nicht-Primary) delegieren.
if local != nil && local.PGRole == "primary" { if isPrimaryNode(local) {
standby := findSubscriberPeer(all, h.LocalID)
if h.Aggregator == nil { if h.Aggregator == nil {
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar")) response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
return return
} }
if standby == nil { 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 return
} }
res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath) 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. // Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück.
func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) { func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) {
local := findNode(all, h.LocalID) local := findNode(all, h.LocalID)
primary := findByPGRole(all, "primary") primary := findPrimary(all)
if local != nil && local.PGRole == "primary" { if isPrimaryNode(local) {
return "", errors.New("dieser Node ist der PostgreSQL-Primary — Resync läuft nur auf einem Standby/Subscriber") return "", errors.New("dieser Node ist der Cluster-Primary — Resync läuft nur auf einem Standby/Subscriber")
} }
if primary == nil { 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 { if primary.ID == h.LocalID {
return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich") 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 h.Store != nil {
if all, err := h.Store.List(c.Request.Context()); err == nil { if all, err := h.Store.List(c.Request.Context()); err == nil {
local := findNode(all, h.LocalID) local := findNode(all, h.LocalID)
standby := findByPGRole(all, "standby") standby := findSubscriberPeer(all, h.LocalID)
if local != nil && local.PGRole == "primary" && h.Aggregator != nil && standby != nil { if isPrimaryNode(local) && h.Aggregator != nil && standby != nil {
results := h.Aggregator.FanOut(c.Request.Context(), results := h.Aggregator.FanOut(c.Request.Context(),
[]models.HANode{*standby}, repairAgentPath+"/status", h.LocalID) []models.HANode{*standby}, repairAgentPath+"/status", h.LocalID)
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 { 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 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 { for i := range nodes {
if nodes[i].PGRole == role { if isPrimaryNode(&nodes[i]) {
return &nodes[i] return &nodes[i]
} }
} }
return nil 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: // pickPrimaryHost wählt die beste erreichbare Adresse des Primary:
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige // Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern). // CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).

View File

@@ -394,12 +394,15 @@ export default function ClusterPage() {
// Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel // Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel
// existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert // existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert
// an den Standby-Peer). // an den Subscriber-Peer). Primary = role ODER pg_role 'primary'
const localRole = data?.local_node?.pg_role // (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 const canRepair = !isViewer
&& !!data?.drift_found && !!data?.drift_found
&& (localRole === 'standby' && (localIsPrimary ? hasSubscriberPeer : hasPrimary)
|| (localRole === 'primary' && (data?.peers?.some(p => p.pg_role === 'standby') ?? false)))
const peerColumns: ColumnsType<HANode> = [ const peerColumns: ColumnsType<HANode> = [
{ {
@@ -540,8 +543,7 @@ export default function ClusterPage() {
description={ description={
<> <>
<Paragraph style={{ marginBottom: 8 }}>{t('cluster.driftBannerDesc')}</Paragraph> <Paragraph style={{ marginBottom: 8 }}>{t('cluster.driftBannerDesc')}</Paragraph>
{data.local_node?.pg_role === 'primary' {localIsPrimary && !hasSubscriberPeer
&& !(data.peers?.some(p => p.pg_role === 'standby'))
&& <Text type="secondary">{t('cluster.repair.noStandbyHint')}</Text>} && <Text type="secondary">{t('cluster.repair.noStandbyHint')}</Text>}
</> </>
} }