From 38599bf81b37d0a3dd910b7b98486b02e6f68af1 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Fri, 24 Jul 2026 22:07:29 +0200 Subject: [PATCH] sonic: Generate the entries of the default VRF VRF, BGP_GLOBALS, BGP_GLOBALS_AF, BGP_GLOBALS_ROUTE_ADVERTISE and ROUTE_REDISTRIBUTE are owned tables, so their base content is dropped up front and rebuilt from NetBox data and hardcoded policy. The default-VRF entries, however, only ever shipped in the image-provided config_db.json and were never regenerated: the generator writes these tables only for the VRFs NetBox carries, and it replaced BGP_GLOBALS|default wholesale with router_id and local_asn. As a result every regen left BGP_GLOBALS|default without its global BGP attributes and dropped VRF|default as well as the connected-route redistribution of the default VRF; on switches without VNI VRFs BGP_GLOBALS_AF and BGP_GLOBALS_ROUTE_ADVERTISE came back empty. The default VRF then stopped advertising its IPv4/IPv6 unicast routes into L2VPN EVPN, so traffic between subnets attached to different leaves was dropped. VRFs with a VNI inherited the attribute loss as well, because their BGP_GLOBALS entry is copied from the default one. Emit the default-VRF entries from the new _add_default_vrf_configuration as hardcoded SONiC policy, with the values the base config used to carry, and drop them from files/sonic/config_db.json. router_id and local_asn are merged into the generated entry instead of replacing it. The policy lives in read-only module constants that are copied per device, so it cannot be rewritten process-wide by an in-place edit. All five tables are co-owned by _add_default_vrf_configuration and _add_vrf_configuration from now on, so they are listed in MULTI_OWNER_OWNED_TABLE_KEYS to keep both writers on the per-key merge pattern. Closes #2515 Assisted-by: Claude:claude-opus-5 Signed-off-by: Christian Berendt --- files/sonic/config_db.json | 50 +---- .../tasks/conductor/sonic/config_generator.py | 199 ++++++++++++++---- .../test_config_generator_orchestrator.py | 171 ++++++++++++++- 3 files changed, 334 insertions(+), 86 deletions(-) diff --git a/files/sonic/config_db.json b/files/sonic/config_db.json index 1474af69..50e12d1d 100644 --- a/files/sonic/config_db.json +++ b/files/sonic/config_db.json @@ -1,41 +1,8 @@ { - "BGP_GLOBALS": { - "default": { - "always_compare_med": "true", - "ebgp_requires_policy": "false", - "external_compare_router_id": "false", - "fast_external_failover": "true", - "holdtime": "180", - "ignore_as_path_length": "false", - "keepalive": "60", - "load_balance_mp_relax": "false", - "log_nbr_state_changes": "true", - "network_import_check": "true" - } - }, - "BGP_GLOBALS_AF": { - "default|ipv4_unicast": { - "ibgp_equal_cluster_length": "false", - "max_ebgp_paths": "2", - "max_ibgp_paths": "2", - "route_flap_dampen": "false" - }, - "default|ipv6_unicast": { - "ibgp_equal_cluster_length": "false", - "max_ebgp_paths": "2", - "max_ibgp_paths": "2" - }, - "default|l2vpn_evpn": { - "advertise-all-vni": "true", - "advertise-svi-ip": "true", - "dad-enabled": "true" - } - }, + "BGP_GLOBALS": {}, + "BGP_GLOBALS_AF": {}, "BGP_GLOBALS_AF_NETWORK": {}, - "BGP_GLOBALS_ROUTE_ADVERTISE": { - "default|L2VPN_EVPN|IPV4_UNICAST": {}, - "default|L2VPN_EVPN|IPV6_UNICAST": {} - }, + "BGP_GLOBALS_ROUTE_ADVERTISE": {}, "BGP_NEIGHBOR": {}, "BGP_NEIGHBOR_AF": {}, "BREAKOUT_CFG": {}, @@ -409,10 +376,7 @@ "PORTCHANNEL": {}, "PORTCHANNEL_INTERFACE": {}, "PORTCHANNEL_MEMBER": {}, - "ROUTE_REDISTRIBUTE": { - "default|connected|bgp|ipv4": {}, - "default|connected|bgp|ipv6": {} - }, + "ROUTE_REDISTRIBUTE": {}, "SNMP_SERVER": { "SYSTEM": {} }, @@ -466,11 +430,7 @@ "VLAN": {}, "VLAN_INTERFACE": {}, "VLAN_MEMBER": {}, - "VRF": { - "default": { - "enabled": "true" - } - }, + "VRF": {}, "VXLAN_EVPN_NVO": {}, "VXLAN_TUNNEL": {}, "VXLAN_TUNNEL_MAP": {}, diff --git a/osism/tasks/conductor/sonic/config_generator.py b/osism/tasks/conductor/sonic/config_generator.py index bc2d92db..adf2a731 100644 --- a/osism/tasks/conductor/sonic/config_generator.py +++ b/osism/tasks/conductor/sonic/config_generator.py @@ -7,6 +7,7 @@ import json import os import re +from types import MappingProxyType from typing import Optional from loguru import logger @@ -156,9 +157,99 @@ # helpers (SSH, SNMP, gNMI). They are listed here ahead of those helpers so the # per-key pattern is enforced from the first one that lands; they join # ON_DEMAND_OWNED_TABLE_KEYS in the same change (so the central drop clears them -# before any helper merges). The per-key rule is enforced by +# before any helper merges). The VRF, BGP_GLOBALS* and ROUTE_REDISTRIBUTE +# tables are co-owned by _add_default_vrf_configuration (default-VRF entries, +# see DEFAULT_VRF_* below) and _add_vrf_configuration (per-VRF entries). The +# per-key rule is enforced by # test_config_generator_ownership.py::TestMultiOwnerTableGuard. -MULTI_OWNER_OWNED_TABLE_KEYS = ("ACL_TABLE", "ACL_RULE") +MULTI_OWNER_OWNED_TABLE_KEYS = ( + "ACL_TABLE", + "ACL_RULE", + "VRF", + "BGP_GLOBALS", + "BGP_GLOBALS_AF", + "BGP_GLOBALS_ROUTE_ADVERTISE", + "ROUTE_REDISTRIBUTE", +) + +# Configuration of the default VRF, emitted by _add_default_vrf_configuration +# on every regen. +# +# These entries used to ship in the image-provided base config_db.json, with +# the generator adding only the VRF-specific ones. The tables holding them are +# owned, so their base content is dropped up front -- which silently removed +# the default-VRF entries on every regen and, with them, the default VRF's +# EVPN route advertisement. The generator therefore owns these entries too; +# the values are the ones the base config used to carry. +# +# The mappings are read-only views: they are module-level state shared by every +# device, so an in-place edit would rewrite the policy for the whole process. +# Callers copy them into the config with dict(), which both detaches the entry +# and unwraps the proxy -- a mappingproxy in the config would not survive JSON +# serialization. A shallow copy suffices because the values are strings. +DEFAULT_VRF_VRF = MappingProxyType({"enabled": "true"}) + +DEFAULT_VRF_BGP_GLOBALS = MappingProxyType( + { + "always_compare_med": "true", + "ebgp_requires_policy": "false", + "external_compare_router_id": "false", + "fast_external_failover": "true", + "holdtime": "180", + "ignore_as_path_length": "false", + "keepalive": "60", + "load_balance_mp_relax": "false", + "log_nbr_state_changes": "true", + "network_import_check": "true", + } +) + +# Address families of the default VRF, keyed by the afi-safi part of the +# BGP_GLOBALS_AF key ("default|"). +DEFAULT_VRF_BGP_GLOBALS_AF = MappingProxyType( + { + "ipv4_unicast": MappingProxyType( + { + "ibgp_equal_cluster_length": "false", + "max_ebgp_paths": "2", + "max_ibgp_paths": "2", + "route_flap_dampen": "false", + } + ), + "ipv6_unicast": MappingProxyType( + { + "ibgp_equal_cluster_length": "false", + "max_ebgp_paths": "2", + "max_ibgp_paths": "2", + } + ), + "l2vpn_evpn": MappingProxyType( + { + "advertise-all-vni": "true", + "advertise-svi-ip": "true", + "dad-enabled": "true", + } + ), + } +) + +# Route advertisements of the default VRF, keyed by the part of the +# BGP_GLOBALS_ROUTE_ADVERTISE key that follows "default|". Advertising the +# IPv4/IPv6 unicast routes of the default VRF into L2VPN EVPN is what makes +# subnets attached to one leaf reachable from another. +DEFAULT_VRF_BGP_GLOBALS_ROUTE_ADVERTISE = ( + "L2VPN_EVPN|IPV4_UNICAST", + "L2VPN_EVPN|IPV6_UNICAST", +) + +# Route redistribution of the default VRF, keyed by the part of the +# ROUTE_REDISTRIBUTE key that follows "default|". Without these, the routes of +# the networks directly attached to the switch are no longer redistributed into +# BGP for the default VRF. +DEFAULT_VRF_ROUTE_REDISTRIBUTE = ( + "connected|bgp|ipv4", + "connected|bgp|ipv6", +) def natural_sort_key(port_name): @@ -342,38 +433,8 @@ def generate_sonic_config(device, hwsku, device_as_mapping=None, config_version= } ) - # Add BGP_GLOBALS configuration with router_id set to primary IP address - primary_ip = None - if device.primary_ip4: - primary_ip = str(device.primary_ip4.address).split("/")[0] - elif device.primary_ip6: - primary_ip = str(device.primary_ip6.address).split("/")[0] - - if primary_ip: - # BGP_GLOBALS is a generated section fully owned by this generator, so - # replace the default VRF entry wholesale rather than merging into a - # pre-existing one — pre-existing fields from config_db.json must not - # survive regen (see the ownership model in the docstring). - config["BGP_GLOBALS"]["default"] = {"router_id": primary_ip} - - # Calculate and add local_asn from router_id (only for IPv4) - if device.primary_ip4: - try: - # Check if device is in a spine/superspine group with pre-calculated AS - if device_as_mapping and device.id in device_as_mapping: - local_asn = device_as_mapping[device.id] - logger.debug( - f"Using group-calculated AS {local_asn} for spine/superspine device {device.name}" - ) - else: - # Use normal AS calculation for leaf switches and non-grouped devices - local_asn = calculate_local_asn_from_ipv4(primary_ip) - - config["BGP_GLOBALS"]["default"]["local_asn"] = str(local_asn) - except ValueError as e: - logger.warning( - f"Could not calculate local ASN for device {device.name}: {e}" - ) + # Add the configuration of the default VRF + _add_default_vrf_configuration(config, device, device_as_mapping) # Add port configurations _add_port_configurations( @@ -2070,6 +2131,70 @@ def _get_vrf_info(device): return vrf_info +def _add_default_vrf_configuration(config, device, device_as_mapping=None): + """Add the configuration of the default VRF to config. + + The tables written here are owned, so their base content is dropped before + this runs and the default VRF has to be regenerated from policy — + _add_vrf_configuration only writes entries for the VRFs NetBox carries, so + without this the default VRF loses its BGP attributes, stops advertising + its routes into EVPN and stops redistributing the routes of its directly + attached networks into BGP. + + Each entry is assigned wholesale, so pre-existing fields from + config_db.json do not survive regen (see the ownership model on + generate_sonic_config); the tables themselves are merged per key, as + _add_vrf_configuration adds its per-VRF entries to them afterwards. + + Args: + config: Configuration dictionary to update + device: NetBox device object + device_as_mapping: Dict mapping device IDs to pre-calculated AS numbers + for spine/superspine groups + """ + config["VRF"]["default"] = dict(DEFAULT_VRF_VRF) + config["BGP_GLOBALS"]["default"] = dict(DEFAULT_VRF_BGP_GLOBALS) + for af_name, af_attributes in DEFAULT_VRF_BGP_GLOBALS_AF.items(): + config["BGP_GLOBALS_AF"][f"default|{af_name}"] = dict(af_attributes) + for advertise_key in DEFAULT_VRF_BGP_GLOBALS_ROUTE_ADVERTISE: + config["BGP_GLOBALS_ROUTE_ADVERTISE"][f"default|{advertise_key}"] = {} + # ROUTE_REDISTRIBUTE is not part of the scaffold, so it may be missing here. + config.setdefault("ROUTE_REDISTRIBUTE", {}) + for redistribute_key in DEFAULT_VRF_ROUTE_REDISTRIBUTE: + config["ROUTE_REDISTRIBUTE"][f"default|{redistribute_key}"] = {} + + # Set router_id of the default VRF to the primary IP address + primary_ip = None + if device.primary_ip4: + primary_ip = str(device.primary_ip4.address).split("/")[0] + elif device.primary_ip6: + primary_ip = str(device.primary_ip6.address).split("/")[0] + + if not primary_ip: + return + + config["BGP_GLOBALS"]["default"]["router_id"] = primary_ip + + # Calculate and add local_asn from router_id (only for IPv4) + if device.primary_ip4: + try: + # Check if device is in a spine/superspine group with pre-calculated AS + if device_as_mapping and device.id in device_as_mapping: + local_asn = device_as_mapping[device.id] + logger.debug( + f"Using group-calculated AS {local_asn} for spine/superspine device {device.name}" + ) + else: + # Use normal AS calculation for leaf switches and non-grouped devices + local_asn = calculate_local_asn_from_ipv4(primary_ip) + + config["BGP_GLOBALS"]["default"]["local_asn"] = str(local_asn) + except ValueError as e: + logger.warning( + f"Could not calculate local ASN for device {device.name}: {e}" + ) + + def _add_vrf_configuration(config, vrf_info, netbox_interfaces): """Add VRF configuration to config. @@ -2140,9 +2265,9 @@ def _add_vrf_configuration(config, vrf_info, netbox_interfaces): config["BGP_GLOBALS_ROUTE_ADVERTISE"][ipv6_adv_key] = {} logger.info(f"Added BGP_GLOBALS_ROUTE_ADVERTISE for VRF {vrf_name}") - # Add ROUTE_REDISTRIBUTE for VRF - if "ROUTE_REDISTRIBUTE" not in config: - config["ROUTE_REDISTRIBUTE"] = {} + # Add ROUTE_REDISTRIBUTE for VRF. The table is co-owned with the + # orchestrator (default VRF), so create it without rebinding it. + config.setdefault("ROUTE_REDISTRIBUTE", {}) route_redistribute_key = f"{vrf_name}|connected|bgp|ipv4" config["ROUTE_REDISTRIBUTE"][route_redistribute_key] = {} logger.info(f"Added ROUTE_REDISTRIBUTE {route_redistribute_key}") diff --git a/tests/unit/tasks/conductor/sonic/test_config_generator_orchestrator.py b/tests/unit/tasks/conductor/sonic/test_config_generator_orchestrator.py index 97a6031b..c292fe28 100644 --- a/tests/unit/tasks/conductor/sonic/test_config_generator_orchestrator.py +++ b/tests/unit/tasks/conductor/sonic/test_config_generator_orchestrator.py @@ -9,12 +9,18 @@ isolation. """ +import json from types import SimpleNamespace import pytest from osism.tasks.conductor.sonic import config_generator from osism.tasks.conductor.sonic.config_generator import ( + DEFAULT_VRF_BGP_GLOBALS, + DEFAULT_VRF_BGP_GLOBALS_AF, + DEFAULT_VRF_BGP_GLOBALS_ROUTE_ADVERTISE, + DEFAULT_VRF_ROUTE_REDISTRIBUTE, + DEFAULT_VRF_VRF, OWNED_TABLE_KEYS, TOP_LEVEL_SCAFFOLD_KEYS, _add_ctrlplane_acls, @@ -301,17 +307,20 @@ def test_generate_sonic_config_router_id_falls_back_to_primary_ip6( assert "local_asn" not in config["BGP_GLOBALS"].get("default", {}) -def test_generate_sonic_config_no_primary_ip_skips_bgp_globals_default( +def test_generate_sonic_config_no_primary_ip_omits_router_id_and_local_asn( mocker, patch_orchestrator_helpers, make_orchestrator_device ): """Edge case: a device without any primary IP (rare in production but - worth pinning) must not produce a partial ``BGP_GLOBALS["default"]``.""" + worth pinning) still gets the default-VRF BGP policy, just without the + NetBox-derived ``router_id``/``local_asn``.""" patch_base_config(mocker) device = make_orchestrator_device(primary_ip4=None, primary_ip6=None) config = generate_sonic_config(device, "HWSKU") - assert "default" not in config["BGP_GLOBALS"] + assert config["BGP_GLOBALS"]["default"] == DEFAULT_VRF_BGP_GLOBALS + assert "router_id" not in config["BGP_GLOBALS"]["default"] + assert "local_asn" not in config["BGP_GLOBALS"]["default"] patch_orchestrator_helpers.calculate_local_asn_from_ipv4.assert_not_called() @@ -535,6 +544,152 @@ def test_generate_sonic_config_version_existing_in_base_preserved( assert config["VERSIONS"]["DATABASE"]["VERSION"] == "version_4_5_0" +# --------------------------------------------------------------------------- +# generate_sonic_config — default-VRF entries +# --------------------------------------------------------------------------- + + +def test_generate_sonic_config_emits_default_vrf_entries_without_vni_vrfs( + mocker, patch_orchestrator_helpers, make_orchestrator_device +): + """A device without VNI VRFs still gets the full default-VRF config. + + VRF, the BGP_GLOBALS* tables and ROUTE_REDISTRIBUTE are owned, so whatever + the image base config_db.json carries for the default VRF is dropped on + regen, and ``_add_vrf_configuration`` only writes entries for the VRFs + NetBox carries. ``_add_default_vrf_configuration`` therefore emits the + default-VRF entries — without them the default VRF stops advertising its + IPv4/IPv6 unicast routes into EVPN, silently blackholing traffic between + subnets on different leaves, and stops redistributing its connected routes + into BGP. + + The values are asserted literally rather than against the policy constants, + so a change to the constants shows up here as a behavior change. + """ + patch_base_config(mocker) + device = make_orchestrator_device(primary_ip4=_ip("10.0.0.1/32")) + + config = generate_sonic_config(device, "HWSKU") + + assert config["VRF"] == {"default": {"enabled": "true"}} + assert config["ROUTE_REDISTRIBUTE"] == { + "default|connected|bgp|ipv4": {}, + "default|connected|bgp|ipv6": {}, + } + assert config["BGP_GLOBALS"]["default"] == { + "always_compare_med": "true", + "ebgp_requires_policy": "false", + "external_compare_router_id": "false", + "fast_external_failover": "true", + "holdtime": "180", + "ignore_as_path_length": "false", + "keepalive": "60", + "load_balance_mp_relax": "false", + "log_nbr_state_changes": "true", + "network_import_check": "true", + "router_id": "10.0.0.1", + "local_asn": "4200000001", + } + assert config["BGP_GLOBALS_AF"] == { + "default|ipv4_unicast": { + "ibgp_equal_cluster_length": "false", + "max_ebgp_paths": "2", + "max_ibgp_paths": "2", + "route_flap_dampen": "false", + }, + "default|ipv6_unicast": { + "ibgp_equal_cluster_length": "false", + "max_ebgp_paths": "2", + "max_ibgp_paths": "2", + }, + "default|l2vpn_evpn": { + "advertise-all-vni": "true", + "advertise-svi-ip": "true", + "dad-enabled": "true", + }, + } + assert config["BGP_GLOBALS_ROUTE_ADVERTISE"] == { + "default|L2VPN_EVPN|IPV4_UNICAST": {}, + "default|L2VPN_EVPN|IPV6_UNICAST": {}, + } + + +def test_generate_sonic_config_default_vrf_policy_not_shared_across_devices( + mocker, patch_orchestrator_helpers, make_orchestrator_device +): + """The policy constants must be copied into each device's config. + + They are module-level state shared by every device, so emitting them by + reference would let one device's config (or a caller mutating the returned + dict) rewrite the policy for every device generated afterwards. The + constants are read-only views, so the copies must also be plain, mutable + dicts — the mutations below would raise on a leaked mappingproxy. + """ + patch_base_config(mocker) + first = generate_sonic_config( + make_orchestrator_device(primary_ip4=_ip("10.0.0.1/32")), "HWSKU" + ) + first["VRF"]["default"]["enabled"] = "false" + first["BGP_GLOBALS"]["default"]["holdtime"] = "9" + first["BGP_GLOBALS_AF"]["default|l2vpn_evpn"]["advertise-all-vni"] = "false" + + patch_base_config(mocker) + second = generate_sonic_config( + make_orchestrator_device(device_id=2, primary_ip4=_ip("10.0.0.2/32")), "HWSKU" + ) + + assert second["VRF"]["default"]["enabled"] == "true" + assert second["BGP_GLOBALS"]["default"]["holdtime"] == "180" + assert second["BGP_GLOBALS_AF"]["default|l2vpn_evpn"]["advertise-all-vni"] == "true" + assert DEFAULT_VRF_VRF["enabled"] == "true" + assert DEFAULT_VRF_BGP_GLOBALS["holdtime"] == "180" + assert DEFAULT_VRF_BGP_GLOBALS_AF["l2vpn_evpn"]["advertise-all-vni"] == "true" + + +def test_generate_sonic_config_default_vrf_entries_match_policy_constants( + mocker, patch_orchestrator_helpers, make_orchestrator_device +): + """Every policy entry reaches the config under the expected key. + + Complements the literal-value test above: that one pins the values, this + one pins that no entry is dropped when the constants grow. The generated + config must also stay JSON-serializable — the policy constants are + ``MappingProxyType`` views, which ``json.dumps`` rejects, so a copy that + only detached the outer mapping would surface here. + """ + patch_base_config(mocker) + device = make_orchestrator_device(primary_ip4=_ip("10.0.0.1/32")) + + config = generate_sonic_config(device, "HWSKU") + + assert config["VRF"]["default"] == DEFAULT_VRF_VRF + for af_name, af_attributes in DEFAULT_VRF_BGP_GLOBALS_AF.items(): + assert config["BGP_GLOBALS_AF"][f"default|{af_name}"] == af_attributes + for advertise_key in DEFAULT_VRF_BGP_GLOBALS_ROUTE_ADVERTISE: + assert config["BGP_GLOBALS_ROUTE_ADVERTISE"][f"default|{advertise_key}"] == {} + for redistribute_key in DEFAULT_VRF_ROUTE_REDISTRIBUTE: + assert config["ROUTE_REDISTRIBUTE"][f"default|{redistribute_key}"] == {} + assert DEFAULT_VRF_BGP_GLOBALS.items() <= config["BGP_GLOBALS"]["default"].items() + json.dumps(config) + + +def test_default_vrf_policy_constants_are_read_only(): + """The policy constants must reject in-place edits. + + They are shared by every device generated in the process, so an accidental + write anywhere in the codebase would silently change the policy for all of + them instead of failing at the offending line. + """ + with pytest.raises(TypeError): + DEFAULT_VRF_VRF["enabled"] = "false" + with pytest.raises(TypeError): + DEFAULT_VRF_BGP_GLOBALS["holdtime"] = "9" + with pytest.raises(TypeError): + DEFAULT_VRF_BGP_GLOBALS_AF["l2vpn_evpn"] = {} + with pytest.raises(TypeError): + DEFAULT_VRF_BGP_GLOBALS_AF["l2vpn_evpn"]["advertise-all-vni"] = "false" + + # --------------------------------------------------------------------------- # generate_sonic_config — ownership model: BGP_GLOBALS["default"] # --------------------------------------------------------------------------- @@ -559,12 +714,16 @@ def test_generate_sonic_config_bgp_globals_default_extra_fields_dropped_on_regen "local_asn": "4200000001", "custom_timer": "operator-value", # not produced by the generator } + base["BGP_GLOBALS_AF"]["default|ipv4_unicast"] = {"max_ebgp_paths": "64"} patch_base_config(mocker, base_config=base) device = make_orchestrator_device(primary_ip4=_ip("10.0.0.1/32")) config = generate_sonic_config(device, "HWSKU") assert "custom_timer" not in config["BGP_GLOBALS"]["default"] + # The same holds for the address families: the generated policy wins over + # whatever the base config carried for the default VRF. + assert config["BGP_GLOBALS_AF"]["default|ipv4_unicast"]["max_ebgp_paths"] == "2" def test_generate_sonic_config_stale_owned_entries_dropped_on_regen( @@ -595,7 +754,11 @@ def test_generate_sonic_config_stale_owned_entries_dropped_on_regen( # Scaffolded owned tables are emptied; the orchestrator rewrites only the # default VRF in BGP_GLOBALS. assert config["BGP_GLOBALS"] == { - "default": {"router_id": "10.0.0.1", "local_asn": "4200000001"} + "default": { + **DEFAULT_VRF_BGP_GLOBALS, + "router_id": "10.0.0.1", + "local_asn": "4200000001", + } } assert config["VLAN_MEMBER"] == {} assert config["VXLAN_TUNNEL_MAP"] == {}