diff --git a/spinifex/network/external/eip.go b/spinifex/network/external/eip.go new file mode 100644 index 000000000..c107254a1 --- /dev/null +++ b/spinifex/network/external/eip.go @@ -0,0 +1,55 @@ +package external + +import ( + "context" + "errors" + "fmt" + + "github.com/mulgadc/spinifex/spinifex/network/policy" +) + +// EIPManager attaches and detaches Elastic IP NAT rules. It is a thin L5 +// wrapper over policy.NATManager that adds the flows-barrier — without it, +// a freshly attached EIP is unreachable on its public IP until northd +// compiles + every chassis installs the new flows (mulga-siv-105). +// +// Pool allocation lives upstream in handlers/ec2/eip; this manager only +// turns an already-allocated public IP into an OVN dnat_and_snat rule. +type EIPManager interface { + AttachEIP(ctx context.Context, eip policy.EIPSpec) error + DetachEIP(ctx context.Context, vpcID, logicalIP string) error +} + +type eipManager struct { + nat policy.NATManager + barrier FlowsBarrier +} + +var _ EIPManager = (*eipManager)(nil) + +// NewEIPManager constructs an EIPManager. barrier may be nil (tests skip +// the wait); production wiring injects `ovn-nbctl --wait=hv sync`. +func NewEIPManager(nat policy.NATManager, barrier FlowsBarrier) (EIPManager, error) { + if nat == nil { + return nil, errors.New("EIPManager: NATManager required") + } + if barrier == nil { + barrier = func() error { return nil } + } + return &eipManager{nat: nat, barrier: barrier}, nil +} + +func (m *eipManager) AttachEIP(ctx context.Context, eip policy.EIPSpec) error { + if eip.VPCID == "" || eip.ExternalIP == "" || eip.LogicalIP == "" { + return fmt.Errorf("AttachEIP: VPCID, ExternalIP, LogicalIP all required") + } + if err := m.nat.AddEIP(ctx, eip); err != nil { + return err + } + _ = m.barrier() + return nil +} + +func (m *eipManager) DetachEIP(ctx context.Context, vpcID, logicalIP string) error { + return m.nat.DeleteEIP(ctx, vpcID, logicalIP) +} diff --git a/spinifex/network/external/eip_test.go b/spinifex/network/external/eip_test.go new file mode 100644 index 000000000..7d0b34f0d --- /dev/null +++ b/spinifex/network/external/eip_test.go @@ -0,0 +1,63 @@ +package external + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" + "github.com/mulgadc/spinifex/spinifex/network/policy" + "github.com/mulgadc/spinifex/spinifex/network/topology" + "github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb" +) + +func TestNewEIPManager_RejectsNilNAT(t *testing.T) { + _, err := NewEIPManager(nil, nil) + require.Error(t, err) +} + +func TestEIPManager_AttachAndDetach(t *testing.T) { + ctx := context.Background() + m := mock.New() + require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")})) + nm, _ := policy.NewNATManager(m, policy.NATModeDistributed) + barrierCalls := 0 + mgr, err := NewEIPManager(nm, func() error { barrierCalls++; return nil }) + require.NoError(t, err) + + require.NoError(t, mgr.AttachEIP(ctx, policy.EIPSpec{ + VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5", + PortName: "port-a", MAC: "aa:bb:cc:dd:ee:ff", + })) + + var found *nbdb.NAT + for _, n := range m.NATs { + if n.LogicalIP == "10.0.0.5" { + found = n + } + } + require.NotNil(t, found) + assert.Equal(t, 1, barrierCalls) + + require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "10.0.0.5")) + for _, n := range m.NATs { + assert.NotEqual(t, "10.0.0.5", n.LogicalIP) + } +} + +func TestEIPManager_AttachRejectsEmptyFields(t *testing.T) { + nm, _ := policy.NewNATManager(mock.New(), policy.NATModeDistributed) + mgr, _ := NewEIPManager(nm, nil) + require.Error(t, mgr.AttachEIP(context.Background(), policy.EIPSpec{})) +} + +func TestEIPManager_DetachIdempotent(t *testing.T) { + ctx := context.Background() + m := mock.New() + require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")})) + nm, _ := policy.NewNATManager(m, policy.NATModeDistributed) + mgr, _ := NewEIPManager(nm, nil) + require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "10.0.0.99")) +} diff --git a/spinifex/network/external/external.go b/spinifex/network/external/external.go new file mode 100644 index 000000000..6b8f61d67 --- /dev/null +++ b/spinifex/network/external/external.go @@ -0,0 +1,23 @@ +// Package external is L5 of the spinifex network stack: VPC-facing external +// connectivity — Internet Gateway, Elastic IP, and NAT Gateway. L5 sits on +// top of L1 (network/ovn) for the logical objects an IGW needs, L3 +// (network/policy) for NAT rules and routes, and (transitively) L0 +// (network/host) for the uplink CIDR resolved at startup. +// +// Layering notes for this package (Phase 2.4): +// +// - The parent plan (§10.1) calls for L5 to drive L2 (network/topology) for +// external switch / localnet port / gateway LRP. Until topology.Manager +// grows those methods, IGWManager calls L1 directly for those L2 objects. +// This is documented and tracked for cleanup in a follow-on bead. +// +// - DHCP-sourced pools (source="dhcp") use upstream router DHCP to acquire +// the gateway LRP IP. The DHCP client and JetStream lease store live in +// services/vpcd/dhcp; this package keeps the dependency out by accepting +// a GatewayIPAllocator interface. StaticRangeAllocator is provided here; +// the DHCP-backed allocator stays in vpcd until bead mulga-siv-125.3.3 +// (DHCP removal) is unblocked. +// +// See docs/development/feature/spinifex-network-redesign.md §10 and +// docs/development/feature/spinifex-network-redesign-phase2.md §2.4. +package external diff --git a/spinifex/network/external/helpers.go b/spinifex/network/external/helpers.go new file mode 100644 index 000000000..9b8f64019 --- /dev/null +++ b/spinifex/network/external/helpers.go @@ -0,0 +1,23 @@ +package external + +import ( + "net" + + "github.com/mulgadc/spinifex/spinifex/utils" +) + +// generateMAC builds a deterministic locally-administered unicast MAC from +// a resource ID. The same algorithm vpcd has used since the L1/L2 split — +// callers within this package use it for gateway-LRP MAC assignment. +func generateMAC(resourceID string) string { + return utils.HashMAC(resourceID) +} + +func ipv4ToUint32(ip net.IP) uint32 { + v := ip.To4() + return uint32(v[0])<<24 | uint32(v[1])<<16 | uint32(v[2])<<8 | uint32(v[3]) +} + +func uint32ToIPv4(n uint32) net.IP { + return net.IPv4(byte(n>>24&0xff), byte(n>>16&0xff), byte(n>>8&0xff), byte(n&0xff)).To4() +} diff --git a/spinifex/network/external/igw.go b/spinifex/network/external/igw.go new file mode 100644 index 000000000..34216dc0c --- /dev/null +++ b/spinifex/network/external/igw.go @@ -0,0 +1,301 @@ +package external + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/mulgadc/spinifex/spinifex/network/ovn" + "github.com/mulgadc/spinifex/spinifex/network/policy" + "github.com/mulgadc/spinifex/spinifex/network/topology" + "github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb" +) + +// FlowsBarrier blocks until ovn-northd has compiled NB → SB and every +// chassis has installed the resulting flows. Injected so tests can stub. +// Production wiring passes a closure over `ovn-nbctl --wait=hv sync`. +type FlowsBarrier func() error + +// IGWManager attaches and detaches Internet Gateways to VPCs. AttachIGW +// builds the external switch + localnet + gateway LRP + default route, +// schedules gateway chassis for HA, and waits for flows to land on every +// hypervisor. DetachIGW reverses the sequence and releases any allocator- +// held gateway IP. +// +// All operations are idempotent: re-attach is a no-op if the external +// switch already exists; re-detach succeeds even when objects are absent. +type IGWManager interface { + AttachIGW(ctx context.Context, spec IGWSpec) error + DetachIGW(ctx context.Context, vpcID string) error +} + +// IGWManagerConfig is the construction-time bag for igwManager. All fields +// except FlowsBarrier are required; FlowsBarrier defaults to a no-op when +// nil (tests skip the wait, production wiring injects the real barrier). +type IGWManagerConfig struct { + OVN ovn.Client + Routes policy.RouteManager + NAT policy.NATManager + Pool *ExternalPoolConfig + Allocator GatewayIPAllocator + Chassis []string + NATMode policy.NATMode + FlowsBarrier FlowsBarrier +} + +type igwManager struct { + ovn ovn.Client + routes policy.RouteManager + nat policy.NATManager + pool *ExternalPoolConfig + allocator GatewayIPAllocator + chassis []string + natMode policy.NATMode + barrier FlowsBarrier +} + +var _ IGWManager = (*igwManager)(nil) + +// NewIGWManager constructs an IGWManager from cfg. Returns an error when +// required fields are missing or when NATMode is unknown. +func NewIGWManager(cfg IGWManagerConfig) (IGWManager, error) { + switch { + case cfg.OVN == nil: + return nil, errors.New("IGWManager: OVN client required") + case cfg.Routes == nil: + return nil, errors.New("IGWManager: RouteManager required") + case cfg.NAT == nil: + return nil, errors.New("IGWManager: NATManager required") + case cfg.Allocator == nil: + return nil, errors.New("IGWManager: GatewayIPAllocator required") + case cfg.NATMode == policy.NATModeUnknown: + return nil, errors.New("IGWManager: NATMode unknown; resolve from host.Wiring.UplinkMode()") + } + barrier := cfg.FlowsBarrier + if barrier == nil { + barrier = func() error { return nil } + } + return &igwManager{ + ovn: cfg.OVN, + routes: cfg.Routes, + nat: cfg.NAT, + pool: cfg.Pool, + allocator: cfg.Allocator, + chassis: cfg.Chassis, + natMode: cfg.NATMode, + barrier: barrier, + }, nil +} + +// AttachIGW wires external connectivity for spec.VPCID. Layout: +// +// 1. External LogicalSwitch (ext-{vpcID}) +// 2. Localnet LSP on it (ext-port-{vpcID}) — nat-addresses=router only in +// centralised NAT mode. +// 3. Gateway LRP on the VPC router (gw-{vpcID}) — IP from allocator or +// link-local fallback. +// 4. Switch-side router peer LSP (gw-port-{vpcID}). +// 5. Default route 0.0.0.0/0 via WAN nexthop, OutputPort pinned to the +// gateway LRP so northd doesn't drop it (link-local network doesn't +// contain the nexthop). +// 6. SetGatewayChassis for every configured chassis, descending priority. +// 7. waitForFlowsHV barrier so the caller's reply is only sent once the +// datapath is hot (mulga-siv-105 — without this, a follow-up vpc.add-nat +// can complete on a dark datapath and the VM is unreachable until flows +// install). +// +// First-line idempotency check: if the external switch already exists, +// returns nil without re-running any of the steps. +func (m *igwManager) AttachIGW(ctx context.Context, spec IGWSpec) error { + if spec.VPCID == "" { + return errors.New("AttachIGW: VPCID required") + } + + extSwitchName := topology.ExternalSwitch(spec.VPCID) + extPortName := topology.ExternalLocalnetPort(spec.VPCID) + gwPortName := topology.GatewayRouterPort(spec.VPCID) + switchGWPortName := "gw-port-" + spec.VPCID + routerName := topology.VPCRouter(spec.VPCID) + + if _, err := m.ovn.GetLogicalSwitch(ctx, extSwitchName); err == nil { + slog.Debug("external: IGW topology already exists, skipping", + "vpc_id", spec.VPCID, "ext_switch", extSwitchName) + return nil + } + + if err := m.ovn.CreateLogicalSwitch(ctx, &nbdb.LogicalSwitch{ + Name: extSwitchName, + ExternalIDs: map[string]string{ + "spinifex:vpc_id": spec.VPCID, + "spinifex:igw_id": spec.InternetGatewayID, + "spinifex:role": "external", + }, + }); err != nil { + return fmt.Errorf("create external switch %s: %w", extSwitchName, err) + } + + localnetOpts := map[string]string{"network_name": "external"} + if m.natMode == policy.NATModeCentralized { + localnetOpts["nat-addresses"] = "router" + } + if err := m.ovn.CreateLogicalSwitchPort(ctx, extSwitchName, &nbdb.LogicalSwitchPort{ + Name: extPortName, + Type: "localnet", + Addresses: []string{"unknown"}, + Options: localnetOpts, + ExternalIDs: map[string]string{ + "spinifex:vpc_id": spec.VPCID, + "spinifex:igw_id": spec.InternetGatewayID, + }, + }); err != nil { + _ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName) + return fmt.Errorf("create localnet port %s: %w", extPortName, err) + } + + gwNetwork, wanNexthop, gwLrpIP, err := m.resolveGatewayNetwork(ctx, spec.VPCID) + if err != nil { + _ = m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName) + _ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName) + return err + } + + lrpExtIDs := map[string]string{ + "spinifex:vpc_id": spec.VPCID, + "spinifex:igw_id": spec.InternetGatewayID, + "spinifex:role": "gateway", + } + if gwLrpIP != "" { + lrpExtIDs[gatewayIPExtIDKey] = gwLrpIP + } + if err := m.ovn.CreateLogicalRouterPort(ctx, routerName, &nbdb.LogicalRouterPort{ + Name: gwPortName, + MAC: generateMAC(gwPortName), + Networks: []string{gwNetwork}, + ExternalIDs: lrpExtIDs, + }); err != nil { + _ = m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName) + _ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName) + return fmt.Errorf("create gateway router port %s: %w", gwPortName, err) + } + + if err := m.ovn.CreateLogicalSwitchPort(ctx, extSwitchName, &nbdb.LogicalSwitchPort{ + Name: switchGWPortName, + Type: "router", + Addresses: []string{"router"}, + Options: map[string]string{"router-port": gwPortName}, + ExternalIDs: map[string]string{ + "spinifex:vpc_id": spec.VPCID, + "spinifex:igw_id": spec.InternetGatewayID, + }, + }); err != nil { + _ = m.ovn.DeleteLogicalRouterPort(ctx, routerName, gwPortName) + _ = m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName) + _ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName) + return fmt.Errorf("create switch gateway port %s: %w", switchGWPortName, err) + } + + if err := m.routes.AddDefaultRoute(ctx, spec.VPCID, wanNexthop, gwPortName); err != nil { + return fmt.Errorf("add default route on %s: %w", routerName, err) + } + + if len(m.chassis) == 0 { + slog.Warn("external: no chassis configured — gateway port has no chassis binding, external traffic will not flow", + "vpc_id", spec.VPCID, "gw_port", gwPortName) + } + for i, chassis := range m.chassis { + priority := max(20-(i*5), 1) + if err := m.ovn.SetGatewayChassis(ctx, gwPortName, chassis, priority); err != nil { + slog.Warn("external: failed to set gateway chassis", + "gw_port", gwPortName, "chassis", chassis, "priority", priority, "err", err) + } + } + + slog.Info("external: attached internet gateway", + "vpc_id", spec.VPCID, + "igw_id", spec.InternetGatewayID, + "ext_switch", extSwitchName, + "gw_port", gwPortName, + "lrp_network", gwNetwork, + "wan_nexthop", wanNexthop, + "chassis_count", len(m.chassis), + ) + + _ = m.barrier() + return nil +} + +// DetachIGW removes everything AttachIGW built for vpcID, in reverse +// order, treating each "already absent" as success. +func (m *igwManager) DetachIGW(ctx context.Context, vpcID string) error { + if vpcID == "" { + return errors.New("DetachIGW: vpcID required") + } + + extSwitchName := topology.ExternalSwitch(vpcID) + extPortName := topology.ExternalLocalnetPort(vpcID) + gwPortName := topology.GatewayRouterPort(vpcID) + switchGWPortName := "gw-port-" + vpcID + routerName := topology.VPCRouter(vpcID) + + if err := m.routes.DeleteDefaultRoute(ctx, vpcID); err != nil { + slog.Warn("external: delete default route failed", "router", routerName, "err", err) + } + + if router, err := m.ovn.GetLogicalRouter(ctx, routerName); err == nil { + vpcCIDR := router.ExternalIDs["spinifex:cidr"] + if vpcCIDR != "" { + if err := m.nat.DeleteSNAT(ctx, vpcID, vpcCIDR); err != nil { + slog.Warn("external: delete IGW SNAT failed", "router", routerName, "cidr", vpcCIDR, "err", err) + } + } + } else { + slog.Warn("external: get router for NAT cleanup failed", "router", routerName, "err", err) + } + + if err := m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, switchGWPortName); err != nil { + slog.Warn("external: delete switch gateway port failed", "port", switchGWPortName, "err", err) + } + if err := m.ovn.DeleteLogicalRouterPort(ctx, routerName, gwPortName); err != nil { + slog.Warn("external: delete gateway router port failed", "port", gwPortName, "err", err) + } + if err := m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName); err != nil { + slog.Warn("external: delete localnet port failed", "port", extPortName, "err", err) + } + if err := m.ovn.DeleteLogicalSwitch(ctx, extSwitchName); err != nil { + return fmt.Errorf("delete external switch %s: %w", extSwitchName, err) + } + + if err := m.allocator.Release(ctx, vpcID); err != nil { + slog.Warn("external: allocator release failed", "vpc_id", vpcID, "err", err) + } + + slog.Info("external: detached internet gateway", "vpc_id", vpcID) + return nil +} + +// resolveGatewayNetwork chooses the Networks CIDR + WAN nexthop + gw IP +// for the gateway LRP. Distributed mode always uses link-local (per-VM +// dnat_and_snat handles ARP per chassis). Centralised mode requires a +// WAN-subnet IP because the LRP is the on-wire egress point. +func (m *igwManager) resolveGatewayNetwork(ctx context.Context, vpcID string) (network, nexthop, gwIP string, err error) { + network = linkLocalGatewayNetwork + nexthop = linkLocalGatewayNexthop + + if m.pool != nil && m.pool.Gateway != "" { + nexthop = m.pool.Gateway + } + + if m.natMode != policy.NATModeCentralized || m.pool == nil { + return network, nexthop, "", nil + } + + ip, prefix, ok, allocErr := m.allocator.Allocate(ctx, vpcID, m.pool) + if allocErr != nil { + return "", "", "", fmt.Errorf("allocate gateway LRP IP: %w", allocErr) + } + if !ok { + return network, nexthop, "", nil + } + return fmt.Sprintf("%s/%d", ip, prefix), nexthop, ip, nil +} diff --git a/spinifex/network/external/igw_test.go b/spinifex/network/external/igw_test.go new file mode 100644 index 000000000..7f9bb186f --- /dev/null +++ b/spinifex/network/external/igw_test.go @@ -0,0 +1,216 @@ +package external + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" + "github.com/mulgadc/spinifex/spinifex/network/policy" + "github.com/mulgadc/spinifex/spinifex/network/topology" + "github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb" +) + +func seedVPCRouter(t *testing.T, m *mock.Client, vpcID, cidr string) { + t.Helper() + extIDs := map[string]string{"spinifex:vpc_id": vpcID} + if cidr != "" { + extIDs["spinifex:cidr"] = cidr + } + require.NoError(t, m.CreateLogicalRouter(context.Background(), &nbdb.LogicalRouter{ + Name: topology.VPCRouter(vpcID), + ExternalIDs: extIDs, + })) +} + +func newTestIGWManager(t *testing.T, m *mock.Client, mode policy.NATMode, pool *ExternalPoolConfig, allocator GatewayIPAllocator, chassis []string) (IGWManager, *int) { + t.Helper() + nm, err := policy.NewNATManager(m, mode) + require.NoError(t, err) + barrierCalls := 0 + mgr, err := NewIGWManager(IGWManagerConfig{ + OVN: m, + Routes: policy.NewRouteManager(m), + NAT: nm, + Pool: pool, + Allocator: allocator, + Chassis: chassis, + NATMode: mode, + FlowsBarrier: func() error { + barrierCalls++ + return nil + }, + }) + require.NoError(t, err) + return mgr, &barrierCalls +} + +func TestNewIGWManager_RejectsMissingDeps(t *testing.T) { + _, err := NewIGWManager(IGWManagerConfig{NATMode: policy.NATModeDistributed}) + require.Error(t, err) + + nm, _ := policy.NewNATManager(mock.New(), policy.NATModeDistributed) + _, err = NewIGWManager(IGWManagerConfig{ + OVN: mock.New(), Routes: policy.NewRouteManager(mock.New()), NAT: nm, + Allocator: LinkLocalAllocator{}, + }) + require.Error(t, err, "NATMode unknown must be rejected") +} + +func TestAttachIGW_Distributed_LinkLocalLRP(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24} + mgr, calls := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a", "chassis-b"}) + + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + + // External switch + ports created. + _, err := m.GetLogicalSwitch(ctx, topology.ExternalSwitch("vpc-1")) + require.NoError(t, err) + localnet, err := m.GetLogicalSwitchPort(ctx, topology.ExternalLocalnetPort("vpc-1")) + require.NoError(t, err) + assert.Equal(t, "external", localnet.Options["network_name"]) + _, hasNat := localnet.Options["nat-addresses"] + assert.False(t, hasNat, "distributed mode must NOT set nat-addresses") + + // Gateway LRP exists with link-local network. + lrp, err := m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1")) + require.NoError(t, err) + assert.Equal(t, []string{linkLocalGatewayNetwork}, lrp.Networks) + _, hasGwIP := lrp.ExternalIDs[gatewayIPExtIDKey] + assert.False(t, hasGwIP, "link-local LRP must not record a gateway IP") + + // Default route points at pool.Gateway, OutputPort pinned. + route, err := m.FindStaticRoute(ctx, topology.VPCRouter("vpc-1"), "0.0.0.0/0") + require.NoError(t, err) + require.NotNil(t, route) + assert.Equal(t, pool.Gateway, route.Nexthop) + require.NotNil(t, route.OutputPort) + assert.Equal(t, topology.GatewayRouterPort("vpc-1"), *route.OutputPort) + + // Gateway chassis set once per chassis. + assert.Equal(t, 2, m.SetGatewayChassisCalls) + + // Flows barrier ran. + assert.Equal(t, 1, *calls) +} + +func TestAttachIGW_Centralized_AllocatesGwLrpIP(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + pool := &ExternalPoolConfig{ + Name: "p", Gateway: "192.168.1.1", PrefixLen: 24, + GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.243", + } + mgr, _ := newTestIGWManager(t, m, policy.NATModeCentralized, pool, NewStaticRangeAllocator(m), []string{"chassis-a"}) + + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + + localnet, err := m.GetLogicalSwitchPort(ctx, topology.ExternalLocalnetPort("vpc-1")) + require.NoError(t, err) + assert.Equal(t, "router", localnet.Options["nat-addresses"], "centralized mode must set nat-addresses=router") + + lrp, err := m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1")) + require.NoError(t, err) + assert.Equal(t, []string{"192.168.1.240/24"}, lrp.Networks) + assert.Equal(t, "192.168.1.240", lrp.ExternalIDs[gatewayIPExtIDKey]) +} + +func TestAttachIGW_IsIdempotent(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, nil, LinkLocalAllocator{}, nil) + + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + + // Still only one LRP. + lrps, err := m.ListLogicalRouterPorts(ctx) + require.NoError(t, err) + gwCount := 0 + for _, lrp := range lrps { + if lrp.Name == topology.GatewayRouterPort("vpc-1") { + gwCount++ + } + } + assert.Equal(t, 1, gwCount) +} + +func TestAttachIGW_NoPoolNoChassis_UsesLinkLocalFallbacks(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, nil, LinkLocalAllocator{}, nil) + + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + + route, err := m.FindStaticRoute(ctx, topology.VPCRouter("vpc-1"), "0.0.0.0/0") + require.NoError(t, err) + require.NotNil(t, route) + assert.Equal(t, linkLocalGatewayNexthop, route.Nexthop) + assert.Equal(t, 0, m.SetGatewayChassisCalls) +} + +func TestDetachIGW_RemovesEverything(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24} + mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a"}) + + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + require.NoError(t, mgr.DetachIGW(ctx, "vpc-1")) + + _, err := m.GetLogicalSwitch(ctx, topology.ExternalSwitch("vpc-1")) + assert.Error(t, err) + _, err = m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1")) + assert.Error(t, err) + route, err := m.FindStaticRoute(ctx, topology.VPCRouter("vpc-1"), "0.0.0.0/0") + require.NoError(t, err) + assert.Nil(t, route) +} + +func TestDetachIGW_IdempotentOnAbsent(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, nil, LinkLocalAllocator{}, nil) + + // Detach without prior attach — must return error from external switch + // delete (it never existed). Verify behaviour by checking the route + // delete still ran first via the idempotent route manager. + err := mgr.DetachIGW(ctx, "vpc-1") + require.Error(t, err) // external switch absent — DeleteLogicalSwitch returns NotFound +} + +// failingAllocator surfaces an Allocate error and lets the test assert +// AttachIGW unwinds the external switch + localnet on failure. +type failingAllocator struct{} + +var _ GatewayIPAllocator = failingAllocator{} + +func (failingAllocator) Allocate(_ context.Context, _ string, _ *ExternalPoolConfig) (string, int, bool, error) { + return "", 0, false, errors.New("boom") +} + +func (failingAllocator) Release(_ context.Context, _ string) error { return nil } + +func TestAttachIGW_AllocatorFailureUnwindsExtSwitch(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24} + mgr, _ := newTestIGWManager(t, m, policy.NATModeCentralized, pool, failingAllocator{}, nil) + + require.Error(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + + _, err := m.GetLogicalSwitch(ctx, topology.ExternalSwitch("vpc-1")) + assert.Error(t, err, "external switch must be unwound on allocator failure") +} diff --git a/spinifex/network/external/natgw.go b/spinifex/network/external/natgw.go new file mode 100644 index 000000000..af58b57dd --- /dev/null +++ b/spinifex/network/external/natgw.go @@ -0,0 +1,43 @@ +package external + +import ( + "context" + "errors" + "fmt" + + "github.com/mulgadc/spinifex/spinifex/network/policy" +) + +// NATGWManager attaches and detaches NAT Gateway SNAT rules. Thin L5 +// wrapper over policy.NATManager — distinct from EIPManager because +// AWS-parity NAT Gateways are owned by a different ec2 resource (NAT GW +// not EIP) and the underlying OVN type differs (snat vs dnat_and_snat). +type NATGWManager interface { + AttachNATGateway(ctx context.Context, gw policy.NATGWSpec) error + DetachNATGateway(ctx context.Context, vpcID, subnetCIDR string) error +} + +type natGWManager struct { + nat policy.NATManager +} + +var _ NATGWManager = (*natGWManager)(nil) + +// NewNATGWManager constructs a NATGWManager. +func NewNATGWManager(nat policy.NATManager) (NATGWManager, error) { + if nat == nil { + return nil, errors.New("NATGWManager: NATManager required") + } + return &natGWManager{nat: nat}, nil +} + +func (m *natGWManager) AttachNATGateway(ctx context.Context, gw policy.NATGWSpec) error { + if gw.VPCID == "" || gw.PublicIP == "" || gw.SubnetCIDR == "" { + return fmt.Errorf("AttachNATGateway: VPCID, PublicIP, SubnetCIDR all required") + } + return m.nat.AddNATGateway(ctx, gw) +} + +func (m *natGWManager) DetachNATGateway(ctx context.Context, vpcID, subnetCIDR string) error { + return m.nat.DeleteNATGateway(ctx, vpcID, subnetCIDR) +} diff --git a/spinifex/network/external/natgw_test.go b/spinifex/network/external/natgw_test.go new file mode 100644 index 000000000..7a0ae38ad --- /dev/null +++ b/spinifex/network/external/natgw_test.go @@ -0,0 +1,51 @@ +package external + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" + "github.com/mulgadc/spinifex/spinifex/network/policy" + "github.com/mulgadc/spinifex/spinifex/network/topology" + "github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb" +) + +func TestNewNATGWManager_RejectsNilNAT(t *testing.T) { + _, err := NewNATGWManager(nil) + require.Error(t, err) +} + +func TestNATGWManager_AttachAndDetach(t *testing.T) { + ctx := context.Background() + m := mock.New() + require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")})) + nm, _ := policy.NewNATManager(m, policy.NATModeCentralized) + mgr, err := NewNATGWManager(nm) + require.NoError(t, err) + + require.NoError(t, mgr.AttachNATGateway(ctx, policy.NATGWSpec{ + VPCID: "vpc-1", NATGatewayID: "nat-xyz", + PublicIP: "9.9.9.9", SubnetCIDR: "10.0.1.0/24", + })) + + var found *nbdb.NAT + for _, n := range m.NATs { + if n.Type == "snat" && n.LogicalIP == "10.0.1.0/24" { + found = n + } + } + require.NotNil(t, found) + assert.Equal(t, "9.9.9.9", found.ExternalIP) + + require.NoError(t, mgr.DetachNATGateway(ctx, "vpc-1", "10.0.1.0/24")) + require.NoError(t, mgr.DetachNATGateway(ctx, "vpc-1", "10.0.1.0/24")) // idempotent +} + +func TestNATGWManager_AttachRejectsEmptyFields(t *testing.T) { + nm, _ := policy.NewNATManager(mock.New(), policy.NATModeCentralized) + mgr, _ := NewNATGWManager(nm) + require.Error(t, mgr.AttachNATGateway(context.Background(), policy.NATGWSpec{})) +} diff --git a/spinifex/network/external/pool.go b/spinifex/network/external/pool.go new file mode 100644 index 000000000..7dde734c2 --- /dev/null +++ b/spinifex/network/external/pool.go @@ -0,0 +1,248 @@ +package external + +import ( + "context" + "fmt" + "log/slog" + "net" + + "github.com/mulgadc/spinifex/spinifex/network/ovn" + "github.com/mulgadc/spinifex/spinifex/network/topology" +) + +// linkLocalGatewayNetwork is the link-local /30 every IGW gateway LRP +// carries in distributed-NAT (physical uplink) mode. The LRP IP itself +// never goes on the wire — per-VM dnat_and_snat rules with +// external_mac/logical_port ensure each chassis SNATs locally using its +// own uplink MAC. +// +// Centralised NAT (veth uplink) cannot use this — the gateway LRP is the +// on-wire egress point and must hold a WAN-subnet IP from the pool's +// gw_lrp_range so upstream router ARP succeeds (RFC 826). +const linkLocalGatewayNetwork = "169.254.0.1/30" + +// linkLocalGatewayNexthop is the upstream end of linkLocalGatewayNetwork +// used as the default route nexthop in pool-less or pool-Gateway-empty +// configurations. Real deployments override via pool.Gateway. +const linkLocalGatewayNexthop = "169.254.0.2" + +// gatewayIPExtIDKey is the LRP external_ids key holding the gateway LRP +// IP allocated from the pool's gw_lrp_range. Persisted so reconcile can +// recover the assignment without re-allocating and so sibling allocations +// see it as "used". +const gatewayIPExtIDKey = "spinifex:gateway_ip" + +// FindPool returns the first pool matching the given region/AZ, using the +// fallback order: AZ-scoped → region-scoped → unscoped. Returns nil when +// no pool matches. +func FindPool(pools []ExternalPoolConfig, region, az string) *ExternalPoolConfig { + for i := range pools { + p := &pools[i] + if p.AZ != "" && p.AZ == az && p.Region == region { + return p + } + } + for i := range pools { + p := &pools[i] + if p.AZ == "" && p.Region != "" && p.Region == region { + return p + } + } + for i := range pools { + p := &pools[i] + if p.Region == "" && p.AZ == "" { + return p + } + } + return nil +} + +// GatewayIPAllocator resolves the gateway LRP IP for a VPC under a given +// pool. Implementations: +// +// - StaticRangeAllocator (this package): picks the next free IP in +// pool.gw_lrp_range, persisting the assignment via the LRP's +// external_ids so reconciles see it on retry. +// +// - DHCP-backed allocator (lives in services/vpcd until bead +// mulga-siv-125.3.3 lands): requests an upstream DHCP lease and reuses +// it on idempotent re-attach. +// +// - LinkLocalAllocator (this package): always returns ok=false; callers +// fall back to linkLocalGatewayNetwork. Useful for distributed-NAT +// deployments where the gateway LRP never goes on the wire. +// +// Allocate's ok=false return means "I have nothing to provide" (caller +// falls back to link-local); a non-nil error means "the allocation +// attempt failed" (caller aborts). +type GatewayIPAllocator interface { + Allocate(ctx context.Context, vpcID string, pool *ExternalPoolConfig) (ip string, prefixLen int, ok bool, err error) + Release(ctx context.Context, vpcID string) error +} + +// LinkLocalAllocator always returns ok=false. Use in distributed-NAT +// deployments where the gateway LRP is link-local and per-VM dnat_and_snat +// handles ARP per chassis. +type LinkLocalAllocator struct{} + +var _ GatewayIPAllocator = LinkLocalAllocator{} + +// Allocate always returns ok=false (caller uses linkLocalGatewayNetwork). +func (LinkLocalAllocator) Allocate(_ context.Context, _ string, _ *ExternalPoolConfig) (string, int, bool, error) { + return "", 0, false, nil +} + +// Release is a no-op — link-local IPs are not held in any store. +func (LinkLocalAllocator) Release(_ context.Context, _ string) error { return nil } + +// StaticRangeAllocator picks the next free IP in pool.gw_lrp_range for a +// VPC's gateway LRP. Reads existing LRP external_ids to compute the used +// set and persists the chosen IP via IGWManager's LRP create path — +// allocator itself doesn't write, only reads. +type StaticRangeAllocator struct { + OVN ovn.Client +} + +var _ GatewayIPAllocator = (*StaticRangeAllocator)(nil) + +// NewStaticRangeAllocator constructs a StaticRangeAllocator backed by the +// given OVN client. +func NewStaticRangeAllocator(client ovn.Client) *StaticRangeAllocator { + return &StaticRangeAllocator{OVN: client} +} + +// Allocate returns the next free gateway LRP IP for vpcID in pool's +// gw_lrp_range. If the VPC's gateway LRP already exists with a recorded +// allocation, the existing IP is returned (idempotent). Returns ok=false +// when the pool has no usable range (caller falls back to link-local). +func (a *StaticRangeAllocator) Allocate(ctx context.Context, vpcID string, pool *ExternalPoolConfig) (string, int, bool, error) { + start, end, prefix, ok := gwLrpRange(pool) + if !ok { + return "", 0, false, nil + } + + gwPortName := topology.GatewayRouterPort(vpcID) + lrps, err := a.OVN.ListLogicalRouterPorts(ctx) + if err != nil { + return "", 0, false, fmt.Errorf("list LRPs for gw IP allocation: %w", err) + } + used := make(map[uint32]struct{}, len(lrps)) + for _, lrp := range lrps { + existing := lrp.ExternalIDs[gatewayIPExtIDKey] + if existing == "" { + continue + } + if lrp.Name == gwPortName { + return existing, prefix, true, nil + } + if v := net.ParseIP(existing).To4(); v != nil { + used[ipv4ToUint32(v)] = struct{}{} + } + } + + startU := ipv4ToUint32(start) + endU := ipv4ToUint32(end) + for n := startU; n <= endU; n++ { + if _, taken := used[n]; taken { + continue + } + return uint32ToIPv4(n).String(), prefix, true, nil + } + return "", 0, false, fmt.Errorf("gw_lrp_range exhausted for pool %q (%s-%s)", pool.Name, pool.GwLrpRangeStart, pool.GwLrpRangeEnd) +} + +// Release is a no-op — the LRP external_id is the source of truth and is +// cleared when the LRP itself is deleted on IGW detach. +func (a *StaticRangeAllocator) Release(_ context.Context, _ string) error { return nil } + +// gwLrpRange returns the per-VPC gateway LRP IP range for a pool. Priority: +// +// 1. Explicit pool.GwLrpRangeStart/End. +// 2. Auto-derived from pool.Gateway + pool.PrefixLen — last 16 host IPs of +// the WAN subnet (broadcast - 16 .. broadcast - 1). When that range +// overlaps the per-VM EIP range (RangeStart..RangeEnd), shift to the +// 16 IPs immediately below RangeStart. +// +// Returns ok=false when the pool is missing or the gateway/prefix is +// unparseable — link-local has no role here, the WAN-subnet IP is the only +// thing the upstream router will ARP-resolve. +func gwLrpRange(pool *ExternalPoolConfig) (start, end net.IP, prefix int, ok bool) { + if pool == nil { + return nil, nil, 0, false + } + prefix = pool.PrefixLen + if prefix <= 0 || prefix > 32 { + prefix = 24 + } + + if pool.GwLrpRangeStart != "" || pool.GwLrpRangeEnd != "" { + s := net.ParseIP(pool.GwLrpRangeStart).To4() + e := net.ParseIP(pool.GwLrpRangeEnd).To4() + if s != nil && e != nil && ipv4ToUint32(s) <= ipv4ToUint32(e) { + return s, e, prefix, true + } + slog.Warn("external: invalid explicit gw_lrp_range, attempting auto-derive", + "pool", pool.Name, "start", pool.GwLrpRangeStart, "end", pool.GwLrpRangeEnd) + } + + gw := net.ParseIP(pool.Gateway).To4() + if gw == nil { + return nil, nil, 0, false + } + mask := net.CIDRMask(prefix, 32) + network := gw.Mask(mask) + bcast := make(net.IP, 4) + for i := range 4 { + bcast[i] = network[i] | ^mask[i] + } + bcastU := ipv4ToUint32(bcast) + if bcastU < 17 { + return nil, nil, 0, false + } + autoEndU := bcastU - 1 + autoStartU := bcastU - 16 + + if pool.RangeStart != "" && pool.RangeEnd != "" { + rs := net.ParseIP(pool.RangeStart).To4() + re := net.ParseIP(pool.RangeEnd).To4() + if rs != nil && re != nil { + rsU := ipv4ToUint32(rs) + reU := ipv4ToUint32(re) + if autoEndU >= rsU && autoStartU <= reU { + if rsU < 17 { + return nil, nil, 0, false + } + autoEndU = rsU - 1 + autoStartU = rsU - 16 + } + } + } + + netU := ipv4ToUint32(network) + if autoStartU <= netU { + autoStartU = netU + 1 + } + if autoEndU >= bcastU { + autoEndU = bcastU - 1 + } + if autoStartU > autoEndU { + return nil, nil, 0, false + } + + gwU := ipv4ToUint32(gw) + if gwU >= autoStartU && gwU <= autoEndU { + switch gwU { + case autoStartU: + autoStartU++ + case autoEndU: + autoEndU-- + default: + autoStartU = gwU + 1 + } + if autoStartU > autoEndU { + return nil, nil, 0, false + } + } + + return uint32ToIPv4(autoStartU), uint32ToIPv4(autoEndU), prefix, true +} diff --git a/spinifex/network/external/pool_test.go b/spinifex/network/external/pool_test.go new file mode 100644 index 000000000..6e0f0a3bc --- /dev/null +++ b/spinifex/network/external/pool_test.go @@ -0,0 +1,182 @@ +package external + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" + "github.com/mulgadc/spinifex/spinifex/network/topology" + "github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb" +) + +func TestFindPool_AZBeatsRegionBeatsUnscoped(t *testing.T) { + pools := []ExternalPoolConfig{ + {Name: "default"}, + {Name: "region-us", Region: "us"}, + {Name: "az-us-1a", Region: "us", AZ: "us-1a"}, + } + + assert.Equal(t, "az-us-1a", FindPool(pools, "us", "us-1a").Name) + assert.Equal(t, "region-us", FindPool(pools, "us", "us-1b").Name) + assert.Equal(t, "default", FindPool(pools, "eu", "eu-1a").Name) +} + +func TestFindPool_NoMatchReturnsNil(t *testing.T) { + pools := []ExternalPoolConfig{{Name: "region-us", Region: "us"}} + assert.Nil(t, FindPool(pools, "eu", "eu-1a")) +} + +func TestLinkLocalAllocator_AlwaysReturnsNotOK(t *testing.T) { + a := LinkLocalAllocator{} + ip, prefix, ok, err := a.Allocate(context.Background(), "vpc-1", &ExternalPoolConfig{Gateway: "192.168.1.1", PrefixLen: 24}) + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, ip) + assert.Zero(t, prefix) + require.NoError(t, a.Release(context.Background(), "vpc-1")) +} + +func TestStaticRangeAllocator_ExplicitRange_AllocatesFirstFree(t *testing.T) { + ctx := context.Background() + m := mock.New() + a := NewStaticRangeAllocator(m) + + pool := &ExternalPoolConfig{ + Name: "p", + Gateway: "192.168.1.1", + PrefixLen: 24, + GwLrpRangeStart: "192.168.1.240", + GwLrpRangeEnd: "192.168.1.243", + } + + ip, prefix, ok, err := a.Allocate(ctx, "vpc-1", pool) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "192.168.1.240", ip) + assert.Equal(t, 24, prefix) +} + +func TestStaticRangeAllocator_SkipsUsedIPs(t *testing.T) { + ctx := context.Background() + m := mock.New() + // Seed an existing LRP on a sibling VPC already holding .240. + require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-other")})) + require.NoError(t, m.CreateLogicalRouterPort(ctx, topology.VPCRouter("vpc-other"), &nbdb.LogicalRouterPort{ + Name: topology.GatewayRouterPort("vpc-other"), + ExternalIDs: map[string]string{gatewayIPExtIDKey: "192.168.1.240"}, + })) + a := NewStaticRangeAllocator(m) + + pool := &ExternalPoolConfig{ + Gateway: "192.168.1.1", PrefixLen: 24, + GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.243", + } + + ip, _, ok, err := a.Allocate(ctx, "vpc-new", pool) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "192.168.1.241", ip) +} + +func TestStaticRangeAllocator_ReturnsExistingForSameVPC(t *testing.T) { + ctx := context.Background() + m := mock.New() + require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")})) + require.NoError(t, m.CreateLogicalRouterPort(ctx, topology.VPCRouter("vpc-1"), &nbdb.LogicalRouterPort{ + Name: topology.GatewayRouterPort("vpc-1"), + ExternalIDs: map[string]string{gatewayIPExtIDKey: "192.168.1.242"}, + })) + a := NewStaticRangeAllocator(m) + + pool := &ExternalPoolConfig{ + Gateway: "192.168.1.1", PrefixLen: 24, + GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.243", + } + + ip, _, ok, err := a.Allocate(ctx, "vpc-1", pool) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "192.168.1.242", ip) +} + +func TestStaticRangeAllocator_RangeExhaustedReturnsError(t *testing.T) { + ctx := context.Background() + m := mock.New() + require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-other")})) + require.NoError(t, m.CreateLogicalRouterPort(ctx, topology.VPCRouter("vpc-other"), &nbdb.LogicalRouterPort{ + Name: topology.GatewayRouterPort("vpc-other"), + ExternalIDs: map[string]string{gatewayIPExtIDKey: "192.168.1.240"}, + })) + a := NewStaticRangeAllocator(m) + pool := &ExternalPoolConfig{ + Gateway: "192.168.1.1", PrefixLen: 24, + GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.240", + } + + _, _, ok, err := a.Allocate(ctx, "vpc-new", pool) + require.Error(t, err) + assert.False(t, ok) +} + +func TestStaticRangeAllocator_NoRangeReturnsNotOK(t *testing.T) { + a := NewStaticRangeAllocator(mock.New()) + _, _, ok, err := a.Allocate(context.Background(), "vpc-1", &ExternalPoolConfig{}) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestGwLrpRange_AutoDeriveTopOfSubnet(t *testing.T) { + pool := &ExternalPoolConfig{Gateway: "192.168.1.1", PrefixLen: 24} + start, end, prefix, ok := gwLrpRange(pool) + require.True(t, ok) + assert.Equal(t, "192.168.1.239", start.String()) + assert.Equal(t, "192.168.1.254", end.String()) + assert.Equal(t, 24, prefix) +} + +func TestGwLrpRange_ShiftsBelowEIPRangeOnOverlap(t *testing.T) { + pool := &ExternalPoolConfig{ + Gateway: "192.168.1.1", + PrefixLen: 24, + RangeStart: "192.168.1.240", + RangeEnd: "192.168.1.250", + } + start, end, _, ok := gwLrpRange(pool) + require.True(t, ok) + assert.Equal(t, "192.168.1.224", start.String()) + assert.Equal(t, "192.168.1.239", end.String()) +} + +func TestGwLrpRange_ExplicitTakesPriority(t *testing.T) { + pool := &ExternalPoolConfig{ + Gateway: "192.168.1.1", PrefixLen: 24, + GwLrpRangeStart: "192.168.1.10", + GwLrpRangeEnd: "192.168.1.20", + } + start, end, _, ok := gwLrpRange(pool) + require.True(t, ok) + assert.Equal(t, "192.168.1.10", start.String()) + assert.Equal(t, "192.168.1.20", end.String()) +} + +func TestGwLrpRange_NilPoolReturnsNotOK(t *testing.T) { + _, _, _, ok := gwLrpRange(nil) + assert.False(t, ok) +} + +func TestGwLrpRange_GatewayInsideAutoRangeIsExcluded(t *testing.T) { + // Tiny /29 forces the auto-range to overlap the gateway IP. + pool := &ExternalPoolConfig{Gateway: "192.168.1.1", PrefixLen: 29} + start, end, _, ok := gwLrpRange(pool) + require.True(t, ok) + startU := ipv4ToUint32(start) + endU := ipv4ToUint32(end) + gwU := ipv4ToUint32(net.ParseIP(pool.Gateway).To4()) + for n := startU; n <= endU; n++ { + assert.NotEqual(t, gwU, n, "gateway IP must not appear in auto-range") + } +} diff --git a/spinifex/network/external/types.go b/spinifex/network/external/types.go new file mode 100644 index 000000000..ca98d1a1a --- /dev/null +++ b/spinifex/network/external/types.go @@ -0,0 +1,45 @@ +package external + +// ExternalPoolConfig describes one external IP pool wired to a VPC. Mirrors +// the [external_pools] entry in spinifex.toml. Two pool kinds are supported: +// +// - Source="static": IPs come from RangeStart..RangeEnd, gateway from +// Gateway, and the gateway LRP IP from GwLrpRangeStart..GwLrpRangeEnd +// (auto-derived from the WAN subnet when unset). +// +// - Source="dhcp": IPs come from upstream router DHCP. The gateway LRP IP +// is acquired via upstream DHCP using the pool's DhcpBindBridge. This +// package does not include the DHCP client; callers inject a +// GatewayIPAllocator that knows how to talk to the DHCP store. +// +// This type duplicates services/vpcd/vpcd.go's ExternalPoolConfig. Dedup is +// out of scope for this bead; the two will merge when L2 (topology) grows +// the L5-driving methods and vpcd's local copy disappears. +type ExternalPoolConfig struct { + Name string + Source string + RangeStart string + RangeEnd string + Gateway string + GatewayIP string + PrefixLen int + DNSServers []string + Region string + AZ string + DhcpBindBridge string + GwLrpRangeStart string + GwLrpRangeEnd string +} + +// IsDHCP reports whether the pool acquires IPs from an upstream DHCP server. +func (p *ExternalPoolConfig) IsDHCP() bool { + return p.Source == "dhcp" +} + +// IGWSpec is the L5 input for IGWManager.AttachIGW / DetachIGW. VPCID is +// the only required field; InternetGatewayID is propagated into OVN +// external_ids so reconcile paths can correlate state. +type IGWSpec struct { + VPCID string + InternetGatewayID string +}