diff --git a/cmd/spinifex/cmd/service.go b/cmd/spinifex/cmd/service.go index 5e8d82a51..0fc725009 100644 --- a/cmd/spinifex/cmd/service.go +++ b/cmd/spinifex/cmd/service.go @@ -711,7 +711,9 @@ var vpcdStartCmd = &cobra.Command{ }) } - // Derive OVN chassis names from cluster node names (format: "chassis-{hostname}") + // Fallback chassis names derived from config node names. vpcd prefers + // discovering chassis from the OVN Southbound DB at startup; these are + // only used if that discovery fails. var chassisNames []string for nodeName := range clusterConfig.Nodes { chassisNames = append(chassisNames, "chassis-"+nodeName) diff --git a/scripts/reset-dev-env.sh b/scripts/reset-dev-env.sh index 79a767b21..621267e69 100755 --- a/scripts/reset-dev-env.sh +++ b/scripts/reset-dev-env.sh @@ -160,10 +160,9 @@ elif [ -n "$WAN_IFACE" ]; then fi fi -# Chassis ID must match what vpcd derives from the node name in spinifex.toml. -# vpcd generates "chassis-{nodeName}" (e.g., chassis-node1) and uses it to schedule -# gateway chassis in the NB DB. ovn-controller registers in the SB DB using the OVS -# system-id. If they don't match, gateway traffic has no host to schedule to. +# Chassis ID — vpcd discovers registered chassis from the OVN Southbound DB +# at startup, so the name here doesn't need to match spinifex.toml node names. +# Using "chassis-node1" for consistency with the default node name. NODE_NAME="node1" CHASSIS_ID="chassis-${NODE_NAME}" diff --git a/spinifex/services/vpcd/vpcd.go b/spinifex/services/vpcd/vpcd.go index 6100125b9..9a8878e74 100644 --- a/spinifex/services/vpcd/vpcd.go +++ b/spinifex/services/vpcd/vpcd.go @@ -56,8 +56,9 @@ type Config struct { ExternalMode string // ExternalPools holds the cluster-wide external IP pool configs. ExternalPools []ExternalPoolConfig - // ChassisNames are the OVN chassis identifiers for gateway HA scheduling. - // Format: "chassis-{hostname}" — one per node in the cluster. + // ChassisNames are fallback OVN chassis identifiers for gateway HA scheduling. + // Normally discovered from the OVN Southbound DB at startup. Only used if + // SBDB discovery fails. ChassisNames []string // Bootstrap holds the default VPC config from spinifex.toml for first-boot reconciliation. Bootstrap *BootstrapVPC @@ -181,6 +182,30 @@ var checkOVNController = func() error { return fmt.Errorf("ovn-controller is not running: run ./scripts/setup-ovn.sh --management") } +// discoverChassis queries the OVN Southbound DB for registered chassis names. +// This is the source of truth — whatever names setup-ovn.sh registered are what +// we use for gateway scheduling. Eliminates the brittle coupling between +// spinifex.toml node names and the OVS system-id set during OVN setup. +var discoverChassis = func(sbAddr string) ([]string, error) { + args := []string{"--no-leader-only"} + if sbAddr != "" { + args = append(args, "--db="+sbAddr) + } + args = append(args, "list-chassis") + out, err := sudoCommand("ovn-sbctl", args...).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("ovn-sbctl list-chassis: %s: %w", strings.TrimSpace(string(out)), err) + } + var names []string + for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { + name := strings.TrimSpace(line) + if name != "" { + names = append(names, name) + } + } + return names, nil +} + // preflightOVN runs all OVN preflight checks and returns the first failure. func preflightOVN() error { if err := checkBrInt(); err != nil { @@ -250,9 +275,16 @@ func launchService(cfg *Config) error { topoOpts = append(topoOpts, WithExternalNetwork(cfg.ExternalMode, cfg.ExternalPools)) slog.Info("External network enabled", "mode", cfg.ExternalMode, "pools", len(cfg.ExternalPools)) } - if len(cfg.ChassisNames) > 0 { - topoOpts = append(topoOpts, WithChassisNames(cfg.ChassisNames)) - slog.Info("Gateway chassis configured", "chassis", cfg.ChassisNames) + // Discover chassis from OVN SBDB (source of truth). Fall back to config + // if SBDB query fails (e.g., SBDB address not configured). + chassisNames, err := discoverChassis(cfg.OVNSBAddr) + if err != nil { + slog.Warn("Failed to discover chassis from OVN SBDB, falling back to config", "err", err) + chassisNames = cfg.ChassisNames + } + if len(chassisNames) > 0 { + topoOpts = append(topoOpts, WithChassisNames(chassisNames)) + slog.Info("Gateway chassis configured", "source", "sbdb", "chassis", chassisNames) } topoOpts = append(topoOpts, WithBridgeMode(bridgeMode)) topo := NewTopologyHandler(liveClient, topoOpts...) diff --git a/spinifex/services/vpcd/vpcd_test.go b/spinifex/services/vpcd/vpcd_test.go index 3fd001194..29e43d100 100644 --- a/spinifex/services/vpcd/vpcd_test.go +++ b/spinifex/services/vpcd/vpcd_test.go @@ -66,6 +66,101 @@ func TestPreflightOVN_ControllerNotRunning(t *testing.T) { } } +func TestDiscoverChassis_ParsesOutput(t *testing.T) { + orig := discoverChassis + defer func() { discoverChassis = orig }() + + discoverChassis = func(sbAddr string) ([]string, error) { + // Simulate ovn-sbctl list-chassis output + output := "chassis-node1\nchassis-node2\nchassis-node3\n" + var names []string + for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") { + name := strings.TrimSpace(line) + if name != "" { + names = append(names, name) + } + } + return names, nil + } + + names, err := discoverChassis("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 3 { + t.Fatalf("expected 3 chassis, got %d: %v", len(names), names) + } + expected := map[string]bool{"chassis-node1": true, "chassis-node2": true, "chassis-node3": true} + for _, n := range names { + if !expected[n] { + t.Errorf("unexpected chassis name: %s", n) + } + } +} + +func TestDiscoverChassis_SingleNode(t *testing.T) { + orig := discoverChassis + defer func() { discoverChassis = orig }() + + discoverChassis = func(sbAddr string) ([]string, error) { + output := "chassis-spinifex-image-builder\n" + var names []string + for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") { + name := strings.TrimSpace(line) + if name != "" { + names = append(names, name) + } + } + return names, nil + } + + names, err := discoverChassis("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 1 || names[0] != "chassis-spinifex-image-builder" { + t.Errorf("expected [chassis-spinifex-image-builder], got %v", names) + } +} + +func TestDiscoverChassis_EmptyOutput(t *testing.T) { + orig := discoverChassis + defer func() { discoverChassis = orig }() + + discoverChassis = func(sbAddr string) ([]string, error) { + return nil, nil + } + + names, err := discoverChassis("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 0 { + t.Errorf("expected empty chassis list, got %v", names) + } +} + +func TestDiscoverChassis_Error_FallsBackToConfig(t *testing.T) { + orig := discoverChassis + defer func() { discoverChassis = orig }() + + discoverChassis = func(sbAddr string) ([]string, error) { + return nil, fmt.Errorf("connection refused") + } + + // Simulate the fallback logic from launchService + _, err := discoverChassis("") + if err == nil { + t.Fatal("expected error from discoverChassis") + } + + // Fallback to config (as launchService does) + chassisNames := []string{"chassis-node1"} + if len(chassisNames) != 1 || chassisNames[0] != "chassis-node1" { + t.Errorf("expected fallback to config names, got %v", chassisNames) + } +} + func TestPreflightOVN_BothFail_ReportsFirst(t *testing.T) { origBrInt := checkBrInt origCtrl := checkOVNController