feat: Phase 1 completion — external IPAM, br-external setup, config template

Add external IPAM module (CAS-based IP allocation from admin-defined
pools in NATS KV). Supports multi-pool with region/AZ scoping, pool
fallback logic, gateway IP reservation, and concurrent allocation via
CAS retries. 12 unit tests covering allocation, release, exhaustion,
CAS conflict, multi-pool, fallback, validation, and idempotent init.

Add --external-bridge flag to setup-ovn.sh: creates OVS bridge
br-external, adds WAN NIC, migrates host IP to bridge, and sets
ovn-bridge-mappings=external:br-external for OVN localnet ports.

Add commented [network] section and external_interface to spinifex.toml
config template for discoverability.
This commit is contained in:
Ben Duncan
2026-03-20 03:59:47 +00:00
parent c6af189638
commit c1cdfc07c3
4 changed files with 850 additions and 18 deletions
+15
View File
@@ -3,6 +3,20 @@ version = "1.0"
epoch = 1
node = "{{.Node}}"
# External network for public subnets (disabled by default).
# Set external_mode = "pool" and define pools to enable public IPs.
# See docs/development/feature/vpc-public-subnet.md for details.
#
# [network]
# external_mode = "pool" # "pool" (public IPs), "nat" (outbound-only), or "" (disabled)
#
# [[network.external_pools]]
# name = "wan"
# range_start = "192.168.1.150"
# range_end = "192.168.1.250"
# gateway = "192.168.1.1"
# prefix_len = 24
[nodes.{{.Node}}]
node = "{{.Node}}"
host = "{{.BindIP}}"
@@ -41,6 +55,7 @@ shardwal = false
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
ovn_sb_addr = "{{.OVNSBAddr}}"
# external_interface = "eth1" # WAN NIC for br-external (public subnets)
[nodes.{{.Node}}.predastore]
host = "{{.BindIP}}:8443"
+87 -18
View File
@@ -13,15 +13,20 @@
# ./scripts/setup-ovn.sh [options]
#
# Options:
# --management Also start OVN central services (NB DB, SB DB, ovn-northd)
# --ovn-remote OVN SB DB address (default: tcp:127.0.0.1:6642)
# --encap-ip Geneve tunnel endpoint IP (default: auto-detect)
# --chassis-id OVN chassis identifier (default: hostname)
# --management Also start OVN central services (NB DB, SB DB, ovn-northd)
# --external-bridge Create br-external for public subnet WAN uplink
# --external-iface WAN NIC to add to br-external (default: eth1)
# --ovn-remote OVN SB DB address (default: tcp:127.0.0.1:6642)
# --encap-ip Geneve tunnel endpoint IP (default: auto-detect)
# --chassis-id OVN chassis identifier (default: hostname)
#
# Examples:
# # Single-node development (management + compute on same host):
# ./scripts/setup-ovn.sh --management
#
# # With external bridge for public subnets:
# ./scripts/setup-ovn.sh --management --external-bridge --external-iface=eth1
#
# # Compute node joining an existing cluster:
# ./scripts/setup-ovn.sh --ovn-remote=tcp:10.0.0.1:6642 --encap-ip=10.0.0.2
#
@@ -34,6 +39,8 @@ set -e
# Defaults
MANAGEMENT=false
EXTERNAL_BRIDGE=false
EXTERNAL_IFACE="eth1"
OVN_REMOTE="tcp:127.0.0.1:6642"
ENCAP_IP=""
CHASSIS_ID=""
@@ -41,12 +48,14 @@ CHASSIS_ID=""
# Parse arguments
for arg in "$@"; do
case "$arg" in
--management) MANAGEMENT=true ;;
--ovn-remote=*) OVN_REMOTE="${arg#*=}" ;;
--encap-ip=*) ENCAP_IP="${arg#*=}" ;;
--chassis-id=*) CHASSIS_ID="${arg#*=}" ;;
--management) MANAGEMENT=true ;;
--external-bridge) EXTERNAL_BRIDGE=true ;;
--external-iface=*) EXTERNAL_IFACE="${arg#*=}" ;;
--ovn-remote=*) OVN_REMOTE="${arg#*=}" ;;
--encap-ip=*) ENCAP_IP="${arg#*=}" ;;
--chassis-id=*) CHASSIS_ID="${arg#*=}" ;;
--help|-h)
head -30 "$0" | tail -28
head -34 "$0" | tail -32
exit 0
;;
*)
@@ -72,10 +81,14 @@ if [ -z "$CHASSIS_ID" ]; then
fi
echo "=== Spinifex OVN Compute Node Setup ==="
echo " Management node: $MANAGEMENT"
echo " OVN Remote (SB): $OVN_REMOTE"
echo " Encap IP: $ENCAP_IP"
echo " Chassis ID: $CHASSIS_ID"
echo " Management node: $MANAGEMENT"
echo " External bridge: $EXTERNAL_BRIDGE"
if [ "$EXTERNAL_BRIDGE" = true ]; then
echo " External iface: $EXTERNAL_IFACE"
fi
echo " OVN Remote (SB): $OVN_REMOTE"
echo " Encap IP: $ENCAP_IP"
echo " Chassis ID: $CHASSIS_ID"
echo ""
# --- Step 1: Install packages ---
@@ -135,15 +148,61 @@ sudo ovs-vsctl set Bridge br-int other-config:disable-in-band=true
sudo ip link set br-int up
echo " br-int: created, fail-mode=secure, up"
# --- Step 3b: Create br-external for WAN uplink (optional) ---
if [ "$EXTERNAL_BRIDGE" = true ]; then
echo ""
echo "Step 3b: Configuring br-external for public subnet WAN uplink..."
# Verify the WAN NIC exists
if ! ip link show "$EXTERNAL_IFACE" >/dev/null 2>&1; then
echo " ERROR: interface $EXTERNAL_IFACE does not exist"
echo " Available interfaces:"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
# Create br-external and add the WAN NIC
sudo ovs-vsctl --may-exist add-br br-external
sudo ovs-vsctl --may-exist add-port br-external "$EXTERNAL_IFACE"
sudo ip link set br-external up
# If the WAN NIC has an IP, move it to br-external so the host keeps
# connectivity. This is the standard OpenStack/OVN approach for sharing
# a NIC between host and OVN localnet traffic.
WAN_IP=$(ip -4 addr show dev "$EXTERNAL_IFACE" 2>/dev/null | awk '/inet /{print $2}' | head -1)
if [ -n "$WAN_IP" ]; then
WAN_GW=$(ip -4 route show default dev "$EXTERNAL_IFACE" 2>/dev/null | awk '{print $3}' | head -1)
sudo ip addr del "$WAN_IP" dev "$EXTERNAL_IFACE" 2>/dev/null || true
sudo ip addr add "$WAN_IP" dev br-external
if [ -n "$WAN_GW" ]; then
sudo ip route add default via "$WAN_GW" dev br-external 2>/dev/null || true
fi
echo " Migrated $WAN_IP from $EXTERNAL_IFACE to br-external"
fi
echo " br-external: created with port $EXTERNAL_IFACE"
fi
# --- Step 4: Configure OVN external_ids ---
echo ""
echo "Step 4: Setting OVS external_ids for OVN..."
sudo ovs-vsctl set Open_vSwitch . \
external_ids:system-id="$CHASSIS_ID" \
external_ids:ovn-remote="$OVN_REMOTE" \
external_ids:ovn-encap-ip="$ENCAP_IP" \
external_ids:ovn-encap-type="geneve"
BRIDGE_MAPPINGS="external:br-external"
if [ "$EXTERNAL_BRIDGE" = true ]; then
sudo ovs-vsctl set Open_vSwitch . \
external_ids:system-id="$CHASSIS_ID" \
external_ids:ovn-remote="$OVN_REMOTE" \
external_ids:ovn-encap-ip="$ENCAP_IP" \
external_ids:ovn-encap-type="geneve" \
external_ids:ovn-bridge-mappings="$BRIDGE_MAPPINGS"
echo " ovn-bridge-mappings: $BRIDGE_MAPPINGS"
else
sudo ovs-vsctl set Open_vSwitch . \
external_ids:system-id="$CHASSIS_ID" \
external_ids:ovn-remote="$OVN_REMOTE" \
external_ids:ovn-encap-ip="$ENCAP_IP" \
external_ids:ovn-encap-type="geneve"
fi
echo " system-id: $CHASSIS_ID"
echo " ovn-remote: $OVN_REMOTE"
@@ -311,6 +370,16 @@ else
OK=false
fi
# Check br-external (only if --external-bridge was used)
if [ "$EXTERNAL_BRIDGE" = true ]; then
if sudo ovs-vsctl br-exists br-external; then
echo " br-external: OK"
else
echo " br-external: FAILED"
OK=false
fi
fi
# Check ovn-controller
if sudo ovs-appctl -t ovn-controller version >/dev/null 2>&1 || systemctl is-active --quiet ovn-controller 2>/dev/null; then
echo " ovn-controller: OK"
+327
View File
@@ -0,0 +1,327 @@
package handlers_ec2_vpc
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/big"
"net"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
const (
KVBucketExternalIPAM = "spinifex-external-ipam"
KVBucketExternalIPAMVersion = 1
)
// ExternalIPAllocation describes how an external IP is being used.
type ExternalIPAllocation struct {
Type string `json:"type"` // "gateway", "auto_assign", "elastic_ip"
AllocationID string `json:"allocation_id,omitempty"` // For elastic IPs
Association string `json:"association,omitempty"` // ENI ID for elastic IPs
ENIId string `json:"eni_id,omitempty"` // ENI owning this IP
InstanceId string `json:"instance_id,omitempty"` // Instance owning this IP
Note string `json:"note,omitempty"` // Human-readable note
}
// ExternalIPAMRecord tracks allocated external IPs for a single pool.
type ExternalIPAMRecord struct {
PoolName string `json:"pool_name"`
RangeStart string `json:"range_start"`
RangeEnd string `json:"range_end"`
Gateway string `json:"gateway"`
GatewayIP string `json:"gateway_ip"`
PrefixLen int `json:"prefix_len"`
Region string `json:"region,omitempty"`
AZ string `json:"az,omitempty"`
Allocated map[string]ExternalIPAllocation `json:"allocated"`
}
// ExternalPoolConfig is the admin-defined pool from spinifex.toml.
type ExternalPoolConfig struct {
Name string
RangeStart string
RangeEnd string
Gateway string
GatewayIP string
PrefixLen int
Region string
AZ string
}
// ExternalIPAM manages external IP allocation from admin-defined pools using NATS KV with CAS.
type ExternalIPAM struct {
kv nats.KeyValue
pools []ExternalPoolConfig
}
// NewExternalIPAM creates a new ExternalIPAM backed by NATS JetStream KV.
func NewExternalIPAM(js nats.JetStreamContext, pools []ExternalPoolConfig) (*ExternalIPAM, error) {
kv, err := utils.GetOrCreateKVBucket(js, KVBucketExternalIPAM, 5)
if err != nil {
return nil, fmt.Errorf("create external IPAM KV bucket: %w", err)
}
if err := utils.WriteVersion(kv, KVBucketExternalIPAMVersion); err != nil {
return nil, fmt.Errorf("write version to %s: %w", KVBucketExternalIPAM, err)
}
ipam := &ExternalIPAM{kv: kv, pools: pools}
if err := ipam.initPools(); err != nil {
return nil, fmt.Errorf("init external IPAM pools: %w", err)
}
return ipam, nil
}
// NewExternalIPAMWithKV creates an ExternalIPAM with an existing KV bucket (for testing).
func NewExternalIPAMWithKV(kv nats.KeyValue, pools []ExternalPoolConfig) *ExternalIPAM {
return &ExternalIPAM{kv: kv, pools: pools}
}
// initPools ensures each configured pool has a KV record. Idempotent — safe to call
// on every vpcd startup. Reserves the gateway IP in each pool.
func (m *ExternalIPAM) initPools() error {
for _, pool := range m.pools {
if err := m.initPool(pool); err != nil {
return fmt.Errorf("init pool %q: %w", pool.Name, err)
}
}
return nil
}
func (m *ExternalIPAM) initPool(pool ExternalPoolConfig) error {
_, _, err := m.getRecord(pool.Name)
if err == nil {
slog.Debug("external IPAM pool already initialized", "pool", pool.Name)
return nil // Already exists
}
if !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
gwIP := pool.GatewayIP
if gwIP == "" {
gwIP = pool.RangeStart
}
record := &ExternalIPAMRecord{
PoolName: pool.Name,
RangeStart: pool.RangeStart,
RangeEnd: pool.RangeEnd,
Gateway: pool.Gateway,
GatewayIP: gwIP,
PrefixLen: pool.PrefixLen,
Region: pool.Region,
AZ: pool.AZ,
Allocated: map[string]ExternalIPAllocation{
gwIP: {Type: "gateway", Note: "OVN router SNAT address"},
},
}
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal pool record: %w", err)
}
if _, err := m.kv.Create(pool.Name, data); err != nil {
// Another instance may have initialized concurrently — that's fine.
if errors.Is(err, nats.ErrKeyExists) {
return nil
}
return fmt.Errorf("create pool KV entry: %w", err)
}
slog.Info("external IPAM pool initialized", "pool", pool.Name, "gateway_ip", gwIP)
return nil
}
// AllocateIP allocates the next available external IP from the best pool
// matching the given region/AZ. Returns the allocated IP and pool name.
func (m *ExternalIPAM) AllocateIP(region, az, allocType, eniID, instanceID string) (string, string, error) {
pool := m.findPool(region, az)
if pool == nil {
return "", "", fmt.Errorf("InsufficientAddressCapacity: no external pool available for region=%q az=%q", region, az)
}
ip, err := m.allocateFromPool(pool.Name, allocType, eniID, instanceID)
if err != nil {
return "", "", err
}
return ip, pool.Name, nil
}
// AllocateFromPool allocates an IP from a specific named pool.
func (m *ExternalIPAM) AllocateFromPool(poolName, allocType, eniID, instanceID string) (string, error) {
return m.allocateFromPool(poolName, allocType, eniID, instanceID)
}
func (m *ExternalIPAM) allocateFromPool(poolName, allocType, eniID, instanceID string) (string, error) {
for attempt := range 5 {
record, revision, err := m.getRecord(poolName)
if err != nil {
return "", fmt.Errorf("get external IPAM record: %w", err)
}
ip, err := nextAvailableExternalIP(record)
if err != nil {
return "", err
}
record.Allocated[ip] = ExternalIPAllocation{
Type: allocType,
ENIId: eniID,
InstanceId: instanceID,
}
data, err := json.Marshal(record)
if err != nil {
return "", fmt.Errorf("marshal external IPAM record: %w", err)
}
if _, err := m.kv.Update(poolName, data, revision); err != nil {
slog.Debug("external IPAM CAS conflict, retrying", "pool", poolName, "attempt", attempt)
continue
}
slog.Info("external IPAM allocated IP", "pool", poolName, "ip", ip, "type", allocType)
return ip, nil
}
return "", fmt.Errorf("external IPAM allocation failed after CAS retries for pool %s", poolName)
}
// ReleaseIP releases a previously allocated external IP back to its pool.
func (m *ExternalIPAM) ReleaseIP(poolName, ip string) error {
for attempt := range 5 {
record, revision, err := m.getRecord(poolName)
if err != nil {
return fmt.Errorf("get external IPAM record for release: %w", err)
}
alloc, ok := record.Allocated[ip]
if !ok {
return fmt.Errorf("IP %s not allocated in pool %s", ip, poolName)
}
if alloc.Type == "gateway" {
return fmt.Errorf("cannot release gateway IP %s in pool %s", ip, poolName)
}
delete(record.Allocated, ip)
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal external IPAM record: %w", err)
}
if _, err := m.kv.Update(poolName, data, revision); err != nil {
slog.Debug("external IPAM release CAS conflict, retrying", "pool", poolName, "attempt", attempt)
continue
}
slog.Info("external IPAM released IP", "pool", poolName, "ip", ip)
return nil
}
return fmt.Errorf("external IPAM release failed after CAS retries for pool %s", poolName)
}
// GetPoolRecord returns the current IPAM record for a pool.
func (m *ExternalIPAM) GetPoolRecord(poolName string) (*ExternalIPAMRecord, error) {
record, _, err := m.getRecord(poolName)
return record, err
}
// findPool returns the best pool for the given region/AZ using the same
// fallback order as topology.go: AZ-scoped → region-scoped → unscoped.
func (m *ExternalIPAM) findPool(region, az string) *ExternalPoolConfig {
// 1. AZ-scoped match
for i := range m.pools {
p := &m.pools[i]
if p.AZ != "" && p.AZ == az && p.Region == region {
return p
}
}
// 2. Region-scoped (no AZ)
for i := range m.pools {
p := &m.pools[i]
if p.AZ == "" && p.Region != "" && p.Region == region {
return p
}
}
// 3. Unscoped (global pool)
for i := range m.pools {
p := &m.pools[i]
if p.Region == "" && p.AZ == "" {
return p
}
}
return nil
}
func (m *ExternalIPAM) getRecord(poolName string) (*ExternalIPAMRecord, uint64, error) {
entry, err := m.kv.Get(poolName)
if err != nil {
return nil, 0, err
}
var record ExternalIPAMRecord
if err := json.Unmarshal(entry.Value(), &record); err != nil {
return nil, 0, fmt.Errorf("unmarshal external IPAM record: %w", err)
}
return &record, entry.Revision(), nil
}
// nextAvailableExternalIP finds the next unallocated IP in the pool's range.
func nextAvailableExternalIP(record *ExternalIPAMRecord) (string, error) {
startIP := net.ParseIP(record.RangeStart).To4()
endIP := net.ParseIP(record.RangeEnd).To4()
if startIP == nil || endIP == nil {
return "", fmt.Errorf("invalid IP range: %s - %s", record.RangeStart, record.RangeEnd)
}
startInt := ipToInt(startIP)
endInt := ipToInt(endIP)
for i := startInt.Int64(); i <= endInt.Int64(); i++ {
candidate := intToIP(intFromInt64(i)).String()
if _, taken := record.Allocated[candidate]; !taken {
return candidate, nil
}
}
return "", fmt.Errorf("InsufficientAddressCapacity: pool %s exhausted", record.PoolName)
}
// ValidatePoolConfig checks that a pool config is valid.
func ValidatePoolConfig(pool ExternalPoolConfig) error {
if pool.Name == "" {
return fmt.Errorf("pool name is required")
}
startIP := net.ParseIP(pool.RangeStart)
if startIP == nil {
return fmt.Errorf("invalid range_start: %q", pool.RangeStart)
}
endIP := net.ParseIP(pool.RangeEnd)
if endIP == nil {
return fmt.Errorf("invalid range_end: %q", pool.RangeEnd)
}
if ipToInt(startIP.To4()).Cmp(ipToInt(endIP.To4())) > 0 {
return fmt.Errorf("range_start %s is greater than range_end %s", pool.RangeStart, pool.RangeEnd)
}
if pool.Gateway == "" {
return fmt.Errorf("gateway is required for pool %q", pool.Name)
}
if net.ParseIP(pool.Gateway) == nil {
return fmt.Errorf("invalid gateway IP: %q", pool.Gateway)
}
if pool.GatewayIP != "" && net.ParseIP(pool.GatewayIP) == nil {
return fmt.Errorf("invalid gateway_ip: %q", pool.GatewayIP)
}
return nil
}
// intFromInt64 wraps a plain int64 into a *big.Int-compatible IP conversion.
func intFromInt64(v int64) *big.Int {
return big.NewInt(v)
}
@@ -0,0 +1,421 @@
package handlers_ec2_vpc
import (
"sync"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestExternalIPAM(t *testing.T, pools []ExternalPoolConfig) *ExternalIPAM {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
js, err := nc.JetStream()
require.NoError(t, err)
ipam, err := NewExternalIPAM(js, pools)
require.NoError(t, err)
return ipam
}
func testPool() ExternalPoolConfig {
return ExternalPoolConfig{
Name: "wan",
RangeStart: "192.168.1.150",
RangeEnd: "192.168.1.160",
Gateway: "192.168.1.1",
PrefixLen: 24,
}
}
func TestExternalIPAM_AllocateSequential(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// .150 is reserved for gateway, so first allocable is .151
ip1, poolName, err := ipam.AllocateIP("", "", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip1)
assert.Equal(t, "wan", poolName)
ip2, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "192.168.1.152", ip2)
ip3, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-3", "i-3")
require.NoError(t, err)
assert.Equal(t, "192.168.1.153", ip3)
}
func TestExternalIPAM_GatewayIPReserved(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
record, err := ipam.GetPoolRecord("wan")
require.NoError(t, err)
// Gateway IP (.150) should be reserved
alloc, ok := record.Allocated["192.168.1.150"]
assert.True(t, ok, "gateway IP should be in allocated map")
assert.Equal(t, "gateway", alloc.Type)
// Cannot release gateway IP
err = ipam.ReleaseIP("wan", "192.168.1.150")
assert.ErrorContains(t, err, "cannot release gateway IP")
}
func TestExternalIPAM_ExplicitGatewayIP(t *testing.T) {
pool := testPool()
pool.GatewayIP = "192.168.1.155"
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
record, err := ipam.GetPoolRecord("wan")
require.NoError(t, err)
// Explicit gateway IP (.155) reserved, not .150
_, ok := record.Allocated["192.168.1.155"]
assert.True(t, ok)
_, ok = record.Allocated["192.168.1.150"]
assert.False(t, ok)
// First allocable is .150 since .155 is the gateway
ip1, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.150", ip1)
}
func TestExternalIPAM_Release(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
ip1, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip1)
ip2, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "192.168.1.152", ip2)
// Release first
err = ipam.ReleaseIP("wan", "192.168.1.151")
require.NoError(t, err)
// Next allocation reuses released IP
ip3, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-3", "i-3")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip3)
}
func TestExternalIPAM_Exhaustion(t *testing.T) {
pool := ExternalPoolConfig{
Name: "tiny",
RangeStart: "10.0.0.1",
RangeEnd: "10.0.0.3",
Gateway: "10.0.0.254",
PrefixLen: 24,
}
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// .1 reserved for gateway, .2 and .3 allocable
ip1, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "10.0.0.2", ip1)
ip2, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "10.0.0.3", ip2)
// Pool exhausted
_, _, err = ipam.AllocateIP("", "", "auto_assign", "eni-3", "i-3")
assert.ErrorContains(t, err, "InsufficientAddressCapacity")
}
func TestExternalIPAM_CASConflict(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// Concurrent allocations should all succeed (CAS retry handles conflicts)
var wg sync.WaitGroup
results := make([]string, 5)
errs := make([]error, 5)
for i := range 5 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ip, _, err := ipam.AllocateIP("", "", "auto_assign", "eni-"+itoa(idx), "i-"+itoa(idx))
results[idx] = ip
errs[idx] = err
}(i)
}
wg.Wait()
// All should succeed
for i, err := range errs {
assert.NoError(t, err, "concurrent allocation %d should succeed", i)
}
// All IPs should be unique
seen := make(map[string]bool)
for _, ip := range results {
assert.False(t, seen[ip], "duplicate IP: %s", ip)
seen[ip] = true
}
}
func TestExternalIPAM_MultiPool(t *testing.T) {
pools := []ExternalPoolConfig{
{
Name: "us-east",
RangeStart: "203.0.113.2",
RangeEnd: "203.0.113.10",
Gateway: "203.0.113.1",
PrefixLen: 24,
Region: "us-east-1",
AZ: "us-east-1a",
},
{
Name: "eu-west",
RangeStart: "198.51.100.2",
RangeEnd: "198.51.100.10",
Gateway: "198.51.100.1",
PrefixLen: 24,
Region: "eu-west-1",
AZ: "eu-west-1a",
},
}
ipam := setupTestExternalIPAM(t, pools)
// Allocate from US pool
ip1, poolName1, err := ipam.AllocateIP("us-east-1", "us-east-1a", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "203.0.113.3", ip1) // .2 reserved for gateway
assert.Equal(t, "us-east", poolName1)
// Allocate from EU pool
ip2, poolName2, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", "auto_assign", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "198.51.100.3", ip2)
assert.Equal(t, "eu-west", poolName2)
}
func TestExternalIPAM_PoolFallback(t *testing.T) {
pools := []ExternalPoolConfig{
{
Name: "az-pool",
RangeStart: "10.0.0.1",
RangeEnd: "10.0.0.2",
Gateway: "10.0.0.254",
PrefixLen: 24,
Region: "us-east-1",
AZ: "us-east-1a",
},
{
Name: "region-pool",
RangeStart: "10.0.1.1",
RangeEnd: "10.0.1.10",
Gateway: "10.0.1.254",
PrefixLen: 24,
Region: "us-east-1",
},
{
Name: "global-pool",
RangeStart: "10.0.2.1",
RangeEnd: "10.0.2.10",
Gateway: "10.0.2.254",
PrefixLen: 24,
},
}
ipam := setupTestExternalIPAM(t, pools)
// AZ match: us-east-1a → az-pool
ip1, pool1, err := ipam.AllocateIP("us-east-1", "us-east-1a", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "az-pool", pool1)
assert.Equal(t, "10.0.0.2", ip1) // .1 reserved for gw
// Region match (different AZ): us-east-1b → region-pool
ip2, pool2, err := ipam.AllocateIP("us-east-1", "us-east-1b", "auto_assign", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "region-pool", pool2)
assert.Equal(t, "10.0.1.2", ip2)
// No match: eu-west-1 → global-pool
ip3, pool3, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", "auto_assign", "eni-3", "i-3")
require.NoError(t, err)
assert.Equal(t, "global-pool", pool3)
assert.Equal(t, "10.0.2.2", ip3)
}
func TestExternalIPAM_SpecificPool(t *testing.T) {
pools := []ExternalPoolConfig{
{
Name: "pool-a",
RangeStart: "10.0.0.1",
RangeEnd: "10.0.0.10",
Gateway: "10.0.0.254",
PrefixLen: 24,
},
{
Name: "pool-b",
RangeStart: "10.0.1.1",
RangeEnd: "10.0.1.10",
Gateway: "10.0.1.254",
PrefixLen: 24,
},
}
ipam := setupTestExternalIPAM(t, pools)
// Allocate specifically from pool-b
ip, err := ipam.AllocateFromPool("pool-b", "elastic_ip", "", "")
require.NoError(t, err)
assert.Equal(t, "10.0.1.2", ip) // .1 reserved for gw
}
func TestExternalIPAM_RangeValidation(t *testing.T) {
tests := []struct {
name string
pool ExternalPoolConfig
wantErr string
}{
{
name: "empty name",
pool: ExternalPoolConfig{RangeStart: "10.0.0.1", RangeEnd: "10.0.0.10", Gateway: "10.0.0.254"},
wantErr: "pool name is required",
},
{
name: "bad range_start",
pool: ExternalPoolConfig{Name: "bad", RangeStart: "not-an-ip", RangeEnd: "10.0.0.10", Gateway: "10.0.0.254"},
wantErr: "invalid range_start",
},
{
name: "bad range_end",
pool: ExternalPoolConfig{Name: "bad", RangeStart: "10.0.0.1", RangeEnd: "not-an-ip", Gateway: "10.0.0.254"},
wantErr: "invalid range_end",
},
{
name: "start > end",
pool: ExternalPoolConfig{Name: "bad", RangeStart: "10.0.0.10", RangeEnd: "10.0.0.1", Gateway: "10.0.0.254"},
wantErr: "greater than range_end",
},
{
name: "missing gateway",
pool: ExternalPoolConfig{Name: "bad", RangeStart: "10.0.0.1", RangeEnd: "10.0.0.10"},
wantErr: "gateway is required",
},
{
name: "bad gateway",
pool: ExternalPoolConfig{Name: "bad", RangeStart: "10.0.0.1", RangeEnd: "10.0.0.10", Gateway: "nope"},
wantErr: "invalid gateway IP",
},
{
name: "bad gateway_ip",
pool: ExternalPoolConfig{Name: "ok", RangeStart: "10.0.0.1", RangeEnd: "10.0.0.10", Gateway: "10.0.0.254", GatewayIP: "nope"},
wantErr: "invalid gateway_ip",
},
{
name: "valid",
pool: ExternalPoolConfig{Name: "ok", RangeStart: "10.0.0.1", RangeEnd: "10.0.0.10", Gateway: "10.0.0.254"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePoolConfig(tt.pool)
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
})
}
}
func TestExternalIPAM_InitFromConfig(t *testing.T) {
pool := testPool()
// Create IPAM twice — second init should be idempotent
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
js, err := nc.JetStream()
require.NoError(t, err)
// First init
ipam1, err := NewExternalIPAM(js, []ExternalPoolConfig{pool})
require.NoError(t, err)
// Allocate an IP
ip, _, err := ipam1.AllocateIP("", "", "auto_assign", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip)
// Second init (simulating restart) — should not lose allocation
ipam2, err := NewExternalIPAM(js, []ExternalPoolConfig{pool})
require.NoError(t, err)
record, err := ipam2.GetPoolRecord("wan")
require.NoError(t, err)
assert.Contains(t, record.Allocated, "192.168.1.151")
assert.Contains(t, record.Allocated, "192.168.1.150") // gateway still reserved
}
func TestExternalIPAM_ReleaseNotAllocated(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
err := ipam.ReleaseIP("wan", "192.168.1.200")
assert.ErrorContains(t, err, "not allocated")
}
func TestExternalIPAM_NoPoolAvailable(t *testing.T) {
pool := ExternalPoolConfig{
Name: "us-only",
RangeStart: "10.0.0.1",
RangeEnd: "10.0.0.10",
Gateway: "10.0.0.254",
PrefixLen: 24,
Region: "us-east-1",
AZ: "us-east-1a",
}
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// No pool matches eu-west
_, _, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", "auto_assign", "eni-1", "i-1")
assert.ErrorContains(t, err, "InsufficientAddressCapacity")
}