cluster-init-replication: - listen_addresses = '*' damit Cluster-Peers PG auf :5432 erreichen können - max_replication_slots = 20 / max_wal_senders = 10 (verhindert Slot-Erschöpfung bei initaler Tabellen-Synchronisation mit vielen gleichzeitigen Sync-Workern) - pg-replication-secret: Ownership an edgeguard-User (API-Lesezugriff) - detectPGConfig() statt hardcoded PG 16 (System läuft PG 17) cluster-setup-standby: - syncMasterKey(): holt /var/lib/edgeguard/.master_key via mTLS vom Primary — ohne identischen Master-Key können replizierte WireGuard-Keys nicht entschlüsselt werden - render-config: sudo -u edgeguard statt als root (DB-Zugriff) nftables Template: - Port 5432 (PG) + 6379 (KeyDB) für Cluster-Peers (@peer_ipv4/@peer_ipv6) freigegeben handlers/cluster.go: - GET /agent/cluster/master-key: gibt .master_key via mTLS zurück (hex-kodiert) v1.2.15 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
161 lines
5.4 KiB
Go
161 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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 promotes this node's PostgreSQL instance from Hot-Standby
|
|
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
|
|
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
|
|
//
|
|
// Ablauf:
|
|
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
|
|
// 2. pg_ctlcluster promote → PG wird Primary
|
|
// 3. Warten bis pg_is_in_recovery() = false
|
|
// 4. ha_nodes.pg_role auf 'primary' setzen
|
|
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
|
|
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
|
|
// 7. keepalived reload
|
|
func cmdPromote(args []string) int {
|
|
pg, err := detectPGConfig()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
|
|
return 1
|
|
}
|
|
|
|
// 1. Standby-Signal prüfen
|
|
signalPath := filepath.Join(pg.DataDir, "standby.signal")
|
|
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
|
|
fmt.Fprintf(os.Stderr,
|
|
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
|
|
signalPath)
|
|
return 1
|
|
}
|
|
|
|
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
|
|
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
|
|
CombinedOutput(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
|
|
return 1
|
|
}
|
|
fmt.Println("✓ pg_ctlcluster promote gesendet")
|
|
|
|
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
|
|
fmt.Print("→ Warte auf PG Primary-Mode")
|
|
deadline := time.Now().Add(60 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
|
if err == nil && strings.TrimSpace(string(out)) == "f" {
|
|
break
|
|
}
|
|
fmt.Print(".")
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
fmt.Println()
|
|
// Nochmal prüfen
|
|
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
|
if err != nil || strings.TrimSpace(string(out)) != "f" {
|
|
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
|
|
return 1
|
|
}
|
|
fmt.Println("✓ PostgreSQL ist jetzt Primary")
|
|
|
|
// 3. ha_nodes.pg_role + role 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 updaten: 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")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. KeyDB cluster:pg-primary-url updaten
|
|
if err := updateKeyDBPrimaryURL(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
|
|
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
|
|
} else {
|
|
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
|
|
}
|
|
|
|
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
|
|
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: edgeguard-ctl render-config --only=keepalived")
|
|
} else {
|
|
fmt.Println("✓ keepalived.conf neu gerendert (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 (falls noch nicht laufend)")
|
|
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
|
|
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
|
|
}
|
|
|
|
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
|
|
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
|
|
func updateKeyDBPrimaryURL() error {
|
|
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
|
|
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
|
|
out, err := exec.Command("redis-cli",
|
|
"-s", "/var/run/keydb/keydb.sock",
|
|
"SET", "cluster:pg-primary-url", localDSN,
|
|
).CombinedOutput()
|
|
if err != nil {
|
|
// Fallback: Standard-Port
|
|
out2, err2 := exec.Command("redis-cli",
|
|
"-p", "6379",
|
|
"SET", "cluster:pg-primary-url", localDSN,
|
|
).CombinedOutput()
|
|
if err2 != nil {
|
|
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
|
|
}
|
|
}
|
|
return nil
|
|
}
|