Skip to content

Commit 3571566

Browse files
committed
Add capability for L2 VNIs
AI-assisted: Claude Code Signed-off-by: Freerk-Ole Zakfeld <fzakfeld@scaleuptech.com> Introduce Layer-2 EVPN Features AI-assisted: Claude Code Signed-off-by: Freerk-Ole Zakfeld <fzakfeld@scaleuptech.com>
1 parent 14a9289 commit 3571566

3 files changed

Lines changed: 144 additions & 31 deletions

File tree

osism/tasks/conductor/netbox.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,19 @@ def get_device_vlans(device):
160160
{
161161
'vlans': {vid: {'name': name, 'description': desc}},
162162
'vlan_members': {vid: {'port_name': 'tagging_mode'}},
163-
'vlan_interfaces': {vid: {'addresses': [ip_with_prefix, ...]}}
163+
'vlan_interfaces': {vid: {'addresses': [ip_with_prefix, ...]}},
164+
'l2vni_vlans': {vid: vni} -- VLANs tagged evpn-l2vni (VNI == VID)
164165
}
165166
"""
166167
from .sonic.cache import get_cached_device_interfaces
168+
from .sonic.constants import EVPN_L2VNI_TAG
167169

168170
vlans = {}
169171
vlan_members = {}
170172
vlan_interfaces = {}
173+
l2vni_vlans = {}
174+
# Map of NetBox VLAN object id -> vid, collected while iterating interfaces
175+
vlan_obj_ids = {}
171176

172177
try:
173178
# Use cached interfaces instead of separate query
@@ -204,6 +209,7 @@ def get_device_vlans(device):
204209
"name": vlan.name or f"Vlan{vid}",
205210
"description": vlan.description or "",
206211
}
212+
vlan_obj_ids[vlan.id] = vid
207213

208214
# Add interface to VLAN members as untagged
209215
if vid not in vlan_members:
@@ -223,6 +229,7 @@ def get_device_vlans(device):
223229
"name": vlan.name or f"Vlan{vid}",
224230
"description": vlan.description or "",
225231
}
232+
vlan_obj_ids[vlan.id] = vid
226233

227234
# Add interface to VLAN members as tagged
228235
if vid not in vlan_members:
@@ -250,9 +257,7 @@ def get_device_vlans(device):
250257
for ip_addr in ip_addresses:
251258
if ip_addr.address:
252259
role = getattr(ip_addr, "role", None)
253-
role_value = (
254-
getattr(role, "value", None) if role else None
255-
)
260+
role_value = getattr(role, "value", None) if role else None
256261
if role_value == "anycast":
257262
anycast_addresses.append(ip_addr.address)
258263
else:
@@ -264,18 +269,67 @@ def get_device_vlans(device):
264269
if addresses:
265270
vlan_interfaces[vid]["addresses"] = addresses
266271
if anycast_addresses:
267-
vlan_interfaces[vid]["anycast_addresses"] = anycast_addresses
272+
vlan_interfaces[vid][
273+
"anycast_addresses"
274+
] = anycast_addresses
275+
if hasattr(interface, "vrf") and interface.vrf:
276+
vlan_interfaces[vid]["vrf_name"] = interface.vrf.name
277+
# Extract default route nexthops from sonic_parameters
278+
279+
sonic_params = (
280+
interface.custom_fields.get("sonic_parameters")
281+
if hasattr(interface, "custom_fields")
282+
and interface.custom_fields
283+
else None
284+
)
285+
286+
if sonic_params:
287+
if (
288+
"default_route_ipv4" in sonic_params
289+
and sonic_params["default_route_ipv4"]
290+
):
291+
vlan_interfaces[vid]["default_route_ipv4"] = (
292+
sonic_params["default_route_ipv4"]
293+
)
294+
if (
295+
"default_route_ipv6" in sonic_params
296+
and sonic_params["default_route_ipv6"]
297+
):
298+
vlan_interfaces[vid]["default_route_ipv6"] = (
299+
sonic_params["default_route_ipv6"]
300+
)
301+
268302
except (ValueError, IndexError):
269303
# Skip if interface name doesn't follow Vlan<number> pattern
270304
pass
271305

306+
# Determine which VLANs are tagged evpn-l2vni by fetching full VLAN objects
307+
l2vni_vlans = {}
308+
if vlan_obj_ids:
309+
try:
310+
full_vlans = list(
311+
utils.nb.ipam.vlans.filter(id=list(vlan_obj_ids.keys()))
312+
)
313+
for v in full_vlans:
314+
if any(
315+
getattr(t, "slug", None) == EVPN_L2VNI_TAG
316+
for t in getattr(v, "tags", [])
317+
):
318+
l2vni_vlans[v.vid] = v.vid # VNI equals VID
319+
logger.debug(
320+
f"VLAN {v.vid} tagged {EVPN_L2VNI_TAG}, will add L2 VXLAN_TUNNEL_MAP entry"
321+
)
322+
except Exception as e:
323+
logger.warning(f"Could not fetch VLAN tags for L2 VNI check: {e}")
324+
272325
except Exception as e:
273326
logger.warning(f"Could not get VLANs for device {device.name}: {e}")
274327

275328
return {
276329
"vlans": vlans,
277330
"vlan_members": vlan_members,
278331
"vlan_interfaces": vlan_interfaces,
332+
"l2vni_vlans": l2vni_vlans,
279333
}
280334

281335

osism/tasks/conductor/sonic/config_generator.py

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
get_connected_interface_ipv4_address,
3535
)
3636
from .cache import get_cached_device_interfaces
37-
from .constants import BGP_AF_L2VPN_EVPN_TAG, DEFAULT_SONIC_ROLES
37+
from .constants import BGP_AF_L2VPN_EVPN_TAG, DEFAULT_SONIC_ROLES, DEFAULT_EVPN_SYSTEM_MAC, DEFAULT_SAG_MAC
3838

3939
# Global cache for NTP servers to avoid multiple queries
4040
_ntp_servers_cache = None
@@ -73,16 +73,7 @@ def generate_sonic_config(device, hwsku, device_as_mapping=None, config_version=
7373
# Get port channel configuration from NetBox first (needed by get_connected_interfaces)
7474
portchannel_info = detect_port_channels(device)
7575

76-
# Resolve evpn_system_mac early so it is validated once and passed explicitly later
77-
_raw_evpn_mac = device.config_context.get("_evpn_system_mac")
78-
evpn_system_mac = (
79-
_raw_evpn_mac if isinstance(_raw_evpn_mac, str) and _raw_evpn_mac else None
80-
)
81-
if _raw_evpn_mac and not evpn_system_mac:
82-
logger.warning(
83-
f"Device {device.name}: '_evpn_system_mac' in config_context is not a valid string"
84-
f" (got {type(_raw_evpn_mac).__name__!r}), ignoring"
85-
)
76+
evpn_system_mac = DEFAULT_EVPN_SYSTEM_MAC
8677

8778
# Get connected interfaces to determine admin_status
8879
connected_interfaces, connected_portchannels = get_connected_interfaces(
@@ -270,7 +261,8 @@ def generate_sonic_config(device, hwsku, device_as_mapping=None, config_version=
270261
config["MGMT_INTERFACE"]["eth0"] = {"admin_status": "up"}
271262
config["MGMT_INTERFACE"][f"eth0|{oob_ip}/{prefix_len}"] = {}
272263
metalbox_ip = _get_metalbox_ip_for_device(device)
273-
config["STATIC_ROUTE"] = {}
264+
if "STATIC_ROUTE" not in config:
265+
config["STATIC_ROUTE"] = {}
274266
config["STATIC_ROUTE"]["mgmt|0.0.0.0/0"] = {"nexthop": metalbox_ip}
275267
else:
276268
oob_ip = None
@@ -288,7 +280,7 @@ def generate_sonic_config(device, hwsku, device_as_mapping=None, config_version=
288280
_add_portchannel_configuration(config, portchannel_info, evpn_system_mac)
289281

290282
# Add VRF configuration
291-
_add_vrf_configuration(config, vrf_info, netbox_interfaces)
283+
_add_vrf_configuration(config, vrf_info, vlan_info, netbox_interfaces)
292284

293285
# Set DATABASE VERSION from config_version parameter or default
294286
if "VERSIONS" not in config:
@@ -1756,7 +1748,11 @@ def _add_vlan_configuration(config, vlan_info, netbox_interfaces, device):
17561748

17571749
if addresses or anycast_addresses:
17581750
# Add the VLAN interface base entry
1759-
config["VLAN_INTERFACE"][vlan_name] = {"admin_status": "up"}
1751+
vlan_iface_entry = {"admin_status": "up"}
1752+
vrf_name = interface_data.get("vrf_name")
1753+
if vrf_name:
1754+
vlan_iface_entry["vrf_name"] = vrf_name
1755+
config["VLAN_INTERFACE"][vlan_name] = vlan_iface_entry
17601756

17611757
# Add regular IP configuration for each address (IPv4 and IPv6)
17621758
for address in addresses:
@@ -1786,20 +1782,40 @@ def _add_vlan_configuration(config, vlan_info, netbox_interfaces, device):
17861782
config["SAG"][f"{vlan_name}|IPv6"] = {"gwip": ipv6_anycast}
17871783

17881784
if sag_enabled:
1789-
gwmac = device.config_context.get("_sag_gwmac")
1790-
if not gwmac:
1791-
raise ValueError(
1792-
f"Device {device.name} has SAG anycast addresses but no '_sag_gwmac' "
1793-
"defined in its config context"
1794-
)
17951785
if "SAG_GLOBAL" not in config:
17961786
config["SAG_GLOBAL"] = {}
17971787
config["SAG_GLOBAL"]["IP"] = {
17981788
"IPv4": "enable",
17991789
"IPv6": "enable",
1800-
"gwmac": gwmac,
1790+
"gwmac": DEFAULT_SAG_MAC,
18011791
}
18021792

1793+
# Add static default routes per VRF from sonic_parameters on VLAN interfaces
1794+
for vid, interface_data in vlan_info["vlan_interfaces"].items():
1795+
vrf_name = interface_data.get("vrf_name")
1796+
if not vrf_name:
1797+
continue
1798+
logger.debug(f"Adding static default routes for VRF {vrf_name} (Vlan{vid})")
1799+
1800+
default_route_ipv4 = interface_data.get("default_route_ipv4")
1801+
default_route_ipv6 = interface_data.get("default_route_ipv6")
1802+
if not default_route_ipv4 and not default_route_ipv6:
1803+
continue
1804+
if "STATIC_ROUTE" not in config:
1805+
config["STATIC_ROUTE"] = {}
1806+
if default_route_ipv4:
1807+
config["STATIC_ROUTE"][f"{vrf_name}|0.0.0.0/0"] = {
1808+
"nexthop": default_route_ipv4
1809+
}
1810+
logger.debug(
1811+
f"Added static IPv4 default route for VRF {vrf_name} via {default_route_ipv4} (Vlan{vid})"
1812+
)
1813+
if default_route_ipv6:
1814+
config["STATIC_ROUTE"][f"{vrf_name}|::/0"] = {"nexthop": default_route_ipv6}
1815+
logger.debug(
1816+
f"Added static IPv6 default route for VRF {vrf_name} via {default_route_ipv6} (Vlan{vid})"
1817+
)
1818+
18031819

18041820
def _add_loopback_configuration(config, loopback_info):
18051821
"""Add Loopback configuration from NetBox."""
@@ -1978,12 +1994,13 @@ def _get_vrf_info(device):
19781994
return vrf_info
19791995

19801996

1981-
def _add_vrf_configuration(config, vrf_info, netbox_interfaces):
1997+
def _add_vrf_configuration(config, vrf_info, vlan_info, netbox_interfaces):
19821998
"""Add VRF configuration to config.
19831999
19842000
Args:
19852001
config: Configuration dictionary to update
19862002
vrf_info: VRF information dictionary from _get_vrf_info()
2003+
vlan_info: VLAN information dictionary from get_device_vlans()
19872004
netbox_interfaces: Dict mapping SONiC names to NetBox interface info
19882005
"""
19892006
# Track VRFs with VNI for VXLAN configuration
@@ -2070,8 +2087,11 @@ def _add_vrf_configuration(config, vrf_info, netbox_interfaces):
20702087
config["BGP_GLOBALS"][vrf_name] = copy.deepcopy(default_bgp)
20712088
logger.info(f"Added BGP_GLOBALS for VRF {vrf_name}")
20722089

2073-
# Add VXLAN configuration if there are VRFs with VNI
2074-
if vrfs_with_vni:
2090+
# Collect L2 VNI VLANs (tagged evpn-l2vni in NetBox, VNI == VID)
2091+
l2vni_vlans = vlan_info.get("l2vni_vlans", {})
2092+
2093+
# Add VXLAN configuration if there are VRFs with VNI or L2 VNI VLANs
2094+
if vrfs_with_vni or l2vni_vlans:
20752095
# Get source IP from BGP_GLOBALS default router_id
20762096
src_ip = config.get("BGP_GLOBALS", {}).get("default", {}).get("router_id", "")
20772097

@@ -2090,7 +2110,7 @@ def _add_vrf_configuration(config, vrf_info, netbox_interfaces):
20902110
}
20912111
logger.info(f"Added VXLAN_EVPN_NVO nvo1 with source_vtep {VXLAN_VTEP_NAME}")
20922112

2093-
# Add VXLAN_TUNNEL_MAP for each VRF with VNI
2113+
# Add VXLAN_TUNNEL_MAP for each VRF with VNI (L3 / IRB)
20942114
for vrf_entry in vrfs_with_vni:
20952115
vni = vrf_entry["vni"]
20962116
vlan_name = f"Vlan{vni}"
@@ -2101,6 +2121,22 @@ def _add_vrf_configuration(config, vrf_info, netbox_interfaces):
21012121
}
21022122
logger.info(f"Added VXLAN_TUNNEL_MAP {map_key}")
21032123

2124+
# Add VXLAN_TUNNEL_MAP for each L2 VNI VLAN (pure L2, no VRF assignment)
2125+
vrf_vnis = {entry["vni"] for entry in vrfs_with_vni}
2126+
for vid, vni in l2vni_vlans.items():
2127+
if vni in vrf_vnis:
2128+
logger.debug(
2129+
f"Skipping L2 VNI {vni} for Vlan{vid}: already covered by VRF tunnel map"
2130+
)
2131+
continue
2132+
vlan_name = f"Vlan{vid}"
2133+
map_key = f"{VXLAN_VTEP_NAME}|map_{vni}_{vlan_name}"
2134+
config["VXLAN_TUNNEL_MAP"][map_key] = {
2135+
"vlan": vlan_name,
2136+
"vni": str(vni),
2137+
}
2138+
logger.info(f"Added L2 VXLAN_TUNNEL_MAP {map_key}")
2139+
21042140
# Add VRF assignments to interfaces
21052141
for sonic_interface, vrf_name in vrf_info["interface_vrf_mapping"].items():
21062142
# Check if this is a regular interface
@@ -2119,7 +2155,7 @@ def _add_vrf_configuration(config, vrf_info, netbox_interfaces):
21192155
)
21202156

21212157

2122-
def _add_portchannel_configuration(config, portchannel_info, evpn_system_mac=None):
2158+
def _add_portchannel_configuration(config, portchannel_info, evpn_system_mac):
21232159
"""Add port channel configuration from NetBox."""
21242160
if portchannel_info["portchannels"]:
21252161
for pc_name, pc_data in portchannel_info["portchannels"].items():
@@ -2134,6 +2170,18 @@ def _add_portchannel_configuration(config, portchannel_info, evpn_system_mac=Non
21342170
pc_config["system_mac"] = evpn_system_mac
21352171
config["PORTCHANNEL"][pc_name] = pc_config
21362172

2173+
# Add EVPN_ETHERNET_SEGMENT configuration for EVPN multihoming LAGs
2174+
if pc_data.get("evpn_lag"):
2175+
if "EVPN_ETHERNET_SEGMENT" not in config:
2176+
config["EVPN_ETHERNET_SEGMENT"] = {}
2177+
config["EVPN_ETHERNET_SEGMENT"][pc_name] = {
2178+
"esi": "AUTO",
2179+
"esi_type": "TYPE_3_MAC_BASED",
2180+
"ifname": pc_name,
2181+
}
2182+
if "EVPN_MH_GLOBAL" not in config:
2183+
config["EVPN_MH_GLOBAL"] = {"default": {"startup_delay": "300"}}
2184+
21372185
# Add PORTCHANNEL_INTERFACE configuration to enable IPv6 link-local
21382186
config["PORTCHANNEL_INTERFACE"][pc_name] = {
21392187
"ipv6_use_link_local_only": "enable"

osism/tasks/conductor/sonic/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,20 @@
88
# Tag to enable EVPN Multihoming (evpn-lag mode) on a port channel
99
EVPN_LAG_TAG = "evpn-lag"
1010

11+
# Tag to enable L2 VxLAN (EVPN L2 VNI) for a VLAN — VNI equals VLAN ID
12+
EVPN_L2VNI_TAG = "evpn-l2vni"
13+
1114
# Default AS prefix for local ASN calculation
1215
DEFAULT_LOCAL_AS_PREFIX = 4200
1316

17+
# Default Base MAC for EVPN PortChannels (Calculated with each PortChannel Index)
18+
# using a locally administered MAC adress range
19+
DEFAULT_EVPN_SYSTEM_MAC = "02:00:00:00:00:00"
20+
21+
# Default MAC for Static Anycast Gateway (L2 Anycast Gateway)
22+
# using a locally administered MAC adress range
23+
DEFAULT_SAG_MAC = "02:00:10:00:00:00"
24+
1425
# Default SONiC device roles
1526
DEFAULT_SONIC_ROLES = [
1627
"accessleaf",

0 commit comments

Comments
 (0)