feat(network/external): L5 IGW/EIP/NATGW managers

Pure additive — new package, no topology.Manager changes, no rewiring of
existing vpcd handlers. IGWManager builds the external switch + localnet +
gateway LRP + default route + chassis HA, with a flows barrier callers can
inject (production: ovn-nbctl --wait=hv sync). EIP/NATGW are thin L5
wrappers over policy.NATManager. GatewayIPAllocator interface lets the
DHCP-backed allocator stay in vpcd until the DHCP-mgr removal bead lands;
LinkLocalAllocator + StaticRangeAllocator ship here.
This commit is contained in:
joshsiv-mulga
2026-05-21 15:49:39 +10:00
parent 6b922be3bd
commit f99b967f0d
11 changed files with 1250 additions and 0 deletions
+55
View File
@@ -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)
}
+63
View File
@@ -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"))
}
+23
View File
@@ -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
+23
View File
@@ -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()
}
+301
View File
@@ -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
}
+216
View File
@@ -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")
}
+43
View File
@@ -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)
}
+51
View File
@@ -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{}))
}
+248
View File
@@ -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
}
+182
View File
@@ -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")
}
}
+45
View File
@@ -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
}