From 2c393e4eb920c6bec1668992ed3d474f9a0f475a Mon Sep 17 00:00:00 2001 From: Claus Holbech Date: Fri, 12 Jan 2024 18:02:22 +0100 Subject: [PATCH] Feat(eos_designs): Preview - Generate CV Tags and metadata for WAN (#3487) --- .../intended/structured_configs/DC1-BL1A.yml | 2 + .../structured_configs/cv-pathfinder-edge.yml | 43 +++ .../cv-pathfinder-pathfinder.yml | 81 +++++ .../cv-pathfinder-pathfinder1.yml | 57 ++++ .../cv-pathfinder-pathfinder2.yml | 69 ++++ .../cv-pathfinder-transit.yml | 43 +++ .../group_vars/CV_PATHFINDER_TESTS.yml | 1 + .../eos_designs_shared_utils/wan.py | 208 +++++++++++- .../docs/tables/metadata.md | 94 ++++++ .../eos_cli_config_gen.jsonschema.json | 302 ++++++++++++++++++ .../schemas/eos_cli_config_gen.schema.yml | 129 ++++++++ .../schema_fragments/metadata.schema.yml | 129 ++++++++ .../avd/roles/eos_designs/docs/wan-preview.md | 22 ++ .../metadata/avdstructuredconfig.py | 26 +- .../python_modules/metadata/cv_pathfinder.py | 126 ++++++++ .../python_modules/metadata/cv_tags.py | 72 ++++- .../router_adaptive_virtual_topology.py | 10 +- .../python_modules/overlay/router_bgp.py | 4 +- .../overlay/router_path_selection.py | 4 +- .../python_modules/overlay/utils.py | 183 +---------- 20 files changed, 1395 insertions(+), 210 deletions(-) create mode 100644 ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_pathfinder.py diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/DC1-BL1A.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/DC1-BL1A.yml index c3ea942b090..694e69cd41f 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/DC1-BL1A.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/DC1-BL1A.yml @@ -629,6 +629,8 @@ metadata: value: EOS_DESIGNS_UNIT_TESTS - name: topology_hint_type value: leaf + - name: topology_hint_rack + value: DC1_BL1 sflow: vrfs: - name: OOB diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-edge.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-edge.yml index 87d15969a98..4d18040257c 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-edge.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-edge.yml @@ -210,3 +210,46 @@ vxlan_interface: vrfs: - name: default vni: 1 +metadata: + cv_tags: + device_tags: + - name: Role + value: edge + - name: Region + value: AVD_Land_East + - name: Zone + value: DEFAULT-ZONE + - name: Site + value: Site511 + interface_tags: + - interface: Ethernet1 + tags: + - name: Type + value: wan + - name: Carrier + value: ATT + - name: Circuit + value: '666' + - interface: Ethernet2 + tags: + - name: Type + value: wan + - name: Carrier + value: Colt + - name: Circuit + value: '10555' + cv_pathfinder: + role: edge + vtep_ip: 192.168.42.1 + region: AVD_Land_East + zone: DEFAULT-ZONE + site: Site511 + interfaces: + - name: Ethernet1 + carrier: ATT + pathgroup: INET + - name: Ethernet2 + carrier: Colt + pathgroup: MPLS + pathfinders: + - vtep_ip: 192.168.44.1 diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder.yml index 760db54b905..3967e3e4ce1 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder.yml @@ -204,3 +204,84 @@ vxlan_interface: vrfs: - name: default vni: 1 +metadata: + cv_tags: + device_tags: + - name: Role + value: pathfinder + - name: PathfinderSet + value: PATHFINDERS + interface_tags: + - interface: Ethernet1 + tags: + - name: Type + value: wan + - name: Carrier + value: Bouygues_Telecom + - name: Circuit + value: '777' + - interface: Ethernet2 + tags: + - name: Type + value: wan + - name: Carrier + value: Colt + - name: Circuit + value: '10000' + - interface: Ethernet3 + tags: + - name: Type + value: wan + - name: Carrier + value: Another-ISP + - name: Circuit + value: '999' + cv_pathfinder: + role: pathfinder + vtep_ip: 192.168.44.1 + interfaces: + - name: Ethernet1 + carrier: Bouygues_Telecom + pathgroup: INET + public_ip: 10.7.7.7 + - name: Ethernet2 + carrier: Colt + pathgroup: MPLS + public_ip: 172.16.0.1 + - name: Ethernet3 + carrier: Another-ISP + pathgroup: INET + public_ip: 10.9.9.9 + pathgroups: + - name: MPLS + carriers: + - name: Colt + - name: ATT-MPLS + - name: INET + carriers: + - name: Comcast + - name: ATT + - name: Bouygues_Telecom + - name: SFR + - name: Orange + - name: Another-ISP + - name: LTE + regions: + - name: AVD_Land_West + id: 42 + zones: + - name: DEFAULT-ZONE + id: 1 + sites: + - name: Site422 + id: 422 + location: + address: Somewhere + - name: AVD_Land_East + id: 43 + zones: + - name: DEFAULT-ZONE + id: 1 + sites: + - name: Site511 + id: 511 diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder1.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder1.yml index 06b6b0b9de7..91363cf08e0 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder1.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder1.yml @@ -214,3 +214,60 @@ vxlan_interface: vrfs: - name: default vni: 1 +metadata: + cv_tags: + device_tags: + - name: Role + value: pathfinder + - name: PathfinderSet + value: PATHFINDERS + interface_tags: + - interface: Ethernet1 + tags: + - name: Type + value: wan + - name: Carrier + value: Orange + - name: Circuit + value: '888' + cv_pathfinder: + role: pathfinder + vtep_ip: 192.168.44.2 + interfaces: + - name: Ethernet1 + carrier: Orange + pathgroup: INET + public_ip: 10.8.8.8 + pathgroups: + - name: MPLS + carriers: + - name: Colt + - name: ATT-MPLS + - name: INET + carriers: + - name: Comcast + - name: ATT + - name: Bouygues_Telecom + - name: SFR + - name: Orange + - name: Another-ISP + - name: LTE + regions: + - name: AVD_Land_West + id: 42 + zones: + - name: DEFAULT-ZONE + id: 1 + sites: + - name: Site422 + id: 422 + location: + address: Somewhere + - name: AVD_Land_East + id: 43 + zones: + - name: DEFAULT-ZONE + id: 1 + sites: + - name: Site511 + id: 511 diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder2.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder2.yml index 1eee5cd3489..af94d773154 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder2.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-pathfinder2.yml @@ -232,3 +232,72 @@ vxlan_interface: vrfs: - name: default vni: 1 +metadata: + cv_tags: + device_tags: + - name: Role + value: pathfinder + - name: PathfinderSet + value: PATHFINDERS + interface_tags: + - interface: Ethernet1 + tags: + - name: Type + value: wan + - name: Carrier + value: SFR + - name: Circuit + value: '999' + - interface: Ethernet2 + tags: + - name: Type + value: wan + - name: Carrier + value: ATT-MPLS + - name: Circuit + value: '10999' + cv_pathfinder: + role: pathfinder + vtep_ip: 192.168.44.3 + interfaces: + - name: Ethernet1 + carrier: SFR + pathgroup: INET + public_ip: 10.9.9.9 + - name: Ethernet2 + carrier: ATT-MPLS + pathgroup: MPLS + public_ip: 172.19.9.9 + pathgroups: + - name: MPLS + carriers: + - name: Colt + - name: ATT-MPLS + - name: INET + carriers: + - name: Comcast + - name: ATT + - name: Bouygues_Telecom + - name: SFR + - name: Orange + - name: Another-ISP + - name: LTE + regions: + - name: AVD_Land_West + id: 42 + zones: + - name: DEFAULT-ZONE + id: 1 + sites: + - name: Site422 + id: 422 + location: + address: Somewhere + - name: AVD_Land_East + id: 43 + zones: + - name: DEFAULT-ZONE + id: 1 + sites: + - name: Site511 + id: 511 diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-transit.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-transit.yml index 28215c556ce..de58006329e 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-transit.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/intended/structured_configs/cv-pathfinder-transit.yml @@ -220,3 +220,46 @@ vxlan_interface: vrfs: - name: default vni: 1 +metadata: + cv_tags: + device_tags: + - name: Role + value: transit region + - name: Region + value: AVD_Land_West + - name: Zone + value: DEFAULT-ZONE + - name: Site + value: Site422 + interface_tags: + - interface: Ethernet1 + tags: + - name: Type + value: wan + - name: Carrier + value: Comcast + - name: Circuit + value: '667' + - interface: Ethernet2 + tags: + - name: Type + value: wan + - name: Carrier + value: Colt + - name: Circuit + value: '10666' + cv_pathfinder: + role: transit region + vtep_ip: 192.168.43.1 + region: AVD_Land_West + zone: DEFAULT-ZONE + site: Site422 + interfaces: + - name: Ethernet1 + carrier: Comcast + pathgroup: INET + - name: Ethernet2 + carrier: Colt + pathgroup: MPLS + pathfinders: + - vtep_ip: 192.168.44.1 diff --git a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/inventory/group_vars/CV_PATHFINDER_TESTS.yml b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/inventory/group_vars/CV_PATHFINDER_TESTS.yml index b5e131305ce..f6e410668e3 100644 --- a/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/inventory/group_vars/CV_PATHFINDER_TESTS.yml +++ b/ansible_collections/arista/avd/molecule/eos_designs_unit_tests/inventory/group_vars/CV_PATHFINDER_TESTS.yml @@ -11,6 +11,7 @@ cv_pathfinder_regions: sites: - name: Site422 id: 422 + location: Somewhere - name: AVD_Land_East id: 43 description: AVD Region diff --git a/ansible_collections/arista/avd/plugins/plugin_utils/eos_designs_shared_utils/wan.py b/ansible_collections/arista/avd/plugins/plugin_utils/eos_designs_shared_utils/wan.py index 5b11eed8c29..2c677885d3f 100644 --- a/ansible_collections/arista/avd/plugins/plugin_utils/eos_designs_shared_utils/wan.py +++ b/ansible_collections/arista/avd/plugins/plugin_utils/eos_designs_shared_utils/wan.py @@ -6,7 +6,9 @@ from functools import cached_property from typing import TYPE_CHECKING -from ansible_collections.arista.avd.plugins.plugin_utils.errors import AristaAvdError +from ansible_collections.arista.avd.plugins.filter.natural_sort import natural_sort +from ansible_collections.arista.avd.plugins.plugin_utils.errors import AristaAvdError, AristaAvdMissingVariableError +from ansible_collections.arista.avd.plugins.plugin_utils.strip_empties import strip_empties_from_dict from ansible_collections.arista.avd.plugins.plugin_utils.utils import get, get_item if TYPE_CHECKING: @@ -70,24 +72,27 @@ def wan_interfaces(self: SharedUtils) -> list: return wan_interfaces + @cached_property + def wan_carriers(self: SharedUtils) -> list: + return get(self.hostvars, "wan_carriers", required=True) + @cached_property def wan_local_carriers(self: SharedUtils) -> list: """ List of carriers present on this router based on the wan_interfaces with the associated WAN interfaces interfaces: - name: ... - ip: ... + ip: ... (for route-servers the IP may come from wan_route_servers) """ if not self.wan_role: return [] local_carriers_dict = {} - global_carriers = get(self.hostvars, "wan_carriers", required=True) for interface in self.wan_interfaces: interface_carrier = interface["wan_carrier"] if interface_carrier not in local_carriers_dict: local_carriers_dict[interface_carrier] = get_item( - global_carriers, + self.wan_carriers, "name", interface["wan_carrier"], required=True, @@ -98,13 +103,17 @@ def wan_local_carriers(self: SharedUtils) -> list: local_carriers_dict[interface_carrier]["interfaces"].append( { "name": get(interface, "name", required=True), - "ip_address": get(interface, "ip", required=True), + "ip_address": self.get_public_ip_for_wan_interface(interface), "connected_to_pathfinder": get(interface, "connected_to_pathfinder", default=True), } ) return list(local_carriers_dict.values()) + @cached_property + def wan_path_groups(self: SharedUtils) -> list: + return get(self.hostvars, "wan_path_groups", required=True) + @cached_property def wan_local_path_groups(self: SharedUtils) -> list: """ @@ -118,12 +127,12 @@ def wan_local_path_groups(self: SharedUtils) -> list: return [] local_path_groups_dict = {} - global_path_groups = get(self.hostvars, "wan_path_groups", required=True) + for carrier in self.wan_local_carriers: path_group_name = get(carrier, "path_group", required=True) if path_group_name not in local_path_groups_dict: local_path_groups_dict[path_group_name] = get_item( - global_path_groups, + self.wan_path_groups, "name", path_group_name, required=True, @@ -136,3 +145,188 @@ def wan_local_path_groups(self: SharedUtils) -> list: local_path_groups_dict[path_group_name]["interfaces"].extend(carrier["interfaces"]) return list(local_path_groups_dict.values()) + + @cached_property + def this_wan_route_server(self: SharedUtils) -> dict: + """ + Returns the instance for this wan_rs found under wan_route_servers. + Should only be called when the device is actually a wan_rs. + """ + wan_route_servers = get(self.hostvars, "wan_route_servers", default=[]) + return get_item(wan_route_servers, "hostname", self.hostname, default={}) + + def get_public_ip_for_wan_interface(self: SharedUtils, interface: dict) -> str: + """ + Takes a dict which looks like `l3_interface` from node config + + If not a WAN route-server this just returns the interface IP. + + For WAN route-servers we try to find the IP under wan_route_servers.path_groups.interfaces. + If not found we use the IP under the interface, unless it is "dhcp" where we raise. + """ + if self.wan_role != "server": + return interface["ip"] + + for path_group in self.this_wan_route_server.get("path_groups", []): + if (found_interface := get_item(path_group["interfaces"], "name", interface["name"])) is None: + continue + + if found_interface.get("ip_address") is not None: + return found_interface["ip_address"] + + if interface["ip"] == "dhcp": + raise AristaAvdError( + f"The IP address for WAN interface '{interface['name']}' on Route Server '{self.hostname}' is set 'dhcp'." + "Clients need to peer with a static IP which must be set under the 'wan_route_servers.path_groups.interfaces' key." + ) + + return interface["ip"] + + @cached_property + def wan_site(self: SharedUtils) -> dict: + """ + WAN site for CV Pathfinder + """ + node_defined_site = get( + self.switch_data_combined, + "cv_pathfinder_site", + required=True, + org_key="A node variable 'cv_pathfinder_site' must be defined when 'cv_pathfinder_role' is 'edge' or 'transit'.", + ) + sites = get(self.wan_region, "sites", required=True, org_key=f"The CV Pathfinder region '{self.wan_region['name']}' is missing a list of sites") + return get_item( + sites, + "name", + node_defined_site, + required=True, + custom_error_msg=( + f"The 'cv_pathfinder_site '{node_defined_site}' defined at the node level could not be found under the 'sites' list for the region" + f" '{self.wan_region['name']}'." + ), + ) + + @cached_property + def wan_region(self: SharedUtils) -> dict: + """ + WAN region for CV Pathfinder + + Also checking if site names are unique across all regions. + """ + node_defined_region = get( + self.switch_data_combined, + "cv_pathfinder_region", + required=True, + org_key="A node variable 'cv_pathfinder_region' must be defined when 'cv_pathfinder_role' is 'edge' or 'transit'.", + ) + regions = get( + self.hostvars, "cv_pathfinder_regions", required=True, org_key="'cv_pathfinder_regions' key must be set when 'wan_mode' is 'cv-pathfinder'." + ) + + # Verify that site names are unique across all regions. + site_names = [site["name"] for region in regions for site in region["sites"]] + if len(site_names) != len(set(site_names)): + # We have some site names that are not unique + # Now find them (slow so no need to do if we don't have duplicates) + duplicate_site_names = [site_name for site_name in site_names if site_names.count(site_name) > 1] + raise AristaAvdError( + "WAN Site names must be unique across all regions. " + f"Found the following duplicate site name(s) under 'cv_pathfinder_regions.[].sites. {duplicate_site_names}" + ) + + return get_item( + regions, + "name", + node_defined_region, + required=True, + custom_error_msg="The 'cv_pathfinder_region' defined at the node level could not be found under the 'cv_pathfinder_regions' key.", + ) + + @property + def wan_zone(self: SharedUtils) -> dict: + """ + WAN zone for Pathfinder + + Currently, only default zone DEFAULT-ZONE with ID 1 is supported. + """ + # Injecting zone DEFAULT-ZONE with id 1. + return {"name": "DEFAULT-ZONE", "id": 1} + + @cached_property + def filtered_wan_route_servers(self: SharedUtils) -> dict: + """ + Return a dict keyed by Wan RR based on the the wan_mode type with only the path_groups the router should connect to. + + It the RR is part of the inventory, the peer_facts are read.. + If any key is specified in the variables, it overwrites whatever is in the peer_facts. + + If no peer_fact is found the variables are required in the inventory. + """ + wan_route_servers = {} + + wan_route_servers_list = get(self.hostvars, "wan_route_servers", default=[]) + + for wan_rs_dict in natural_sort(wan_route_servers_list, sort_key="hostname"): + # These remote gw can be outside of the inventory + wan_rs = wan_rs_dict["hostname"] + + if wan_rs == self.hostname: + # Don't add yourself + continue + + if (peer_facts := self.get_peer_facts(wan_rs, required=False)) is not None: + # Found a matching server in inventory + bgp_as = peer_facts.get("bgp_as") + # Only ibgp is supported for WAN so raise if peer from peer_facts BGP AS is different from ours. + if bgp_as != self.bgp_as: + raise AristaAvdError(f"Only iBGP is supported for WAN, the BGP AS {bgp_as} on {wan_rs} is different from our own: {self.bgp_as}.") + + # Prefer values coming from the input variables over peer facts + router_id = get(wan_rs_dict, "router_id", default=peer_facts.get("router_id")) + wan_path_groups = get(wan_rs_dict, "path_groups", default=peer_facts.get("wan_path_groups")) + + if router_id is None: + raise AristaAvdMissingVariableError( + f"'router_id' is missing for peering with {wan_rs}, either set it in under 'wan_route_servers' or something is wrong with the peer" + " facts." + ) + if wan_path_groups is None: + raise AristaAvdMissingVariableError( + f"'wan_path_groups' is missing for peering with {wan_rs}, either set it in under 'wan_route_servers'" + " or something is wrong with the peer facts." + ) + + else: + # Retrieve the values from the dictionary, making them required if the peer_facts were not found + router_id = get(wan_rs_dict, "router_id", required=True) + wan_path_groups = get( + wan_rs_dict, + "path_groups", + required=True, + org_key=( + f"'path_groups' is missing for peering with {wan_rs} which was not found in the inventory, either set it in under 'wan_route_servers'" + " or check your inventory." + ), + ) + + # Filtering wan_path_groups to only take the ones this device uses to connect to pathfinders. + wan_rs_result_dict = { + "router_id": router_id, + "wan_path_groups": [path_group for path_group in wan_path_groups if self.should_connect_to_wan_rs([path_group["name"]])], + } + + wan_route_servers[wan_rs] = strip_empties_from_dict(wan_rs_result_dict) + + return wan_route_servers + + def should_connect_to_wan_rs(self: SharedUtils, path_groups: list) -> bool: + """ + This helper implements whether or not a connection to the wan_router_server should be made or not based on a list of path-groups. + + To do this the logic is the following: + * Look at the wan_interfaces on the router and check if there is any path-group in common with the RR where + `connected_to_pathfinder` is not False. + """ + return any( + local_path_group["name"] in path_groups and any(wan_interface["connected_to_pathfinder"] for wan_interface in local_path_group["interfaces"]) + for local_path_group in self.wan_local_path_groups + ) diff --git a/ansible_collections/arista/avd/roles/eos_cli_config_gen/docs/tables/metadata.md b/ansible_collections/arista/avd/roles/eos_cli_config_gen/docs/tables/metadata.md index e8ce449ef24..b9130ead69a 100644 --- a/ansible_collections/arista/avd/roles/eos_cli_config_gen/docs/tables/metadata.md +++ b/ansible_collections/arista/avd/roles/eos_cli_config_gen/docs/tables/metadata.md @@ -18,6 +18,52 @@ | [        tags](## "metadata.cv_tags.interface_tags.[].tags") | List, items: Dictionary | | | | | | [          - name](## "metadata.cv_tags.interface_tags.[].tags.[].name") | String | Required | | | | | [            value](## "metadata.cv_tags.interface_tags.[].tags.[].value") | String | Required | | | | + | [  cv_pathfinder](## "metadata.cv_pathfinder") | Dictionary | | | | Metadata used for CV Pathfinder visualization on CloudVision | + | [    role](## "metadata.cv_pathfinder.role") | String | | | | | + | [    region](## "metadata.cv_pathfinder.region") | String | | | | | + | [    zone](## "metadata.cv_pathfinder.zone") | String | | | | | + | [    site](## "metadata.cv_pathfinder.site") | String | | | | | + | [    vtep_ip](## "metadata.cv_pathfinder.vtep_ip") | String | | | | | + | [    ssl_profile](## "metadata.cv_pathfinder.ssl_profile") | String | | | | | + | [    pathfinders](## "metadata.cv_pathfinder.pathfinders") | List, items: Dictionary | | | | | + | [      - vtep_ip](## "metadata.cv_pathfinder.pathfinders.[].vtep_ip") | String | | | | | + | [    interfaces](## "metadata.cv_pathfinder.interfaces") | List, items: Dictionary | | | | | + | [      - name](## "metadata.cv_pathfinder.interfaces.[].name") | String | | | | | + | [        carrier](## "metadata.cv_pathfinder.interfaces.[].carrier") | String | | | | | + | [        circuit_id](## "metadata.cv_pathfinder.interfaces.[].circuit_id") | String | | | | | + | [        pathgroup](## "metadata.cv_pathfinder.interfaces.[].pathgroup") | String | | | | | + | [        public_ip](## "metadata.cv_pathfinder.interfaces.[].public_ip") | String | | | | | + | [    pathgroups](## "metadata.cv_pathfinder.pathgroups") | List, items: Dictionary | | | | | + | [      - name](## "metadata.cv_pathfinder.pathgroups.[].name") | String | | | | | + | [        carriers](## "metadata.cv_pathfinder.pathgroups.[].carriers") | List, items: Dictionary | | | | | + | [          - name](## "metadata.cv_pathfinder.pathgroups.[].carriers.[].name") | String | | | | | + | [        imported_carriers](## "metadata.cv_pathfinder.pathgroups.[].imported_carriers") | List, items: Dictionary | | | | | + | [          - name](## "metadata.cv_pathfinder.pathgroups.[].imported_carriers.[].name") | String | | | | | + | [    regions](## "metadata.cv_pathfinder.regions") | List, items: Dictionary | | | | | + | [      - id](## "metadata.cv_pathfinder.regions.[].id") | Integer | | | | | + | [        name](## "metadata.cv_pathfinder.regions.[].name") | String | | | | | + | [        zones](## "metadata.cv_pathfinder.regions.[].zones") | List, items: Dictionary | | | | | + | [          - id](## "metadata.cv_pathfinder.regions.[].zones.[].id") | Integer | | | | | + | [            name](## "metadata.cv_pathfinder.regions.[].zones.[].name") | String | | | | | + | [            sites](## "metadata.cv_pathfinder.regions.[].zones.[].sites") | List, items: Dictionary | | | | | + | [              - id](## "metadata.cv_pathfinder.regions.[].zones.[].sites.[].id") | Integer | | | | | + | [                name](## "metadata.cv_pathfinder.regions.[].zones.[].sites.[].name") | String | | | | | + | [                location](## "metadata.cv_pathfinder.regions.[].zones.[].sites.[].location") | Dictionary | | | | | + | [                  address](## "metadata.cv_pathfinder.regions.[].zones.[].sites.[].location.address") | String | | | | | + | [    vrfs](## "metadata.cv_pathfinder.vrfs") | List, items: Dictionary | | | | | + | [      - name](## "metadata.cv_pathfinder.vrfs.[].name") | String | | | | | + | [        vni](## "metadata.cv_pathfinder.vrfs.[].vni") | Integer | | | | | + | [        avts](## "metadata.cv_pathfinder.vrfs.[].avts") | List, items: Dictionary | | | | | + | [          - constraints](## "metadata.cv_pathfinder.vrfs.[].avts.[].constraints") | Dictionary | | | | | + | [              jitter](## "metadata.cv_pathfinder.vrfs.[].avts.[].constraints.jitter") | Integer | | | | | + | [              latency](## "metadata.cv_pathfinder.vrfs.[].avts.[].constraints.latency") | Integer | | | | | + | [              lossrate](## "metadata.cv_pathfinder.vrfs.[].avts.[].constraints.lossrate") | String | | | | | + | [            description](## "metadata.cv_pathfinder.vrfs.[].avts.[].description") | String | | | | | + | [            id](## "metadata.cv_pathfinder.vrfs.[].avts.[].id") | Integer | | | | | + | [            name](## "metadata.cv_pathfinder.vrfs.[].avts.[].name") | String | | | | | + | [            pathgroups](## "metadata.cv_pathfinder.vrfs.[].avts.[].pathgroups") | List, items: Dictionary | | | | | + | [              - name](## "metadata.cv_pathfinder.vrfs.[].avts.[].pathgroups.[].name") | String | | | | | + | [                preference](## "metadata.cv_pathfinder.vrfs.[].avts.[].pathgroups.[].preference") | String | | | | | === "YAML" @@ -35,4 +81,52 @@ tags: - name: value: + + # Metadata used for CV Pathfinder visualization on CloudVision + cv_pathfinder: + role: + region: + zone: + site: + vtep_ip: + ssl_profile: + pathfinders: + - vtep_ip: + interfaces: + - name: + carrier: + circuit_id: + pathgroup: + public_ip: + pathgroups: + - name: + carriers: + - name: + imported_carriers: + - name: + regions: + - id: + name: + zones: + - id: + name: + sites: + - id: + name: + location: + address: + vrfs: + - name: + vni: + avts: + - constraints: + jitter: + latency: + lossrate: + description: + id: + name: + pathgroups: + - name: + preference: ``` diff --git a/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.jsonschema.json b/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.jsonschema.json index 69bc6478094..9674457a480 100644 --- a/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.jsonschema.json +++ b/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.jsonschema.json @@ -10284,6 +10284,308 @@ "^_.+$": {} }, "title": "Cv Tags" + }, + "cv_pathfinder": { + "type": "object", + "description": "Metadata used for CV Pathfinder visualization on CloudVision", + "properties": { + "role": { + "type": "string", + "title": "Role" + }, + "region": { + "type": "string", + "title": "Region" + }, + "zone": { + "type": "string", + "title": "Zone" + }, + "site": { + "type": "string", + "title": "Site" + }, + "vtep_ip": { + "type": "string", + "title": "Vtep IP" + }, + "ssl_profile": { + "type": "string", + "title": "SSL Profile" + }, + "pathfinders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "vtep_ip": { + "type": "string", + "title": "Vtep IP" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Pathfinders" + }, + "interfaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "carrier": { + "type": "string", + "title": "Carrier" + }, + "circuit_id": { + "type": "string", + "title": "Circuit ID" + }, + "pathgroup": { + "type": "string", + "title": "Pathgroup" + }, + "public_ip": { + "type": "string", + "title": "Public IP" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Interfaces" + }, + "pathgroups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "carriers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Carriers" + }, + "imported_carriers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Imported Carriers" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Pathgroups" + }, + "regions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "title": "ID" + }, + "name": { + "type": "string", + "title": "Name" + }, + "zones": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "title": "ID" + }, + "name": { + "type": "string", + "title": "Name" + }, + "sites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "title": "ID" + }, + "name": { + "type": "string", + "title": "Name" + }, + "location": { + "type": "object", + "properties": { + "address": { + "type": "string", + "title": "Address" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + }, + "title": "Location" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Sites" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Zones" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Regions" + }, + "vrfs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "vni": { + "type": "integer", + "title": "Vni" + }, + "avts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "constraints": { + "type": "object", + "properties": { + "jitter": { + "type": "integer", + "title": "Jitter" + }, + "latency": { + "type": "integer", + "title": "Latency" + }, + "lossrate": { + "type": "string", + "title": "Lossrate" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + }, + "title": "Constraints" + }, + "description": { + "type": "string", + "title": "Description" + }, + "id": { + "type": "integer", + "title": "ID" + }, + "name": { + "type": "string", + "title": "Name" + }, + "pathgroups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "preference": { + "type": "string", + "title": "Preference" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Pathgroups" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "Avts" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + } + }, + "title": "VRFs" + } + }, + "additionalProperties": false, + "patternProperties": { + "^_.+$": {} + }, + "title": "Cv Pathfinder" } }, "additionalProperties": false, diff --git a/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.schema.yml b/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.schema.yml index 4d2b1ce1ded..f34dac555ce 100644 --- a/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.schema.yml +++ b/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/eos_cli_config_gen.schema.yml @@ -6070,6 +6070,135 @@ keys: value: type: str required: true + cv_pathfinder: + type: dict + description: Metadata used for CV Pathfinder visualization on CloudVision + keys: + role: + type: str + region: + type: str + zone: + type: str + site: + type: str + vtep_ip: + type: str + ssl_profile: + type: str + pathfinders: + type: list + items: + type: dict + keys: + vtep_ip: + type: str + interfaces: + type: list + items: + type: dict + keys: + name: + type: str + carrier: + type: str + circuit_id: + type: str + pathgroup: + type: str + public_ip: + type: str + pathgroups: + type: list + items: + type: dict + keys: + name: + type: str + carriers: + type: list + items: + type: dict + keys: + name: + type: str + imported_carriers: + type: list + items: + type: dict + keys: + name: + type: str + regions: + type: list + items: + type: dict + keys: + id: + type: int + name: + type: str + zones: + type: list + items: + type: dict + keys: + id: + type: int + name: + type: str + sites: + type: list + items: + type: dict + keys: + id: + type: int + name: + type: str + location: + type: dict + keys: + address: + type: str + vrfs: + type: list + items: + type: dict + keys: + name: + type: str + vni: + type: int + avts: + type: list + items: + type: dict + keys: + constraints: + type: dict + keys: + jitter: + type: int + latency: + type: int + lossrate: + type: str + description: + type: str + id: + type: int + name: + type: str + pathgroups: + type: list + items: + type: dict + keys: + name: + type: str + preference: + type: str mlag_configuration: type: dict display_name: Multi-Chassis Link Aggregation (MLAG) Configuration diff --git a/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/schema_fragments/metadata.schema.yml b/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/schema_fragments/metadata.schema.yml index ff6c2c014aa..38fa8f8c49e 100644 --- a/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/schema_fragments/metadata.schema.yml +++ b/ansible_collections/arista/avd/roles/eos_cli_config_gen/schemas/schema_fragments/metadata.schema.yml @@ -47,3 +47,132 @@ keys: value: type: str required: true + cv_pathfinder: + type: dict + description: Metadata used for CV Pathfinder visualization on CloudVision + keys: + role: + type: str + region: + type: str + zone: + type: str + site: + type: str + vtep_ip: + type: str + ssl_profile: + type: str + pathfinders: + type: list + items: + type: dict + keys: + vtep_ip: + type: str + interfaces: + type: list + items: + type: dict + keys: + name: + type: str + carrier: + type: str + circuit_id: + type: str + pathgroup: + type: str + public_ip: + type: str + pathgroups: + type: list + items: + type: dict + keys: + name: + type: str + carriers: + type: list + items: + type: dict + keys: + name: + type: str + imported_carriers: + type: list + items: + type: dict + keys: + name: + type: str + regions: + type: list + items: + type: dict + keys: + id: + type: int + name: + type: str + zones: + type: list + items: + type: dict + keys: + id: + type: int + name: + type: str + sites: + type: list + items: + type: dict + keys: + id: + type: int + name: + type: str + location: + type: dict + keys: + address: + type: str + vrfs: + type: list + items: + type: dict + keys: + name: + type: str + vni: + type: int + avts: + type: list + items: + type: dict + keys: + constraints: + type: dict + keys: + jitter: + type: int + latency: + type: int + lossrate: + type: str + description: + type: str + id: + type: int + name: + type: str + pathgroups: + type: list + items: + type: dict + keys: + name: + type: str + preference: + type: str diff --git a/ansible_collections/arista/avd/roles/eos_designs/docs/wan-preview.md b/ansible_collections/arista/avd/roles/eos_designs/docs/wan-preview.md index 977b10e3daa..aeadd457d69 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/docs/wan-preview.md +++ b/ansible_collections/arista/avd/roles/eos_designs/docs/wan-preview.md @@ -143,3 +143,25 @@ roles/eos_designs/docs/tables/node-type-wan-configuration.md --8<-- roles/eos_designs/docs/tables/node-type-key-wan-configuration.md --8<-- + +### CloudVision Tags + +`arista.avd.eos_designs` will generate CloudVision Tags that assist CloudVision with visualizing the WAN. + +#### Device Tags + +| Tag Name | Source of information | +| -------- | --------------------- | +| `Region` | `cv_pathfinder_region` if `cv_pathfinder_role` is set but not `pathfinder` | +| `Zone` | `DEFAULT-ZONE` if `cv_pathfinder_role` is set but not `pathfinder` | +| `Site` | `cv_pathfinder_site` if `cv_pathfinder_role` is set but not `pathfinder` | +| `PathfinderSet` | name of `node_group` or default `PATHFINDERS` if `cv_pathfinder_role` is `pathfinder` | +| `Role` | `cv_pathfinder_role` if set | + +#### Interface Tags + +| Hint Tag Name | Source of information | +| ------------- | --------------------- | +| `Type` | `lan` or `wan` if `cv_pathfinder_role` is set | +| `Carrier` | `wan_carrier` if `cv_pathfinder_role` is set and this is a WAN interface | +| `Circuit` | `wan_circiot_id` if `cv_pathfinder_role` is set and this is a LAN interface | diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/avdstructuredconfig.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/avdstructuredconfig.py index 6d41d0358b4..2b69d42a263 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/avdstructuredconfig.py +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/avdstructuredconfig.py @@ -6,12 +6,13 @@ from functools import cached_property from ansible_collections.arista.avd.plugins.plugin_utils.avdfacts import AvdFacts -from ansible_collections.arista.avd.plugins.plugin_utils.strip_empties import strip_null_from_data +from ansible_collections.arista.avd.plugins.plugin_utils.strip_empties import strip_empties_from_dict +from .cv_pathfinder import CvPathfinderMixin from .cv_tags import CvTagsMixin -class AvdStructuredConfigMetadata(AvdFacts, CvTagsMixin): +class AvdStructuredConfigMetadata(AvdFacts, CvTagsMixin, CvPathfinderMixin): """ This returns the metadata data strucutre as per the below example { @@ -25,20 +26,26 @@ class AvdStructuredConfigMetadata(AvdFacts, CvTagsMixin): {"name": "topology_hint_pod", "value": }, {"name": "topolgoy_hint_rack", "value": }, {"name": "", "value": "custom tag value"}, - {"name": "", "value": ""} + {"name": "", "value": ""}, + {"name": "Region", "value": }, + {"name": "Zone", "value": }, + {"name": "Site", "value": }, + {"name": "PathfinderSet", "value": }, + {"name": "Role", "value": } }, "interface_tags": [ { "interface": "Ethernet1", "tags":[ - { - "name": "peer" - "value": "leaf1a" - } + {"name": "peer", "value": "leaf1a"} + {"name": "Type", <"lan" or "wan" if cv_pathfinder_role is set>}, + {"name": "Carrier", }, + {"name": "Circuit", } ] } ] - } + }, + "cv_pathfinder": {} } } """ @@ -48,5 +55,6 @@ def metadata(self) -> dict | None: metadata = { "platform": self.shared_utils.platform, "cv_tags": self._cv_tags(), + "cv_pathfinder": self._cv_pathfinder(), } - return strip_null_from_data(metadata) or None + return strip_empties_from_dict(metadata) or None diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_pathfinder.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_pathfinder.py new file mode 100644 index 00000000000..6fb900eb5cc --- /dev/null +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_pathfinder.py @@ -0,0 +1,126 @@ +# Copyright (c) 2023-2024 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ansible_collections.arista.avd.plugins.plugin_utils.utils import get + +if TYPE_CHECKING: + from .avdstructuredconfig import AvdStructuredConfigMetadata + + +class CvPathfinderMixin: + """ + Mixin Class used to generate structured config for one key. + Class should only be used as Mixin to a AvdStructuredConfig class + """ + + def _cv_pathfinder(self: AvdStructuredConfigMetadata) -> dict | None: + """ + Generate metadata for CV Pathfinder feature. + Only relevant if cv_pathfinder_role is not None + """ + if self.shared_utils.cv_pathfinder_role is None: + return None + + # Pathfinder + if self.shared_utils.cv_pathfinder_role == "pathfinder": + return { + "role": self.shared_utils.cv_pathfinder_role, + "ssl_profile": None, # TODO: Pick up ssl profile from self.shared_utils.this_wan_route_server.ssl_profile_name + "vtep_ip": self.shared_utils.router_id, + "interfaces": self._metadata_interfaces(), + "pathgroups": self._metadata_pathgroups(), + "regions": self._metadata_regions(), + "vrfs": self._metadata_vrfs(), + } + + # Edge or transit + return { + "role": self.shared_utils.cv_pathfinder_role, + "vtep_ip": self.shared_utils.router_id, + "region": self.shared_utils.wan_region["name"], + "zone": self.shared_utils.wan_zone["name"], + "site": self.shared_utils.wan_site["name"], + "interfaces": self._metadata_interfaces(), + "pathfinders": self._metadata_pathfinder_vtep_ips(), + } + + def _metadata_interfaces(self: AvdStructuredConfigMetadata) -> list: + return [ + { + "name": interface["name"], + "carrier": carrier["name"], + "circuit_id": interface.get("wan_circuit_id"), + "pathgroup": carrier["path_group"], + "public_ip": str(interface["ip_address"]).split("/", maxsplit=1)[0] if self.shared_utils.cv_pathfinder_role == "pathfinder" else None, + } + for carrier in self.shared_utils.wan_local_carriers + for interface in carrier["interfaces"] + ] + + def _metadata_pathgroups(self: AvdStructuredConfigMetadata) -> list: + return [ + { + "name": pathgroup["name"], + "carriers": [ + { + "name": carrier["name"], + } + for carrier in self.shared_utils.wan_carriers + if carrier["path_group"] == pathgroup["name"] + ], + "imported_carriers": [ + { + "name": carrier["name"], + } + for carrier in self.shared_utils.wan_carriers + if carrier["path_group"] in [imported_pathgroup["remote"] for imported_pathgroup in pathgroup.get("import_path_groups", [])] + ], + } + for pathgroup in self.shared_utils.wan_path_groups + ] + + def _metadata_regions(self: AvdStructuredConfigMetadata) -> list: + regions = get( + self._hostvars, "cv_pathfinder_regions", required=True, org_key="'cv_pathfinder_regions' key must be set when 'wan_mode' is 'cv-pathfinder'." + ) + return [ + { + "name": region["name"], + "id": region["id"], + "zones": [ + { + # TODO: Once we give configurable zones this should be updated + "name": self.shared_utils.wan_zone["name"], + "id": self.shared_utils.wan_zone["id"], + "sites": [ + { + "name": site["name"], + "id": site["id"], + "location": { + "address": site.get("location"), + } + if site.get("location") + else None, + } + for site in region["sites"] + ], + } + ], + } + for region in regions + ] + + def _metadata_vrfs(self: AvdStructuredConfigMetadata) -> list: + return [] # TODO + + def _metadata_pathfinder_vtep_ips(self: AvdStructuredConfigMetadata) -> list: + return [ + { + "vtep_ip": wan_route_server["router_id"], + } + for wan_route_server in self.shared_utils.filtered_wan_route_servers.values() + ] diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_tags.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_tags.py index e45abbbc1ae..a2983a9bfd8 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_tags.py +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/metadata/cv_tags.py @@ -8,7 +8,7 @@ from ansible_collections.arista.avd.plugins.plugin_utils.errors import AristaAvdError from ansible_collections.arista.avd.plugins.plugin_utils.strip_empties import strip_empties_from_dict, strip_empties_from_list -from ansible_collections.arista.avd.plugins.plugin_utils.utils import get +from ansible_collections.arista.avd.plugins.plugin_utils.utils import default, get, get_item if TYPE_CHECKING: from .avdstructuredconfig import AvdStructuredConfigMetadata @@ -36,7 +36,7 @@ "hostname", "terminattr", ] -"""These tag names overlap with CV system tags""" +"""These tag names overlap with CV system tags or topology_hints""" class CvTagsMixin: @@ -53,10 +53,11 @@ def _cv_tags(self: AvdStructuredConfigMetadata) -> dict | None: """ Generate the data structure `metadata.cv_tags`. """ - if not self._generate_cv_tags: + if not self._generate_cv_tags and not self.shared_utils.cv_pathfinder_role: return None device_tags = self._get_topology_hints() + device_tags.extend(self._get_cv_pathfinder_device_tags()) device_tags.extend(self._get_device_tags()) cv_tags = {"device_tags": device_tags, "interface_tags": self._get_interface_tags()} @@ -83,10 +84,40 @@ def _get_topology_hints(self: AvdStructuredConfigMetadata) -> list: self._tag_dict("topology_hint_fabric", self.shared_utils.fabric_name), self._tag_dict("topology_hint_pod", self.shared_utils.pod_name), self._tag_dict("topology_hint_type", get(self._hostvars, "cv_tags_topology_type", default=default_type_hint)), - self._tag_dict("topology_hint_rack", self.shared_utils.rack), + self._tag_dict("topology_hint_rack", default(self.shared_utils.rack, self.shared_utils.group)), ] ) + def _get_cv_pathfinder_device_tags(self: AvdStructuredConfigMetadata) -> list: + """ + Return list of device_tags for cv_pathfinder solution + Example: [ + {"name": "Region", "value": }, + {"name": "Zone", "value": }, + {"name": "Site", "value": }, + {"name": "PathfinderSet", "value": }, + {"name": "Role", "value": } + ] + """ + if self.shared_utils.cv_pathfinder_role is None: + return [] + + device_tags = [ + self._tag_dict("Role", self.shared_utils.cv_pathfinder_role), + ] + if self.shared_utils.cv_pathfinder_role == "pathfinder": + device_tags.append(self._tag_dict("PathfinderSet", self.shared_utils.group or "PATHFINDERS")) + else: + device_tags.extend( + [ + self._tag_dict("Region", self.shared_utils.wan_region["name"]), + self._tag_dict("Zone", "DEFAULT-ZONE"), + self._tag_dict("Site", self.shared_utils.wan_site["name"]), + ] + ) + + return strip_empties_from_list(device_tags) + def _get_device_tags(self: AvdStructuredConfigMetadata) -> list: """ Return list of device_tags @@ -125,7 +156,7 @@ def _get_interface_tags(self: AvdStructuredConfigMetadata) -> list: """ Return list of interface_tags """ - if not (tags_to_generate := get(self._generate_cv_tags, "interface_tags")): + if not (tags_to_generate := get(self._generate_cv_tags, "interface_tags", default=[])) and not self.shared_utils.cv_pathfinder_role: return [] interface_tags = [] @@ -151,7 +182,38 @@ def _get_interface_tags(self: AvdStructuredConfigMetadata) -> list: if value: tags.append(self._tag_dict(generate_tag["name"], value)) + tags.extend(self._get_cv_pathfinder_interface_tags(ethernet_interface)) + if tags: interface_tags.append({"interface": ethernet_interface["name"], "tags": tags}) return interface_tags + + def _get_cv_pathfinder_interface_tags(self: AvdStructuredConfigMetadata, ethernet_interface: dict) -> list: + """ + Return list of device_tags for cv_pathfinder solution + Example: [ + {"name": "Type", <"lan" or "wan" if cv_pathfinder_role is set>}, + {"name": "Carrier", }, + {"name": "Circuit", } + ] + """ + # Skip if not cv_pathfinder_role or if this is a subinterface. + if self.shared_utils.cv_pathfinder_role is None or "." in ethernet_interface["name"]: + return [] + + if ethernet_interface["name"] in self._wan_interface_names: + wan_interface = get_item(self.shared_utils.wan_interfaces, "name", ethernet_interface["name"], required=True) + return strip_empties_from_list( + [ + self._tag_dict("Type", "wan"), + self._tag_dict("Carrier", get(wan_interface, "wan_carrier")), + self._tag_dict("Circuit", get(wan_interface, "wan_circuit_id")), + ] + ) + + return [self._tag_dict("Type", "lan")] + + @cached_property + def _wan_interface_names(self: AvdStructuredConfigMetadata): + return [wan_interface["name"] for wan_interface in self.shared_utils.wan_interfaces] diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_adaptive_virtual_topology.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_adaptive_virtual_topology.py index 58f4f42d94f..17ed069c487 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_adaptive_virtual_topology.py +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_adaptive_virtual_topology.py @@ -28,13 +28,13 @@ def router_adaptive_virtual_topology(self) -> dict | None: router_adaptive_virtual_topology.update( { "region": { - "name": self._wan_region["name"], - "id": self._wan_region["id"], + "name": self.shared_utils.wan_region["name"], + "id": self.shared_utils.wan_region["id"], }, - "zone": self._wan_zone, + "zone": self.shared_utils.wan_zone, "site": { - "name": self._wan_site["name"], - "id": self._wan_site["id"], + "name": self.shared_utils.wan_site["name"], + "id": self.shared_utils.wan_site["id"], }, } ) diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_bgp.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_bgp.py index aa72e9a60df..027779163ae 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_bgp.py +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_bgp.py @@ -484,12 +484,12 @@ def _neighbors(self) -> list | None: f"Loopback0 IP {self.shared_utils.router_id} is not in the Route Reflector listen range prefixes" " 'bgp_peer_groups.wan_overlay_peers.listen_range_prefixes'." ) - for wan_route_server, data in self._filtered_wan_route_servers.items(): + for wan_route_server, data in self.shared_utils.filtered_wan_route_servers.items(): neighbor = self._create_neighbor(data["router_id"], wan_route_server, self.shared_utils.bgp_peer_groups["wan_overlay_peers"]["name"]) neighbors.append(neighbor) if self.shared_utils.wan_role == "server": # No neighbor configured on the `wan_overlay_peers` peer group as it is covered by listen ranges - for wan_route_server, data in self._filtered_wan_route_servers.items(): + for wan_route_server, data in self.shared_utils.filtered_wan_route_servers.items(): neighbor = self._create_neighbor(data["router_id"], wan_route_server, self.shared_utils.bgp_peer_groups["rr_overlay_peers"]["name"]) neighbors.append(neighbor) diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_path_selection.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_path_selection.py index 2e673d812f8..02a30a97462 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_path_selection.py +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/router_path_selection.py @@ -128,7 +128,7 @@ def _get_local_interfaces_for_path_group(self, path_group_name: str) -> list | N for interface in path_group.get("interfaces", []): local_interface = {"name": get(interface, "name", required=True)} - if self.shared_utils.wan_role == "client" and self._should_connect_to_wan_rs([path_group_name]): + if self.shared_utils.wan_role == "client" and self.shared_utils.should_connect_to_wan_rs([path_group_name]): stun_server_profiles = self._stun_server_profiles.get(path_group_name, []) if stun_server_profiles: local_interface["stun"] = {"server_profiles": [profile["name"] for profile in stun_server_profiles]} @@ -153,7 +153,7 @@ def _get_static_peers_for_path_group(self, path_group_name: str) -> list | None: return None static_peers = [] - for wan_route_server, data in self._filtered_wan_route_servers.items(): + for wan_route_server, data in self.shared_utils.filtered_wan_route_servers.items(): if (path_group := get_item(data["wan_path_groups"], "name", path_group_name)) is not None: ipv4_addresses = [] diff --git a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/utils.py b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/utils.py index 0fd72889f81..c5df100de65 100644 --- a/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/utils.py +++ b/ansible_collections/arista/avd/roles/eos_designs/python_modules/overlay/utils.py @@ -7,9 +7,7 @@ from ansible_collections.arista.avd.plugins.filter.natural_sort import natural_sort from ansible_collections.arista.avd.plugins.plugin_utils.eos_designs_shared_utils.shared_utils import SharedUtils -from ansible_collections.arista.avd.plugins.plugin_utils.errors import AristaAvdError, AristaAvdMissingVariableError -from ansible_collections.arista.avd.plugins.plugin_utils.strip_empties import strip_empties_from_dict -from ansible_collections.arista.avd.plugins.plugin_utils.utils import get, get_item +from ansible_collections.arista.avd.plugins.plugin_utils.utils import get class UtilsMixin: @@ -262,200 +260,25 @@ def _append_peer(self, peers_dict: dict, peer_name: str, peer_facts: dict) -> No @cached_property def _is_wan_server_with_peers(self) -> bool: - return self.shared_utils.wan_role == "server" and len(self._filtered_wan_route_servers) > 0 + return self.shared_utils.wan_role == "server" and len(self.shared_utils.filtered_wan_route_servers) > 0 @cached_property def _wan_listen_ranges(self): return get(self.shared_utils.bgp_peer_groups["wan_overlay_peers"], "listen_range_prefixes", required=True) - @cached_property - def _wan_site(self) -> dict | None: - """ - Here assuming that cv_pathfinder_name is unique across zones and regions - """ - if not self.shared_utils.cv_pathfinder_role: - return None - - node_defined_site = get( - self.shared_utils.switch_data_combined, - "cv_pathfinder_site", - required=True, - org_key="A node variable 'cv_pathfinder_site' must be defined when 'cv_pathfinder_role' is 'edge' or 'transit'.", - ) - sites = get(self._wan_region, "sites", required=True, org_key=f"The CV Pathfinder region '{self._wan_region['name']}' is missing a list of sites") - return get_item( - sites, - "name", - node_defined_site, - required=True, - custom_error_msg=( - f"The 'cv_pathfinder_site '{node_defined_site}' defined at the node level could not be found under the 'sites' list for the region" - f" '{self._wan_region['name']}'." - ), - ) - - @cached_property - def _wan_region(self) -> dict | None: - """ - WAN region for Pathfinder - """ - if not self.shared_utils.cv_pathfinder_role: - return None - - node_defined_region = get( - self.shared_utils.switch_data_combined, - "cv_pathfinder_region", - required=True, - org_key="A node variable 'cv_pathfinder_region' must be defined when 'cv_pathfinder_role' is 'edge' or 'transit'.", - ) - regions = get( - self._hostvars, "cv_pathfinder_regions", required=True, org_key="'cv_pathfinder_regions' key must be set when 'wan_mode' is 'cv-pathfinder'." - ) - - return get_item( - regions, - "name", - node_defined_region, - required=True, - custom_error_msg="The 'cv_pathfinder_region' defined at the node level could not be found under the 'cv_pathfinder_regions' key.", - ) - - @cached_property - def _wan_zone(self) -> dict | None: - """ - WAN zone for Pathfinder - - Currently, only default zone DEFAULT-ZONE with ID 1 is supported. - """ - if not self.shared_utils.cv_pathfinder_role: - return None - - # Injecting zone DEFAULT-ZONE with id 1. - return {"name": "DEFAULT-ZONE", "id": 1} - - @cached_property - def _wan_route_servers(self) -> dict: - """ - Return a dict keyed by Wan RR based on the the wan_mode type. - - It the RR is part of the inventory, the peer_facts are read.. - If any key is specified in the variables, it overwrites whatever is in the peer_facts. - - If no peer_fact is found the variables are required in the inventory. - """ - # TODO - need to factor this with other function once we fix - # https://github.com/aristanetworks/ansible-avd/issues/3392 - if not self.shared_utils.wan_mode: - return {} - - wan_route_servers = {} - - wan_route_servers_list = get(self._hostvars, "wan_route_servers", default=[]) - - for wan_rs_dict in natural_sort(wan_route_servers_list, sort_key="hostname"): - # These remote gw can be outside of the inventory - wan_rs = wan_rs_dict["hostname"] - - if wan_rs == self.shared_utils.hostname: - # Don't add yourself - continue - - if (peer_facts := self.shared_utils.get_peer_facts(wan_rs, required=False)) is not None: - # Found a matching server in inventory - bgp_as = peer_facts.get("bgp_as") - # Only ibgp is supported for WAN so raise if peer from peer_facts BGP AS is different from ours. - if bgp_as != self.shared_utils.bgp_as: - raise AristaAvdError( - f"Only iBGP is supported for WAN, the BGP AS {bgp_as} on {wan_rs} is different from our own: {self.shared_utils.bgp_as}." - ) - - # Prefer values coming from the input variables over peer facts - router_id = get(wan_rs_dict, "router_id", default=peer_facts.get("router_id")) - wan_path_groups = get(wan_rs_dict, "path_groups", default=peer_facts.get("wan_path_groups")) - - if router_id is None: - raise AristaAvdMissingVariableError( - f"'router_id' is missing for peering with {wan_rs}, either set it in under 'wan_route_servers' or something is wrong with the peer" - " facts." - ) - if wan_path_groups is None: - raise AristaAvdMissingVariableError( - f"'wan_path_groups' is missing for peering with {wan_rs}, either set it in under 'wan_route_servers'" - " or something is wrong with the peer facts." - ) - - else: - # Retrieve the values from the dictionary, making them required if the peer_facts were not found - router_id = get(wan_rs_dict, "router_id", required=True) - wan_path_groups = get( - wan_rs_dict, - "path_groups", - required=True, - org_key=( - f"'path_groups' is missing for peering with {wan_rs} which was not found in the inventory, either set it in under 'wan_route_servers'" - " or check your inventory." - ), - ) - - wan_rs_result_dict = { - "router_id": router_id, - "wan_path_groups": wan_path_groups, - } - - if any(interface["ip_address"] == "dhcp" for path_group in wan_rs_result_dict["wan_path_groups"] for interface in path_group.get("interfaces", [])): - raise AristaAvdError( - f"The IP address for a WAN interface on a Route Server '{wan_rs}' is set 'dhcp'. Clients need to peer with a static IP which can be set" - " under the 'wan_route_servers.path_groups.interfaces' key." - ) - - wan_route_servers[wan_rs] = strip_empties_from_dict(wan_rs_result_dict) - - return wan_route_servers - def _stun_server_profile_name(self, wan_route_server_name: str, path_group_name: str, interface_name: str) -> str: """ Return a string to use as the name of the stun server_profile """ return f"{path_group_name}-{wan_route_server_name}-{interface_name}" - def _should_connect_to_wan_rs(self, path_groups: list) -> bool: - """ - This helper implements wherther or not a connection to the wan_rs should be made or not based on a list of path-groups. - - To do this the logic is the following: - * Look at the wan_interfaces on the router and check if there is any path-group in common with the RR where - `connected_to_pathfinder` is not False. - """ - return any( - local_path_group["name"] in path_groups and any(wan_interface["connected_to_pathfinder"] for wan_interface in local_path_group["interfaces"]) - for local_path_group in self.shared_utils.wan_local_path_groups - ) - - @cached_property - def _filtered_wan_route_servers(self) -> dict: - """ - Return a dictionary of wan_route_servers with only the path_groups the router should connect to - """ - filtered_wan_route_servers = {} - for wan_route_server, data in self._wan_route_servers.items(): - if wan_route_server == self.shared_utils.hostname: - # Do not include yourself - continue - for path_group in data.get("wan_path_groups", []): - if self._should_connect_to_wan_rs([path_group["name"]]): - filtered_wan_route_servers.setdefault(wan_route_server, {"router_id": data["router_id"], "wan_path_groups": []})["wan_path_groups"].append( - path_group - ) - - return filtered_wan_route_servers - @cached_property def _stun_server_profiles(self) -> list: """ Return a dictionary of _stun_server_profiles with ip_address per local path_group """ stun_server_profiles = {} - for wan_route_server, data in self._filtered_wan_route_servers.items(): + for wan_route_server, data in self.shared_utils.filtered_wan_route_servers.items(): for path_group in data.get("wan_path_groups", []): stun_server_profiles.setdefault(path_group["name"], []).extend( {