diff --git a/spinifex/network/policy/nat.go b/spinifex/network/policy/nat.go index 75c4737c7..fa1bc3017 100644 --- a/spinifex/network/policy/nat.go +++ b/spinifex/network/policy/nat.go @@ -171,7 +171,7 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error { "router", router, "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP) // Skip row churn but still re-prime: stop->start re-attaches the same EIP // and the host neigh stays dark until ARP times out without a fresh prime. - m.primeReachability(eip, distributed) + m.primeReachability(ctx, eip, distributed) return nil } @@ -188,22 +188,42 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error { if err := m.barrier(); err != nil { slog.Warn("policy: AddEIP flows barrier failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err) } - m.primeReachability(eip, distributed) + m.primeReachability(ctx, eip, distributed) return nil } -// primeReachability re-announces an EIP: distributed mode programs the ARP entry -// directly; centralised mode (no MAC) flushes and re-arms ARP. Best-effort. -func (m *natManager) primeReachability(eip EIPSpec, distributed bool) { - if distributed { - if err := m.neighPrime(eip); err != nil { +// primeReachability programs the host neigh entry to the MAC owning the EIP on +// the WAN segment — external_mac (distributed) or gateway router-port MAC +// (centralised) — falling back to an ARP flush when no MAC resolves. +func (m *natManager) primeReachability(ctx context.Context, eip EIPSpec, distributed bool) { + primeMAC := eip.MAC + if !distributed { + primeMAC = m.gatewayPortMAC(ctx, eip.VPCID) + } + if primeMAC != "" { + primed := eip + primed.MAC = primeMAC + if err := m.neighPrime(primed); err != nil { slog.Warn("policy: AddEIP neighbour prime failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err) } - } else if err := m.neigh(eip.ExternalIP); err != nil { + return + } + if err := m.neigh(eip.ExternalIP); err != nil { slog.Warn("policy: AddEIP neighbour flush failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err) } } +// gatewayPortMAC returns the MAC of the VPC gateway router's external port, the +// L2 owner of a centralised EIP on the WAN segment. Empty on lookup miss so the +// caller falls back to an ARP flush. +func (m *natManager) gatewayPortMAC(ctx context.Context, vpcID string) string { + lrp, err := m.ovn.GetLogicalRouterPort(ctx, topology.GatewayRouterPort(vpcID)) + if err != nil || lrp == nil { + return "" + } + return lrp.MAC +} + func (m *natManager) DeleteEIP(ctx context.Context, vpcID, externalIP, logicalIP string) error { router := topology.VPCRouter(vpcID) if err := m.ovn.DeleteNAT(ctx, router, "dnat_and_snat", logicalIP); err != nil { diff --git a/spinifex/network/policy/nat_test.go b/spinifex/network/policy/nat_test.go index 7e4922c04..a726f3110 100644 --- a/spinifex/network/policy/nat_test.go +++ b/spinifex/network/policy/nat_test.go @@ -13,6 +13,13 @@ import ( "github.com/mulgadc/spinifex/spinifex/network/topology" ) +func seedGatewayPort(t *testing.T, m *mock.Client, vpcID, mac string) { + t.Helper() + require.NoError(t, m.CreateLogicalRouterPort(context.Background(), + topology.VPCRouter(vpcID), + &nbdb.LogicalRouterPort{Name: topology.GatewayRouterPort(vpcID), MAC: mac})) +} + func seedRouter(t *testing.T, m *mock.Client, vpcID string) string { t.Helper() name := topology.VPCRouter(vpcID) @@ -142,10 +149,11 @@ func TestNATManager_NeighPrime_OnDistributedAttach_FlushOnDetach(t *testing.T) { "detach must still flush the released external IP") } -func TestNATManager_NeighFlush_OnCentralizedAttach(t *testing.T) { +func TestNATManager_NeighPrime_OnCentralizedAttach(t *testing.T) { ctx := context.Background() m := mock.New() seedRouter(t, m, "vpc-1") + seedGatewayPort(t, m, "vpc-1", "02:gw:00:00:00:01") var primed []EIPSpec var flushed []string nm, err := NewNATManager(m, NATModeCentralized, @@ -162,9 +170,36 @@ func TestNATManager_NeighFlush_OnCentralizedAttach(t *testing.T) { require.NoError(t, nm.AddEIP(ctx, EIPSpec{ VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5", })) - assert.Empty(t, primed, "centralized attach has no external_mac to prime") + require.Len(t, primed, 1, + "centralized attach must prime the host neighbour with the gateway port MAC") + assert.Equal(t, "02:gw:00:00:00:01", primed[0].MAC, + "centralized prime must use the VPC gateway router-port MAC") + assert.Empty(t, flushed, "a successful prime must not also flush") +} + +func TestNATManager_NeighFlush_CentralizedNoGatewayMAC(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") // no gateway port seeded → MAC unresolvable + var primed []EIPSpec + var flushed []string + nm, err := NewNATManager(m, NATModeCentralized, + WithNeighPrimer(func(eip EIPSpec) error { + primed = append(primed, eip) + return nil + }), + WithNeighFlusher(func(externalIP string) error { + flushed = append(flushed, externalIP) + return nil + })) + require.NoError(t, err) + + require.NoError(t, nm.AddEIP(ctx, EIPSpec{ + VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5", + })) + assert.Empty(t, primed, "no gateway MAC to prime") assert.Equal(t, []string{"1.2.3.4"}, flushed, - "centralized attach must fall back to a neighbour flush") + "centralized attach must fall back to a neighbour flush when no MAC resolves") } func TestNATManager_NeighHooks_FailureNonFatal(t *testing.T) {