diff --git a/catalystwan/endpoints/configuration/topology_group.py b/catalystwan/endpoints/configuration/topology_group.py index 5c91eecc..2121a6c4 100644 --- a/catalystwan/endpoints/configuration/topology_group.py +++ b/catalystwan/endpoints/configuration/topology_group.py @@ -3,15 +3,31 @@ # mypy: disable-error-code="empty-body" from uuid import UUID -from catalystwan.endpoints import APIEndpoints, delete, get, post, versions -from catalystwan.models.configuration.topology_group import TopologyGroup, TopologyGroupId +from catalystwan.endpoints import APIEndpoints, delete, get, post, put, versions +from catalystwan.models.configuration.topology_group import ( + ActivateRequest, + DeployResponse, + Preview, + TopologyGroup, + TopologyGroupId, +) from catalystwan.typed_list import DataSequence class TopologyGroupEndpoints(APIEndpoints): @post("/v1/topology-group") @versions(">=20.12") - def create_topology_group(self, payload: TopologyGroup) -> TopologyGroupId: + def create(self, payload: TopologyGroup) -> TopologyGroupId: + ... + + @put("/v1/topology-group/{group_id}") + @versions(">=20.12") + def edit(self, group_id: UUID, payload: TopologyGroup) -> TopologyGroupId: + ... + + @get("/v1/topology-group/{group_id}") + @versions(">=20.12") + def get_by_id(self, group_id: UUID) -> TopologyGroup: ... @get("/v1/topology-group") @@ -23,3 +39,13 @@ def get_all(self) -> DataSequence[TopologyGroupId]: @versions(">=20.12") def delete(self, group_id: UUID) -> None: ... + + @post("/v1/topology-group/{group_id}/device/{device_id}/preview") + @versions(">=20.12") + def preview(self, group_id: UUID, device_id: UUID, payload: ActivateRequest) -> Preview: + ... + + @post("/v1/topology-group/{group_id}/device/deploy") + @versions(">=20.12") + def deploy(self, group_id: UUID, payload: ActivateRequest) -> DeployResponse: + ... diff --git a/catalystwan/integration_tests/feature_profile/sdwan/topology/test_topology.py b/catalystwan/integration_tests/feature_profile/sdwan/topology/test_topology.py index 9542fad6..bba00344 100644 --- a/catalystwan/integration_tests/feature_profile/sdwan/topology/test_topology.py +++ b/catalystwan/integration_tests/feature_profile/sdwan/topology/test_topology.py @@ -67,7 +67,7 @@ def test_hubspoke(self): def test_custom_control(self): cc = CustomControlParcel(parcel_name="CustomControlParcel-1") cc.set_default_action("accept") - cc.assign_target([self.lanvpn_parcel_name]) + cc.assign_target_sites([self.lanvpn_parcel_name]) s = cc.add_sequence("my_sequence", 1, "route", "ipv4", "reject") s.match_carrier("carrier4") s.match_domain_id(555) diff --git a/catalystwan/models/configuration/config_migration.py b/catalystwan/models/configuration/config_migration.py index 46ecdf24..c7a7036e 100644 --- a/catalystwan/models/configuration/config_migration.py +++ b/catalystwan/models/configuration/config_migration.py @@ -40,7 +40,7 @@ ) from catalystwan.models.settings import ThreatGridApi from catalystwan.models.templates import FeatureTemplateInformation, TemplateInformation -from catalystwan.version import parse_api_version +from catalystwan.version import NullVersion, parse_api_version T = TypeVar("T", bound=AnyParcel) TO = TypeVar("TO") @@ -235,6 +235,9 @@ def insert_parcel_type_from_headers(cls, values: Dict[str, Any]): def list_transformed_parcels_with_origin(self, origin: Set[UUID]) -> List[TransformedParcel]: return [p for p in self.profile_parcels if p.header.origin in origin] + def remove_transformed_parcels_with_origin(self, origin: Set[UUID]): + self.profile_parcels = [p for p in self.profile_parcels if p.header.origin not in origin] + def add_subelement_in_config_group( self, profile_types: List[ProfileType], device_template_id: UUID, subelement: UUID ) -> bool: @@ -542,6 +545,7 @@ class QoSMapResidues: @dataclass class PolicyConvertContext: # conversion input + platform_version: Version = NullVersion() region_map: Dict[str, int] = field(default_factory=dict) site_map: Dict[str, int] = field(default_factory=dict) lan_vpn_map: Dict[str, Union[int, str]] = field(default_factory=dict) @@ -582,8 +586,9 @@ def generate_as_path_list_num_from_name(self, name: str) -> int: def from_configs( network_hierarchy: List[NodeInfo], transformed_parcels: List[TransformedParcel], + platform_version: Version, ) -> "PolicyConvertContext": - context = PolicyConvertContext() + context = PolicyConvertContext(platform_version=platform_version) for node in network_hierarchy: if node.data.hierarchy_id is not None: if node.data.label == "SITE" and node.data.hierarchy_id.site_id is not None: diff --git a/catalystwan/models/configuration/feature_profile/sdwan/topology/custom_control.py b/catalystwan/models/configuration/feature_profile/sdwan/topology/custom_control.py index 7a848a0d..dc2fcb9d 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/topology/custom_control.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/topology/custom_control.py @@ -1,5 +1,5 @@ from ipaddress import IPv4Address -from typing import List, Literal, Optional +from typing import List, Literal, Optional, overload from uuid import UUID from pydantic import AliasPath, BaseModel, ConfigDict, Field, model_validator @@ -69,6 +69,13 @@ class Tloc(BaseModel): encap: Optional[Global[EncapType]] = Field(default=None) ip: Optional[Global[str]] = Field(default=None) + @staticmethod + def from_params(color: TLOCColor, encap: EncapType, ip: IPv4Address) -> "Tloc": + _color = as_global(color, TLOCColor) + _encap = as_global(encap, EncapType) + _ip = as_global(str(ip)) + return Tloc(color=_color, encap=_encap, ip=_ip) + class Entry(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -162,7 +169,7 @@ class Actions(BaseModel): class Sequence(BaseModel): model_config = ConfigDict(populate_by_name=True) - actions: Optional[List[Actions]] = Field(default=None) + actions: Optional[List[Actions]] = Field(default_factory=list) base_action: Optional[Global[BaseAction]] = Field( default=None, validation_alias="baseAction", serialization_alias="baseAction" ) @@ -199,6 +206,19 @@ def _get_regions_entry(self) -> Entry: entries.append(entry) return entry + @property + def _action(self) -> Actions: + if not self.actions: + self.actions = [Actions()] + return self.actions[0] + + @property + def _action_set(self) -> ActionSet: + action = self._action + if action.set is None: + action.set = [ActionSet()] + return action.set[0] + def match_carrier(self, carrier: CarrierType): entry = Entry(carrier=as_global(carrier, CarrierType)) self._match(entry) @@ -284,6 +304,56 @@ def match_vpns(self, vpns: List[str]): entry = Entry(vpn=as_global(vpns)) self._match(entry) + def associate_affinitty_action(self, affinity: int) -> None: + self._action_set.affinity = as_global(affinity) + + def associate_community_additive_action(self, additive: bool) -> None: + self._action_set.community_additive = as_global(additive) + + def associate_community_action(self, community: str) -> None: + self._action_set.community = as_global(community) + + def associate_omp_tag_action(self, omp_tag: int) -> None: + self._action_set.omp_tag = as_global(omp_tag) + + def associate_preference_action(self, preference: int) -> None: + self._action_set.preference = as_global(preference) + + @overload + def associate_service_action(self, service_type: ServiceType, vpn: Optional[int], *, tloc_list_id: UUID) -> None: + ... + + @overload + def associate_service_action( + self, service_type: ServiceType, vpn: Optional[int], *, ip: IPv4Address, color: TLOCColor, encap: EncapType + ) -> None: + ... + + def associate_service_action( + self, service_type=ServiceType, vpn=Optional[int], *, tloc_list_id=None, ip=None, color=None, encap=None + ) -> None: + _vpn = as_global(vpn) if vpn is not None else None + _service_type = as_global(service_type, ServiceType) + if tloc_list_id is not None: + service = Service( + tloc_list=RefIdItem(ref_id=as_global(str(tloc_list_id))), + type=_service_type, + vpn=_vpn, + ) + else: + _tloc = Tloc.from_params(color=color, encap=encap, ip=ip) + service = Service(tloc=_tloc, type=_service_type, vpn=_vpn) + self._action_set.service = service + + def associate_tloc(self, color: TLOCColor, encap: EncapType, ip: IPv4Address) -> None: + self._action_set.tloc = Tloc.from_params(color=color, encap=encap, ip=ip) + + def associate_tloc_action(self, tloc_action_type: TLOCActionType) -> None: + self._action_set.tloc_action = as_global(tloc_action_type, TLOCActionType) + + def associate_tloc_list(self, tloc_list_id: UUID) -> None: + self._action_set.tloc_list = RefIdItem(ref_id=as_global(str(tloc_list_id))) + class CustomControlParcel(_ParcelBase): model_config = ConfigDict(populate_by_name=True) @@ -305,16 +375,17 @@ class CustomControlParcel(_ParcelBase): def set_default_action(self, action: BaseAction = "reject"): self.default_action = as_global(action, BaseAction) - def assign_target( + def assign_target_sites( self, - vpns: List[str], - inbound_sites: Optional[List[str]] = None, - outbound_sites: Optional[List[str]] = None, + inbound_sites: List[str], + outbound_sites: List[str], + _dummy_vpns: List[str] = [";dummy-vpn"], ) -> Target: self.target = Target( + vpn=Global[List[str]](value=_dummy_vpns), + level=as_global("SITE", Level), inbound_sites=as_global(inbound_sites) if inbound_sites else None, outbound_sites=as_global(outbound_sites) if outbound_sites else None, - vpn=Global[List[str]](value=vpns), ) return self.target diff --git a/catalystwan/models/configuration/topology_group.py b/catalystwan/models/configuration/topology_group.py index ad984c25..b56477ca 100644 --- a/catalystwan/models/configuration/topology_group.py +++ b/catalystwan/models/configuration/topology_group.py @@ -31,5 +31,21 @@ def add_profiles(self, ids: List[UUID]): self.profiles.extend([Profile(id=i) for i in ids]) +class ActivateRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + deactivate_topology: bool = Field(validation_alias="deactivateTopology", serialization_alias="deactivateTopology") + + +class DeployResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + parent_task_id: str = Field(validation_alias="parentTaskId", serialization_alias="parentTaskId") + + +class Preview(BaseModel): + model_config = ConfigDict(populate_by_name=True) + existing_config: str = Field(validation_alias="existingConfig", serialization_alias="existingConfig") + new_config: str = Field(validation_alias="newConfig", serialization_alias="newConfig") + + class TopologyGroupId(BaseModel): id: UUID diff --git a/catalystwan/models/policy/centralized.py b/catalystwan/models/policy/centralized.py index 63a14992..f0322e52 100644 --- a/catalystwan/models/policy/centralized.py +++ b/catalystwan/models/policy/centralized.py @@ -71,7 +71,7 @@ def assign_to_inbound_sites(self, site_lists: List[UUID]) -> None: self.entries.append(entry) def assign_to_outbound_sites(self, site_lists: List[UUID]) -> None: - entry = ControlApplicationEntry(direction="in", site_lists=site_lists) + entry = ControlApplicationEntry(direction="out", site_lists=site_lists) self.entries.append(entry) @overload @@ -200,6 +200,12 @@ class CentralizedPolicyDefinition(PolicyDefinition): assembly: List[AnyAssemblyItem] = [] model_config = ConfigDict(populate_by_name=True) + def find_assembly_item_by_definition_id(self, definition_id: UUID) -> Optional[AnyAssemblyItem]: + for item in self.assembly: + if item.definition_id == definition_id: + return item + return None + class CentralizedPolicy(PolicyCreationPayload): model_config = ConfigDict(populate_by_name=True) diff --git a/catalystwan/models/policy/definition/control.py b/catalystwan/models/policy/definition/control.py index 07e85ce9..2bd3489c 100644 --- a/catalystwan/models/policy/definition/control.py +++ b/catalystwan/models/policy/definition/control.py @@ -1,14 +1,15 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Address -from typing import Any, List, Literal, Optional, Union, overload +from typing import List, Literal, Optional, Union, overload from uuid import UUID from pydantic import ConfigDict, Field from typing_extensions import Annotated -from catalystwan.models.common import AcceptRejectActionType, EncapType, TLOCColor +from catalystwan.models.common import AcceptRejectActionType, EncapType, SequenceIpType, TLOCColor from catalystwan.models.policy.policy_definition import ( + ActionSet, AffinityEntry, CarrierEntry, CarrierType, @@ -49,6 +50,7 @@ TLOCEntry, TLOCEntryValue, TLOCListEntry, + VPNEntry, VPNListEntry, accept_action, ) @@ -73,6 +75,7 @@ TLOCEntry, TLOCListEntry, VPNListEntry, + VPNEntry, ], Field(discriminator="field"), ] @@ -99,8 +102,14 @@ Field(discriminator="field"), ] -ControlPolicyRouteSequenceActions = Any # TODO -ControlPolicyTLOCSequenceActions = Any # TODO +ControlPolicyRouteSequenceActions = Annotated[ + Union[ + ActionSet, + ExportToAction, + ], + Field(discriminator="type"), +] +ControlPolicyTLOCSequenceActions = ActionSet class ControlPolicyHeader(PolicyDefinitionBase): @@ -108,7 +117,7 @@ class ControlPolicyHeader(PolicyDefinitionBase): class ControlPolicyRouteSequenceMatch(Match): - entries: List[AnyControlPolicyRouteSequenceMatchEntry] = [] + entries: List[AnyControlPolicyRouteSequenceMatchEntry] = Field(default_factory=list) class ControlPolicyTLOCSequenceMatch(Match): @@ -218,7 +227,7 @@ def associate_service_action( else: tloc_entry = None tloc_list_entry = TLOCListEntry(ref=tloc_list_id) - service_value = ServiceEntryValue(type=service_type, vpn=str(vpn), tloc=tloc_entry, tloc_list=tloc_list_entry) + service_value = ServiceEntryValue(type=service_type, vpn=vpn, tloc=tloc_entry, tloc_list=tloc_list_entry) self._insert_action_in_set(ServiceEntry(value=service_value)) @accept_action @@ -322,23 +331,23 @@ class ControlPolicy(ControlPolicyHeader, DefinitionWithSequencesCommonBase): model_config = ConfigDict(populate_by_name=True) def add_route_sequence( - self, name: str = "Route", base_action: AcceptRejectActionType = "reject" + self, name: str = "Route", base_action: AcceptRejectActionType = "reject", ip_type: SequenceIpType = "ipv4" ) -> ControlPolicyRouteSequence: seq = ControlPolicyRouteSequence( sequence_name=name, base_action=base_action, - sequence_ip_type="ipv4", + sequence_ip_type=ip_type, ) self.add(seq) return seq def add_tloc_sequence( - self, name: str = "TLOC", base_action: AcceptRejectActionType = "reject" + self, name: str = "TLOC", base_action: AcceptRejectActionType = "reject", ip_type: SequenceIpType = "ipv4" ) -> ControlPolicyTLOCSequence: seq = ControlPolicyTLOCSequence( sequence_name=name, base_action=base_action, - sequence_ip_type="ipv4", + sequence_ip_type=ip_type, ) self.add(seq) return seq diff --git a/catalystwan/models/policy/list/as_path.py b/catalystwan/models/policy/list/as_path.py index b263b7a2..9e964352 100644 --- a/catalystwan/models/policy/list/as_path.py +++ b/catalystwan/models/policy/list/as_path.py @@ -17,6 +17,10 @@ class ASPathList(PolicyListBase): type: Literal["asPath"] = "asPath" entries: List[ASPathListEntry] = [] + def add_as_path(self, as_path: str): + as_path_entry = ASPathListEntry(as_path=as_path) + self._add_entry(entry=as_path_entry) + class ASPathListEditPayload(ASPathList, PolicyListId): pass diff --git a/catalystwan/models/policy/policy_definition.py b/catalystwan/models/policy/policy_definition.py index 04e89d2b..652530ab 100644 --- a/catalystwan/models/policy/policy_definition.py +++ b/catalystwan/models/policy/policy_definition.py @@ -720,7 +720,7 @@ class ClassMapListEntry(BaseModel): class ServiceEntryValue(BaseModel): model_config = ConfigDict(populate_by_name=True) type: ServiceType - vpn: str + vpn: IntStr tloc: Optional[TLOCEntryValue] = None tloc_list: Optional[TLOCListEntry] = Field( default=None, validation_alias="tlocList", serialization_alias="tlocList" @@ -930,14 +930,11 @@ class ActionSet(BaseModel): MatchEntry = Annotated[ Union[ - AdvancedCommunityEntry, - ExpandedCommunityListEntry, - ExpandedCommunityInLineEntry, AddressEntry, + AdvancedCommunityEntry, AppListEntry, AppListFlatEntry, AsPathListMatchEntry, - LocalPreferenceEntry, CarrierEntry, ClassMapListEntry, ColorListEntry, @@ -959,16 +956,23 @@ class ActionSet(BaseModel): DNSEntry, DomainIDEntry, DSCPEntry, + ExpandedCommunityInLineEntry, + ExpandedCommunityListEntry, ExpandedCommunityListEntry, + ExtendedCommunityEntry, GroupIDEntry, ICMPMessageEntry, - NextHeaderEntry, + LocalPreferenceEntry, MetricEntry, + NextHeaderEntry, + NextHopMatchEntry, OMPTagEntry, OriginatorEntry, OriginEntry, + OspfTagEntry, PacketLengthEntry, PathTypeEntry, + PeerEntry, PLPEntry, PreferenceEntry, PrefixListEntry, @@ -999,11 +1003,8 @@ class ActionSet(BaseModel): TLOCListEntry, TrafficClassEntry, TrafficToEntry, + VPNEntry, VPNListEntry, - NextHopMatchEntry, - OspfTagEntry, - PeerEntry, - ExtendedCommunityEntry, ], Field(discriminator="field"), ] diff --git a/catalystwan/tests/config_migration/policy_converters/test_custom_control.py b/catalystwan/tests/config_migration/policy_converters/test_custom_control.py new file mode 100644 index 00000000..84f05ee8 --- /dev/null +++ b/catalystwan/tests/config_migration/policy_converters/test_custom_control.py @@ -0,0 +1,200 @@ +import unittest +from ipaddress import IPv4Address +from uuid import uuid4 + +from catalystwan.models.configuration.config_migration import PolicyConvertContext +from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import CustomControlParcel +from catalystwan.models.policy.definition.control import ControlPolicy +from catalystwan.models.policy.policy_definition import PolicyAcceptRejectAction +from catalystwan.utils.config_migration.converters.policy.policy_definitions import convert + + +class TestCustomControlConverter(unittest.TestCase): + def test_custom_control_with_route_sequence_conversion(self): + # Arrange + default_action = "accept" + name = "custom_control" + description = "Custom control policy" + # Arrange policy + policy = ControlPolicy( + default_action=PolicyAcceptRejectAction(type=default_action), + name=name, + description=description, + ) + # Arrange Route Sequence + color_list = uuid4() + community_list = uuid4() + expanded_community_list = uuid4() + omp_tag = 2 + origin = "eigrp-summary" + originator = IPv4Address("20.30.3.1") + path_type = "direct-path" + preference = 100 + prefix_list = uuid4() + region_id = 200 + region_role = "border-router" + site_id = 1 + tloc_ip = IPv4Address("30.1.2.3") + tloc_color = "private5" + tloc_encap = "ipsec" + vpn_list_id = uuid4() + vpn_list_entries = ["30", "31", "32"] + seq_name = "route_sequence" + base_action = "accept" + ip_type = "ipv4" + action_affinity = 3 + action_community = "1000:10000" + action_community_additive = True + action_export_vpn_list_id = uuid4() + # action_export_vpn_list_entries = ["50", "51"] + action_omp_tag = 5 + action_preference = 109 + action_service_type = "netsvc1" + action_service_tloc_list_id = uuid4() + action_service_vpn = 70 + + route = policy.add_route_sequence( + name=seq_name, + base_action=base_action, + ip_type=ip_type, + ) + route.match_color_list(color_list) + route.match_community_list(community_list) + route.match_expanded_community_list(expanded_community_list) + route.match_omp_tag(omp_tag) + route.match_origin(origin) + route.match_originator(originator) + route.match_path_type(path_type) + route.match_preference(preference) + route.match_prefix_list(prefix_list) + route.match_region(region_id, region_role) + route.match_site(site_id) + route.match_tloc(ip=tloc_ip, color=tloc_color, encap=tloc_encap) + route.match_vpn_list(vpn_list_id) + route.associate_affinity_action(action_affinity) + route.associate_community_action(action_community, action_community_additive) + route.associate_export_to_action(action_export_vpn_list_id) + route.associate_omp_tag_action(action_omp_tag) + route.associate_preference_action(action_preference) + route.associate_service_action( + action_service_type, action_service_vpn, tloc_list_id=action_service_tloc_list_id + ) + # Arrange context + context = PolicyConvertContext(lan_vpns_by_list_id=dict.fromkeys([vpn_list_id], vpn_list_entries)) + # Act + parcel = convert(policy, uuid4(), context).output + # Assert parcel + assert isinstance(parcel, CustomControlParcel) + assert parcel.parcel_name == name + assert parcel.parcel_description == description + assert parcel.default_action.value == default_action + # Assert route sequence + assert len(parcel.sequences) == 1 + seq = parcel.sequences[0] + assert seq.sequence_type.value == "route" + assert seq.sequence_ip_type.value == ip_type + assert seq.sequence_name.value == seq_name + assert seq.base_action.value == base_action + entries = seq.match.entries + assert entries[0].color_list.ref_id.value == str(color_list) + assert entries[1].community.ref_id.value == str(community_list) + assert entries[2].expanded_community.ref_id.value == str(expanded_community_list) + assert entries[3].omp_tag.value == omp_tag + assert entries[4].origin.value == origin + assert entries[5].originator.value == str(originator) + assert entries[6].path_type.value == path_type + assert entries[7].preference.value == preference + assert entries[8].prefix_list.ref_id.value == str(prefix_list) + assert entries[9].regions[0].region.value == str(region_id) + assert entries[10].role.value == region_role + assert entries[11].site.value == [str(site_id)] + assert entries[12].tloc.ip.value == str(tloc_ip) + assert entries[12].tloc.color.value == tloc_color + assert entries[12].tloc.encap.value == tloc_encap + assert entries[13].vpn.value == vpn_list_entries + action_set = seq.actions[0].set[0] + assert action_set.affinity.value == action_affinity + assert action_set.community.value == str(action_community) + assert action_set.community_additive.value == action_community_additive + + def test_custom_control_with_tloc_sequence_conversion(self): + # Arrange + default_action = "accept" + name = "custom_control" + description = "Custom control policy" + # Arrange policy + policy = ControlPolicy( + default_action=PolicyAcceptRejectAction(type=default_action), + name=name, + description=description, + ) + # Arrange Tloc Sequence + carrier = "carrier1" + color_list = uuid4() + domain_id = 100 + group_id = 200 + omp_tag = 2 + originator = IPv4Address("20.30.3.1") + preference = 100 + region_list_id = uuid4() + region_list_entries = ["Region-9", "Region-10"] + region_role = "edge-router" + site_list_id = uuid4() + site_list_entries = ["SITE_100", "SITE_200"] + tloc_list_id = uuid4() + seq_name = "tloc_sequence" + base_action = "accept" + ip_type = "ipv4" + action_affinity = 3 + action_omp_tag = 9 + action_preference = 11 + tloc = policy.add_tloc_sequence( + name=seq_name, + base_action=base_action, + ip_type=ip_type, + ) + tloc.match_carrier(carrier) + tloc.match_color_list(color_list) + tloc.match_domain_id(domain_id) + tloc.match_group_id(group_id) + tloc.match_omp_tag(omp_tag) + tloc.match_originator(originator) + tloc.match_preference(preference) + tloc.match_region_list(region_list_id, region_role) + tloc.match_site_list(site_list_id) + tloc.match_tloc_list(tloc_list_id) + tloc.associate_affinity_action(action_affinity) + tloc.associate_omp_tag_action(action_omp_tag) + tloc.associate_preference_action(action_preference) + # Arrange context + context = PolicyConvertContext( + sites_by_list_id=dict.fromkeys([site_list_id], site_list_entries), + regions_by_list_id=dict.fromkeys([region_list_id], region_list_entries), + ) + # Act + parcel = convert(policy, uuid4(), context).output + # Assert parcel + assert isinstance(parcel, CustomControlParcel) + assert parcel.parcel_name == name + assert parcel.parcel_description == description + assert parcel.default_action.value == default_action + # Assert tloc sequence + assert len(parcel.sequences) == 1 + seq = parcel.sequences[0] + assert seq.sequence_type.value == "tloc" + assert seq.sequence_ip_type.value == ip_type + assert seq.sequence_name.value == seq_name + assert seq.base_action.value == base_action + entries = seq.match.entries + assert entries[0].carrier.value == carrier + assert entries[1].color_list.ref_id.value == str(color_list) + assert entries[2].domain_id.value == domain_id + assert entries[3].group_id.value == group_id + assert entries[4].omp_tag.value == omp_tag + assert entries[5].originator.value == str(originator) + assert entries[6].preference.value == preference + assert entries[7].regions[0].region.value == region_list_entries[0] + assert entries[7].regions[1].region.value == region_list_entries[1] + assert entries[8].role.value == region_role + assert entries[9].site.value == site_list_entries + assert entries[10].tloc_list.ref_id.value == str(tloc_list_id) diff --git a/catalystwan/utils/config_migration/converters/feature_template/base.py b/catalystwan/utils/config_migration/converters/feature_template/base.py index 98092e30..46825113 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/base.py +++ b/catalystwan/utils/config_migration/converters/feature_template/base.py @@ -1,10 +1,12 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from abc import ABC, abstractmethod -from typing import Optional, Tuple +from typing import Dict, Optional, Tuple, Union +from catalystwan.api.configuration_groups.parcel import Global, Variable, as_global from catalystwan.models.configuration.config_migration import ConvertResult from catalystwan.models.configuration.feature_profile.parcel import AnyParcel from catalystwan.utils.config_migration.converters.exceptions import CatalystwanConverterCantConvertException +from catalystwan.utils.config_migration.converters.utils import convert_interface_name class FTConverter(ABC): @@ -33,3 +35,16 @@ def convert(self, name: str, description: str, template_values: dict) -> Convert except (CatalystwanConverterCantConvertException, AttributeError, LookupError, TypeError, ValueError) as e: self._convert_result.update_status("failed", str(e.__class__.__name__ + ": " + str(e))) return self._convert_result + + def parse_interface_name(self, data: Dict) -> Union[Global[str], Variable]: + if_name = data.get("if_name") + if isinstance(if_name, Variable): + return if_name + elif isinstance(if_name, Global): + converted_if_name = convert_interface_name(if_name.value) + if converted_if_name != if_name.value: + self._convert_result.update_status( + "partial", f"Converted interface name: {if_name.value} -> {converted_if_name}" + ) + return as_global(converted_if_name) + raise CatalystwanConverterCantConvertException("Interface name is required") diff --git a/catalystwan/utils/config_migration/converters/feature_template/ethernet.py b/catalystwan/utils/config_migration/converters/feature_template/ethernet.py index 6ccf19d2..64fd6dab 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/ethernet.py +++ b/catalystwan/utils/config_migration/converters/feature_template/ethernet.py @@ -23,7 +23,6 @@ StaticIPv6AddressConfig, ) from catalystwan.utils.config_migration.converters.feature_template.helpers import create_dict_without_none -from catalystwan.utils.config_migration.converters.utils import parse_interface_name from catalystwan.utils.config_migration.steps.constants import MANAGEMENT_VPN_ETHERNET from .base import FTConverter @@ -39,7 +38,7 @@ def create_parcel(self, name: str, description: str, template_values: dict) -> I parcel_name=name, parcel_description=description, advanced=self.parse_advanced(data), - interface_name=parse_interface_name(self, data), + interface_name=self.parse_interface_name(data), interface_description=data.get("description", Default[None](value=None)), intf_ip_address=self.parse_ipv4_address(data), shutdown=data.get("shutdown"), diff --git a/catalystwan/utils/config_migration/converters/feature_template/lan/ethernet.py b/catalystwan/utils/config_migration/converters/feature_template/lan/ethernet.py index 20a5a4ad..bdbd90df 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/lan/ethernet.py +++ b/catalystwan/utils/config_migration/converters/feature_template/lan/ethernet.py @@ -30,7 +30,6 @@ VrrpIPv6, ) from catalystwan.utils.config_migration.converters.feature_template.base import FTConverter -from catalystwan.utils.config_migration.converters.utils import parse_interface_name from catalystwan.utils.config_migration.steps.constants import LAN_VPN_ETHERNET @@ -92,7 +91,7 @@ def create_parcel(self, name: str, description: str, template_values: dict) -> I parcel_name=name, parcel_description=description, shutdown=data.get("shutdown", as_default(True)), - interface_name=parse_interface_name(self, data), + interface_name=self.parse_interface_name(data), ethernet_description=data.get("description"), interface_ip_address=self.configure_ipv4_address(data), dhcp_helper=data.get("dhcp_helper"), diff --git a/catalystwan/utils/config_migration/converters/feature_template/wan/ethernet.py b/catalystwan/utils/config_migration/converters/feature_template/wan/ethernet.py index b8ffec02..1b01b487 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/wan/ethernet.py +++ b/catalystwan/utils/config_migration/converters/feature_template/wan/ethernet.py @@ -32,7 +32,6 @@ ) from catalystwan.utils.config_migration.converters.feature_template.base import FTConverter from catalystwan.utils.config_migration.converters.feature_template.helpers import create_dict_without_none -from catalystwan.utils.config_migration.converters.utils import parse_interface_name from catalystwan.utils.config_migration.steps.constants import WAN_VPN_ETHERNET logger = logging.getLogger(__name__) @@ -45,7 +44,7 @@ def create_parcel(self, name: str, description: str, template_values: dict) -> I data = deepcopy(template_values) encapsulation = self.parse_encapsulations(data.get("tunnel_interface", {}).get("encapsulation", [])) - interface_name = parse_interface_name(self, data) + interface_name = self.parse_interface_name(data) interface_description = data.get( "description", as_global(description) ) # Edge case where model doesn't have description but its required diff --git a/catalystwan/utils/config_migration/converters/policy/centralized_policy.py b/catalystwan/utils/config_migration/converters/policy/centralized_policy.py new file mode 100644 index 00000000..c29914eb --- /dev/null +++ b/catalystwan/utils/config_migration/converters/policy/centralized_policy.py @@ -0,0 +1,184 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates + +from dataclasses import dataclass, field +from typing import Dict, List, Set +from uuid import UUID, uuid4 + +from pydantic import ValidationError + +from catalystwan.models.configuration.config_migration import ( + FailedConversionItem, + PolicyConvertContext, + TransformedFeatureProfile, + TransformedParcel, + TransformedTopologyGroup, + TransformHeader, + UX1Config, + UX2Config, +) +from catalystwan.models.configuration.feature_profile.common import FeatureProfileCreationPayload +from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import CustomControlParcel +from catalystwan.models.configuration.feature_profile.sdwan.topology.hubspoke import HubSpokeParcel +from catalystwan.models.configuration.feature_profile.sdwan.topology.mesh import MeshParcel +from catalystwan.models.configuration.topology_group import TopologyGroup +from catalystwan.models.policy.centralized import CentralizedPolicy, CentralizedPolicyDefinition, ControlPolicyItem +from catalystwan.models.policy.definition.control import ControlPolicy +from catalystwan.models.policy.definition.hub_and_spoke import HubAndSpokePolicy +from catalystwan.models.policy.definition.mesh import MeshPolicy + +TOPOLOGY_POLICIES = ["control", "hubAndSpoke", "mesh"] + + +@dataclass +class ControlPolicyApplication: + inbound_sites: List[str] = field(default_factory=list) + outbound_sites: List[str] = field(default_factory=list) + + +def find_control_assembly(policy: CentralizedPolicy, control_definition_id: UUID) -> ControlPolicyItem: + definition = policy.policy_definition + assert isinstance(definition, CentralizedPolicyDefinition) + assembly = definition.find_assembly_item_by_definition_id(control_definition_id) + assert isinstance(assembly, ControlPolicyItem) + return assembly + + +def convert_control_policy_application( + assembly: ControlPolicyItem, context: PolicyConvertContext +) -> ControlPolicyApplication: + application = ControlPolicyApplication() + for entry in assembly.entries: + if entry.site_lists: + for site_list_id in entry.site_lists: + sites = context.sites_by_list_id.get(site_list_id, []) + if entry.direction == "in": + application.inbound_sites.extend(sites) + elif entry.direction == "out": + application.outbound_sites.extend(sites) + application.inbound_sites = list(set(application.inbound_sites)) + application.outbound_sites = list(set(application.outbound_sites)) + return application + + +class CentralizedPolicyConverter: + def __init__(self, ux1: UX1Config, context: PolicyConvertContext, ux2: UX2Config): + self.ux1 = ux1 + self.context = context + self.ux2 = ux2 + self.unreferenced_topologies: List[TransformedParcel] = list() + self.topology_lookup: Dict[UUID, List[TransformedParcel]] = dict() + self._create_topology_by_policy_id_lookup() + self.failed_items: List[FailedConversionItem] = list() + + def _create_topology_by_policy_id_lookup(self) -> None: + for policy_definition in self.ux1.policies.policy_definitions: + if policy_definition.type in TOPOLOGY_POLICIES: + assert isinstance(policy_definition, (ControlPolicy, HubAndSpokePolicy, MeshPolicy)) + transformed_topology_parcels = self.ux2.list_transformed_parcels_with_origin( + {policy_definition.definition_id} + ) + if policy_definition.reference_count == 0: + self.unreferenced_topologies.extend(transformed_topology_parcels) + for ref_id in set([ref.id for ref in policy_definition.references]): + if self.topology_lookup.get(ref_id) is not None: + self.topology_lookup[ref_id].extend(transformed_topology_parcels) + else: + self.topology_lookup[ref_id] = list(transformed_topology_parcels) + + def update_topology_groups_and_profiles(self) -> None: + parcel_remove_ids: Set[UUID] = set() + for centralized_policy in self.ux1.policies.centralized_policies: + problems: List[str] = list() + if centralized_policy.policy_type == "feature": + dst_transformed_parcels: List[TransformedParcel] = list() + if src_transformed_parcels := self.topology_lookup.get(centralized_policy.policy_id): + for src_transformed_parcel in src_transformed_parcels: + try: + assert isinstance( + src_transformed_parcel.parcel, (MeshParcel, HubSpokeParcel, CustomControlParcel) + ) + parcel_remove_ids.add(src_transformed_parcel.header.origin) + parcel = src_transformed_parcel.parcel.model_copy(deep=True) + header = src_transformed_parcel.header.model_copy(deep=True) + if parcel.type_ == "custom-control": + assembly = find_control_assembly(centralized_policy, header.origin) + application = convert_control_policy_application( + assembly=assembly, context=self.context + ) + parcel.assign_target_sites( + inbound_sites=application.inbound_sites, + outbound_sites=application.outbound_sites, + ) + header.origin = uuid4() + dst_transformed_parcel = TransformedParcel(header=header, parcel=parcel) + dst_transformed_parcels.append(dst_transformed_parcel) + self.ux2.profile_parcels.append(dst_transformed_parcel) + except (ValidationError, AssertionError) as e: + problems.append(str(e)) + if dst_transformed_parcels: + self.ux2.feature_profiles.append( + TransformedFeatureProfile( + header=TransformHeader( + type="topology", + origin=centralized_policy.policy_id, + subelements=set([p.header.origin for p in dst_transformed_parcels]), + origname=centralized_policy.policy_name, + ), + feature_profile=FeatureProfileCreationPayload( + name=centralized_policy.policy_name, + description=centralized_policy.policy_description, + ), + ) + ) + self.ux2.topology_groups.append( + TransformedTopologyGroup( + header=TransformHeader( + type="", + origin=centralized_policy.policy_id, + origname=centralized_policy.policy_name, + subelements={centralized_policy.policy_id}, + ), + topology_group=TopologyGroup( + name=centralized_policy.policy_name, + description=centralized_policy.policy_description, + solution="sdwan", + ), + ) + ) + if problems: + self.failed_items.append( + FailedConversionItem(policy=centralized_policy, exception_message="\n".join(problems)) + ) + self.export_standalone_topology_parcels() + self.ux2.remove_transformed_parcels_with_origin(parcel_remove_ids) + + def export_standalone_topology_parcels(self): + # Topology Group and Profile + if self.unreferenced_topologies: + topology_name = "Unreferenced-Topologies" + topology_description = ( + "Created by config migration tool, " + "contains topologies which were not attached to any Centralized Policy" + ) + self.ux2.feature_profiles.append( + TransformedFeatureProfile( + header=TransformHeader( + type="topology", + origin=UUID(int=0), + subelements=set([p.header.origin for p in self.unreferenced_topologies]), + origname=topology_name, + ), + feature_profile=FeatureProfileCreationPayload( + name=topology_name, + description=topology_description, + ), + ) + ) + self.ux2.topology_groups.append( + TransformedTopologyGroup( + header=TransformHeader(type="", origin=UUID(int=0), origname=topology_name, subelements=set()), + topology_group=TopologyGroup( + name=topology_name, description=topology_description, solution="sdwan" + ), + ) + ) diff --git a/catalystwan/utils/config_migration/converters/policy/policy_definitions.py b/catalystwan/utils/config_migration/converters/policy/policy_definitions.py index 6b31fab4..62d99059 100644 --- a/catalystwan/utils/config_migration/converters/policy/policy_definitions.py +++ b/catalystwan/utils/config_migration/converters/policy/policy_definitions.py @@ -50,7 +50,10 @@ ) from catalystwan.models.configuration.feature_profile.sdwan.system.device_access import DeviceAccessIPv4Parcel from catalystwan.models.configuration.feature_profile.sdwan.system.device_access_ipv6 import DeviceAccessIPv6Parcel -from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import CustomControlParcel +from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import ( + BaseAction, + CustomControlParcel, +) from catalystwan.models.configuration.feature_profile.sdwan.topology.hubspoke import HubSpokeParcel from catalystwan.models.configuration.feature_profile.sdwan.topology.mesh import MeshParcel from catalystwan.models.configuration.network_hierarchy.cflowd import CflowdParcel @@ -168,10 +171,120 @@ def advanced_malware_protection( ) -def control(in_: ControlPolicy, uuid: UUID, context) -> ConvertResult[CustomControlParcel]: - # out = CustomControlParcel(**_get_parcel_name_desc(in_)) - # TODO: convert definition - return ConvertResult[CustomControlParcel](output=None, status="unsupported") +def control(in_: ControlPolicy, uuid: UUID, context: PolicyConvertContext) -> ConvertResult[CustomControlParcel]: + result = ConvertResult[CustomControlParcel](output=None) + out = CustomControlParcel( + **_get_parcel_name_desc(in_), default_action=as_global(in_.default_action.type, BaseAction) + ) + for in_seq in in_.sequences: + ip_type = in_seq.sequence_ip_type + if not ip_type: + ip_type = "ipv4" + out_seq = out.add_sequence( + id_=in_seq.sequence_id, + name=in_seq.sequence_name, + type_=in_seq.sequence_type, + ip_type=ip_type, + base_action=in_seq.base_action, + ) + for in_match in in_seq.match.entries: + if in_match.field == "carrier": + out_seq.match_carrier(in_match.value) + elif in_match.field == "colorList": + out_seq.match_color_list(in_match.ref) + elif in_match.field == "community": + out_seq.match_community(in_match.ref) + elif in_match.field == "domainId": + out_seq.match_domain_id(int(in_match.value)) + elif in_match.field == "expandedCommunity": + out_seq.match_expanded_community(in_match.ref) + elif in_match.field == "groupId": + out_seq.match_group_id(int(in_match.value)) + elif in_match.field == "ompTag": + out_seq.match_omp_tag(int(in_match.value)) + elif in_match.field == "origin": + out_seq.match_origin(in_match.value) + elif in_match.field == "originator": + out_seq.match_originator(in_match.value) + elif in_match.field == "pathType": + out_seq.match_path_type(in_match.value) + elif in_match.field == "preference": + out_seq.match_preference(int(in_match.value)) + elif in_match.field == "prefixList": + out_seq.match_prefix_list(in_match.ref) + elif in_match.field == "regionId": + out_seq.match_region(in_match.value) + elif in_match.field == "regionList": + regions = context.regions_by_list_id.get(in_match.ref, []) + if regions: + for region in regions: + out_seq.match_region(region=region) + else: + result.update_status( + "partial", + f"sequence[{in_seq.sequence_id}] contains region list which is not matching any defined region " + f"{in_match.field} = {in_match.ref}", + ) + elif in_match.field == "role": + out_seq.match_role(in_match.value) + elif in_match.field == "siteId": + out_seq.match_sites([in_match.value]) + elif in_match.field == "siteList": + sites = context.sites_by_list_id.get(in_match.ref, []) + if sites: + out_seq.match_sites(sites=sites) + else: + result.update_status( + "partial", + f"sequence[{in_seq.sequence_id}] contains site list which is not matching any defined site " + f"{in_match.field} = {in_match.ref}", + ) + elif in_match.field == "tloc": + out_seq.match_tloc(ip=in_match.value.ip, color=in_match.value.color, encap=in_match.value.encap) + elif in_match.field == "tlocList": + out_seq.match_tloc_list(in_match.ref) + elif in_match.field == "vpnList": + vpns = context.lan_vpns_by_list_id.get(in_match.ref, []) + if vpns: + out_seq.match_vpns(vpns=vpns) + else: + result.update_status( + "partial", + f"sequence[{in_seq.sequence_id}] contains vpn list which is not matching any defined vpn " + f"{in_match.field} = {in_match.ref}", + ) + for in_action in in_seq.actions: + if in_action.type == "set": + for param in in_action.parameter: + if param.field == "affinity": + out_seq.associate_affinitty_action(affinity=int(param.value)) + elif param.field == "community" and param.value is not None: + out_seq.associate_community_action(community=param.value) + elif param.field == "communityAdditive": + out_seq.associate_community_additive_action(additive=True) + elif param.field == "service": + if param.value.tloc_list is not None: + out_seq.associate_service_action( + service_type=param.value.type, + vpn=param.value.vpn, + tloc_list_id=param.value.tloc_list.ref, + ) + elif param.value.tloc is not None: + out_seq.associate_service_action( + service_type=param.value.type, + vpn=param.value.vpn, + ip=param.value.tloc.ip, + color=param.value.tloc.color, + encap=param.value.tloc.encap, + ) + elif param.field == "tloc": + out_seq.associate_tloc(color=param.value.color, encap=param.value.encap, ip=param.value.ip) + elif param.field == "tlocAction": + out_seq.associate_tloc_action(tloc_action_type=param.value) + elif param.field == "tlocList": + out_seq.associate_tloc_list(tloc_list_id=param.ref) + result.output = out + return result def dns_security(in_: DnsSecurityPolicy, uuid: UUID, context: PolicyConvertContext) -> ConvertResult[DnsParcel]: diff --git a/catalystwan/utils/config_migration/converters/utils.py b/catalystwan/utils/config_migration/converters/utils.py index 56ecb67a..95553f61 100644 --- a/catalystwan/utils/config_migration/converters/utils.py +++ b/catalystwan/utils/config_migration/converters/utils.py @@ -1,10 +1,5 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates import re -from typing import Dict, Union - -from catalystwan.api.configuration_groups.parcel import Global, Variable, as_global -from catalystwan.utils.config_migration.converters.exceptions import CatalystwanConverterCantConvertException -from catalystwan.utils.config_migration.converters.feature_template.base import FTConverter NEGATIVE_VARIABLE_REGEX = re.compile(r"[^.\/\[\]a-zA-Z0-9_-]") @@ -24,17 +19,3 @@ def convert_interface_name(if_name: str) -> str: new_if_name = value + if_name[len(key) :] return new_if_name return if_name - - -def parse_interface_name(converter: FTConverter, data: Dict) -> Union[Global[str], Variable]: - if_name = data.get("if_name") - if isinstance(if_name, Variable): - return if_name - elif isinstance(if_name, Global): - converted_if_name = convert_interface_name(if_name.value) - if converted_if_name != if_name.value: - converter._convert_result.update_status( - "partial", f"Converted interface name: {if_name.value} -> {converted_if_name}" - ) - return as_global(converted_if_name) - raise CatalystwanConverterCantConvertException("Interface name is required") diff --git a/catalystwan/utils/config_migration/creators/config_pusher.py b/catalystwan/utils/config_migration/creators/config_pusher.py index 5e5a75f8..2ebf8bb0 100644 --- a/catalystwan/utils/config_migration/creators/config_pusher.py +++ b/catalystwan/utils/config_migration/creators/config_pusher.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates import logging -from typing import Callable, Dict, List, Optional, Set, Tuple, cast +from typing import Callable, Dict, List, cast from uuid import UUID from pydantic import BaseModel @@ -10,20 +10,17 @@ from catalystwan.exceptions import ManagerHTTPError from catalystwan.models.configuration.config_migration import ( PushContext, - TopologyGroupReport, TransformedFeatureProfile, TransformedParcel, UX2Config, UX2ConfigPushResult, ) from catalystwan.models.configuration.feature_profile.common import ProfileType -from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import CustomControlParcel -from catalystwan.models.configuration.feature_profile.sdwan.topology.hubspoke import HubSpokeParcel -from catalystwan.models.configuration.feature_profile.sdwan.topology.mesh import MeshParcel from catalystwan.session import ManagerSession from catalystwan.utils.config_migration.creators.groups_of_interests_pusher import GroupsOfInterestPusher from catalystwan.utils.config_migration.creators.localized_policy_pusher import LocalizedPolicyPusher from catalystwan.utils.config_migration.creators.security_policy_pusher import SecurityPolicyPusher +from catalystwan.utils.config_migration.creators.topology_groups_pusher import TopologyGroupsPusher from catalystwan.utils.config_migration.factories.parcel_pusher import ParcelPusherFactory logger = logging.getLogger(__name__) @@ -67,6 +64,13 @@ def __init__( push_result=self._push_result, push_context=self._push_context, ) + self._topology_groups_pusher = TopologyGroupsPusher( + ux2_config=ux2_config, + session=session, + progress=progress, + push_result=self._push_result, + push_context=self._push_context, + ) self._ux2_config = ux2_config self._progress = progress @@ -85,9 +89,7 @@ def push(self) -> UX2ConfigPushResult: self._groups_of_interests_pusher.push() self._localized_policy_feature_pusher.push() self._security_policy_pusher.push() - self._create_topology_groups( - self._push_context.default_policy_object_profile_id - ) # needs to be executed after vpn parcels and groups of interests are created + self._topology_groups_pusher.push() self._push_result.report.set_failed_push_parcels_flat_list() logger.debug(f"Configuration push completed. Rollback configuration {self._push_result}") return self._push_result @@ -193,63 +195,3 @@ def _create_parcels_list(self, transformed_feature_profile: TransformedFeaturePr else: parcels.append(transformed_parcel) return parcels - - def _create_topology_groups(self, default_policy_object_profile_id: Optional[UUID]): - if default_policy_object_profile_id is None: - logger.error("Cannot create Topology Group without Default Policy Object Profile") - return - profile_origin_map: Dict[str, Tuple[UUID, Set[UUID]]] = {} - profile_api = self._session.api.sdwan_feature_profiles.topology - group_api = self._session.endpoints.configuration.topology_group - ttps = [p for p in self._ux2_config.feature_profiles if p.header.type == "topology"] - for i, ttp in enumerate(ttps): - profile = ttp.feature_profile - try: - profile_id = profile_api.create_profile(profile.name, profile.description).id - self._push_result.rollback.add_feature_profile(profile_id, "topology") - profile_origin_map[profile.name] = (profile_id, ttp.header.subelements) - self._progress( - f"Creating Topology Profile: {profile.name}", - i + 1, - len(ttps), - ) - except ManagerHTTPError as e: - logger.error(f"Error occured during topology profile creation: {e}") - - ttgs = self._ux2_config.topology_groups - for ttg in ttgs: - group = ttg.topology_group - profile_id, origins = profile_origin_map[group.name] - group.add_profiles([profile_id, default_policy_object_profile_id]) - try: - group_id = group_api.create_topology_group(group).id - self._push_result.rollback.add_topology_group(group_id) - self._progress( - f"Creating Topology Group: {group.name}", - i + 1, - len(ttps), - ) - profile_report = FeatureProfileBuildReport(profile_name=group.name, profile_uuid=profile_id) - group_report = TopologyGroupReport(name=group.name, uuid=group_id, feature_profiles=[profile_report]) - self._push_result.report.topology_groups.append(group_report) - except ManagerHTTPError as e: - logger.error(f"Error occured during topology group creation: {e}") - continue - - for transformed_parcel in self._ux2_config.list_transformed_parcels_with_origin(origins): - parcel = transformed_parcel.parcel - if isinstance(parcel, (CustomControlParcel, HubSpokeParcel, MeshParcel)): - try: - parcel_id = profile_api.create_parcel(profile_id, parcel).id - profile_report.add_created_parcel(parcel_name=parcel.parcel_name, parcel_uuid=parcel_id) - self._push_context.id_lookup[transformed_parcel.header.origin] = parcel_id - except ManagerHTTPError as e: - logger.error(f"Error occured during topology profile parcel creation: {e}") - profile_report.add_failed_parcel( - parcel_name=parcel.parcel_name, - parcel_type=parcel.type_, - error_info=e.info, - request=e.request, - ) - else: - logger.warning(f"Unexpected parcel type {type(parcel)}") diff --git a/catalystwan/utils/config_migration/creators/localized_policy_pusher.py b/catalystwan/utils/config_migration/creators/localized_policy_pusher.py index 441a8146..0f93f3ea 100644 --- a/catalystwan/utils/config_migration/creators/localized_policy_pusher.py +++ b/catalystwan/utils/config_migration/creators/localized_policy_pusher.py @@ -239,6 +239,7 @@ def push(self): app_prio_builder.add_parcel(parcel) try: report = app_prio_builder.build() + self._push_result.rollback.add_feature_profile(report.profile_uuid, app_prio_profile.header.type) app_prio_reports.append(report) except ManagerHTTPError as e: logger.error(f"Error occured during Application Priority profile creation: {e.info}") diff --git a/catalystwan/utils/config_migration/creators/topology_groups_pusher.py b/catalystwan/utils/config_migration/creators/topology_groups_pusher.py new file mode 100644 index 00000000..75019105 --- /dev/null +++ b/catalystwan/utils/config_migration/creators/topology_groups_pusher.py @@ -0,0 +1,102 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +import logging +from typing import Callable + +from catalystwan.api.builders.feature_profiles.report import FeatureProfileBuildReport +from catalystwan.exceptions import ManagerHTTPError +from catalystwan.models.configuration.config_migration import ( + PushContext, + TopologyGroupReport, + UX2Config, + UX2ConfigPushResult, +) +from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import CustomControlParcel +from catalystwan.models.configuration.feature_profile.sdwan.topology.hubspoke import HubSpokeParcel +from catalystwan.models.configuration.feature_profile.sdwan.topology.mesh import MeshParcel +from catalystwan.session import ManagerSession +from catalystwan.utils.config_migration.creators.references_updater import update_parcel_references + +logger = logging.getLogger(__name__) + + +class TopologyGroupsPusher: + def __init__( + self, + ux2_config: UX2Config, + session: ManagerSession, + push_result: UX2ConfigPushResult, + push_context: PushContext, + progress: Callable[[str, int, int], None], + ) -> None: + self._ux2_config = ux2_config + self._session = session + self._push_result = push_result + self._push_context = push_context + self._progress = progress + self.push_context = push_context + + def push(self) -> None: + if self.push_context.default_policy_object_profile_id is None: + logger.error("Cannot create Topology Group without Default Policy Object Profile") + return + profile_api = self._session.api.sdwan_feature_profiles.topology + group_api = self._session.endpoints.configuration.topology_group + + # Top-Down starting from Topology Groups + ttgs = self._ux2_config.topology_groups + ttps_map = { + ttp.header.origin: ttp for ttp in self._ux2_config.feature_profiles if ttp.header.type == "topology" + } + for i, ttg in enumerate(ttgs): + self._progress( + f"Creating Topology Group: {ttg.topology_group.name}", + i + 1, + len(ttgs), + ) + + # Create Topology Feature Profile + ttp = ttps_map.get(ttg.header.origin) + if ttp is None: + logger.error(f"Topology Profile not found for Topology Group {ttg.topology_group.name}") + continue + profile = ttp.feature_profile + try: + profile_id = profile_api.create_profile(profile.name, profile.description).id + profile_report = FeatureProfileBuildReport(profile_name=profile.name, profile_uuid=profile_id) + self._push_result.rollback.add_feature_profile(profile_id, "topology") + + except ManagerHTTPError as e: + logger.error(f"Error occured during Topology Profile creation: {e}") + continue + + # Push Topology Feature Profile Parcels + for transformed_parcel in self._ux2_config.list_transformed_parcels_with_origin(ttp.header.subelements): + parcel = update_parcel_references(transformed_parcel.parcel, self.push_context.id_lookup) + if isinstance(parcel, (CustomControlParcel, HubSpokeParcel, MeshParcel)): + try: + parcel_id = profile_api.create_parcel(profile_id, parcel).id + profile_report.add_created_parcel(parcel_name=parcel.parcel_name, parcel_uuid=parcel_id) + self.push_context.id_lookup[transformed_parcel.header.origin] = parcel_id + except ManagerHTTPError as e: + logger.error(f"Error occured during topology profile parcel creation: {e}") + profile_report.add_failed_parcel( + parcel_name=parcel.parcel_name, + parcel_type=parcel.type_, + error_info=e.info, + request=e.request, + ) + else: + logger.warning(f"Unexpected parcel type {type(parcel)}") + + # Push Topology Group + group = ttg.topology_group + group.add_profiles([profile_id, self.push_context.default_policy_object_profile_id]) + try: + group_id = group_api.create(group).id + self._push_result.rollback.add_topology_group(group_id) + group_report = TopologyGroupReport(name=group.name, uuid=group_id, feature_profiles=[profile_report]) + self._push_result.report.topology_groups.append(group_report) + self._push_context.id_lookup[ttg.header.origin] = group_id + except ManagerHTTPError as e: + logger.error(f"Error occured during topology group creation: {e}") + continue diff --git a/catalystwan/workflows/config_migration.py b/catalystwan/workflows/config_migration.py index d0de0265..b428db55 100644 --- a/catalystwan/workflows/config_migration.py +++ b/catalystwan/workflows/config_migration.py @@ -17,7 +17,6 @@ TransformedConfigGroup, TransformedFeatureProfile, TransformedParcel, - TransformedTopologyGroup, TransformHeader, UX1Config, UX2Config, @@ -25,12 +24,12 @@ VersionInfo, ) from catalystwan.models.configuration.feature_profile.common import FeatureProfileCreationPayload -from catalystwan.models.configuration.topology_group import TopologyGroup from catalystwan.models.policy import AnyPolicyDefinitionInfo from catalystwan.session import ManagerSession from catalystwan.utils.config_migration.converters.feature_template.cloud_credentials import ( create_cloud_credentials_from_templates, ) +from catalystwan.utils.config_migration.converters.policy.centralized_policy import CentralizedPolicyConverter from catalystwan.utils.config_migration.converters.policy.policy_definitions import convert as convert_policy_definition from catalystwan.utils.config_migration.converters.policy.policy_lists import convert as convert_policy_list from catalystwan.utils.config_migration.converters.policy.policy_settings import convert_localized_policy_settings @@ -223,8 +222,6 @@ "vpn-vedge", ] -TOPOLOGY_POLICIES = ["control", "hubAndSpoke", "mesh"] - def log_progress(task: str, completed: int, total: int) -> None: logger.info(f"{task} {completed}/{total}") @@ -373,7 +370,9 @@ def transform(ux1: UX1Config, add_suffix: bool = False) -> ConfigTransformResult ux2.cloud_credentials = create_cloud_credentials_from_templates(cloud_credential_templates) # Prepare Context for Policy Conversion (VPN Parcels must be already transformed) - policy_context = PolicyConvertContext.from_configs(ux1.network_hierarchy, ux2.profile_parcels) + policy_context = PolicyConvertContext.from_configs( + ux1.network_hierarchy, ux2.profile_parcels, ux1.version.platform_api + ) policy_context.populate_activated_centralized_policy_item_ids(ux1.policies.centralized_policies) # Policy Lists @@ -521,30 +520,10 @@ def transform(ux1: UX1Config, add_suffix: bool = False) -> ConfigTransformResult ) ux2.profile_parcels.append(TransformedParcel(header=header, parcel=sp_parcel)) - # Topology Group and Profile - topology_sources = [p.definition_id for p in ux1.policies.policy_definitions if p.type in TOPOLOGY_POLICIES] - topology_name = "Migrated-from-policy-config" - topology_description = "Created by config migration tool" - ux2.feature_profiles.append( - TransformedFeatureProfile( - header=TransformHeader( - type="topology", - origin=UUID(int=0), - subelements=set(topology_sources), - origname=topology_name, - ), - feature_profile=FeatureProfileCreationPayload( - name=topology_name, - description=topology_description, - ), - ) - ) - ux2.topology_groups.append( - TransformedTopologyGroup( - header=TransformHeader(type="", origin=UUID(int=0), origname=topology_name, subelements=set()), - topology_group=TopologyGroup(name=topology_name, description=topology_description, solution="sdwan"), - ) - ) + # Centralized Policies + centralized_policy_converter = CentralizedPolicyConverter(ux1=ux1, context=policy_context, ux2=ux2) + centralized_policy_converter.update_topology_groups_and_profiles() + transform_result.failed_items.extend(centralized_policy_converter.failed_items) # Add additional objects emmited by the conversion ux2.thread_grid_api = policy_context.threat_grid_api