refactor(vpcd): remove macvlan bridge mode

Macvlan was a single-NIC SSH-safe hack that predated veth mode and was
never selected by any active config path. Deletion is Phase 0 item 1 of
the network layer redesign (mulga-siv-125.1).

Removed:
- BridgeModeMacvlan const + setMacvlanMAC + ifaceIsMacvlan probe
- Macvlan MAC alignment block in launchService (only fired on dead path)
- Macvlan branch in detectBridgeMode + matching tests
- Macvlan duplicates of IGW/AddNAT centralized-NAT tests (veth versions
  already cover the same path)
- --macvlan flag + macvlan case branch in setup-ovn.sh

Refactored:
- useCentralizedNAT() now == BridgeModeVeth (was != BridgeModeDirect);
  verifyBridgeMode already rejects empty/unknown at startup
- Three IGW-pool tests now explicitly WithBridgeMode(BridgeModeVeth)
  (previously relied on empty-mode defaulting to centralized)

Kept intentionally:
- Deprecation notes in docs/compute/vpc-networking/README.md
- Legacy macvlan-interface cleanup in reset-dev-env.sh + ansible
  teardown role (migration for operator systems with stale spx-ext-*
  interfaces from pre-veth installs)
This commit is contained in:
joshsiv-mulga
2026-05-21 11:37:42 +10:00
parent 80ab3fb37a
commit d8128ec2f3
14 changed files with 57 additions and 293 deletions
+4 -3
View File
@@ -304,9 +304,10 @@ func installSpinifex(cfg *Config) error {
return err
}
// dhcpcd-base is present on the installed system (used by setup-ovn.sh for
// macvlan mode). Mask the standalone dhcpcd.service so it never auto-starts
// and races with systemd-networkd's built-in DHCP client on br-wan.
// dhcpcd-base is present on the installed system (used by setup-ovn.sh
// for WAN DHCP acquisition). Mask the standalone dhcpcd.service so it
// never auto-starts and races with systemd-networkd's built-in DHCP
// client on br-wan.
if err := maskSystemdUnit(mountRoot, "dhcpcd.service"); err != nil {
slog.Warn("installSpinifex: failed to mask dhcpcd.service", "err", err)
}
+3 -3
View File
@@ -893,9 +893,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (macvlan for external bridge)")
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
fmt.Printf("\n Mode: %d LAN + 1 WAN (macvlan for external bridge)\n", detected.LANCount)
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
}
// Apply auto-detected values when flags not explicitly set
@@ -984,7 +984,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
// For pool/dhcp source, the gateway IP is obtained via DHCP on the
// macvlan/bridge interface (no static pool range or explicit gateway-ip).
// WAN bridge interface (no static pool range or explicit gateway-ip).
useExternalDHCP := externalMode == "pool" && externalSource == "dhcp" && gatewayIP == ""
// Validate IP address format
+3 -2
View File
@@ -170,9 +170,10 @@ if [ -e /etc/systemd/network/15-spinifex-veth-wan.netdev ] || \
sudo networkctl reload 2>/dev/null || true
fi
# Remove macvlan interfaces created by setup-ovn.sh
# Legacy: clean up macvlan interfaces left over from pre-veth setup-ovn.sh
# runs. Safe to remove once no operator system retains these.
for iface in $(ip -o link show type macvlan 2>/dev/null | awk -F': ' '{print $2}' | grep '^spx-ext-'); do
echo " Deleting macvlan: $iface"
echo " Deleting legacy macvlan: $iface"
sudo ip link del "$iface" 2>/dev/null || true
done
+6 -72
View File
@@ -16,9 +16,7 @@
# Options:
# --management Also start OVN central services (NB DB, SB DB, ovn-northd)
# --wan-bridge=NAME OVS bridge for WAN traffic (default: auto-detect from default route)
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge or --macvlan)
# --macvlan Create macvlan off --wan-iface instead of moving NIC directly.
# SSH-safe for single-NIC hosts where WAN NIC carries SSH.
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge)
# --dhcp Obtain gateway IP via DHCP on the WAN bridge interface
# --mgmt-bridge=NAME OVS bridge for system-instance control plane (default: br-mgmt)
# --mgmt-cidr=CIDR IPv4 CIDR to assign on the mgmt bridge (default: 10.15.8.1/24)
@@ -41,9 +39,6 @@
# # Dedicated WAN NIC (not your SSH NIC — you take responsibility):
# ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
#
# # Single-NIC host (SSH-safe macvlan):
# ./scripts/setup-ovn.sh --management --macvlan --wan-iface=eth0
#
# # Compute node joining an existing cluster:
# ./scripts/setup-ovn.sh --ovn-remote=tcp:10.0.0.1:6642 --encap-ip=10.0.0.2
#
@@ -56,7 +51,6 @@ set -e
MANAGEMENT=false
WAN_BRIDGE=""
WAN_IFACE=""
MACVLAN_MODE=false
EXTERNAL_DHCP=false
MGMT_BRIDGE_ENABLED=true
MGMT_BRIDGE="br-mgmt"
@@ -69,7 +63,6 @@ ENCAP_IP=""
for arg in "$@"; do
case "$arg" in
--management) MANAGEMENT=true ;;
--macvlan) MACVLAN_MODE=true ;;
--dhcp) EXTERNAL_DHCP=true ;;
--wan-bridge=*) WAN_BRIDGE="${arg#*=}" ;;
--wan-iface=*) WAN_IFACE="${arg#*=}" ;;
@@ -92,15 +85,13 @@ done
# --- WAN bridge auto-detection ---
# Determine the WAN bridge name and how to set it up.
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", "macvlan", or ""
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", or ""
LINUX_BRIDGE="" # Set when WAN_BRIDGE_MODE="veth" — the Linux bridge behind the veth pair
detect_wan_bridge() {
# If --wan-bridge was explicitly given, use it
if [ -n "$WAN_BRIDGE" ]; then
if [ "$MACVLAN_MODE" = true ] && [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="macvlan"
elif [ -n "$WAN_IFACE" ]; then
if [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="direct"
elif sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
WAN_BRIDGE_MODE="existing"
@@ -111,17 +102,6 @@ detect_wan_bridge() {
return
fi
# If --macvlan was given without --wan-bridge, we need --wan-iface
if [ "$MACVLAN_MODE" = true ]; then
if [ -z "$WAN_IFACE" ]; then
echo "ERROR: --macvlan requires --wan-iface=<NIC>"
exit 1
fi
WAN_BRIDGE="br-wan"
WAN_BRIDGE_MODE="macvlan"
return
fi
# Auto-detect: find the default route interface
local default_dev
default_dev=$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1)
@@ -176,10 +156,7 @@ detect_wan_bridge() {
echo " 2. Dedicated WAN NIC (NOT your SSH connection):"
echo " ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=$default_dev"
echo ""
echo " 3. Single-NIC host (SSH-safe macvlan):"
echo " ./scripts/setup-ovn.sh --management --macvlan --wan-iface=$default_dev"
echo ""
echo " 4. No external networking (overlay-only):"
echo " 3. No external networking (overlay-only):"
echo " ./scripts/setup-ovn.sh --management --encap-ip=$wan_ip"
echo "============================================================"
echo ""
@@ -477,36 +454,6 @@ NETWORK
echo " $WAN_BRIDGE: direct bridge on $WAN_IFACE"
echo " NOTE: $WAN_IFACE is now an OVS port — no host IP on this NIC"
;;
macvlan)
# Create a macvlan sub-interface in bridge mode off the WAN NIC.
# The host keeps its IP on the parent NIC — SSH-safe. OVN localnet
# traffic flows through the macvlan to the physical wire.
if ! ip link show "$WAN_IFACE" >/dev/null 2>&1; then
echo " ERROR: interface $WAN_IFACE does not exist"
echo " Available interfaces:"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
sudo ip link set "$WAN_BRIDGE" up
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan $MACVLAN_NAME already exists"
else
sudo ip link add "$MACVLAN_NAME" link "$WAN_IFACE" type macvlan mode bridge
echo " created macvlan: $MACVLAN_NAME (bridge mode) on $WAN_IFACE"
fi
sudo ip link set "$MACVLAN_NAME" up
sudo ovs-vsctl --may-exist add-port "$WAN_BRIDGE" "$MACVLAN_NAME"
echo " $WAN_BRIDGE: macvlan port $MACVLAN_NAME on $WAN_IFACE"
echo " NOTE: host keeps its IP on $WAN_IFACE (SSH-safe)"
echo " QUIRK: host cannot reach VMs at their public IPs (macvlan isolation)"
;;
esac
# --- DHCP: obtain gateway IP for OVN SNAT ---
@@ -514,13 +461,8 @@ NETWORK
echo ""
echo "Step 3c: Obtaining external gateway IP via DHCP..."
# For macvlan mode, DHCP on the macvlan interface (it has L2 access to WAN).
# For direct/existing bridge, DHCP on the bridge itself.
if [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
DHCP_IFACE="spx-ext-${WAN_IFACE}"
else
DHCP_IFACE="$WAN_BRIDGE"
fi
# DHCP on the WAN bridge itself (direct/existing/veth).
DHCP_IFACE="$WAN_BRIDGE"
# Run DHCP client to get a lease
if command -v dhcpcd >/dev/null 2>&1; then
@@ -815,14 +757,6 @@ if [ -n "$WAN_BRIDGE" ]; then
echo " direct bridge: FAILED ($WAN_IFACE not on $WAN_BRIDGE)"
OK=false
fi
elif [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan: OK ($MACVLAN_NAME)"
else
echo " macvlan: FAILED ($MACVLAN_NAME not found)"
OK=false
fi
fi
else
echo " $WAN_BRIDGE:$(printf '%*s' $((15 - ${#WAN_BRIDGE})) '') FAILED"
+1 -1
View File
@@ -73,7 +73,7 @@ type ConfigSettings struct {
ExternalMode string // "pool" or "" (disabled)
ExternalIface string // WAN NIC name (e.g., "eth0", "eth1")
DhcpBindBridge string // Bridge where the DHCP AF_PACKET socket binds (Linux bridge in veth mode, OVS bridge in direct mode; never "br-ext")
ExternalDHCP bool // Obtain gateway IP via DHCP on macvlan/bridge
ExternalDHCP bool // Obtain gateway IP via DHCP on the WAN bridge
PoolName string // External pool name (e.g., "wan")
PoolSource string // IP source: "static" (default) or "dhcp" (from router DHCP)
PoolStart string // First IP in external pool range (static source only)
+1 -1
View File
@@ -32,7 +32,7 @@ type ExternalPool struct {
Region string `mapstructure:"region"` // Scope to region (optional — empty means any region)
AZ string `mapstructure:"az"` // Scope to AZ (optional — empty means any AZ in region)
// GwLrpRangeStart/End reserve a sub-range of the LAN for OVN gateway LRP IPs
// in centralized NAT mode (veth/macvlan). Each VPC consumes one IP from this
// in centralized NAT mode (veth). Each VPC consumes one IP from this
// range so its gateway router port can ARP with a sender IP on the LAN
// subnet — link-local 169.254.0.1/30 makes upstream routers reject ARP per
// RFC 826 (mulga-siv-36). Must NOT overlap [RangeStart, RangeEnd] or IPAM
+16 -21
View File
@@ -74,7 +74,7 @@ const (
// chassis so the LRP IP itself never goes on the wire. The default route's
// OutputPort is set explicitly so the WAN nexthop need not be on this subnet.
//
// Centralized-NAT (veth/macvlan) bypasses this constant: the gateway LRP is
// Centralized-NAT (veth) bypasses this constant: the gateway LRP is
// the on-wire egress point and must hold a WAN-subnet IP from the pool's
// gw_lrp_range, otherwise the upstream router silently drops ARP requests
// from an off-subnet sender (RFC 826) and the default route never resolves
@@ -144,16 +144,13 @@ type NATGatewayEvent struct {
// Bridge mode constants for external connectivity.
const (
// BridgeModeMacvlan uses a macvlan sub-interface on the WAN NIC. SSH-safe
// for single-NIC hosts but requires centralized NAT and MAC alignment.
BridgeModeMacvlan = "macvlan"
// BridgeModeDirect adds the WAN NIC directly to br-external as an OVS port.
// Enables distributed NAT and avoids macvlan workarounds. Only safe when
// the WAN NIC is NOT the SSH/management NIC.
// Enables distributed NAT. Only safe when the WAN NIC is NOT the SSH/
// management NIC.
BridgeModeDirect = "direct"
// BridgeModeVeth uses a veth pair to link a Linux bridge (br-wan) to an
// OVS bridge (br-ext). Requires centralized NAT like macvlan because the
// Linux bridge intermediary breaks distributed NAT hairpin routing.
// OVS bridge (br-ext). Requires centralized NAT because the Linux bridge
// intermediary breaks distributed NAT hairpin routing.
BridgeModeVeth = "veth"
// OvnExternalBridge is the OVS bridge that ovn-bridge-mappings targets
// for the "external" localnet. Owned by setup-ovn.sh's ovn-bridge-mappings
@@ -169,7 +166,7 @@ type TopologyHandler struct {
externalMode string
externalPools []ExternalPoolConfig
chassisNames []string // OVN chassis names for gateway HA scheduling
bridgeMode string // "direct" or "macvlan" — controls NAT mode and localnet options
bridgeMode string // "direct" or "veth" — controls NAT mode and localnet options
// nc is used to talk to vpcd's DHCPManager via vpc.dhcp.acquire /
// vpc.dhcp.release for gateway LRP IPs in centralized NAT on
// source="dhcp" pools (mulga-siv-38). nil when no DHCP pool is wired or
@@ -204,9 +201,8 @@ func WithChassisNames(names []string) TopologyOption {
}
}
// WithBridgeMode sets the external bridge mode ("direct", "macvlan", or "veth").
// Direct bridge enables distributed NAT; macvlan and veth use centralized NAT.
// Defaults to macvlan if not set (backward-compatible).
// WithBridgeMode sets the external bridge mode ("direct" or "veth").
// Direct bridge enables distributed NAT; veth uses centralized NAT.
func WithBridgeMode(mode string) TopologyOption {
return func(h *TopologyHandler) {
h.bridgeMode = mode
@@ -223,11 +219,10 @@ func WithNATSConn(nc *nats.Conn) TopologyOption {
}
// useCentralizedNAT returns true if the bridge mode requires centralized NAT.
// Macvlan and veth modes both need centralized NAT — macvlan because of MAC
// filtering, veth because the Linux bridge intermediary breaks distributed NAT
// hairpin routing. Only direct bridge mode supports distributed NAT.
// Veth mode needs centralized NAT because the Linux bridge intermediary breaks
// distributed NAT hairpin routing. Only direct bridge mode supports distributed NAT.
func (h *TopologyHandler) useCentralizedNAT() bool {
return h.bridgeMode != BridgeModeDirect
return h.bridgeMode == BridgeModeVeth
}
// ensureLocalnetOptions aligns options on an existing localnet port with the
@@ -239,7 +234,7 @@ func (h *TopologyHandler) useCentralizedNAT() bool {
//
// network_name=external is required regardless of bridge mode — it binds the
// port to the ovn-bridge-mappings entry for the external OVS bridge.
// Centralized mode (veth/macvlan): options:nat-addresses=router must be set.
// Centralized mode (veth): options:nat-addresses=router must be set.
// Distributed mode (direct): options:nat-addresses must be absent.
func (h *TopologyHandler) ensureLocalnetOptions(ctx context.Context, extPortName string) error {
lsp, err := h.ovn.GetLogicalSwitchPort(ctx, extPortName)
@@ -1053,9 +1048,9 @@ func (h *TopologyHandler) handleIGWAttach(msg *nats.Msg) {
"network_name": "external",
}
// nat-addresses=router: OVN sends gratuitous ARPs for all NAT external IPs
// using the router port MAC. Required for centralized NAT modes (macvlan,
// veth) so that ARP replies for NAT IPs reach hosts correctly. Not needed
// for direct bridge since OVS sees all traffic on the wire without MAC
// using the router port MAC. Required for centralized NAT (veth mode) so
// that ARP replies for NAT IPs reach hosts correctly. Not needed for
// direct bridge since OVS sees all traffic on the wire without MAC
// filtering and distributed NAT handles ARP per-chassis.
if h.useCentralizedNAT() {
localnetOpts["nat-addresses"] = "router"
@@ -1083,7 +1078,7 @@ func (h *TopologyHandler) handleIGWAttach(msg *nats.Msg) {
// dnat_and_snat row with external_mac/logical_port on every chassis, so
// the LRP IP itself never goes on the wire.
//
// Centralized mode (veth/macvlan): the LRP is the on-wire egress, so it
// Centralized mode (veth): the LRP is the on-wire egress, so it
// must hold a WAN-subnet IP. Allocate one from pool.GwLrpRange (or
// auto-derive from the WAN subnet) so each VPC gets a distinct sender
// IP (mulga-siv-36). Without this the upstream router silently drops
+4 -111
View File
@@ -1075,7 +1075,7 @@ func TestTopologyHandler_IGWAttach_WithExternalPool(t *testing.T) {
PrefixLen: 24,
},
}
topo := NewTopologyHandler(mock, WithExternalNetwork("pool", pools))
topo := NewTopologyHandler(mock, WithExternalNetwork("pool", pools), WithBridgeMode(BridgeModeVeth))
subs, err := topo.Subscribe(nc)
if err != nil {
t.Fatalf("subscribe: %v", err)
@@ -1169,7 +1169,7 @@ func TestTopologyHandler_IGWAttach_PoolWithGatewayIP(t *testing.T) {
PrefixLen: 28,
},
}
topo := NewTopologyHandler(mock, WithExternalNetwork("pool", pools))
topo := NewTopologyHandler(mock, WithExternalNetwork("pool", pools), WithBridgeMode(BridgeModeVeth))
subs, _ := topo.Subscribe(nc)
defer func() {
for _, s := range subs {
@@ -1232,7 +1232,7 @@ func TestTopologyHandler_IGWAttach_MultiVPC_NoIPCollision(t *testing.T) {
PrefixLen: 24,
},
}
topo := NewTopologyHandler(mock, WithExternalNetwork("pool", pools))
topo := NewTopologyHandler(mock, WithExternalNetwork("pool", pools), WithBridgeMode(BridgeModeVeth))
subs, err := topo.Subscribe(nc)
if err != nil {
t.Fatalf("subscribe: %v", err)
@@ -1319,7 +1319,7 @@ func TestTopologyHandler_IGWAttach_MultiVPC_NoIPCollision(t *testing.T) {
}
// TestTopologyHandler_IGWAttach_CentralizedNATAllocatesGwLrpIP guards
// mulga-siv-36: in centralized NAT (veth/macvlan) the gateway LRP must
// mulga-siv-36: in centralized NAT (veth) the gateway LRP must
// hold a WAN-subnet IP from pool.GwLrpRange so it can ARP the upstream
// nexthop. Three VPCs sharing one pool must each get a distinct IP and
// persist it on external_ids:spinifex:gateway_ip.
@@ -1340,8 +1340,6 @@ func TestTopologyHandler_IGWAttach_CentralizedNATAllocatesGwLrpIP(t *testing.T)
GwLrpRangeEnd: "192.168.3.29",
},
}
// Default bridge mode is centralized (useCentralizedNAT returns true
// when bridgeMode != "direct"). Explicit veth here to be unambiguous.
topo := NewTopologyHandler(mock,
WithExternalNetwork("pool", pools),
WithBridgeMode(BridgeModeVeth),
@@ -2651,52 +2649,6 @@ func TestTopologyHandler_IGWAttach_DirectBridge_NoNatAddresses(t *testing.T) {
}
}
func TestTopologyHandler_IGWAttach_MacvlanMode_HasNatAddresses(t *testing.T) {
// In macvlan mode (default), the localnet port SHOULD have nat-addresses=router
_, nc := startTestNATS(t)
mock := NewMockOVNClient()
_ = mock.Connect(context.Background())
ctx := context.Background()
topo := NewTopologyHandler(mock, WithBridgeMode(BridgeModeMacvlan))
subs, err := topo.Subscribe(nc)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
}()
// Pre-create VPC router
_ = mock.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-vpc-mv1",
ExternalIDs: map[string]string{
"spinifex:vpc_id": "vpc-mv1",
"spinifex:cidr": "10.0.0.0/16",
},
})
// Attach IGW
evt := types.IGWEvent{InternetGatewayId: "igw-mv1", VpcId: "vpc-mv1"}
data, _ := json.Marshal(evt)
resp, err := nc.Request(TopicIGWAttach, data, 5_000_000_000)
if err != nil {
t.Fatalf("request vpc.igw-attach: %v", err)
}
assertSuccess(t, resp, "attach IGW macvlan")
// Verify localnet port HAS nat-addresses=router
port, err := mock.GetLogicalSwitchPort(ctx, "ext-port-vpc-mv1")
if err != nil {
t.Fatalf("expected localnet port: %v", err)
}
if port.Options["nat-addresses"] != "router" {
t.Errorf("macvlan mode should have nat-addresses=router, got %q", port.Options["nat-addresses"])
}
}
func TestTopologyHandler_AddNAT_DirectBridge_DistributedNAT(t *testing.T) {
// In direct bridge mode, DNAT rules should have ExternalMAC and LogicalPort set
_, nc := startTestNATS(t)
@@ -2756,65 +2708,6 @@ func TestTopologyHandler_AddNAT_DirectBridge_DistributedNAT(t *testing.T) {
}
}
func TestTopologyHandler_AddNAT_MacvlanMode_CentralizedNAT(t *testing.T) {
// In macvlan mode, DNAT rules should NOT have ExternalMAC/LogicalPort
_, nc := startTestNATS(t)
mock := NewMockOVNClient()
_ = mock.Connect(context.Background())
ctx := context.Background()
topo := NewTopologyHandler(mock, WithBridgeMode(BridgeModeMacvlan))
subs, err := topo.Subscribe(nc)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
}()
// Pre-create VPC router
_ = mock.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-vpc-cnat1",
ExternalIDs: map[string]string{
"spinifex:vpc_id": "vpc-cnat1",
"spinifex:cidr": "10.0.0.0/16",
},
})
// Add NAT with MAC and port — macvlan mode should ignore them
evt := NATEvent{
VpcId: "vpc-cnat1",
ExternalIP: "192.168.1.201",
LogicalIP: "10.0.1.5",
PortName: "port-eni-5678",
MAC: "02:00:00:11:22:33",
}
data, _ := json.Marshal(evt)
resp, err := nc.Request(TopicAddNAT, data, 5_000_000_000)
if err != nil {
t.Fatalf("request vpc.add-nat: %v", err)
}
assertSuccess(t, resp, "add NAT macvlan")
// Verify the NAT rule does NOT have ExternalMAC/LogicalPort (centralized NAT)
router, err := mock.GetLogicalRouter(ctx, "vpc-vpc-cnat1")
if err != nil {
t.Fatalf("expected router: %v", err)
}
if len(router.NAT) != 1 {
t.Fatalf("expected 1 NAT rule, got %d", len(router.NAT))
}
nat := mock.nats[router.NAT[0]]
if nat.ExternalMAC != nil {
t.Errorf("macvlan mode should NOT have ExternalMAC, got %v", *nat.ExternalMAC)
}
if nat.LogicalPort != nil {
t.Errorf("macvlan mode should NOT have LogicalPort, got %v", *nat.LogicalPort)
}
}
func TestTopologyHandler_IGWAttach_VethMode_HasNatAddresses(t *testing.T) {
// In veth mode, the localnet port SHOULD have nat-addresses=router (centralized NAT)
_, nc := startTestNATS(t)
+7 -53
View File
@@ -64,18 +64,17 @@ type Config struct {
ExternalPools []ExternalPoolConfig
// Bootstrap holds the default VPC config from spinifex.toml for first-boot reconciliation.
Bootstrap *BootstrapVPC
// ExternalInterface is the WAN NIC name (e.g., "enp0s3"). Used to align
// the macvlan MAC with the OVN gateway MAC for inbound traffic.
// ExternalInterface is the WAN NIC name (e.g., "enp0s3"). Used by
// detectBridgeMode for veth/direct auto-detection.
ExternalInterface string
// DhcpBindBridge is the bridge where the DHCP client binds its AF_PACKET
// socket. In veth mode this is the Linux bridge that holds the WAN NIC
// (e.g. "br-wan"); in direct mode this is the OVS bridge holding the
// WAN NIC. Never the OVN-side "br-ext" — that never sees LAN DHCP.
DhcpBindBridge string
// BridgeMode is "direct", "macvlan", or "veth". Direct bridge adds the WAN
// NIC directly to the OVS bridge; macvlan creates a sub-interface; veth uses
// a veth pair to link a Linux bridge to OVS. When empty, auto-detected at
// startup.
// BridgeMode is "direct" or "veth". Direct bridge adds the WAN NIC
// directly to the OVS bridge; veth uses a veth pair to link a Linux bridge
// to OVS. When empty, auto-detected at startup.
BridgeMode string
}
@@ -364,21 +363,6 @@ func launchService(cfg *Config) error {
holder, _ := os.Hostname()
releaseLeader, isLeader := AcquireReconcileLeader(nc, holder)
// Macvlan mode only: align macvlan MAC with the OVN gateway router MAC.
// The macvlan only delivers inbound unicast matching its own MAC. With
// centralized NAT, OVN uses the router MAC for all external ARP replies.
// Setting the macvlan MAC to the router MAC ensures inbound traffic reaches OVS.
// Direct bridge mode doesn't need this — OVS sees all traffic on the wire.
if bridgeMode == BridgeModeMacvlan && cfg.Bootstrap != nil && cfg.Bootstrap.VpcId != "" && cfg.ExternalInterface != "" {
gwMAC := generateMAC("gw-" + cfg.Bootstrap.VpcId)
macvlanName := "spx-ext-" + cfg.ExternalInterface
if err := setMacvlanMAC(macvlanName, gwMAC); err != nil {
slog.Warn("vpcd: failed to set macvlan MAC (inbound traffic may not work)", "iface", macvlanName, "mac", gwMAC, "err", err)
} else {
slog.Info("vpcd: macvlan MAC aligned with gateway", "iface", macvlanName, "mac", gwMAC)
}
}
subs, err := topo.Subscribe(nc)
if err != nil {
slog.Error("Failed to subscribe to VPC topics", "err", err)
@@ -554,21 +538,12 @@ func resolveBridgeConfig(cfgBridgeMode, externalIface, cfgDhcpBindBridge string)
return bridgeMode, dhcpBindBridge
}
// ifaceIsMacvlan returns true when the given interface exists and is a
// macvlan sub-interface. Injected as a var so detectBridgeMode tests can stub
// it without launching ip(8).
var ifaceIsMacvlan = func(name string) bool {
out, err := exec.Command("ip", "-d", "link", "show", name).CombinedOutput()
return err == nil && strings.Contains(string(out), "macvlan")
}
// ifaceExists returns true when the kernel reports the named link.
var ifaceExists = func(name string) bool {
return exec.Command("ip", "link", "show", name).Run() == nil
}
// detectBridgeMode checks how the WAN bridge is wired:
// - macvlan: spx-ext-{iface} macvlan sub-interface exists
// - veth: veth-wan-ovs interface exists (Linux bridge linked to OVS via veth pair)
// - direct: physical NIC is added directly to the OVS bridge
//
@@ -577,17 +552,12 @@ var ifaceExists = func(name string) bool {
// silent Debug fall-through is what let the veth-persistence bug hide for
// weeks (mulga-998.b Fix 2).
func detectBridgeMode(externalIface string) string {
macvlanName := "spx-ext-" + externalIface
if ifaceIsMacvlan(macvlanName) {
slog.Info("vpcd: detected macvlan interface on WAN bridge", "iface", macvlanName, "mode", BridgeModeMacvlan)
return BridgeModeMacvlan
}
if ifaceExists("veth-wan-ovs") {
slog.Info("vpcd: detected veth pair linking Linux bridge to OVS", "mode", BridgeModeVeth)
return BridgeModeVeth
}
slog.Warn("vpcd: no macvlan or veth interface found, assuming direct bridge mode",
"external_iface", externalIface, "checked_macvlan", macvlanName, "checked_veth", "veth-wan-ovs",
slog.Warn("vpcd: no veth interface found, assuming direct bridge mode",
"external_iface", externalIface, "checked_veth", "veth-wan-ovs",
"mode", BridgeModeDirect)
return BridgeModeDirect
}
@@ -678,19 +648,3 @@ func verifyBridgeMode(mode, externalIface, dhcpBindBridge string) error {
mode, BridgeModeDirect, BridgeModeVeth)
}
}
// setMacvlanMAC sets the MAC address on a macvlan interface. The interface is
// brought down, MAC changed, and brought back up. Requires sudo/NET_ADMIN.
func setMacvlanMAC(iface, mac string) error {
cmds := [][]string{
{"sudo", "ip", "link", "set", iface, "down"},
{"sudo", "ip", "link", "set", iface, "address", mac},
{"sudo", "ip", "link", "set", iface, "up"},
}
for _, args := range cmds {
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w (%s)", args[3], err, string(out))
}
}
return nil
}
+7 -21
View File
@@ -230,7 +230,7 @@ func TestVerifyBridgeMode_VethWrongLinuxMaster(t *testing.T) {
}
func TestVerifyBridgeMode_UnknownModeLists(t *testing.T) {
err := verifyBridgeMode("macvlan", "enp0s3", "br-wan")
err := verifyBridgeMode("bogus", "enp0s3", "br-wan")
if err == nil {
t.Fatal("expected error for unsupported mode")
}
@@ -279,49 +279,35 @@ func TestVerifyBridgeMode_VethLinuxBrMissing(t *testing.T) {
// detectBridgeMode — mulga-998.b Fix 2.
func stubDetectProbes(t *testing.T, macvlans []string, links []string) {
func stubDetectProbes(t *testing.T, links []string) {
t.Helper()
origMac := ifaceIsMacvlan
origExists := ifaceExists
t.Cleanup(func() {
ifaceIsMacvlan = origMac
ifaceExists = origExists
})
macSet := map[string]bool{}
for _, m := range macvlans {
macSet[m] = true
}
linkSet := map[string]bool{}
for _, l := range links {
linkSet[l] = true
}
ifaceIsMacvlan = func(name string) bool { return macSet[name] }
ifaceExists = func(name string) bool { return linkSet[name] }
}
func TestDetectBridgeMode_MacvlanWins(t *testing.T) {
stubDetectProbes(t, []string{"spx-ext-enp0s3"}, []string{"veth-wan-ovs"})
if got := detectBridgeMode("enp0s3"); got != BridgeModeMacvlan {
t.Errorf("want %q, got %q", BridgeModeMacvlan, got)
}
}
func TestDetectBridgeMode_VethWhenNoMacvlan(t *testing.T) {
stubDetectProbes(t, nil, []string{"veth-wan-ovs"})
func TestDetectBridgeMode_VethWins(t *testing.T) {
stubDetectProbes(t, []string{"veth-wan-ovs"})
if got := detectBridgeMode("enp0s3"); got != BridgeModeVeth {
t.Errorf("want %q, got %q", BridgeModeVeth, got)
}
}
func TestDetectBridgeMode_FallthroughDirect(t *testing.T) {
stubDetectProbes(t, nil, nil)
stubDetectProbes(t, nil)
if got := detectBridgeMode("enp0s3"); got != BridgeModeDirect {
t.Errorf("want %q, got %q", BridgeModeDirect, got)
}
}
func TestResolveBridgeConfig_UsesExplicitMode(t *testing.T) {
stubDetectProbes(t, nil, nil)
stubDetectProbes(t, nil)
mode, br := resolveBridgeConfig(BridgeModeVeth, "enp0s3", "br-wan")
if mode != BridgeModeVeth || br != "br-wan" {
t.Errorf("got (%q,%q), want (%q,br-wan)", mode, br, BridgeModeVeth)
@@ -329,7 +315,7 @@ func TestResolveBridgeConfig_UsesExplicitMode(t *testing.T) {
}
func TestResolveBridgeConfig_AutoDetects(t *testing.T) {
stubDetectProbes(t, nil, []string{"veth-wan-ovs"})
stubDetectProbes(t, []string{"veth-wan-ovs"})
mode, _ := resolveBridgeConfig("", "enp0s3", "br-wan")
if mode != BridgeModeVeth {
t.Errorf("want auto-detect veth, got %q", mode)
+1 -1
View File
@@ -301,7 +301,7 @@ Gated on `external_mode = "pool"` (skipped when only `dev_networking` is configu
- `run-instances` into default subnet → auto-assigned `PublicIpAddress`
- `ovn-nbctl lr-nat-list` shows per-instance `dnat_and_snat` rule for `PUB_PRIVATE_IP`
- Asserts NO blanket VPC CIDR SNAT (mulga-754 AWS-parity regression)
- SSH via public IP succeeds (when bridge/macvlan reachable)
- SSH via public IP succeeds (when bridge reachable)
- Outbound internet from inside the VM (via OVN SNAT)
- Step 2b: In-guest TLS trust — cloud-init injects Spinifex CA into the guest, so `curl https://awsgw` and `https://predastore-s3` work without `--insecure`
- Step 3: Private subnet isolation
+1 -1
View File
@@ -2931,7 +2931,7 @@ if [ "$PUB_SSH_READY" = true ]; then
exit 1
fi
else
echo "WARN: SSH via public IP $PUB_IP not reachable (macvlan isolation or bridge not ready)"
echo "WARN: SSH via public IP $PUB_IP not reachable (bridge not ready)"
echo "Falling back to API-only verification (OVN state already validated above)"
fi
+1 -1
View File
@@ -544,7 +544,7 @@ if [ "$IPS_PRESERVED" = true ]; then
fi
# 8.3: guest VMs relaunched post-reboot (LaunchTime > reboot time via describe-instances)
# Two-hop SSH to VPC private IPs is not possible in single-node macvlan mode —
# Two-hop SSH to VPC private IPs is not possible in single-node mode —
# the host has no route to VPC subnets. LaunchTime from the daemon's recovery
# path (reset to time.Now() on relaunch) is a reliable reboot signal.
log "Verifying guest VMs relaunched post-reboot (LaunchTime check)..."
+2 -2
View File
@@ -418,8 +418,8 @@ func waitForSSHHandshake(t *testing.T, host string, port int, keyPath string) {
// trySSHReady mirrors waitForSSHReady but returns reachability as a bool
// instead of t.Fatal-ing on timeout. Matches run-e2e.sh ~2850–2860, which
// treats SSH-via-public-IP timeout as a WARN ("macvlan isolation or bridge
// not ready") rather than a hard test failure — new-subnet IGW SNAT/DNAT
// treats SSH-via-public-IP timeout as a WARN ("bridge not ready") rather
// than a hard test failure — new-subnet IGW SNAT/DNAT
// flow installation is timing-sensitive on shared CI runners and not part
// of the EC2 surface contract this test exists to validate.
func trySSHReady(host string, port int, keyPath string, timeout time.Duration) bool {