Code-Altlasten aus dem Architektur-Audit bereinigt: - internal/proxy: leerer .gitkeep-Stub (geplanter Write-Proxy nie implementiert) entfernt — keine Go-Referenzen. - promote.go: war reines Physical-Replication-Failover (standby.signal + pg_ctlcluster promote + pg_is_in_recovery) und damit auf dem Logical-Setup TOT (ein Subscriber hat kein standby.signal / ist nie in recovery → Abbruch bei Schritt 1). Neu Logical-aware: Idempotenz-Check (schon Publisher ohne Subscription → fertig) → Subscription lösen (DISABLE+slot_name=NONE+DROP, hängt nicht am toten Publisher) → setupReplicationPrimary (Publisher werden) → ha_nodes.pg_role=primary → keepalived MASTER. Toter KeyDB-Update (cluster:pg-primary-url, wurde nie gelesen) entfernt. - setupReplicationPrimary + dropSubscriptionIfExists aus cluster-init-replication/cluster-setup-standby extrahiert (DRY, bewährte SQL wiederverwendet). WICHTIG: setupReplicationPrimary stellt jetzt sicher dass wal_level=logical AKTIV ist — PG-RESTART falls nötig (reload reicht für wal_level/max_wal_senders nicht; Secondary hat wal_level=replica). Idempotent: Restart nur wenn wal_level != logical. - Doku (CLAUDE.md + architecture.md) auf den bereinigten Stand gezogen. Hinweis: echtes Cross-Node-Failover ist nur im Drill testbar; Build/vet/Tests grün, Bausteine sind die bereits produktiv genutzten SQL-Primitive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
4.5 KiB
Go
125 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
|
)
|
|
|
|
// cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
|
|
// Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
|
|
// ohne externes Quorum zu verhindern.
|
|
//
|
|
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
|
|
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
|
|
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
|
|
// (toten/alten) Primary lösen und selbst Publisher werden.
|
|
//
|
|
// Ablauf:
|
|
// 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
|
|
// 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
|
|
// 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
|
|
// + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
|
|
// 4. ha_nodes.pg_role/role = 'primary'
|
|
// 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
|
|
func cmdPromote(args []string) int {
|
|
pg, err := detectPGConfig()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
|
|
return 1
|
|
}
|
|
|
|
// 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
|
|
pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
|
fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
|
|
subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
|
fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
|
|
hasPub := strings.TrimSpace(string(pubOut)) == "1"
|
|
hasSub := strings.TrimSpace(string(subOut)) == "1"
|
|
if hasPub && !hasSub {
|
|
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
|
|
return 0
|
|
}
|
|
|
|
fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
|
|
|
|
// 2. Subscription zum alten/toten Primary lösen
|
|
if hasSub {
|
|
if err := dropSubscriptionIfExists(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
|
|
return 1
|
|
}
|
|
fmt.Println("✓ Subscription zum alten Primary entfernt")
|
|
}
|
|
|
|
// 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
|
|
if err := setupReplicationPrimary(pg); err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote:", err)
|
|
return 1
|
|
}
|
|
|
|
// 4. ha_nodes-Rolle aktualisieren
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
pool, err := database.Open(ctx, database.ConnStringFromEnv())
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
|
|
fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
|
|
} else {
|
|
defer pool.Close()
|
|
localID, err := loadLocalID()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote: local node ID:", err)
|
|
} else {
|
|
_, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err)
|
|
} else {
|
|
fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
|
|
if pool != nil {
|
|
localID, _ := loadLocalID()
|
|
kg := keepalived.New(pool, localID)
|
|
renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer renderCancel()
|
|
if err := kg.Render(renderCtx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
|
|
fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
|
|
} else {
|
|
fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
|
|
fmt.Println()
|
|
fmt.Println("Empfohlene Nachschritte:")
|
|
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
|
|
fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
|
|
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
|
|
return 0
|
|
}
|
|
|
|
// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf.
|
|
func loadLocalID() (string, error) {
|
|
c, err := cluster.LoadLocalConfig("")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if c.NodeID == "" {
|
|
return "", fmt.Errorf("NODE_ID in node.conf ist leer")
|
|
}
|
|
return c.NodeID, nil
|
|
}
|