From 7615929e850c68f0200643537751d1d7f18aefaa Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Thu, 21 May 2026 13:50:20 +1000 Subject: [PATCH] feat(network/topology): TopologyManager interface + vpcd adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- spinifex/network/topology/manager.go | 66 ++++ spinifex/network/topology/names.go | 57 ++++ spinifex/network/topology/names_test.go | 30 ++ spinifex/services/vpcd/topology_manager.go | 320 ++++++++++++++++++ .../services/vpcd/topology_manager_test.go | 202 +++++++++++ 5 files changed, 675 insertions(+) create mode 100644 spinifex/network/topology/manager.go create mode 100644 spinifex/network/topology/names.go create mode 100644 spinifex/network/topology/names_test.go create mode 100644 spinifex/services/vpcd/topology_manager.go create mode 100644 spinifex/services/vpcd/topology_manager_test.go diff --git a/spinifex/network/topology/manager.go b/spinifex/network/topology/manager.go new file mode 100644 index 000000000..db393dd29 --- /dev/null +++ b/spinifex/network/topology/manager.go @@ -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 +} diff --git a/spinifex/network/topology/names.go b/spinifex/network/topology/names.go new file mode 100644 index 000000000..8c7731553 --- /dev/null +++ b/spinifex/network/topology/names.go @@ -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 } diff --git a/spinifex/network/topology/names_test.go b/spinifex/network/topology/names_test.go new file mode 100644 index 000000000..746c83191 --- /dev/null +++ b/spinifex/network/topology/names_test.go @@ -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) + } + }) + } +} diff --git a/spinifex/services/vpcd/topology_manager.go b/spinifex/services/vpcd/topology_manager.go new file mode 100644 index 000000000..0170699eb --- /dev/null +++ b/spinifex/services/vpcd/topology_manager.go @@ -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 +} diff --git a/spinifex/services/vpcd/topology_manager_test.go b/spinifex/services/vpcd/topology_manager_test.go new file mode 100644 index 000000000..4e5e1576f --- /dev/null +++ b/spinifex/services/vpcd/topology_manager_test.go @@ -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) + } +}