feat: add HA candidate failover to API server backend

- BackendConfig.Peers []string carries host:port candidates from YAML (backend.peers)
- RegisterToBackend iterates Peers in order, connecting to first responsive primary
- parsePeer extracted to peers.go (no build tag) so it's testable without CGO
- Single-host path (no Peers) preserved unchanged
- Tests: parsePeer table-driven (11 cases), stub RegisterToBackend with/without peers
This commit is contained in:
Rongsong Shen 2026-05-08 03:31:35 +00:00
parent 9bc84f1ca1
commit 99d13f5254
7 changed files with 213 additions and 16 deletions

View file

@ -76,9 +76,33 @@ var idCtxOnce sync.Once
// Must be set via StartNotificationListener before RegisterToBackend.
var gHandler NotificationHandler
// tryConnect attempts to connect to a single controller candidate.
// Returns nil on success. The C strings passed in are owned by the caller.
func tryConnect(host string, port int, cFrom, cKey *C.char) error {
cAddr := C.CString(host)
defer C.free(unsafe.Pointer(cAddr))
var cConfig C.xce_client_config_t
cConfig.nmasters = 1
cConfig.masters[0] = cAddr
cConfig.master_ports[0] = C.int(port)
cConfig.from = cFrom
cConfig.key = cKey
rc := C.xce_backend_init(&cConfig, &gCtx)
if rc < 0 {
return fmt.Errorf("xce_backend_init failed on %s:%d: %d", host, port, int(rc))
}
return nil
}
// RegisterToBackend initialises the XCE client library and joins as an API
// server. StartNotificationListener should be called first so that push
// events have a handler ready before the first push can arrive.
//
// When config.Peers is non-empty the function iterates each "host:port" entry
// in order and returns as soon as one succeeds (HA candidate fallback).
// When Peers is empty it connects to config.Address:config.Port directly.
func RegisterToBackend(config *BackendConfig) error {
from := config.From
if from == "" {
@ -89,27 +113,43 @@ func RegisterToBackend(config *BackendConfig) error {
key = "keepsecret$"
}
cAddr := C.CString(config.Address)
cFrom := C.CString(from)
cKey := C.CString(key)
defer func() {
C.free(unsafe.Pointer(cAddr))
C.free(unsafe.Pointer(cFrom))
C.free(unsafe.Pointer(cKey))
}()
var cConfig C.xce_client_config_t
cConfig.nmasters = 1
cConfig.masters[0] = cAddr
cConfig.master_ports[0] = C.int(config.Port)
cConfig.from = cFrom
cConfig.key = cKey
rc := C.xce_backend_init(&cConfig, &gCtx)
if rc < 0 {
return fmt.Errorf("xce_backend_init failed: %d", int(rc))
// HA mode: iterate the peer list and connect to the first responsive primary.
if len(config.Peers) > 0 {
var lastErr error
for _, peer := range config.Peers {
host, port, err := parsePeer(peer)
if err != nil {
logger.Warn("Skipping invalid peer", zap.String("peer", peer), zap.Error(err))
lastErr = err
continue
}
if err := tryConnect(host, port, cFrom, cKey); err != nil {
logger.Warn("Candidate unreachable, trying next",
zap.String("host", host), zap.Int("port", port), zap.Error(err))
lastErr = err
continue
}
logger.Info("Backend connection established (HA)",
zap.String("host", host), zap.Int("port", port))
return nil
}
if lastErr != nil {
return fmt.Errorf("all HA candidates failed: %w", lastErr)
}
return fmt.Errorf("no HA candidates configured")
}
// Single-host mode (no peers configured).
if err := tryConnect(config.Address, config.Port, cFrom, cKey); err != nil {
return err
}
logger.Info("Backend connection established",
zap.String("address", config.Address),
zap.Int("port", config.Port))

View file

@ -7,4 +7,10 @@ type BackendConfig struct {
Port int // controller port (e.g. 9990)
From string // identity sent to controller (default: "xce_apiserver")
Key string // authentication key
// Peers lists additional controller candidates for HA mode in "host:port"
// format. When non-empty, RegisterToBackend tries each candidate in order
// and connects to whichever accepts the join as primary. Address+Port
// serve as a single-host fallback when Peers is empty.
Peers []string
}

View file

@ -0,0 +1,22 @@
package backend
import (
"fmt"
"strconv"
"strings"
)
// parsePeer splits a "host:port" string into host and port components.
// Returns an error if the format is invalid or the port is out of range.
func parsePeer(peer string) (host string, port int, err error) {
idx := strings.LastIndex(peer, ":")
if idx <= 0 {
return "", 0, fmt.Errorf("invalid peer %q: expected host:port", peer)
}
host = peer[:idx]
port, err = strconv.Atoi(peer[idx+1:])
if err != nil || port <= 0 || port > 65535 {
return "", 0, fmt.Errorf("invalid port in peer %q", peer)
}
return host, port, nil
}

View file

@ -0,0 +1,95 @@
package backend
import "testing"
func TestParsePeer(t *testing.T) {
tests := []struct {
name string
peer string
wantHost string
wantPort int
wantError bool
}{
{
name: "valid simple host:port",
peer: "ctrl01:9990",
wantHost: "ctrl01",
wantPort: 9990,
},
{
name: "valid IPv4",
peer: "192.168.1.10:9990",
wantHost: "192.168.1.10",
wantPort: 9990,
},
{
name: "valid FQDN",
peer: "ctrl01.cluster.local:9990",
wantHost: "ctrl01.cluster.local",
wantPort: 9990,
},
{
name: "min valid port",
peer: "host:1",
wantHost: "host",
wantPort: 1,
},
{
name: "max valid port",
peer: "host:65535",
wantHost: "host",
wantPort: 65535,
},
{
name: "missing colon",
peer: "ctrl019990",
wantError: true,
},
{
name: "port zero",
peer: "ctrl01:0",
wantError: true,
},
{
name: "port out of range",
peer: "ctrl01:65536",
wantError: true,
},
{
name: "non-numeric port",
peer: "ctrl01:abc",
wantError: true,
},
{
name: "colon at start (empty host)",
peer: ":9990",
wantError: true,
},
{
name: "empty string",
peer: "",
wantError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, port, err := parsePeer(tc.peer)
if tc.wantError {
if err == nil {
t.Errorf("parsePeer(%q): expected error, got host=%q port=%d", tc.peer, host, port)
}
return
}
if err != nil {
t.Fatalf("parsePeer(%q): unexpected error: %v", tc.peer, err)
}
if host != tc.wantHost {
t.Errorf("host: got %q, want %q", host, tc.wantHost)
}
if port != tc.wantPort {
t.Errorf("port: got %d, want %d", port, tc.wantPort)
}
})
}
}

View file

@ -29,4 +29,31 @@ func TestEvalSelectForHost_StubErrNoCGO(t *testing.T) {
func TestCleanup_StubNoOp(t *testing.T) {
// Should not panic and completes immediately
Cleanup()
}
// TestRegisterToBackend_StubSingleHost verifies the stub returns errNoCGO
// for a single-host BackendConfig (no Peers).
func TestRegisterToBackend_StubSingleHost(t *testing.T) {
err := RegisterToBackend(&BackendConfig{
Address: "ctrl01",
Port: 9990,
From: "xce_apiserver",
Key: "keepsecret$",
})
if err != errNoCGO {
t.Errorf("expected errNoCGO, got %v", err)
}
}
// TestRegisterToBackend_StubWithPeers verifies the stub returns errNoCGO
// even when Peers are configured (HA mode), since CGO is not available.
func TestRegisterToBackend_StubWithPeers(t *testing.T) {
err := RegisterToBackend(&BackendConfig{
Peers: []string{"ctrl01:9990", "ctrl02:9990"},
From: "xce_apiserver",
Key: "keepsecret$",
})
if err != errNoCGO {
t.Errorf("expected errNoCGO with peers configured, got %v", err)
}
}

View file

@ -66,6 +66,7 @@ func main() {
Port: utils.BackendYamlConfig.Port,
From: utils.BackendYamlConfig.From,
Key: utils.BackendYamlConfig.Key,
Peers: utils.BackendYamlConfig.Peers,
}
if err := backend.RegisterToBackend(bcfg); err != nil {
logger.Error("Failed to connect to backend", zap.Error(err))

View file

@ -13,10 +13,15 @@ import (
)
type BackendConfig struct {
Address string `yaml:"address"`
Port int `yaml:"port"`
From string `yaml:"from"`
Key string `yaml:"key"`
Address string `yaml:"address"`
Port int `yaml:"port"`
From string `yaml:"from"`
Key string `yaml:"key"`
// Peers lists additional controller candidates for HA mode in "host:port"
// format. When non-empty, RegisterToBackend iterates all candidates and
// connects to whichever responds as primary. Address+Port remain the
// single-host fallback when Peers is empty.
Peers []string `yaml:"peers"`
}
type AppServerConfig struct {
@ -250,6 +255,7 @@ func InitConf() error {
Port: viper.GetInt("backend.port"),
From: viper.GetString("backend.from"),
Key: viper.GetString("backend.key"),
Peers: viper.GetStringSlice("backend.peers"),
}
return nil