diff --git a/catalystwan/integration_tests/feature_profile/sdwan/test_service.py b/catalystwan/integration_tests/feature_profile/sdwan/test_service.py index 7e903bbd..3b586c8b 100644 --- a/catalystwan/integration_tests/feature_profile/sdwan/test_service.py +++ b/catalystwan/integration_tests/feature_profile/sdwan/test_service.py @@ -25,6 +25,27 @@ ) from catalystwan.models.configuration.feature_profile.sdwan.service.lan.svi import InterfaceSviParcel from catalystwan.models.configuration.feature_profile.sdwan.service.lan.vpn import LanVpnParcel +from catalystwan.models.configuration.feature_profile.sdwan.service.multicast import ( + AutoRpAttributes, + BsrCandidateAttributes, + IgmpAttributes, + IgmpInterfaceParameters, + LocalConfig, + MsdpAttributes, + MsdpPeer, + MsdpPeerAttributes, + MulticastBasicAttributes, + MulticastParcel, + PimAttributes, + PimBsrAttributes, + PimInterfaceParameters, + RPAnnounce, + RpDiscoveryScope, + SmmFlag, + SsmAttributes, + StaticJoin, + StaticRpAddress, +) from catalystwan.models.configuration.feature_profile.sdwan.service.ospf import OspfParcel from catalystwan.models.configuration.feature_profile.sdwan.service.ospfv3 import ( Ospfv3InterfaceParametres, @@ -241,6 +262,111 @@ def test_when_fully_specified_values_switchport_expect_successful_post(self): # Assert assert parcel_id + def test_when_default_values_multicast_expect_successful_post(self): + # Arrange + multicast_parcel = MulticastParcel( + parcel_name="TestMulticastParcel", + parcel_description="Test Multicast Parcel", + basic=MulticastBasicAttributes(), + ) + # Act + parcel_id = self.api.create_parcel(self.profile_uuid, multicast_parcel).id + # Assert + assert parcel_id + + def test_when_fully_specified_values_multicast_expect_successful_post(self): + # Arrange + multicast_parcel = MulticastParcel( + parcel_name="TestMulticastParcel_FullySpecified", + parcel_description="Test Multicast Parcel", + basic=MulticastBasicAttributes( + spt_only=as_global(True), + local_config=LocalConfig( + local=as_global(True), + threshold=as_global(10), + ), + ), + igmp=IgmpAttributes( + interface=[ + IgmpInterfaceParameters( + interface_name=as_global("GigabitEthernet0/0/0"), + version=as_global(2), + join_group=[ + StaticJoin( + group_address=Global[IPv4Address](value=IPv4Address("239.255.255.255")), + ) + ], + ) + ] + ), + pim=PimAttributes( + ssm=SsmAttributes(ssm_range_config=SmmFlag(enable_ssm_flag=as_global(True), range=as_global("20"))), + interface=[ + PimInterfaceParameters( + interface_name=as_global("GigabitEthernet0/0/0"), + query_interval=as_global(10), + join_prune_interval=as_global(10), + ) + ], + rp_address=[ + StaticRpAddress( + address=Global[IPv4Address](value=IPv4Address("40.2.3.1")), + access_list=as_global("TestAccessList"), + override=as_global(True), + ) + ], + auto_rp=AutoRpAttributes( + enable_auto_rp_flag=as_global(False), + send_rp_announce_list=[ + RPAnnounce(interface_name=as_global("GigabitEthernet0/0/0"), scope=as_global(3)) + ], + send_rp_discovery=[ + RPAnnounce(interface_name=as_global("GigabitEthernet0/0/0"), scope=as_global(3)) + ], + ), + pim_bsr=PimBsrAttributes( + rp_candidate=[ + RpDiscoveryScope( + interface_name=as_global("GigabitEthernet0/0/0"), + group_list=as_global("TestGroupList"), + interval=as_global(10), + priority=as_global(10), + ) + ], + bsr_candidate=[ + BsrCandidateAttributes( + interface_name=as_global("GigabitEthernet0/0/0"), + mask=as_global(10), + priority=as_global(10), + accept_rp_candidate=as_global("True"), + ) + ], + ), + ), + msdp=MsdpAttributes( + msdp_list=[ + MsdpPeer( + mesh_group=as_global("TestMeshGroup"), + peer=[ + MsdpPeerAttributes( + peer_ip=Global[IPv4Address](value=IPv4Address("5.5.5.5")), + connect_source_intf=as_global("GigabitEthernet0/0/0"), + remote_as=as_global(10), + password=as_global("TestPassword"), + keepalive_holdtime=as_global(20), + keepalive_interval=as_global(10), + sa_limit=as_global(10), + ) + ], + ) + ] + ), + ) + # Act + parcel_id = self.api.create_parcel(self.profile_uuid, multicast_parcel).id + # Assert + assert parcel_id + def test_when_fully_specified_values_wireless_lan_expect_successful_post(self): # Arrange wireless_lan_parcel = WirelessLanParcel( diff --git a/catalystwan/models/configuration/feature_profile/sdwan/service/multicast.py b/catalystwan/models/configuration/feature_profile/sdwan/service/multicast.py index 17f31921..fce1c0fc 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/service/multicast.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/service/multicast.py @@ -1,5 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates +from ipaddress import IPv4Address from typing import List, Literal, Optional, Union from uuid import UUID @@ -7,6 +8,8 @@ from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase +SptThreshold = Literal["infinity", "0"] + class LocalConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") @@ -29,10 +32,12 @@ class MulticastBasicAttributes(BaseModel): class StaticJoin(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") - group_address: Union[Global[str], Variable] = Field( - serialization_alias="groupAddress", validation_alias="groupAddress" + group_address: Union[Global[IPv4Address], Variable] = Field( + serialization_alias="groupAddress", + validation_alias="groupAddress", + description="Address range: 224.0.0.0 ~ 239.255.255.255", ) - source_address: Optional[Union[Global[str], Variable, Default[None]]] = Field( + source_address: Optional[Union[Global[IPv4Address], Variable, Default[None]]] = Field( serialization_alias="sourceAddress", validation_alias="sourceAddress", default=Default[None](value=None) ) @@ -58,23 +63,20 @@ class IgmpAttributes(BaseModel): class SmmFlag(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") - enable_ssm_flag: Global[bool] = Global[bool](value=True) - range: Optional[Union[Global[str], Variable, Default[None]]] = Default[None](value=None) - - -class SptThreshold: - INFINITY = "infinity" - ZERO = "0" + enable_ssm_flag: Global[bool] = Field( + default=Global[bool](value=True), serialization_alias="enableSSMFlag", validation_alias="enableSSMFlag" + ) + range: Union[Global[str], Variable, Default[None]] = Default[None](value=None) -class SsmAttrubutes(BaseModel): +class SsmAttributes(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") ssm_range_config: SmmFlag = Field(serialization_alias="ssmRangeConfig", validation_alias="ssmRangeConfig") spt_threshold: Optional[Union[Global[SptThreshold], Variable, Default[SptThreshold]]] = Field( serialization_alias="sptThreshold", validation_alias="sptThreshold", - default=Default[SptThreshold](value=SptThreshold.ZERO), + default=Default[SptThreshold](value="0"), ) @@ -95,7 +97,7 @@ class PimInterfaceParameters(BaseModel): class StaticRpAddress(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") - address: Union[Global[str], Variable] + address: Union[Global[IPv4Address], Variable] access_list: Union[Global[str], Variable] = Field(serialization_alias="accessList", validation_alias="accessList") override: Optional[Union[Global[bool], Variable, Default[bool]]] = Default[bool](value=False) @@ -156,16 +158,16 @@ class PimBsrAttributes(BaseModel): serialization_alias="rpCandidate", validation_alias="rpCandidate", default=None ) bsr_candidate: Optional[List[BsrCandidateAttributes]] = Field( - serialization_alias="bsdCandidate", validation_alias="bsdCandidate", default=None + serialization_alias="bsrCandidate", validation_alias="bsrCandidate", default=None ) class PimAttributes(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") - ssm: SsmAttrubutes + ssm: SsmAttributes interface: Optional[List[PimInterfaceParameters]] = None - rp_addres: Optional[List[StaticRpAddress]] = Field( + rp_address: Optional[List[StaticRpAddress]] = Field( serialization_alias="rpAddr", validation_alias="rpAddr", default=None ) auto_rp: Optional[AutoRpAttributes] = Field(serialization_alias="autoRp", validation_alias="autoRp", default=None) @@ -182,7 +184,7 @@ class DefaultMsdpPeer(BaseModel): class MsdpPeerAttributes(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") - peer_ip: Union[Global[str], Variable] = Field(serialization_alias="peerIp", validation_alias="peerIp") + peer_ip: Union[Global[IPv4Address], Variable] = Field(serialization_alias="peerIp", validation_alias="peerIp") connect_source_intf: Optional[Union[Global[str], Variable, Default[None]]] = Field( serialization_alias="connectSourceIntf", validation_alias="connectSourceIntf", default=None ) @@ -194,7 +196,10 @@ class MsdpPeerAttributes(BaseModel): serialization_alias="keepaliveInterval", validation_alias="keepaliveInterval", default=None ) keepalive_holdtime: Optional[Union[Global[int], Variable, Default[None]]] = Field( - serialization_alias="keepaliveHoldTime", validation_alias="keepaliveHoldTime", default=None + serialization_alias="keepaliveHoldTime", + validation_alias="keepaliveHoldTime", + default=None, + description="Hold-Time must be higher than Keep Alive", ) sa_limit: Optional[Union[Global[int], Variable, Default[None]]] = Field( serialization_alias="saLimit", validation_alias="saLimit", default=None diff --git a/catalystwan/tests/test_feature_profile_api.py b/catalystwan/tests/test_feature_profile_api.py index 7007a063..0471b02b 100644 --- a/catalystwan/tests/test_feature_profile_api.py +++ b/catalystwan/tests/test_feature_profile_api.py @@ -24,6 +24,7 @@ from catalystwan.models.configuration.feature_profile.sdwan.service.eigrp import EigrpParcel from catalystwan.models.configuration.feature_profile.sdwan.service.lan.gre import BasicGre from catalystwan.models.configuration.feature_profile.sdwan.service.lan.ipsec import IpsecAddress, IpsecTunnelMode +from catalystwan.models.configuration.feature_profile.sdwan.service.multicast import MulticastParcel from catalystwan.models.configuration.feature_profile.sdwan.service.ospfv3 import Ospfv3IPv4Parcel, Ospfv3IPv6Parcel from catalystwan.models.configuration.feature_profile.sdwan.service.route_policy import RoutePolicyParcel from catalystwan.models.configuration.feature_profile.sdwan.service.wireless_lan import WirelessLanParcel @@ -118,6 +119,7 @@ def test_update_method_with_valid_arguments(self, parcel, expected_path): Ipv6AclParcel: "ipv6-acl", Ipv4AclParcel: "ipv4-acl", SwitchportParcel: "switchport", + MulticastParcel: "routing/multicast", WirelessLanParcel: "wirelesslan", } diff --git a/catalystwan/utils/config_migration/converters/feature_template/multicast.py b/catalystwan/utils/config_migration/converters/feature_template/multicast.py new file mode 100644 index 00000000..2efee6b4 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/multicast.py @@ -0,0 +1,335 @@ +from copy import deepcopy +from ipaddress import IPv4Address +from typing import List, Optional + +from catalystwan.api.configuration_groups.parcel import Global, OptionType, as_default, as_global +from catalystwan.models.configuration.feature_profile.sdwan.service.multicast import ( + AutoRpAttributes, + BsrCandidateAttributes, + IgmpAttributes, + IgmpInterfaceParameters, + LocalConfig, + MulticastBasicAttributes, + MulticastParcel, + PimAttributes, + PimBsrAttributes, + PimInterfaceParameters, + RPAnnounce, + RpDiscoveryScope, + SmmFlag, + SptThreshold, + SsmAttributes, + StaticJoin, + StaticRpAddress, +) + + +class MulticastToMulticastTemplateConverter: + """This is corner case. + Multicast Parcel is not a direct conversion from template. + It is a combination of multiple templates. + Feature Templates: Multicast, IGMP, PIM. + """ + + supported_template_types = ("cedge_multicast",) + + delete_keys = ("multicast", "multicast_replicator") + + def create_parcel(self, name: str, description: str, template_values: dict) -> MulticastParcel: + values = self.prepare_values(template_values) + self.configure_basic_attributes(values) + self.cleanup_keys(values) + return MulticastParcel(parcel_name=name, parcel_description=description, **values) + + def prepare_values(self, template_values: dict) -> dict: + return deepcopy(template_values) + + def configure_basic_attributes(self, values: dict) -> None: + values["basic"] = MulticastBasicAttributes( + spt_only=values.get("multicast", {}).get("spt_only", as_default(False)), + local_config=LocalConfig( + local=values.get("multicast_replicator", {}).get("local", as_default(False)), + threshold=values.get("multicast_replicator", {}).get("threshold", as_default(False)), + ), + ) + + def cleanup_keys(self, values: dict) -> None: + for key in self.delete_keys: + values.pop(key, None) + + def get_example_payload(self): + return { + "multicast_replicator": { + "local": Global[bool](value=False), + "threshold": Global[int](value=11), + }, + "multicast": {"spt_only": Global[bool](value=True)}, + } + + +class PimToMulticastTemplateConverter: + """This is corner case. + Multicast Parcel is not a direct conversion from template. + It is a combination of multiple templates. + Feature Templates: Multicast, IGMP, PIM. + """ + + supported_template_types = ("cedge_pim",) + + def create_parcel(self, name: str, description: str, template_values: dict) -> MulticastParcel: + values = self.prepare_values(template_values) + return MulticastParcel( + parcel_name=name, + parcel_description=description, + pim=self.configure_pim(values), + ) + + def configure_pim(self, values: dict) -> PimAttributes: + return PimAttributes( + ssm=self._set_ssm_attributes(values), + interface=self._set_interface(values), + rp_address=self._set_rp_address(values), + auto_rp=self._set_auto_rp(values), + pim_bsr=self._set_pim_bsr(values), + ) + + def _set_ssm_attributes(self, values: dict) -> SsmAttributes: + spt_threshold = values.get("spt_threshold", as_default("infinity", as_default("0", SptThreshold))) + if spt_threshold.option_type == OptionType.GLOBAL: + spt_threshold = as_global(spt_threshold.value, SptThreshold) + + return SsmAttributes( + ssm_range_config=SmmFlag(range=values.get("ssm", {}).get("range", as_default("eqw"))), + spt_threshold=spt_threshold, + ) + + def _set_interface(self, values: dict) -> Optional[List[PimInterfaceParameters]]: + interfaces = values.get("interface", []) + interface_list = [] + for interface in interfaces: + interface_list.append( + PimInterfaceParameters( + interface_name=interface.get("name"), + query_interval=interface.get("query_interval", as_default(30)), + join_prune_interval=interface.get("join_prune_interval", as_default(60)), + ) + ) + return interface_list + + def _set_rp_address(self, values: dict) -> Optional[List[StaticRpAddress]]: + rp_addresses = values.get("rp_addr", []) + rp_address_list = [] + for rp_address in rp_addresses: + rp_address_list.append( + StaticRpAddress( + address=rp_address.get("address"), + access_list=rp_address.get("access_list"), + override=rp_address.get("override", as_default(False)), + ) + ) + return rp_address_list + + def _set_auto_rp(self, values: dict) -> AutoRpAttributes: + return AutoRpAttributes( + enable_auto_rp_flag=values.get("auto_rp", as_default(False)), + send_rp_discovery=self._set_rp_discovery(values), + ) + + def _set_rp_discovery(self, values: dict) -> Optional[List[RPAnnounce]]: + interface_name = values.get("send_rp_discovery", {}).get("if_name") + scope = values.get("send_rp_discovery", {}).get("scope") + if interface_name is None or scope is None: + return None + return [ + RPAnnounce( + interface_name=interface_name, + scope=scope, + ) + ] + + def _set_pim_bsr(self, values: dict) -> Optional[PimBsrAttributes]: + return PimBsrAttributes( + rp_candidate=self._set_rp_candidate(values), + bsr_candidate=self._set_bsr_candidate(values), + ) + + def _set_rp_candidate(self, values: dict) -> Optional[List[RpDiscoveryScope]]: + rp_candidate = values.get("rp_candidate", []) + if not rp_candidate: + return None + rp_candidate_list = [] + for candidate in rp_candidate: + rp_candidate_list.append( + RpDiscoveryScope( + interface_name=candidate.get("pim_interface_name"), + group_list=candidate.get("group_list"), + interval=candidate.get("interval"), + priority=candidate.get("priority"), + ) + ) + return rp_candidate_list + + def _set_bsr_candidate(self, values: dict) -> Optional[List[BsrCandidateAttributes]]: + bsr_candidate = values.get("bsr_candidate", {}) + if not bsr_candidate: + return None + mask = bsr_candidate.get("mask") + if mask.option_type == OptionType.GLOBAL: + mask = as_global(int(mask.value)) + candidate = BsrCandidateAttributes( + interface_name=bsr_candidate.get("bsr_interface_name"), + mask=mask, + priority=bsr_candidate.get("priority"), + accept_rp_candidate=bsr_candidate.get("accept_rp_candidate"), + ) + return [candidate] + + def prepare_values(self, template_values: dict) -> dict: + return deepcopy(template_values).get("pim", {}) + + def get_example_payload(self): + return { + "pim": { + "send_rp_discovery": { + "if_name": Global[str](value="qeq"), + "scope": Global[int](value=2), + }, + "auto_rp": Global[bool](value=True), + "spt_threshold": Global[str](value="infinity"), + "ssm": {"range": Global[str](value="eqw")}, + "bsr_candidate": { + "bsr_interface_name": Global[str](value="33311"), + "mask": Global[str](value="2"), + "priority": Global[int](value=3), + "accept_rp_candidate": Global[str](value="12"), + }, + "interface": [ + { + "name": Global[str](value="232"), + "query_interval": Global[int](value=23), + "join_prune_interval": Global[int](value=12), + }, + { + "name": Global[str](value="33311"), + "query_interval": Global[int](value=33), + "join_prune_interval": Global[int](value=111), + }, + ], + "rp_addr": [ + { + "address": Global[IPv4Address](value=IPv4Address("1.1.1.1")), + "access_list": Global[str](value="323"), + "override": Global[bool](value=True), + }, + { + "address": Global[IPv4Address](value=IPv4Address("2.2.2.2")), + "access_list": Global[str](value="333"), + "override": Global[bool](value=False), + }, + ], + "rp_candidate": [ + { + "pim_interface_name": Global[str](value="232"), + "group_list": Global[str](value="234"), + "interval": Global[int](value=23), + "priority": Global[int](value=44), + }, + { + "pim_interface_name": Global[str](value="33311"), + "group_list": Global[str](value="23"), + "interval": Global[int](value=1112), + "priority": Global[int](value=33), + }, + ], + } + } + + +class IgmpToMulticastTemplateConverter: + """This is corner case. + Multicast Parcel is not a direct conversion from template. + It is a combination of multiple templates. + Feature Templates: Multicast, IGMP, PIM. + """ + + supported_template_types = ("cedge_igmp",) + + def create_parcel(self, name: str, description: str, template_values: dict) -> MulticastParcel: + print(template_values) + values = self.prepare_values(template_values) + return MulticastParcel( + parcel_name=name, + parcel_description=description, + igmp=self.configure_igmp(values), + ) + + def prepare_values(self, template_values: dict) -> dict: + return deepcopy(template_values)["igmp"] + + def configure_igmp(self, values: dict) -> Optional[IgmpAttributes]: + interface = (self._set_interface(values),) + if not interface: + return None + return IgmpAttributes( + interface=self._set_interface(values), + ) + + def _set_interface(self, values: dict) -> List[IgmpInterfaceParameters]: + interfaces = values.get("interface", []) + interface_list = [] + for interface in interfaces: + interface_list.append( + IgmpInterfaceParameters( + interface_name=interface.get("name"), + join_group=self._set_join_group(interface), + ) + ) + return interface_list + + def _set_join_group(self, interface: dict) -> Optional[List[StaticJoin]]: + join_group = interface.get("join_group", []) + if not join_group: + return None + join_group_list = [] + for group in join_group: + join_group_list.append( + StaticJoin( + group_address=group.get("group_address"), + source_address=group.get("source"), + ) + ) + return join_group_list + + def get_example_payload(self): + return { + "igmp": { + "interface": [ + { + "name": Global[str](value="321213"), + "join_group": [ + { + "group_address": Global[IPv4Address](value=IPv4Address("33.2.2.1")), + "source": Global[IPv4Address](value=IPv4Address("3.3.3.3")), + }, + { + "group_address": Global[IPv4Address](value=IPv4Address("2.1.2.3")), + "source": Global[IPv4Address](value=IPv4Address("1.1.1.1")), + }, + { + "group_address": Global[IPv4Address](value=IPv4Address("4.65.2.4")), + "source": Global[IPv4Address](value=IPv4Address("23.3.3.1")), + }, + ], + }, + { + "name": Global[str](value="33"), + "join_group": [ + { + "group_address": Global[IPv4Address](value=IPv4Address("4.2.3.2")), + "source": Global[IPv4Address](value=IPv4Address("33.2.1.2")), + } + ], + }, + ] + } + } diff --git a/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py b/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py index 94b058a0..42b286c5 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py +++ b/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py @@ -23,6 +23,11 @@ from .gre import InterfaceGRETemplateConverter from .ipsec import InterfaceIpsecTemplateConverter from .logging_ import LoggingTemplateConverter +from .multicast import ( + IgmpToMulticastTemplateConverter, + MulticastToMulticastTemplateConverter, + PimToMulticastTemplateConverter, +) from .normalizer import template_definition_normalization from .ntp import NTPTemplateConverter from .omp import OMPTemplateConverter @@ -60,6 +65,9 @@ OspfTemplateConverter, Ospfv3TemplateConverter, SwitchportTemplateConverter, + MulticastToMulticastTemplateConverter, + PimToMulticastTemplateConverter, + IgmpToMulticastTemplateConverter, WirelessLanTemplateConverter, ]