mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user