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='';") } 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 ") 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 }