diff --git a/src/frontend/apiserver/backend/clib.go b/src/frontend/apiserver/backend/clib.go index 4fc63897..79dba7a4 100644 --- a/src/frontend/apiserver/backend/clib.go +++ b/src/frontend/apiserver/backend/clib.go @@ -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)) diff --git a/src/frontend/apiserver/backend/config.go b/src/frontend/apiserver/backend/config.go index e1245c3a..87caed1c 100644 --- a/src/frontend/apiserver/backend/config.go +++ b/src/frontend/apiserver/backend/config.go @@ -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 } diff --git a/src/frontend/apiserver/backend/peers.go b/src/frontend/apiserver/backend/peers.go new file mode 100644 index 00000000..60873f57 --- /dev/null +++ b/src/frontend/apiserver/backend/peers.go @@ -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 +} diff --git a/src/frontend/apiserver/backend/peers_test.go b/src/frontend/apiserver/backend/peers_test.go new file mode 100644 index 00000000..36d65c9d --- /dev/null +++ b/src/frontend/apiserver/backend/peers_test.go @@ -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) + } + }) + } +} diff --git a/src/frontend/apiserver/backend/stub_funcs_test.go b/src/frontend/apiserver/backend/stub_funcs_test.go index 4b1c3612..4a37183e 100644 --- a/src/frontend/apiserver/backend/stub_funcs_test.go +++ b/src/frontend/apiserver/backend/stub_funcs_test.go @@ -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) + } } \ No newline at end of file diff --git a/src/frontend/apiserver/cmd/main.go b/src/frontend/apiserver/cmd/main.go index 1b200b76..65b53196 100644 --- a/src/frontend/apiserver/cmd/main.go +++ b/src/frontend/apiserver/cmd/main.go @@ -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)) diff --git a/src/frontend/apiserver/utils/config.go b/src/frontend/apiserver/utils/config.go index ceb8820a..7cbb9ec1 100644 --- a/src/frontend/apiserver/utils/config.go +++ b/src/frontend/apiserver/utils/config.go @@ -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