mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
feat!: standardize ports to range 8180-8124 for cluster comms and API
This commit is contained in:
+21
-30
@@ -15,7 +15,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -35,13 +34,14 @@ import (
|
||||
"github.com/alchemillahq/sylve/internal/services/jail"
|
||||
"github.com/alchemillahq/sylve/internal/services/libvirt"
|
||||
"github.com/alchemillahq/sylve/internal/services/lifecycle"
|
||||
"github.com/alchemillahq/sylve/internal/services/network"
|
||||
networkService "github.com/alchemillahq/sylve/internal/services/network"
|
||||
"github.com/alchemillahq/sylve/internal/services/samba"
|
||||
"github.com/alchemillahq/sylve/internal/services/system"
|
||||
"github.com/alchemillahq/sylve/internal/services/utilities"
|
||||
"github.com/alchemillahq/sylve/internal/services/zelta"
|
||||
"github.com/alchemillahq/sylve/internal/services/zfs"
|
||||
|
||||
portnetwork "github.com/alchemillahq/sylve/pkg/network"
|
||||
sysU "github.com/alchemillahq/sylve/pkg/system"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -58,6 +58,9 @@ func main() {
|
||||
|
||||
cfg := config.ParseConfig(cfgPath)
|
||||
logger.InitLogger(cfg.DataPath, cfg.LogLevel)
|
||||
if err := preflightRequiredPorts(cfg, portnetwork.TryBindToPort); err != nil {
|
||||
logger.L.Fatal().Err(err).Msg("startup_port_preflight_failed")
|
||||
}
|
||||
|
||||
d := db.SetupDatabase(cfg, false)
|
||||
_ = db.SetupCache(cfg)
|
||||
@@ -95,6 +98,10 @@ func main() {
|
||||
zeltaS := serviceRegistry.ZeltaService
|
||||
|
||||
clusterSvc := cS.(*cluster.Service)
|
||||
if err := clusterSvc.MigrateLegacyPorts(); err != nil {
|
||||
logger.L.Fatal().Err(err).Msg("failed_to_migrate_legacy_cluster_ports")
|
||||
}
|
||||
|
||||
jailSvc := jS.(*jail.Service)
|
||||
libvirtSvc := lvS.(*libvirt.Service)
|
||||
lifecycleSvc := lifecycle.NewService(d, libvirtSvc, jailSvc)
|
||||
@@ -133,11 +140,7 @@ func main() {
|
||||
|
||||
err = cS.InitRaft(fsm)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "record not found") {
|
||||
logger.L.Error().Err(err).Msg("Failed to initialize RAFT")
|
||||
} else {
|
||||
logger.L.Info().Msg("Not initializing RAFT")
|
||||
}
|
||||
logger.L.Fatal().Err(err).Msg("Failed to initialize RAFT")
|
||||
}
|
||||
|
||||
if err := clusterSvc.StartEmbeddedSSHServer(qCtx); err != nil {
|
||||
@@ -169,7 +172,7 @@ func main() {
|
||||
iS.(*info.Service),
|
||||
zS.(*zfs.Service),
|
||||
dS.(*disk.Service),
|
||||
nS.(*network.Service),
|
||||
nS.(*networkService.Service),
|
||||
uS.(*utilities.Service),
|
||||
sysS.(*system.Service),
|
||||
libvirtSvc,
|
||||
@@ -190,7 +193,7 @@ func main() {
|
||||
Auth: aS.(*auth.Service),
|
||||
Jail: jailSvc,
|
||||
VirtualMachine: libvirtSvc,
|
||||
Network: nS.(*network.Service),
|
||||
Network: nS.(*networkService.Service),
|
||||
QuitChan: sigChan,
|
||||
}
|
||||
|
||||
@@ -220,13 +223,6 @@ func main() {
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
if cfg.HTTPPort == cluster.ClusterEmbeddedHTTPSPort {
|
||||
logger.L.Fatal().
|
||||
Int("http_port", cfg.HTTPPort).
|
||||
Int("intra_cluster_https_port", cluster.ClusterEmbeddedHTTPSPort).
|
||||
Msg("Configured HTTP port conflicts with reserved intra-cluster HTTPS port")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
type namedServer struct {
|
||||
name string
|
||||
@@ -258,20 +254,15 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
startDedicatedClusterHTTPS := cfg.Port == 0 || cfg.Port != cluster.ClusterEmbeddedHTTPSPort
|
||||
if startDedicatedClusterHTTPS {
|
||||
startedServers = append(startedServers, namedServer{name: "Intra-cluster HTTPS", srv: clusterHTTPSServer})
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.L.Info().Msgf("Intra-cluster HTTPS server started on %s:%d", cfg.IP, cluster.ClusterEmbeddedHTTPSPort)
|
||||
if err := clusterHTTPSServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
logger.L.Fatal().Err(err).Msg("Failed to start intra-cluster HTTPS server")
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
logger.L.Info().Msgf("Intra-cluster HTTPS is served by configured HTTPS port on %s:%d", cfg.IP, cfg.Port)
|
||||
}
|
||||
startedServers = append(startedServers, namedServer{name: "Intra-cluster HTTPS", srv: clusterHTTPSServer})
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.L.Info().Msgf("Intra-cluster HTTPS server started on %s:%d", cfg.IP, cluster.ClusterEmbeddedHTTPSPort)
|
||||
if err := clusterHTTPSServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
logger.L.Fatal().Err(err).Msg("Failed to start intra-cluster HTTPS server")
|
||||
}
|
||||
}()
|
||||
|
||||
<-sigChan
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal"
|
||||
"github.com/alchemillahq/sylve/internal/services/cluster"
|
||||
"github.com/alchemillahq/sylve/pkg/utils"
|
||||
)
|
||||
|
||||
type portRequirement struct {
|
||||
role string
|
||||
port int
|
||||
}
|
||||
|
||||
type portBinder func(ip string, port int, proto string) error
|
||||
|
||||
func preflightRequiredPorts(cfg *internal.SylveConfig, binder portBinder) error {
|
||||
reqs, err := buildPortRequirements(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, req := range reqs {
|
||||
if err := binder("", req.port, "tcp"); err != nil {
|
||||
return fmt.Errorf("required_port_not_bindable role=%s port=%d: %w", req.role, req.port, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPortRequirements(cfg *internal.SylveConfig) ([]portRequirement, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config_required")
|
||||
}
|
||||
|
||||
reqs := make([]portRequirement, 0, 5)
|
||||
|
||||
if cfg.HTTPPort != 0 {
|
||||
if !utils.IsValidPort(cfg.HTTPPort) {
|
||||
return nil, fmt.Errorf("invalid_http_port: %d", cfg.HTTPPort)
|
||||
}
|
||||
reqs = append(reqs, portRequirement{role: "http", port: cfg.HTTPPort})
|
||||
}
|
||||
|
||||
if cfg.Port != 0 {
|
||||
if !utils.IsValidPort(cfg.Port) {
|
||||
return nil, fmt.Errorf("invalid_https_port: %d", cfg.Port)
|
||||
}
|
||||
reqs = append(reqs, portRequirement{role: "https", port: cfg.Port})
|
||||
}
|
||||
|
||||
reqs = append(reqs,
|
||||
portRequirement{role: "cluster_ssh", port: cluster.ClusterEmbeddedSSHPort},
|
||||
portRequirement{role: "raft", port: cluster.ClusterRaftPort},
|
||||
portRequirement{role: "cluster_https", port: cluster.ClusterEmbeddedHTTPSPort},
|
||||
)
|
||||
|
||||
for _, req := range reqs {
|
||||
if !utils.IsValidPort(req.port) {
|
||||
return nil, fmt.Errorf("invalid_required_port role=%s port=%d", req.role, req.port)
|
||||
}
|
||||
}
|
||||
|
||||
roleByPort := make(map[int][]string, len(reqs))
|
||||
for _, req := range reqs {
|
||||
roleByPort[req.port] = append(roleByPort[req.port], req.role)
|
||||
}
|
||||
|
||||
ports := make([]int, 0, len(roleByPort))
|
||||
for port := range roleByPort {
|
||||
ports = append(ports, port)
|
||||
}
|
||||
sort.Ints(ports)
|
||||
|
||||
conflicts := make([]string, 0)
|
||||
for _, port := range ports {
|
||||
roles := roleByPort[port]
|
||||
if len(roles) <= 1 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(roles)
|
||||
conflicts = append(conflicts, fmt.Sprintf("port=%d roles=%s", port, strings.Join(roles, ",")))
|
||||
}
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
return nil, fmt.Errorf("port_role_collision: %s", strings.Join(conflicts, "; "))
|
||||
}
|
||||
|
||||
return reqs, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal"
|
||||
"github.com/alchemillahq/sylve/internal/services/cluster"
|
||||
)
|
||||
|
||||
func TestBuildPortRequirementsIncludesConfiguredAndFixedPorts(t *testing.T) {
|
||||
cfg := &internal.SylveConfig{
|
||||
Port: 8181,
|
||||
HTTPPort: 8182,
|
||||
}
|
||||
|
||||
reqs, err := buildPortRequirements(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPortRequirements returned error: %v", err)
|
||||
}
|
||||
|
||||
rolesByPort := map[int]string{}
|
||||
for _, req := range reqs {
|
||||
rolesByPort[req.port] = req.role
|
||||
}
|
||||
|
||||
expected := map[int]string{
|
||||
8181: "https",
|
||||
8182: "http",
|
||||
cluster.ClusterRaftPort: "raft",
|
||||
cluster.ClusterEmbeddedSSHPort: "cluster_ssh",
|
||||
cluster.ClusterEmbeddedHTTPSPort: "cluster_https",
|
||||
}
|
||||
|
||||
if len(rolesByPort) != len(expected) {
|
||||
t.Fatalf("expected %d unique ports, got %d", len(expected), len(rolesByPort))
|
||||
}
|
||||
|
||||
for port, role := range expected {
|
||||
gotRole, ok := rolesByPort[port]
|
||||
if !ok {
|
||||
t.Fatalf("missing required role %q on port %d", role, port)
|
||||
}
|
||||
if gotRole != role {
|
||||
t.Fatalf("unexpected role for port %d: expected %q got %q", port, role, gotRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPortRequirementsAllowsDisabledHTTPAndHTTPS(t *testing.T) {
|
||||
cfg := &internal.SylveConfig{Port: 0, HTTPPort: 0}
|
||||
|
||||
reqs, err := buildPortRequirements(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPortRequirements returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(reqs) != 3 {
|
||||
t.Fatalf("expected only fixed cluster ports when HTTP/HTTPS disabled, got %d", len(reqs))
|
||||
}
|
||||
|
||||
ports := map[int]struct{}{}
|
||||
for _, req := range reqs {
|
||||
ports[req.port] = struct{}{}
|
||||
}
|
||||
|
||||
for _, requiredPort := range []int{cluster.ClusterRaftPort, cluster.ClusterEmbeddedSSHPort, cluster.ClusterEmbeddedHTTPSPort} {
|
||||
if _, ok := ports[requiredPort]; !ok {
|
||||
t.Fatalf("missing required fixed port %d", requiredPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPortRequirementsDetectsRoleCollision(t *testing.T) {
|
||||
cfg := &internal.SylveConfig{
|
||||
Port: cluster.ClusterEmbeddedHTTPSPort,
|
||||
HTTPPort: 8182,
|
||||
}
|
||||
|
||||
_, err := buildPortRequirements(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected collision error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "port_role_collision") {
|
||||
t.Fatalf("expected port_role_collision error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cluster_https") || !strings.Contains(err.Error(), "https") {
|
||||
t.Fatalf("expected collision error to include colliding roles, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightRequiredPortsFailsOnBindError(t *testing.T) {
|
||||
cfg := &internal.SylveConfig{
|
||||
Port: 8181,
|
||||
HTTPPort: 8182,
|
||||
}
|
||||
|
||||
err := preflightRequiredPorts(cfg, func(_ string, port int, proto string) error {
|
||||
if proto != "tcp" {
|
||||
t.Fatalf("expected tcp bind checks, got %q", proto)
|
||||
}
|
||||
if port == cluster.ClusterRaftPort {
|
||||
return errors.New("already in use")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected preflight bind error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "role=raft") || !strings.Contains(err.Error(), "port=8180") {
|
||||
t.Fatalf("expected role-specific bind failure, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightRequiredPortsChecksAllExpectedRoles(t *testing.T) {
|
||||
cfg := &internal.SylveConfig{
|
||||
Port: 8181,
|
||||
HTTPPort: 8182,
|
||||
}
|
||||
|
||||
called := map[int]struct{}{}
|
||||
err := preflightRequiredPorts(cfg, func(_ string, port int, proto string) error {
|
||||
if proto != "tcp" {
|
||||
t.Fatalf("expected tcp bind checks, got %q", proto)
|
||||
}
|
||||
called[port] = struct{}{}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("preflightRequiredPorts returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, expectedPort := range []int{8181, 8182, cluster.ClusterRaftPort, cluster.ClusterEmbeddedSSHPort, cluster.ClusterEmbeddedHTTPSPort} {
|
||||
if _, ok := called[expectedPort]; !ok {
|
||||
t.Fatalf("missing bind check for port %d", expectedPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,8 @@ We will setup an `rc` script for running Sylve but prior to that we need to do 2
|
||||
"keyFile": "/usr/local/etc/letsencrypt/live/sylve.example.net/privkey.pem",
|
||||
},
|
||||
"logLevel": 3,
|
||||
"port": 8181, // You can set to 0 to disable HTTPS entirely
|
||||
"httpPort": 8182, // You can set to 0 to disable HTTP entirely
|
||||
"port": 8181, // You can set to 0 to disable the main/public HTTPS listener
|
||||
"httpPort": 8182, // You can set to 0 to disable the main/public HTTP listener
|
||||
"raft": {
|
||||
"reset": false,
|
||||
},
|
||||
@@ -284,6 +284,10 @@ Sylve uses these ports by default:
|
||||
|
||||
- `8182/tcp`: Main API and web UI over HTTP only. This is insecure and can break features like passkeys and serial/VNC console, so use it only when needed (for example, behind a reverse proxy on a trusted network). Change in `config.json` with `httpPort`.
|
||||
|
||||
- `8180/tcp`: RAFT communication between cluster nodes. You can choose this value during cluster setup, but every node must use the same RAFT port.
|
||||
- `8180/tcp`: RAFT communication between cluster nodes. This port is fixed in Sylve and is not configurable.
|
||||
|
||||
- `8183/tcp`: Cluster SSH channel used for intra-cluster operations. This port is fixed in Sylve and is not configurable.
|
||||
|
||||
- `8184/tcp`: Intra-cluster HTTPS API used for node-to-node control plane traffic (including cluster join flows). This port is fixed in Sylve and is not configurable.
|
||||
|
||||
- `7246/udp`: BTT DHT peer discovery. Used only when `btt.dht.enabled` is `true`. Change in `config.json` with `btt.dht.port`.
|
||||
|
||||
@@ -23,7 +23,7 @@ The upcoming replication feature is also disabled on nodes with less than 3 node
|
||||
|
||||
## Setting up a Cluster
|
||||
|
||||
Setting up a cluster is pretty straightforward, all you need to do is to navigate to Datacenter -> Cluster and then click on "Create Cluster". Once you do that a modal opens up where you can fill out the IP of your node that will be used for clustering and the port as well, this port must be **identical** on all nodes in the cluster, the default port is 8180.
|
||||
Setting up a cluster is pretty straightforward, all you need to do is to navigate to Datacenter -> Cluster and then click on "Create Cluster". Once you do that a modal opens up where you can fill out the IP of your node that will be used for clustering. RAFT uses the fixed intra-cluster port `8180/tcp` on every node.
|
||||
|
||||

|
||||
|
||||
@@ -39,7 +39,7 @@ Treat the cluster key like a password, anyone with the cluster key can join the
|
||||
|
||||
## Joining a Cluster
|
||||
|
||||
To join a cluster, copy the cluster key from the first node or the **LEADER NODE** and click on "Join Cluster" on the node that you want to join the cluster. This will open up a modal where you can paste the cluster key, and the API of the **LEADER NODE**, the API is NOT running on port 8180 or whatever your RAFT port is, it will be running on "192.168.172.203:8181" or whatever ip:port you use to access the WebUI of the leader. Once that is done click on "Join". Once you do that, the node will attempt to join the cluster and if successful, it will be added to the cluster view. This is what the join modal looks like:
|
||||
To join a cluster, copy the cluster key from the first node or the **LEADER NODE** and click on "Join Cluster" on the node that you want to join the cluster. This opens a modal where you provide the cluster key and the IP of the **LEADER NODE** (IP only, no port). Sylve uses the fixed intra-cluster HTTPS port `8184/tcp` internally for leader API communication, and fixed RAFT port `8180/tcp` for consensus. Once that is done click on "Join". Once you do that, the node will attempt to join the cluster and if successful, it will be added to the cluster view. This is what the join modal looks like:
|
||||
|
||||

|
||||
|
||||
@@ -63,4 +63,4 @@ Once you join a cluster successfully, your `/datacenter/summary` dashboard shoul
|
||||
|
||||
## Testing out RAFT
|
||||
|
||||
We have a nice little "Notes" application similar to the Notes application found inside each node, but the difference with this one is that the data is RAFT replicated meaning if you make a note on one node it should eventually be present on all Nodes. This is the best litmus test to see if everything is fine before you proceed with more interesting things.
|
||||
We have a nice little "Notes" application similar to the Notes application found inside each node, but the difference with this one is that the data is RAFT replicated meaning if you make a note on one node it should eventually be present on all Nodes. This is the best litmus test to see if everything is fine before you proceed with more interesting things.
|
||||
|
||||
+5
-17
@@ -646,7 +646,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Create a cluster given a bootstrapping nodes IP and Port",
|
||||
"description": "Create a cluster given a bootstrapping node IP",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -13301,8 +13301,7 @@ const docTemplate = `{
|
||||
"required": [
|
||||
"clusterKey",
|
||||
"nodeId",
|
||||
"nodeIp",
|
||||
"nodePort"
|
||||
"nodeIp"
|
||||
],
|
||||
"properties": {
|
||||
"clusterKey": {
|
||||
@@ -13313,11 +13312,6 @@ const docTemplate = `{
|
||||
},
|
||||
"nodeIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodePort": {
|
||||
"type": "integer",
|
||||
"maximum": 65535,
|
||||
"minimum": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13325,16 +13319,15 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"clusterKey",
|
||||
"leaderApi",
|
||||
"leaderIp",
|
||||
"nodeId",
|
||||
"nodeIp",
|
||||
"nodePort"
|
||||
"nodeIp"
|
||||
],
|
||||
"properties": {
|
||||
"clusterKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"leaderApi": {
|
||||
"leaderIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeId": {
|
||||
@@ -13342,11 +13335,6 @@ const docTemplate = `{
|
||||
},
|
||||
"nodeIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodePort": {
|
||||
"type": "integer",
|
||||
"maximum": 65535,
|
||||
"minimum": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -640,7 +640,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Create a cluster given a bootstrapping nodes IP and Port",
|
||||
"description": "Create a cluster given a bootstrapping node IP",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -13295,8 +13295,7 @@
|
||||
"required": [
|
||||
"clusterKey",
|
||||
"nodeId",
|
||||
"nodeIp",
|
||||
"nodePort"
|
||||
"nodeIp"
|
||||
],
|
||||
"properties": {
|
||||
"clusterKey": {
|
||||
@@ -13307,11 +13306,6 @@
|
||||
},
|
||||
"nodeIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodePort": {
|
||||
"type": "integer",
|
||||
"maximum": 65535,
|
||||
"minimum": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13319,16 +13313,15 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"clusterKey",
|
||||
"leaderApi",
|
||||
"leaderIp",
|
||||
"nodeId",
|
||||
"nodeIp",
|
||||
"nodePort"
|
||||
"nodeIp"
|
||||
],
|
||||
"properties": {
|
||||
"clusterKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"leaderApi": {
|
||||
"leaderIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeId": {
|
||||
@@ -13336,11 +13329,6 @@
|
||||
},
|
||||
"nodeIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodePort": {
|
||||
"type": "integer",
|
||||
"maximum": 65535,
|
||||
"minimum": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3221,36 +3221,26 @@ definitions:
|
||||
type: string
|
||||
nodeIp:
|
||||
type: string
|
||||
nodePort:
|
||||
maximum: 65535
|
||||
minimum: 1024
|
||||
type: integer
|
||||
required:
|
||||
- clusterKey
|
||||
- nodeId
|
||||
- nodeIp
|
||||
- nodePort
|
||||
type: object
|
||||
internal_handlers_cluster.JoinClusterRequest:
|
||||
properties:
|
||||
clusterKey:
|
||||
type: string
|
||||
leaderApi:
|
||||
leaderIp:
|
||||
type: string
|
||||
nodeId:
|
||||
type: string
|
||||
nodeIp:
|
||||
type: string
|
||||
nodePort:
|
||||
maximum: 65535
|
||||
minimum: 1024
|
||||
type: integer
|
||||
required:
|
||||
- clusterKey
|
||||
- leaderApi
|
||||
- leaderIp
|
||||
- nodeId
|
||||
- nodeIp
|
||||
- nodePort
|
||||
type: object
|
||||
internal_handlers_cluster.NoteRequest:
|
||||
properties:
|
||||
@@ -4293,7 +4283,7 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a cluster given a bootstrapping nodes IP and Port
|
||||
description: Create a cluster given a bootstrapping node IP
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -301,7 +301,7 @@ func initClusterRecord(db *gorm.DB) error {
|
||||
Key: "",
|
||||
RaftBootstrap: nil,
|
||||
RaftIP: "",
|
||||
RaftPort: 0,
|
||||
RaftPort: 8180,
|
||||
}
|
||||
|
||||
if err := db.Create(defaultCluster).Error; err != nil {
|
||||
|
||||
@@ -159,7 +159,7 @@ type ClusterSSHIdentity struct {
|
||||
NodeUUID string `gorm:"uniqueIndex;not null" json:"nodeUUID"`
|
||||
SSHUser string `gorm:"not null;default:root" json:"sshUser"`
|
||||
SSHHost string `gorm:"not null" json:"sshHost"`
|
||||
SSHPort int `gorm:"not null;default:8122" json:"sshPort"`
|
||||
SSHPort int `gorm:"not null;default:8183" json:"sshPort"`
|
||||
PublicKey string `gorm:"type:text;not null" json:"publicKey"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
@@ -414,7 +414,7 @@ func upsertClusterSSHIdentity(db *gorm.DB, identity *ClusterSSHIdentity) error {
|
||||
identity.SSHUser = "root"
|
||||
}
|
||||
if identity.SSHPort == 0 {
|
||||
identity.SSHPort = 8122
|
||||
identity.SSHPort = 8183
|
||||
}
|
||||
|
||||
if identity.NodeUUID == "" {
|
||||
|
||||
@@ -24,22 +24,19 @@ import (
|
||||
)
|
||||
|
||||
type CreateClusterRequest struct {
|
||||
IP string `json:"ip" binding:"required,ip"`
|
||||
Port int `json:"port" binding:"required,min=1024,max=65535"`
|
||||
IP string `json:"ip" binding:"required,ip"`
|
||||
}
|
||||
|
||||
type JoinClusterRequest struct {
|
||||
NodeID string `json:"nodeId" binding:"required"`
|
||||
NodeIP string `json:"nodeIp" binding:"required,ip"`
|
||||
NodePort int `json:"nodePort" binding:"required,min=1024,max=65535"`
|
||||
LeaderAPI string `json:"leaderApi" binding:"required"`
|
||||
LeaderIP string `json:"leaderIp" binding:"required,ip"`
|
||||
ClusterKey string `json:"clusterKey" binding:"required"`
|
||||
}
|
||||
|
||||
type AcceptJoinRequest struct {
|
||||
NodeID string `json:"nodeId" binding:"required"`
|
||||
NodeIP string `json:"nodeIp" binding:"required,ip"`
|
||||
NodePort int `json:"nodePort" binding:"required,min=1024,max=65535"`
|
||||
ClusterKey string `json:"clusterKey" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -47,6 +44,10 @@ type RemovePeerRequest struct {
|
||||
NodeID string `json:"nodeId" binding:"required"`
|
||||
}
|
||||
|
||||
func joinLeaderAPIHost(leaderIP string) string {
|
||||
return cluster.ClusterAPIHost(leaderIP)
|
||||
}
|
||||
|
||||
// @Summary Get Cluster
|
||||
// @Description Get cluster details with information about RAFT nodes too
|
||||
// @Tags Cluster
|
||||
@@ -79,7 +80,7 @@ func GetCluster(cS *cluster.Service) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// @Summary Create Cluster
|
||||
// @Description Create a cluster given a bootstrapping nodes IP and Port
|
||||
// @Description Create a cluster given a bootstrapping node IP
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -101,7 +102,7 @@ func CreateCluster(as *auth.Service, cS *cluster.Service, fsm raft.FSM) gin.Hand
|
||||
return
|
||||
}
|
||||
|
||||
if err := cS.CreateCluster(req.IP, req.Port, fsm); err != nil {
|
||||
if err := cS.CreateCluster(req.IP, fsm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "error_creating_cluster",
|
||||
@@ -170,16 +171,18 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.IsValidIPPort(req.LeaderAPI) {
|
||||
if !utils.IsValidIP(req.LeaderIP) {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "invalid_leader_api",
|
||||
Error: "leader_api_must_be_in_host_port_format",
|
||||
Message: "invalid_leader_ip",
|
||||
Error: "leader_ip_must_be_valid",
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
leaderAPIHost := joinLeaderAPIHost(req.LeaderIP)
|
||||
|
||||
userId := c.GetUint("UserID")
|
||||
username := c.GetString("Username")
|
||||
authType := c.GetString("AuthType")
|
||||
@@ -190,7 +193,7 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
|
||||
|
||||
healthURL := fmt.Sprintf(
|
||||
"https://%s/api/health/basic",
|
||||
req.LeaderAPI,
|
||||
leaderAPIHost,
|
||||
)
|
||||
|
||||
if err := utils.HTTPPostJSON(healthURL, req, headers); err != nil {
|
||||
@@ -203,7 +206,7 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
|
||||
return
|
||||
}
|
||||
|
||||
err = cS.StartAsJoiner(fsm, req.NodeIP, req.NodePort, req.ClusterKey)
|
||||
err = cS.StartAsJoiner(fsm, req.NodeIP, req.ClusterKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
@@ -214,11 +217,10 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
|
||||
return
|
||||
}
|
||||
|
||||
acceptURL := fmt.Sprintf("https://%s/api/cluster/accept-join", req.LeaderAPI)
|
||||
acceptURL := fmt.Sprintf("https://%s/api/cluster/accept-join", leaderAPIHost)
|
||||
payload := map[string]any{
|
||||
"nodeId": req.NodeID,
|
||||
"nodeIp": req.NodeIP,
|
||||
"nodePort": req.NodePort,
|
||||
"clusterKey": req.ClusterKey,
|
||||
}
|
||||
|
||||
@@ -275,7 +277,7 @@ func AcceptJoin(cS *cluster.Service) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if err := cS.AcceptJoin(req.NodeID, req.NodeIP, req.NodePort, req.ClusterKey); err != nil {
|
||||
if err := cS.AcceptJoin(req.NodeID, req.NodeIP, req.ClusterKey); err != nil {
|
||||
if strings.HasPrefix(err.Error(), "not_leader;") {
|
||||
c.JSON(http.StatusConflict, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package clusterHandlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal/services/cluster"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newClusterLifecycleValidationRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/cluster", CreateCluster(nil, nil, nil))
|
||||
r.POST("/cluster/join", JoinCluster(nil, nil, nil, nil))
|
||||
r.POST("/cluster/accept-join", AcceptJoin(nil))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCreateClusterRejectsPayloadWithoutIP(t *testing.T) {
|
||||
r := newClusterLifecycleValidationRouter()
|
||||
|
||||
rr := performJSONRequest(t, r, http.MethodPost, "/cluster", []byte(`{"port":8180}`))
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d with body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid json response: %v", err)
|
||||
}
|
||||
if resp.Message != "invalid_request_payload" {
|
||||
t.Fatalf("expected invalid_request_payload, got %q", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinClusterRejectsLegacyLeaderApiPayload(t *testing.T) {
|
||||
r := newClusterLifecycleValidationRouter()
|
||||
|
||||
body := []byte(`{"nodeId":"node-1","nodeIp":"10.0.0.2","leaderApi":"10.0.0.1:8184","nodePort":8180,"clusterKey":"secret"}`)
|
||||
rr := performJSONRequest(t, r, http.MethodPost, "/cluster/join", body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d with body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid json response: %v", err)
|
||||
}
|
||||
if resp.Message != "invalid_request_payload" {
|
||||
t.Fatalf("expected invalid_request_payload, got %q", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptJoinRejectsPayloadWithoutNodeIP(t *testing.T) {
|
||||
r := newClusterLifecycleValidationRouter()
|
||||
|
||||
rr := performJSONRequest(t, r, http.MethodPost, "/cluster/accept-join", []byte(`{"nodeId":"node-1","clusterKey":"secret"}`))
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d with body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid json response: %v", err)
|
||||
}
|
||||
if resp.Message != "invalid_request_payload" {
|
||||
t.Fatalf("expected invalid_request_payload, got %q", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinLeaderAPIHostUsesClusterHTTPSPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{name: "ipv4", ip: "10.0.0.9"},
|
||||
{name: "ipv6", ip: "fd00::9"},
|
||||
{name: "trimmed", ip: " 192.168.10.20 "},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hostPort := joinLeaderAPIHost(tt.ip)
|
||||
host, port, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort failed for %q: %v", hostPort, err)
|
||||
}
|
||||
if host == "" {
|
||||
t.Fatal("expected non-empty host")
|
||||
}
|
||||
if port != strconv.Itoa(cluster.ClusterEmbeddedHTTPSPort) {
|
||||
t.Fatalf("expected cluster HTTPS port %d, got %s", cluster.ClusterEmbeddedHTTPSPort, port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ type NodeResources struct {
|
||||
type ClusterServiceInterface interface {
|
||||
Detail() *Detail
|
||||
InitRaft(fsm raft.FSM) error
|
||||
CreateCluster(ip string, port int, fsm raft.FSM) error
|
||||
CreateCluster(ip string, fsm raft.FSM) error
|
||||
SetupRaft(bootstrap bool, fsm raft.FSM) (*raft.Raft, error)
|
||||
GetClusterDetails() (*ClusterDetails, error)
|
||||
PopulateClusterNodes() error
|
||||
|
||||
@@ -346,11 +346,13 @@ func (s *Service) ResyncClusterState() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCluster(ip string, port int, fsm raft.FSM) error {
|
||||
func (s *Service) CreateCluster(ip string, fsm raft.FSM) error {
|
||||
if s.Raft != nil {
|
||||
return errors.New("raft_already_initialized")
|
||||
}
|
||||
|
||||
port := ClusterRaftPort
|
||||
|
||||
if err := network.TryBindToPort(ip, port, "tcp"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -419,14 +421,12 @@ func (s *Service) CreateCluster(ip string, port int, fsm raft.FSM) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) StartAsJoiner(fsm raft.FSM, ip string, port int, clusterKey string) error {
|
||||
func (s *Service) StartAsJoiner(fsm raft.FSM, ip string, clusterKey string) error {
|
||||
if !utils.IsValidIP(ip) {
|
||||
return errors.New("invalid_ip_address")
|
||||
}
|
||||
|
||||
if !utils.IsValidPort(port) {
|
||||
return errors.New("invalid_port_number")
|
||||
}
|
||||
port := ClusterRaftPort
|
||||
|
||||
if err := network.TryBindToPort(ip, port, "tcp"); err != nil {
|
||||
return fmt.Errorf("failed_to_bind_to_port: %v", err)
|
||||
@@ -471,7 +471,7 @@ func (s *Service) StartAsJoiner(fsm raft.FSM, ip string, port int, clusterKey st
|
||||
_, err = s.SetupRaft(false, fsm)
|
||||
if err != nil {
|
||||
c.RaftIP = ""
|
||||
c.RaftPort = 0
|
||||
c.RaftPort = ClusterRaftPort
|
||||
c.Enabled = false
|
||||
c.Key = ""
|
||||
|
||||
@@ -539,7 +539,7 @@ func (s *Service) ClearClusteredData() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) AcceptJoin(nodeID, nodeIp string, nodePort int, providedKey string) error {
|
||||
func (s *Service) AcceptJoin(nodeID, nodeIp string, providedKey string) error {
|
||||
details, err := s.GetClusterDetails()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -569,7 +569,7 @@ func (s *Service) AcceptJoin(nodeID, nodeIp string, nodePort int, providedKey st
|
||||
|
||||
conf := fut.Configuration()
|
||||
sid := raft.ServerID(nodeID)
|
||||
saddr := raft.ServerAddress(fmt.Sprintf("%s:%d", nodeIp, nodePort))
|
||||
saddr := raft.ServerAddress(RaftServerAddress(nodeIp))
|
||||
|
||||
for _, srv := range conf.Servers {
|
||||
if srv.ID == sid {
|
||||
@@ -623,7 +623,7 @@ func (s *Service) MarkDeclustered() error {
|
||||
c.Key = ""
|
||||
c.RaftBootstrap = nil
|
||||
c.RaftIP = ""
|
||||
c.RaftPort = 0
|
||||
c.RaftPort = ClusterRaftPort
|
||||
|
||||
if err := s.DB.Save(&c).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const legacyClusterEmbeddedSSHPort = 8122
|
||||
|
||||
func (s *Service) MigrateLegacyPorts() error {
|
||||
if s == nil || s.DB == nil {
|
||||
return fmt.Errorf("cluster_service_unavailable")
|
||||
}
|
||||
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var c clusterModels.Cluster
|
||||
if err := tx.First(&c).Error; err != nil {
|
||||
return fmt.Errorf("failed_to_load_cluster_record: %w", err)
|
||||
}
|
||||
|
||||
if c.RaftPort != ClusterRaftPort {
|
||||
if err := tx.Model(&c).Update("raft_port", ClusterRaftPort).Error; err != nil {
|
||||
return fmt.Errorf("failed_to_migrate_raft_port: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&clusterModels.ClusterSSHIdentity{}).
|
||||
Where("ssh_port IN ?", []int{0, legacyClusterEmbeddedSSHPort}).
|
||||
Update("ssh_port", ClusterEmbeddedSSHPort).Error; err != nil {
|
||||
return fmt.Errorf("failed_to_migrate_cluster_ssh_identity_ports: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyPortsRewritesRaftAndSSHPorts(t *testing.T) {
|
||||
db := newClusterServiceTestDB(t, &clusterModels.Cluster{}, &clusterModels.ClusterSSHIdentity{})
|
||||
s := &Service{DB: db}
|
||||
|
||||
clusterRecord := clusterModels.Cluster{
|
||||
Enabled: false,
|
||||
Key: "",
|
||||
RaftIP: "",
|
||||
RaftPort: 7000,
|
||||
}
|
||||
if err := db.Create(&clusterRecord).Error; err != nil {
|
||||
t.Fatalf("failed to seed cluster record: %v", err)
|
||||
}
|
||||
|
||||
identities := []clusterModels.ClusterSSHIdentity{
|
||||
{NodeUUID: "node-a", SSHUser: "root", SSHHost: "10.0.0.10", SSHPort: 0, PublicKey: "pk-a"},
|
||||
{NodeUUID: "node-b", SSHUser: "root", SSHHost: "10.0.0.11", SSHPort: legacyClusterEmbeddedSSHPort, PublicKey: "pk-b"},
|
||||
{NodeUUID: "node-c", SSHUser: "root", SSHHost: "10.0.0.12", SSHPort: 2222, PublicKey: "pk-c"},
|
||||
}
|
||||
if err := db.Create(&identities).Error; err != nil {
|
||||
t.Fatalf("failed to seed cluster ssh identities: %v", err)
|
||||
}
|
||||
|
||||
if err := s.MigrateLegacyPorts(); err != nil {
|
||||
t.Fatalf("MigrateLegacyPorts returned error: %v", err)
|
||||
}
|
||||
|
||||
var updatedCluster clusterModels.Cluster
|
||||
if err := db.First(&updatedCluster, clusterRecord.ID).Error; err != nil {
|
||||
t.Fatalf("failed to fetch migrated cluster record: %v", err)
|
||||
}
|
||||
if updatedCluster.RaftPort != ClusterRaftPort {
|
||||
t.Fatalf("expected raft port %d, got %d", ClusterRaftPort, updatedCluster.RaftPort)
|
||||
}
|
||||
|
||||
var migrated []clusterModels.ClusterSSHIdentity
|
||||
if err := db.Order("node_uuid ASC").Find(&migrated).Error; err != nil {
|
||||
t.Fatalf("failed to fetch migrated cluster ssh identities: %v", err)
|
||||
}
|
||||
if len(migrated) != 3 {
|
||||
t.Fatalf("expected 3 migrated identities, got %d", len(migrated))
|
||||
}
|
||||
|
||||
for _, identity := range migrated {
|
||||
switch identity.NodeUUID {
|
||||
case "node-a", "node-b":
|
||||
if identity.SSHPort != ClusterEmbeddedSSHPort {
|
||||
t.Fatalf("expected %s SSH port to be migrated to %d, got %d", identity.NodeUUID, ClusterEmbeddedSSHPort, identity.SSHPort)
|
||||
}
|
||||
case "node-c":
|
||||
if identity.SSHPort != 2222 {
|
||||
t.Fatalf("expected non-legacy SSH port to remain 2222, got %d", identity.SSHPort)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected node UUID in migrated identities: %s", identity.NodeUUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyPortsFailsWhenClusterRecordMissing(t *testing.T) {
|
||||
db := newClusterServiceTestDB(t, &clusterModels.Cluster{}, &clusterModels.ClusterSSHIdentity{})
|
||||
s := &Service{DB: db}
|
||||
|
||||
err := s.MigrateLegacyPorts()
|
||||
if err == nil {
|
||||
t.Fatal("expected migration error when cluster record is missing, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed_to_load_cluster_record") {
|
||||
t.Fatalf("expected failed_to_load_cluster_record error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyPortsFailsWhenServiceUnavailable(t *testing.T) {
|
||||
s := &Service{}
|
||||
|
||||
err := s.MigrateLegacyPorts()
|
||||
if err == nil {
|
||||
t.Fatal("expected service unavailable error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cluster_service_unavailable") {
|
||||
t.Fatalf("expected cluster_service_unavailable error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,22 @@
|
||||
|
||||
package cluster
|
||||
|
||||
const (
|
||||
ClusterEmbeddedSSHPort = 8122
|
||||
ClusterEmbeddedHTTPSPort = 8124
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ClusterRaftPort = 8180
|
||||
ClusterEmbeddedSSHPort = 8183
|
||||
ClusterEmbeddedHTTPSPort = 8184
|
||||
)
|
||||
|
||||
func ClusterAPIHost(ip string) string {
|
||||
return net.JoinHostPort(strings.TrimSpace(ip), strconv.Itoa(ClusterEmbeddedHTTPSPort))
|
||||
}
|
||||
|
||||
func RaftServerAddress(ip string) string {
|
||||
return net.JoinHostPort(strings.TrimSpace(ip), strconv.Itoa(ClusterRaftPort))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRaftServerAddressUsesFixedPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{name: "ipv4", ip: "10.20.30.40"},
|
||||
{name: "ipv6", ip: "::1"},
|
||||
{name: "trimmed", ip: " 192.168.1.50 "},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr := RaftServerAddress(tt.ip)
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort failed for %q: %v", addr, err)
|
||||
}
|
||||
if port != strconv.Itoa(ClusterRaftPort) {
|
||||
t.Fatalf("expected raft port %d, got %s", ClusterRaftPort, port)
|
||||
}
|
||||
if host == "" {
|
||||
t.Fatal("expected non-empty host")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterAPIHostUsesFixedHTTPSPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{name: "ipv4", ip: "10.20.30.41"},
|
||||
{name: "ipv6", ip: "fd00::abcd"},
|
||||
{name: "trimmed", ip: " 192.168.1.51 "},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hostPort := ClusterAPIHost(tt.ip)
|
||||
host, port, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort failed for %q: %v", hostPort, err)
|
||||
}
|
||||
if port != strconv.Itoa(ClusterEmbeddedHTTPSPort) {
|
||||
t.Fatalf("expected cluster API port %d, got %s", ClusterEmbeddedHTTPSPort, port)
|
||||
}
|
||||
if host == "" {
|
||||
t.Fatal("expected non-empty host")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ func (s *Service) SetupRaft(bootstrap bool, fsm raft.FSM) (*raft.Raft, error) {
|
||||
return nil, fmt.Errorf("failed_to_get_cluster_info: %v", err)
|
||||
}
|
||||
|
||||
err := network.TryBindToPort(c.RaftIP, c.RaftPort, "tcp")
|
||||
port := ClusterRaftPort
|
||||
|
||||
err := network.TryBindToPort(c.RaftIP, port, "tcp")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed_to_bind_raft_port: %v", err)
|
||||
}
|
||||
@@ -84,7 +86,7 @@ func (s *Service) SetupRaft(bootstrap bool, fsm raft.FSM) (*raft.Raft, error) {
|
||||
return nil, fmt.Errorf("failed_to_create_snap_store")
|
||||
}
|
||||
|
||||
bindAddr := fmt.Sprintf("%s:%d", c.RaftIP, c.RaftPort)
|
||||
bindAddr := RaftServerAddress(c.RaftIP)
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", bindAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not resolve address: %s", err)
|
||||
@@ -252,7 +254,7 @@ func (s *Service) ResetRaftNode() error {
|
||||
}
|
||||
|
||||
err = utils.HTTPPostJSON(
|
||||
fmt.Sprintf("https://%s:%d/api/cluster/remove-peer", host, ClusterEmbeddedHTTPSPort), payload, headers)
|
||||
fmt.Sprintf("https://%s/api/cluster/remove-peer", ClusterAPIHost(host)), payload, headers)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed_to_remove_peer_from_leader: %v", err)
|
||||
|
||||
@@ -14,25 +14,22 @@ export async function getDetails(): Promise<ClusterDetails> {
|
||||
return await apiRequest('/cluster', ClusterDetailsSchema, 'GET');
|
||||
}
|
||||
|
||||
export async function createCluster(ip: string, port: number): Promise<APIResponse> {
|
||||
export async function createCluster(ip: string): Promise<APIResponse> {
|
||||
return await apiRequest('/cluster', APIResponseSchema, 'POST', {
|
||||
ip: ip,
|
||||
port: port
|
||||
ip: ip
|
||||
});
|
||||
}
|
||||
|
||||
export async function joinCluster(
|
||||
nodeId: string,
|
||||
nodeIp: string,
|
||||
nodePort: number,
|
||||
leaderApi: string,
|
||||
leaderIp: string,
|
||||
clusterKey: string
|
||||
): Promise<APIResponse> {
|
||||
return await apiRequest('/cluster/join', APIResponseSchema, 'POST', {
|
||||
nodeId: nodeId,
|
||||
nodeIp: nodeIp,
|
||||
nodePort: nodePort,
|
||||
leaderApi: leaderApi,
|
||||
leaderIp: leaderIp,
|
||||
clusterKey: clusterKey
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { storage } from '$lib';
|
||||
import { handleAPIError } from '$lib/utils/http';
|
||||
import { isValidIPv4, isValidIPv6, isValidPortNumber } from '$lib/utils/string';
|
||||
import { onMount } from 'svelte';
|
||||
import { isValidIPv4, isValidIPv6 } from '$lib/utils/string';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { logOut } from '$lib/api/auth';
|
||||
|
||||
@@ -17,8 +16,7 @@
|
||||
|
||||
let { open = $bindable(), reload = $bindable() }: Props = $props();
|
||||
let options = {
|
||||
ip: '',
|
||||
port: 8180
|
||||
ip: ''
|
||||
};
|
||||
|
||||
let properties = $state(options);
|
||||
@@ -39,8 +37,6 @@
|
||||
|
||||
if (!isValidIPv4(properties.ip) && !isValidIPv6(properties.ip)) {
|
||||
error = 'Invalid IP address';
|
||||
} else if (!isValidPortNumber(properties.port)) {
|
||||
error = 'Invalid port number';
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -53,7 +49,7 @@
|
||||
|
||||
loading = true;
|
||||
|
||||
const response = await createCluster(properties.ip, properties.port);
|
||||
const response = await createCluster(properties.ip);
|
||||
reload = true;
|
||||
loading = false;
|
||||
if (response.error) {
|
||||
@@ -123,13 +119,6 @@
|
||||
classes="flex-1 space-y-1.5"
|
||||
/>
|
||||
|
||||
<CustomValueInput
|
||||
bind:value={properties.port}
|
||||
placeholder="Node Port"
|
||||
classes="flex-1 space-y-1.5"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<Dialog.Footer class="flex justify-end">
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<Button onclick={create} type="submit" size="sm" disabled={loading}>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { handleAPIError } from '$lib/utils/http';
|
||||
import { isValidIPv4, isValidIPv6, isValidPortNumber } from '$lib/utils/string';
|
||||
import { isValidIPv4, isValidIPv6 } from '$lib/utils/string';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { storage } from '$lib';
|
||||
|
||||
@@ -20,9 +20,8 @@
|
||||
isValidIPv4(window.location.hostname) || isValidIPv6(window.location.hostname)
|
||||
? window.location.hostname
|
||||
: '',
|
||||
port: 8180,
|
||||
clusterKey: '',
|
||||
leaderApi: ''
|
||||
leaderIp: ''
|
||||
};
|
||||
|
||||
let properties = $state(options);
|
||||
@@ -33,12 +32,10 @@
|
||||
|
||||
if (!isValidIPv4(properties.ip) && !isValidIPv6(properties.ip)) {
|
||||
error = 'Invalid IP address';
|
||||
} else if (!isValidPortNumber(properties.port)) {
|
||||
error = 'Invalid port number';
|
||||
}
|
||||
|
||||
if (!properties.leaderApi) {
|
||||
error = 'Leader API is required';
|
||||
if (!isValidIPv4(properties.leaderIp) && !isValidIPv6(properties.leaderIp)) {
|
||||
error = 'Leader IP is required';
|
||||
} else if (!properties.clusterKey) {
|
||||
error = 'Cluster Key is required';
|
||||
}
|
||||
@@ -57,8 +54,7 @@
|
||||
const response = await joinCluster(
|
||||
storage.nodeId,
|
||||
properties.ip,
|
||||
Number(properties.port),
|
||||
properties.leaderApi,
|
||||
properties.leaderIp,
|
||||
properties.clusterKey
|
||||
);
|
||||
|
||||
@@ -139,13 +135,6 @@
|
||||
placeholder="Node IP"
|
||||
classes="flex-1 space-y-1.5"
|
||||
/>
|
||||
|
||||
<CustomValueInput
|
||||
bind:value={properties.port}
|
||||
placeholder="Node Port"
|
||||
classes="flex-1 space-y-1.5"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
@@ -153,8 +142,8 @@
|
||||
<input type="password" style="display:none" autocomplete="new-password" />
|
||||
|
||||
<CustomValueInput
|
||||
bind:value={properties.leaderApi}
|
||||
placeholder="Leader API (192.168.1.1:8181)"
|
||||
bind:value={properties.leaderIp}
|
||||
placeholder="Leader IP (192.168.1.1)"
|
||||
classes="flex-1 space-y-1.5 w-1/2"
|
||||
/>
|
||||
|
||||
|
||||
+1474
-41
File diff suppressed because it is too large
Load Diff
+953
-30
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user