feat(network/topology): TopologyManager interface + vpcd adapter

Add spinifex/network/topology with Manager interface, VPC/Subnet/Port
specs, and OVN object naming helpers covering the L2 stable contract
(§7 of the redesign plan). Phase 1 keeps existing in-tree OVN names
(rtr-, ext-port-, sg_) and notes the §7.3 targets for a later
reconciliation migration.

TopologyHandler in services/vpcd grows EnsureVPC / DeleteVPC /
EnsureSubnet / DeleteSubnet / EnsurePort / DeletePort /
SetPortSecurityGroups methods that mirror the corresponding NATS
handlers' OVN ops with Spec inputs instead of nats.Msg envelopes,
satisfying topology.Manager via a compile-time check. Existing handlers
are unchanged; the duplication is short-lived (Phase 2 collapses them).

Smoke tests in topology_manager_test.go cover the idempotent create
path, delete cascade, and declarative SG membership move. Deliverable:
TopologyManager.EnsureVPC/Subnet/Port callable; all existing tests
green.

Second sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2).
This commit is contained in:
joshsiv-mulga
2026-05-21 13:50:20 +10:00
parent 4c056ac23e
commit 7615929e85
5 changed files with 675 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
// Package topology is L2 of the spinifex network stack: the logical network
// model. It translates VPC/subnet/ENI/SG API objects into OVN logical network
// objects via L1 (network/ovn). Higher layers (policy, external, federation)
// build on this; lower layers must not call here.
//
// See docs/development/feature/spinifex-network-redesign.md §7 and
// docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
// for the full contract.
package topology
import (
"context"
"net"
"net/netip"
)
// Manager owns the lifecycle of OVN logical objects for AWS-style API
// resources. Implementations must be idempotent: a second EnsureX call with
// the same spec is a no-op.
type Manager interface {
// VPC lifecycle
EnsureVPC(ctx context.Context, vpc VPCSpec) error
DeleteVPC(ctx context.Context, vpcID string) error
// Subnet lifecycle
EnsureSubnet(ctx context.Context, subnet SubnetSpec) error
DeleteSubnet(ctx context.Context, subnet SubnetSpec) error
// Port lifecycle (ENI)
EnsurePort(ctx context.Context, port PortSpec) error
DeletePort(ctx context.Context, port PortSpec) error
// SetPortSecurityGroups applies the declarative set of port-group
// memberships for a port. Manager computes the add/remove diff against
// current OVN state.
SetPortSecurityGroups(ctx context.Context, portID string, sgIDs []string) error
}
// VPCSpec describes a VPC at L2. AZSlice is reserved for Phase 3 per-AZ CIDR
// slicing; Phase 1–2 leave it zero and treat CIDR as the full VPC range.
type VPCSpec struct {
VPCID string
CIDR netip.Prefix
VNI int64
}
// SubnetSpec describes a subnet at L2. CIDR must be contained in the parent
// VPC's CIDR (or AZSlice in Phase 3); API-layer validation enforces that.
type SubnetSpec struct {
SubnetID string
VPCID string
CIDR netip.Prefix
}
// PortSpec describes an ENI / VM port at L2. PrivateIP and MAC are bound to
// the LSP's Addresses + PortSecurity columns at create time. SGIDs is the
// initial port-group membership; subsequent changes go through
// SetPortSecurityGroups.
type PortSpec struct {
PortID string
SubnetID string
VPCID string
PrivateIP netip.Addr
MAC net.HardwareAddr
SGIDs []string
}
+57
View File
@@ -0,0 +1,57 @@
package topology
import "strings"
// OVN object names are deterministic and form a stable contract. Changing any
// of them requires a reconciliation migration. The §7.3 target state in
// docs/development/feature/spinifex-network-redesign.md lists `rp-{subnetID}`
// for the subnet→VPC router port and `ln-ext-{vpcID}` for the external
// localnet port; Phase 1 keeps the existing in-tree names (`rtr-{subnetID}` /
// `ext-port-{vpcID}`) to preserve compatibility with deployed clusters. The
// rename happens in a later phase alongside a reconciler-driven migration.
// VPCRouter is the OVN logical router name for a VPC.
func VPCRouter(vpcID string) string { return "vpc-" + vpcID }
// SubnetSwitch is the OVN logical switch name for a subnet.
func SubnetSwitch(subnetID string) string { return "subnet-" + subnetID }
// SubnetRouterPort is the OVN LRP name on the VPC router that terminates the
// subnet's default gateway. Existing name; §7.3 target `rp-{subnetID}`.
func SubnetRouterPort(subnetID string) string { return "rtr-" + subnetID }
// SubnetSwitchRouterPort is the OVN LSP (type=router) on the subnet switch
// peered with SubnetRouterPort.
func SubnetSwitchRouterPort(subnetID string) string { return "rtr-port-" + subnetID }
// Port is the OVN logical switch port name for an ENI.
func Port(portID string) string { return "port-" + portID }
// GatewayRouterPort is the OVN LRP name for the VPC's uplink/gateway port.
func GatewayRouterPort(vpcID string) string { return "gw-" + vpcID }
// ExternalSwitch is the OVN logical switch name for the per-VPC external
// switch bridging the gateway LRP to the localnet.
func ExternalSwitch(vpcID string) string { return "ext-" + vpcID }
// ExternalLocalnetPort is the OVN LSP name for the localnet bridging the
// external switch onto the host uplink. Existing name; §7.3 target
// `ln-ext-{vpcID}`.
func ExternalLocalnetPort(vpcID string) string { return "ext-port-" + vpcID }
// SecurityGroupPortGroup is the OVN port group name for a security group.
// OVN port group names match [a-zA-Z_][a-zA-Z0-9_]*, so hyphens in sg-xxx
// IDs are replaced with underscores. The per-port-group `_ip4`/`_ip6`
// Address_Set rows in SB are auto-derived by ovn-northd from each port
// group's port addresses. §7.3 of the network redesign plan lists
// `sg-{sgID}` with hyphens preserved; this conflicts with the OVN naming
// regex and existing in-tree behaviour, which uses underscores.
func SecurityGroupPortGroup(sgID string) string {
return strings.ReplaceAll(sgID, "-", "_")
}
// TransitSwitch is the OVN-IC transit switch name (Phase 3+ federation).
func TransitSwitch(azID, vpcID string) string { return "ts-" + azID + "-" + vpcID }
// TransitRouterPort is the OVN-IC transit router port name (Phase 3+).
func TransitRouterPort(azID, vpcID string) string { return "trp-" + azID + "-" + vpcID }
+30
View File
@@ -0,0 +1,30 @@
package topology
import "testing"
func TestNameHelpers(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{"VPCRouter", VPCRouter("vpc-abc"), "vpc-vpc-abc"},
{"SubnetSwitch", SubnetSwitch("subnet-1"), "subnet-subnet-1"},
{"SubnetRouterPort", SubnetRouterPort("subnet-1"), "rtr-subnet-1"},
{"SubnetSwitchRouterPort", SubnetSwitchRouterPort("subnet-1"), "rtr-port-subnet-1"},
{"Port", Port("eni-1"), "port-eni-1"},
{"GatewayRouterPort", GatewayRouterPort("vpc-abc"), "gw-vpc-abc"},
{"ExternalSwitch", ExternalSwitch("vpc-abc"), "ext-vpc-abc"},
{"ExternalLocalnetPort", ExternalLocalnetPort("vpc-abc"), "ext-port-vpc-abc"},
{"SecurityGroupPortGroup", SecurityGroupPortGroup("sg-abc-123"), "sg_abc_123"},
{"TransitSwitch", TransitSwitch("az-1", "vpc-abc"), "ts-az-1-vpc-abc"},
{"TransitRouterPort", TransitRouterPort("az-1", "vpc-abc"), "trp-az-1-vpc-abc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("got %q, want %q", tt.got, tt.want)
}
})
}
}
+320
View File
@@ -0,0 +1,320 @@
package vpcd
// Phase 1 adapter: TopologyHandler implements topology.Manager by wrapping
// the same OVN operations the NATS handlers (handleVPCCreate / handleSubnetCreate
// / handleCreatePort / etc.) drive. The Manager API is callable on the existing
// in-tree handler; Phase 2 replaces the NATS handlers with thin shims that
// route to these methods, and Phase 3 deletes the handlers entirely.
import (
"context"
"fmt"
"log/slog"
"strconv"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb"
)
var _ topology.Manager = (*TopologyHandler)(nil)
// EnsureVPC creates the OVN logical router for the VPC, idempotently. Mirrors
// handleVPCCreate's OVN ops without the NATS envelope.
func (h *TopologyHandler) EnsureVPC(ctx context.Context, spec topology.VPCSpec) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
if spec.VPCID == "" {
return fmt.Errorf("EnsureVPC: empty VPCID")
}
routerName := topology.VPCRouter(spec.VPCID)
if _, err := h.ovn.GetLogicalRouter(ctx, routerName); err == nil {
return nil
}
cidr := ""
if spec.CIDR.IsValid() {
cidr = spec.CIDR.String()
}
lr := &nbdb.LogicalRouter{
Name: routerName,
ExternalIDs: map[string]string{
"spinifex:vpc_id": spec.VPCID,
"spinifex:vni": strconv.FormatInt(spec.VNI, 10),
"spinifex:cidr": cidr,
},
}
if err := h.ovn.CreateLogicalRouter(ctx, lr); err != nil {
return fmt.Errorf("create logical router %q: %w", routerName, err)
}
slog.Info("vpcd: EnsureVPC created router", "router", routerName, "vpc_id", spec.VPCID, "cidr", cidr)
return nil
}
// DeleteVPC removes the OVN logical router for the VPC and cascades through
// any subnets that belong to it. Mirrors handleVPCDelete's OVN ops.
func (h *TopologyHandler) DeleteVPC(ctx context.Context, vpcID string) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
routerName := topology.VPCRouter(vpcID)
switches, err := h.ovn.ListLogicalSwitches(ctx)
if err != nil {
slog.Warn("vpcd: DeleteVPC list switches", "err", err)
} else {
for _, ls := range switches {
if ls.ExternalIDs["spinifex:vpc_id"] != vpcID {
continue
}
if err := h.ovn.DeleteLogicalSwitch(ctx, ls.Name); err != nil {
slog.Warn("vpcd: DeleteVPC cascade switch", "switch", ls.Name, "err", err)
}
}
}
dhcpOpts, err := h.ovn.ListDHCPOptions(ctx)
if err != nil {
slog.Warn("vpcd: DeleteVPC list DHCP options", "err", err)
} else {
for _, opts := range dhcpOpts {
if opts.ExternalIDs["spinifex:vpc_id"] != vpcID {
continue
}
if err := h.ovn.DeleteDHCPOptions(ctx, opts.UUID); err != nil {
slog.Warn("vpcd: DeleteVPC cascade DHCP", "uuid", opts.UUID, "err", err)
}
}
}
if err := h.ovn.DeleteLogicalRouter(ctx, routerName); err != nil {
return fmt.Errorf("delete logical router %q: %w", routerName, err)
}
slog.Info("vpcd: DeleteVPC removed router", "router", routerName, "vpc_id", vpcID)
return nil
}
// EnsureSubnet creates the OVN logical switch, subnet→VPC router port pair,
// and DHCP options for the subnet. Mirrors handleSubnetCreate's OVN ops.
func (h *TopologyHandler) EnsureSubnet(ctx context.Context, spec topology.SubnetSpec) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
if spec.SubnetID == "" || spec.VPCID == "" {
return fmt.Errorf("EnsureSubnet: SubnetID/VPCID required")
}
if !spec.CIDR.IsValid() {
return fmt.Errorf("EnsureSubnet: invalid CIDR for subnet %q", spec.SubnetID)
}
cidr := spec.CIDR.String()
switchName := topology.SubnetSwitch(spec.SubnetID)
routerName := topology.VPCRouter(spec.VPCID)
routerPortName := topology.SubnetRouterPort(spec.SubnetID)
switchRouterPortName := topology.SubnetSwitchRouterPort(spec.SubnetID)
gwIP, mask, err := subnetGateway(cidr)
if err != nil {
return fmt.Errorf("invalid subnet CIDR %q: %w", cidr, err)
}
gwCIDR := fmt.Sprintf("%s/%d", gwIP, mask)
routerMAC := generateMAC(spec.SubnetID)
if _, err := h.ovn.GetLogicalSwitch(ctx, switchName); err == nil {
return nil
}
ls := &nbdb.LogicalSwitch{
Name: switchName,
ExternalIDs: map[string]string{
"spinifex:subnet_id": spec.SubnetID,
"spinifex:vpc_id": spec.VPCID,
},
}
if err := h.ovn.CreateLogicalSwitch(ctx, ls); err != nil {
return fmt.Errorf("create logical switch %q: %w", switchName, err)
}
lrp := &nbdb.LogicalRouterPort{
Name: routerPortName,
MAC: routerMAC,
Networks: []string{gwCIDR},
ExternalIDs: map[string]string{
"spinifex:subnet_id": spec.SubnetID,
"spinifex:vpc_id": spec.VPCID,
},
}
if err := h.ovn.CreateLogicalRouterPort(ctx, routerName, lrp); err != nil {
_ = h.ovn.DeleteLogicalSwitch(ctx, switchName)
return fmt.Errorf("create router port %q: %w", routerPortName, err)
}
lsp := &nbdb.LogicalSwitchPort{
Name: switchRouterPortName,
Type: "router",
Addresses: []string{"router"},
Options: map[string]string{
"router-port": routerPortName,
},
ExternalIDs: map[string]string{
"spinifex:subnet_id": spec.SubnetID,
"spinifex:vpc_id": spec.VPCID,
},
}
if err := h.ovn.CreateLogicalSwitchPort(ctx, switchName, lsp); err != nil {
_ = h.ovn.DeleteLogicalRouterPort(ctx, routerName, routerPortName)
_ = h.ovn.DeleteLogicalSwitch(ctx, switchName)
return fmt.Errorf("create switch router port %q: %w", switchRouterPortName, err)
}
dhcpOpts := &nbdb.DHCPOptions{
CIDR: cidr,
Options: map[string]string{
"server_id": gwIP,
"server_mac": routerMAC,
"lease_time": "3600",
"router": gwIP,
"dns_server": h.dnsServer(),
"mtu": "1442",
},
ExternalIDs: map[string]string{
"spinifex:subnet_id": spec.SubnetID,
"spinifex:vpc_id": spec.VPCID,
},
}
if _, err := h.ovn.CreateDHCPOptions(ctx, dhcpOpts); err != nil {
slog.Warn("vpcd: EnsureSubnet DHCP options create failed", "cidr", cidr, "err", err)
}
slog.Info("vpcd: EnsureSubnet created topology",
"switch", switchName,
"router_port", routerPortName,
"gateway", gwCIDR,
"subnet_id", spec.SubnetID,
)
return nil
}
// DeleteSubnet tears down the subnet's logical switch, router port, switch
// router port, and DHCP options. Mirrors handleSubnetDelete's OVN ops.
func (h *TopologyHandler) DeleteSubnet(ctx context.Context, spec topology.SubnetSpec) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
switchName := topology.SubnetSwitch(spec.SubnetID)
routerName := topology.VPCRouter(spec.VPCID)
routerPortName := topology.SubnetRouterPort(spec.SubnetID)
switchRouterPortName := topology.SubnetSwitchRouterPort(spec.SubnetID)
if err := h.ovn.DeleteLogicalSwitchPort(ctx, switchName, switchRouterPortName); err != nil {
slog.Warn("vpcd: DeleteSubnet switch router port", "port", switchRouterPortName, "err", err)
}
if err := h.ovn.DeleteLogicalRouterPort(ctx, routerName, routerPortName); err != nil {
slog.Warn("vpcd: DeleteSubnet router port", "port", routerPortName, "err", err)
}
if spec.CIDR.IsValid() {
if dhcpOpts, err := h.ovn.FindDHCPOptionsByCIDR(ctx, spec.CIDR.String()); err == nil {
if err := h.ovn.DeleteDHCPOptions(ctx, dhcpOpts.UUID); err != nil {
slog.Warn("vpcd: DeleteSubnet DHCP options", "cidr", spec.CIDR.String(), "err", err)
}
}
}
if err := h.ovn.DeleteLogicalSwitch(ctx, switchName); err != nil {
return fmt.Errorf("delete logical switch %q: %w", switchName, err)
}
slog.Info("vpcd: DeleteSubnet removed topology", "switch", switchName, "subnet_id", spec.SubnetID)
return nil
}
// EnsurePort creates the ENI's LogicalSwitchPort and joins it to its initial
// SG port groups in one OVSDB transaction. Mirrors handleCreatePort's OVN ops.
//
// Idempotent: if the LSP already exists from a crashed prior attempt that
// didn't reach the port-group join, SG memberships converge here rather than
// waiting for the next reconciler pass — that gap would leave a port with zero
// ACLs (OVN default = unrestricted).
func (h *TopologyHandler) EnsurePort(ctx context.Context, spec topology.PortSpec) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
if spec.PortID == "" || spec.SubnetID == "" {
return fmt.Errorf("EnsurePort: PortID/SubnetID required")
}
portName := topology.Port(spec.PortID)
switchName := topology.SubnetSwitch(spec.SubnetID)
if _, err := h.ovn.GetLogicalSwitchPort(ctx, portName); err == nil {
if _, err := h.reconcilePortSGs(ctx, portName, spec.SGIDs); err != nil {
return fmt.Errorf("reconcile SGs for existing port %q: %w", portName, err)
}
return nil
}
addrStr := fmt.Sprintf("%s %s", spec.MAC.String(), spec.PrivateIP.String())
lsp := &nbdb.LogicalSwitchPort{
Name: portName,
Addresses: []string{addrStr},
PortSecurity: []string{addrStr},
ExternalIDs: map[string]string{
"spinifex:eni_id": spec.PortID,
"spinifex:subnet_id": spec.SubnetID,
"spinifex:vpc_id": spec.VPCID,
},
}
if dhcpOpts, err := h.ovn.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", spec.SubnetID); err == nil {
lsp.DHCPv4Options = &dhcpOpts.UUID
}
pgNames := make([]string, 0, len(spec.SGIDs))
for _, sgID := range spec.SGIDs {
pgNames = append(pgNames, topology.SecurityGroupPortGroup(sgID))
}
if err := h.ovn.CreateLogicalSwitchPortInGroups(ctx, switchName, lsp, pgNames); err != nil {
return fmt.Errorf("create logical switch port %q on %q: %w", portName, switchName, err)
}
slog.Info("vpcd: EnsurePort created LSP",
"port", portName,
"switch", switchName,
"eni_id", spec.PortID,
"ip", spec.PrivateIP.String(),
"mac", spec.MAC.String(),
"addr_str", addrStr,
"sgs", spec.SGIDs,
"port_groups", pgNames,
)
return nil
}
// DeletePort clears the ENI's port-group memberships and removes the LSP.
// Mirrors handleDeletePort's OVN ops.
func (h *TopologyHandler) DeletePort(ctx context.Context, spec topology.PortSpec) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
portName := topology.Port(spec.PortID)
switchName := topology.SubnetSwitch(spec.SubnetID)
if _, err := h.reconcilePortSGs(ctx, portName, nil); err != nil {
return fmt.Errorf("clear port group memberships for %q: %w", portName, err)
}
if err := h.ovn.DeleteLogicalSwitchPort(ctx, switchName, portName); err != nil {
return fmt.Errorf("delete logical switch port %q on %q: %w", portName, switchName, err)
}
slog.Info("vpcd: DeletePort removed LSP",
"port", portName,
"switch", switchName,
"eni_id", spec.PortID,
)
return nil
}
// SetPortSecurityGroups reconciles the port's port-group memberships against
// the declared list. Manager computes the add/remove diff via reconcilePortSGs.
func (h *TopologyHandler) SetPortSecurityGroups(ctx context.Context, portID string, sgIDs []string) error {
if h.ovn == nil {
return fmt.Errorf("OVN client not connected")
}
portName := topology.Port(portID)
if _, err := h.reconcilePortSGs(ctx, portName, sgIDs); err != nil {
return fmt.Errorf("reconcile SGs for port %q: %w", portName, err)
}
return nil
}
@@ -0,0 +1,202 @@
package vpcd
import (
"context"
"net"
"net/netip"
"testing"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
func newManagerForTest(t *testing.T) (topology.Manager, *MockOVNClient) {
t.Helper()
mock := NewMockOVNClient()
_ = mock.Connect(context.Background())
h := NewTopologyHandler(mock)
return h, mock
}
func TestManager_EnsureVPC(t *testing.T) {
mgr, mock := newManagerForTest(t)
ctx := context.Background()
spec := topology.VPCSpec{
VPCID: "vpc-mgr1",
CIDR: netip.MustParsePrefix("10.0.0.0/16"),
VNI: 42,
}
if err := mgr.EnsureVPC(ctx, spec); err != nil {
t.Fatalf("EnsureVPC: %v", err)
}
got, err := mock.GetLogicalRouter(ctx, topology.VPCRouter(spec.VPCID))
if err != nil {
t.Fatalf("router not present: %v", err)
}
if got.ExternalIDs["spinifex:vpc_id"] != spec.VPCID {
t.Errorf("vpc_id mismatch: %q", got.ExternalIDs["spinifex:vpc_id"])
}
if got.ExternalIDs["spinifex:cidr"] != "10.0.0.0/16" {
t.Errorf("cidr mismatch: %q", got.ExternalIDs["spinifex:cidr"])
}
// Second call is a no-op (idempotent).
if err := mgr.EnsureVPC(ctx, spec); err != nil {
t.Fatalf("EnsureVPC second call: %v", err)
}
}
func TestManager_EnsureSubnet(t *testing.T) {
mgr, mock := newManagerForTest(t)
ctx := context.Background()
vpc := topology.VPCSpec{VPCID: "vpc-sub", CIDR: netip.MustParsePrefix("10.1.0.0/16")}
if err := mgr.EnsureVPC(ctx, vpc); err != nil {
t.Fatalf("EnsureVPC: %v", err)
}
sub := topology.SubnetSpec{
SubnetID: "subnet-A",
VPCID: vpc.VPCID,
CIDR: netip.MustParsePrefix("10.1.1.0/24"),
}
if err := mgr.EnsureSubnet(ctx, sub); err != nil {
t.Fatalf("EnsureSubnet: %v", err)
}
if _, err := mock.GetLogicalSwitch(ctx, topology.SubnetSwitch(sub.SubnetID)); err != nil {
t.Errorf("subnet switch missing: %v", err)
}
if _, err := mock.GetLogicalRouterPort(ctx, topology.SubnetRouterPort(sub.SubnetID)); err != nil {
t.Errorf("subnet router port missing: %v", err)
}
if _, err := mock.GetLogicalSwitchPort(ctx, topology.SubnetSwitchRouterPort(sub.SubnetID)); err != nil {
t.Errorf("subnet switch-side router port missing: %v", err)
}
if err := mgr.EnsureSubnet(ctx, sub); err != nil {
t.Fatalf("EnsureSubnet second call: %v", err)
}
}
func TestManager_EnsurePort(t *testing.T) {
mgr, mock := newManagerForTest(t)
ctx := context.Background()
vpc := topology.VPCSpec{VPCID: "vpc-port", CIDR: netip.MustParsePrefix("10.2.0.0/16")}
sub := topology.SubnetSpec{SubnetID: "subnet-P", VPCID: vpc.VPCID, CIDR: netip.MustParsePrefix("10.2.1.0/24")}
if err := mgr.EnsureVPC(ctx, vpc); err != nil {
t.Fatalf("EnsureVPC: %v", err)
}
if err := mgr.EnsureSubnet(ctx, sub); err != nil {
t.Fatalf("EnsureSubnet: %v", err)
}
mac, _ := net.ParseMAC("02:00:00:00:00:01")
port := topology.PortSpec{
PortID: "eni-1",
SubnetID: sub.SubnetID,
VPCID: vpc.VPCID,
PrivateIP: netip.MustParseAddr("10.2.1.10"),
MAC: mac,
}
if err := mgr.EnsurePort(ctx, port); err != nil {
t.Fatalf("EnsurePort: %v", err)
}
got, err := mock.GetLogicalSwitchPort(ctx, topology.Port(port.PortID))
if err != nil {
t.Fatalf("port LSP missing: %v", err)
}
if got.ExternalIDs["spinifex:eni_id"] != port.PortID {
t.Errorf("eni_id mismatch: %q", got.ExternalIDs["spinifex:eni_id"])
}
if len(got.Addresses) != 1 || got.Addresses[0] != "02:00:00:00:00:01 10.2.1.10" {
t.Errorf("addresses mismatch: %v", got.Addresses)
}
// Idempotent re-call.
if err := mgr.EnsurePort(ctx, port); err != nil {
t.Fatalf("EnsurePort second call: %v", err)
}
}
func TestManager_DeletePort(t *testing.T) {
mgr, mock := newManagerForTest(t)
ctx := context.Background()
vpc := topology.VPCSpec{VPCID: "vpc-dp", CIDR: netip.MustParsePrefix("10.3.0.0/16")}
sub := topology.SubnetSpec{SubnetID: "subnet-DP", VPCID: vpc.VPCID, CIDR: netip.MustParsePrefix("10.3.1.0/24")}
_ = mgr.EnsureVPC(ctx, vpc)
_ = mgr.EnsureSubnet(ctx, sub)
mac, _ := net.ParseMAC("02:00:00:00:00:02")
port := topology.PortSpec{
PortID: "eni-DP", SubnetID: sub.SubnetID, VPCID: vpc.VPCID,
PrivateIP: netip.MustParseAddr("10.3.1.5"), MAC: mac,
}
if err := mgr.EnsurePort(ctx, port); err != nil {
t.Fatalf("EnsurePort: %v", err)
}
if err := mgr.DeletePort(ctx, port); err != nil {
t.Fatalf("DeletePort: %v", err)
}
if _, err := mock.GetLogicalSwitchPort(ctx, topology.Port(port.PortID)); err == nil {
t.Fatal("expected LSP to be gone after DeletePort")
}
}
func TestManager_DeleteSubnetAndVPC(t *testing.T) {
mgr, mock := newManagerForTest(t)
ctx := context.Background()
vpc := topology.VPCSpec{VPCID: "vpc-del", CIDR: netip.MustParsePrefix("10.4.0.0/16")}
sub := topology.SubnetSpec{SubnetID: "subnet-del", VPCID: vpc.VPCID, CIDR: netip.MustParsePrefix("10.4.1.0/24")}
_ = mgr.EnsureVPC(ctx, vpc)
_ = mgr.EnsureSubnet(ctx, sub)
if err := mgr.DeleteSubnet(ctx, sub); err != nil {
t.Fatalf("DeleteSubnet: %v", err)
}
if _, err := mock.GetLogicalSwitch(ctx, topology.SubnetSwitch(sub.SubnetID)); err == nil {
t.Fatal("expected subnet switch to be gone")
}
if err := mgr.DeleteVPC(ctx, vpc.VPCID); err != nil {
t.Fatalf("DeleteVPC: %v", err)
}
if _, err := mock.GetLogicalRouter(ctx, topology.VPCRouter(vpc.VPCID)); err == nil {
t.Fatal("expected VPC router to be gone")
}
}
func TestManager_SetPortSecurityGroups(t *testing.T) {
mgr, mock := newManagerForTest(t)
ctx := context.Background()
vpc := topology.VPCSpec{VPCID: "vpc-sg", CIDR: netip.MustParsePrefix("10.5.0.0/16")}
sub := topology.SubnetSpec{SubnetID: "subnet-sg", VPCID: vpc.VPCID, CIDR: netip.MustParsePrefix("10.5.1.0/24")}
_ = mgr.EnsureVPC(ctx, vpc)
_ = mgr.EnsureSubnet(ctx, sub)
// Seed an SG port group so the port can join it.
if err := mock.CreatePortGroup(ctx, topology.SecurityGroupPortGroup("sg-A"), nil); err != nil {
t.Fatalf("seed port group: %v", err)
}
mac, _ := net.ParseMAC("02:00:00:00:00:03")
port := topology.PortSpec{
PortID: "eni-sg", SubnetID: sub.SubnetID, VPCID: vpc.VPCID,
PrivateIP: netip.MustParseAddr("10.5.1.7"), MAC: mac,
SGIDs: []string{"sg-A"},
}
if err := mgr.EnsurePort(ctx, port); err != nil {
t.Fatalf("EnsurePort: %v", err)
}
// Seed second SG, then move membership from A to B.
if err := mock.CreatePortGroup(ctx, topology.SecurityGroupPortGroup("sg-B"), nil); err != nil {
t.Fatalf("seed port group B: %v", err)
}
if err := mgr.SetPortSecurityGroups(ctx, port.PortID, []string{"sg-B"}); err != nil {
t.Fatalf("SetPortSecurityGroups: %v", err)
}
names, err := mock.ListPortGroupsForPort(ctx, topology.Port(port.PortID))
if err != nil {
t.Fatalf("ListPortGroupsForPort: %v", err)
}
if len(names) != 1 || names[0] != topology.SecurityGroupPortGroup("sg-B") {
t.Errorf("expected only sg-B membership, got %v", names)
}
}