diff --git a/catalystwan/api/feature_profile_api.py b/catalystwan/api/feature_profile_api.py index e5c9a097d..c553ebfd1 100644 --- a/catalystwan/api/feature_profile_api.py +++ b/catalystwan/api/feature_profile_api.py @@ -178,21 +178,21 @@ def get( return self.endpoint.get_all(profile_id, parcel_type._get_parcel_type()) return self.endpoint.get_by_id(profile_id, parcel_type._get_parcel_type(), parcel_id) - def create(self, profile_id: UUID, payload: AnyOtherParcel) -> ParcelCreationResponse: + def create_parcel(self, profile_id: UUID, payload: AnyOtherParcel) -> ParcelCreationResponse: """ Create Other Parcel for selected profile_id based on payload type """ return self.endpoint.create(profile_id, payload._get_parcel_type(), payload) - def update(self, profile_id: UUID, payload: AnyOtherParcel, parcel_id: UUID) -> ParcelCreationResponse: + def update_parcel(self, profile_id: UUID, payload: AnyOtherParcel, parcel_id: UUID) -> ParcelCreationResponse: """ Update Other Parcel for selected profile_id based on payload type """ return self.endpoint.update(profile_id, payload._get_parcel_type(), parcel_id, payload) - def delete(self, profile_id: UUID, parcel_type: Type[AnyOtherParcel], parcel_id: UUID) -> None: + def delete_parcel(self, profile_id: UUID, parcel_type: Type[AnyOtherParcel], parcel_id: UUID) -> None: """ Delete Other Parcel for selected profile_id based on payload type """ @@ -276,7 +276,7 @@ def get_schema( return self.endpoint.get_schema(profile_id, parcel_type._get_parcel_type()) @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[AAAParcel], @@ -284,7 +284,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[BFDParcel], @@ -292,7 +292,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[LoggingParcel], @@ -300,7 +300,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[BannerParcel], @@ -308,7 +308,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[BasicParcel], @@ -316,7 +316,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[GlobalParcel], @@ -324,7 +324,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[NTPParcel], @@ -332,7 +332,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[MRFParcel], @@ -340,7 +340,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[OMPParcel], @@ -348,7 +348,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[SecurityParcel], @@ -356,7 +356,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[SNMPParcel], @@ -366,7 +366,7 @@ def get( # get by id @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[AAAParcel], @@ -375,7 +375,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[BFDParcel], @@ -384,7 +384,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[LoggingParcel], @@ -393,7 +393,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[BannerParcel], @@ -402,7 +402,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[BasicParcel], @@ -411,7 +411,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[GlobalParcel], @@ -420,7 +420,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[NTPParcel], @@ -429,7 +429,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[MRFParcel], @@ -438,7 +438,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[OMPParcel], @@ -447,7 +447,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[SecurityParcel], @@ -456,7 +456,7 @@ def get( ... @overload - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[SNMPParcel], @@ -464,7 +464,7 @@ def get( ) -> DataSequence[Parcel[SNMPParcel]]: ... - def get( + def get_parcels( self, profile_id: UUID, parcel_type: Type[AnySystemParcel], @@ -478,7 +478,7 @@ def get( return self.endpoint.get_all(profile_id, parcel_type._get_parcel_type()) return self.endpoint.get_by_id(profile_id, parcel_type._get_parcel_type(), parcel_id) - def create(self, profile_id: UUID, payload: AnySystemParcel) -> ParcelCreationResponse: + def create_parcel(self, profile_id: UUID, payload: AnySystemParcel) -> ParcelCreationResponse: """ Create System Parcel for selected profile_id based on payload type """ @@ -493,7 +493,7 @@ def update(self, profile_id: UUID, payload: AnySystemParcel, parcel_id: UUID) -> return self.endpoint.update(profile_id, payload._get_parcel_type(), parcel_id, payload) @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[AAAParcel], @@ -502,7 +502,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[BFDParcel], @@ -511,7 +511,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[LoggingParcel], @@ -520,7 +520,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[BannerParcel], @@ -529,7 +529,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[BasicParcel], @@ -538,7 +538,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[GlobalParcel], @@ -547,7 +547,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[NTPParcel], @@ -556,7 +556,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[MRFParcel], @@ -565,7 +565,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[OMPParcel], @@ -574,7 +574,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[SecurityParcel], @@ -583,7 +583,7 @@ def delete( ... @overload - def delete( + def delete_parcel( self, profile_id: UUID, parcel_type: Type[SNMPParcel], @@ -591,7 +591,7 @@ def delete( ) -> None: ... - def delete(self, profile_id: UUID, parcel_type: Type[AnySystemParcel], parcel_id: UUID) -> None: + def delete_parcel(self, profile_id: UUID, parcel_type: Type[AnySystemParcel], parcel_id: UUID) -> None: """ Delete System Parcel for selected profile_id based on payload type """ diff --git a/catalystwan/integration_tests/feature_profile/sdwan/other/test_models.py b/catalystwan/integration_tests/feature_profile/sdwan/other/test_models.py index 361eb61ad..cde5f51c5 100644 --- a/catalystwan/integration_tests/feature_profile/sdwan/other/test_models.py +++ b/catalystwan/integration_tests/feature_profile/sdwan/other/test_models.py @@ -25,7 +25,7 @@ def test_when_default_values_thousandeyes_parcel_expect_successful_post(self): parcel_description="ThousandEyes Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.other.create(self.profile_id, te_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.other.create_parcel(self.profile_id, te_parcel).id # Assert assert parcel_id @@ -45,7 +45,7 @@ def test_when_default_values_ucse_parcel_expect_successful_post(self): ), ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.other.create(self.profile_id, ucse_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.other.create_parcel(self.profile_id, ucse_parcel).id # Assert assert parcel_id diff --git a/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py b/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py index 9bc5aad62..814c88bf2 100644 --- a/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py +++ b/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py @@ -34,7 +34,7 @@ def test_when_default_values_banner_parcel_expect_successful_post(self): parcel_description="Banner Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, banner_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, banner_parcel).id # Assert assert parcel_id @@ -47,7 +47,7 @@ def test_when_fully_specified_banner_parcel_expect_successful_post(self): banner_parcel.add_login("Login") banner_parcel.add_motd("Hello! Welcome to the network!") # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, banner_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, banner_parcel).id # Assert assert parcel_id @@ -58,7 +58,7 @@ def test_when_default_values_logging_parcel_expect_successful_post(self): parcel_description="Logging Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, logging_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, logging_parcel).id # Assert assert parcel_id @@ -102,7 +102,7 @@ def test_when_fully_specified_logging_parcel_expect_successful_post(self): profile_properties="TLSProfile", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, logging_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, logging_parcel).id # Assert assert parcel_id @@ -113,7 +113,7 @@ def test_when_default_values_bfd_parcel_expect_successful_post(self): parcel_description="BFD Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, bfd_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, bfd_parcel).id # Assert assert parcel_id @@ -131,7 +131,7 @@ def test_when_fully_specified_bfd_parcel_expect_successful_post(self): bfd_parcel.add_color(color="biz-internet") bfd_parcel.add_color(color="public-internet") # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, bfd_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, bfd_parcel).id # Assert assert parcel_id @@ -142,7 +142,7 @@ def test_when_default_values_basic_parcel_expect_successful_post(self): parcel_description="Basic Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, basic_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, basic_parcel).id # Assert assert parcel_id @@ -153,7 +153,7 @@ def test_when_default_values_security_parcel_expect_successful_post(self): parcel_description="Security Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, security_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, security_parcel).id # Assert assert parcel_id @@ -164,7 +164,7 @@ def test_when_default_values_ntp_parcel_expect_successful_post(self): parcel_description="NTP Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, ntp_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, ntp_parcel).id # Assert assert parcel_id @@ -175,7 +175,7 @@ def test_when_default_values_global_parcel_expect_successful_post(self): parcel_description="Global Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, global_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, global_parcel).id # Assert assert parcel_id @@ -186,7 +186,7 @@ def test_when_default_values_mrf_parcel_expect_successful_post(self): parcel_description="MRF Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, mrf_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, mrf_parcel).id # Assert assert parcel_id @@ -197,7 +197,7 @@ def test_when_default_values_snmp_parcel_expect_successful_post(self): parcel_description="SNMP Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, snmp_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, snmp_parcel).id # Assert assert parcel_id @@ -208,7 +208,7 @@ def test_when_default_values_omp_parcel_expect_successful_post(self): parcel_description="OMP Parcel", ) # Act - parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, omp_parcel).id + parcel_id = self.session.api.sdwan_feature_profiles.system.create_parcel(self.profile_id, omp_parcel).id # Assert assert parcel_id diff --git a/catalystwan/models/configuration/config_migration.py b/catalystwan/models/configuration/config_migration.py index 75f9c5e6b..f2f370681 100644 --- a/catalystwan/models/configuration/config_migration.py +++ b/catalystwan/models/configuration/config_migration.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Union +from typing import List, Set, Union from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -68,7 +68,7 @@ class TransformHeader(BaseModel): "Type discriminator is not present in many UX2 item payloads" ) origin: UUID = Field(description="Original UUID of converted item") - subelements: List[UUID] = [] + subelements: Set[UUID] = Field(default_factory=set) class TransformedTopologyGroup(BaseModel): diff --git a/catalystwan/models/configuration/feature_profile/common.py b/catalystwan/models/configuration/feature_profile/common.py index 48b78872b..585dd5207 100644 --- a/catalystwan/models/configuration/feature_profile/common.py +++ b/catalystwan/models/configuration/feature_profile/common.py @@ -68,6 +68,7 @@ "application-priority", "policy-object", "embedded-security", + "other", ] SchemaType = Literal[ diff --git a/catalystwan/models/configuration/profile_type.py b/catalystwan/models/configuration/profile_type.py index 272ba310e..f332a670f 100644 --- a/catalystwan/models/configuration/profile_type.py +++ b/catalystwan/models/configuration/profile_type.py @@ -2,9 +2,4 @@ from typing import Literal -ProfileType = Literal[ - "transport", - "system", - "cli", - "service", -] +ProfileType = Literal["transport", "system", "cli", "service", "other"] diff --git a/catalystwan/tests/test_feature_profile_api.py b/catalystwan/tests/test_feature_profile_api.py index 2174f0770..955f4ed0c 100644 --- a/catalystwan/tests/test_feature_profile_api.py +++ b/catalystwan/tests/test_feature_profile_api.py @@ -48,7 +48,7 @@ def test_delete_method_with_valid_arguments(self, parcel, expected_path, mock_en api.endpoint = mock_endpoint # Act - api.delete(self.profile_uuid, parcel, self.parcel_uuid) + api.delete_parcel(self.profile_uuid, parcel, self.parcel_uuid) # Assert mock_endpoint.delete.assert_called_once_with(self.profile_uuid, expected_path, self.parcel_uuid) @@ -62,7 +62,7 @@ def test_get_method_with_valid_arguments(self, parcel, expected_path, mock_endpo api.endpoint = mock_endpoint # Act - api.get(self.profile_uuid, parcel, self.parcel_uuid) + api.get_parcels(self.profile_uuid, parcel, self.parcel_uuid) # Assert mock_endpoint.get_by_id.assert_called_once_with(self.profile_uuid, expected_path, self.parcel_uuid) @@ -76,7 +76,7 @@ def test_get_all_method_with_valid_arguments(self, parcel, expected_path, mock_e api.endpoint = mock_endpoint # Act - api.get(self.profile_uuid, parcel) + api.get_parcels(self.profile_uuid, parcel) # Assert mock_endpoint.get_all.assert_called_once_with(self.profile_uuid, expected_path) @@ -90,7 +90,7 @@ def test_create_method_with_valid_arguments(self, parcel, expected_path, mock_en api.endpoint = mock_endpoint # Act - api.create(self.profile_uuid, parcel) + api.create_parcel(self.profile_uuid, parcel) # Assert mock_endpoint.create.assert_called_once_with(self.profile_uuid, expected_path, parcel) diff --git a/catalystwan/utils/config_migration/creators/config_group.py b/catalystwan/utils/config_migration/creators/config_group.py deleted file mode 100644 index 4294b5460..000000000 --- a/catalystwan/utils/config_migration/creators/config_group.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -from datetime import datetime -from typing import List -from uuid import UUID - -from catalystwan.endpoints.configuration_feature_profile import ConfigurationFeatureProfile -from catalystwan.endpoints.configuration_group import ConfigGroup -from catalystwan.models.configuration.config_migration import UX2Config -from catalystwan.models.configuration.feature_profile.common import FeatureProfileCreationPayload -from catalystwan.session import ManagerSession - - -class ConfigGroupCreator: - """ - Creates a configuration group and attach feature profiles for migrating UX1 templates to UX2. - """ - - def __init__(self, session: ManagerSession, config: UX2Config, logger: logging.Logger): - """ - Args: - session (ManagerSession): A valid Manager API session. - config (UX2Config): The UX2 configuration to migrate. - logger (logging.Logger): A logger for logging messages. - """ - self.session = session - self.config = config - self.logger = logger - self.profile_ids: List[UUID] = [] - - def create(self) -> ConfigGroup: - """ - Creates a configuration group and attach feature profiles for migrating UX1 templates to UX2. - - Returns: - ConfigGroup: The created configuration group. - """ - self.created_at = datetime.now().strftime("%Y_%m_%d_%H_%M_%S") - self._create_sdwan_system_feature_profile() - self._create_sdwan_policy_objects_feature_profile() - config_group_id = self._create_configuration_group() - return self.session.api.config_group.get(config_group_id) # type: ignore[return-value] - - def _create_sdwan_system_feature_profile(self): - """ - Creates a SDWAN System Feature Profile for migrating UX1 Templates to UX2. - - Args: - session (ManagerSession): A valid Manager API session. - name (str): The name of the SDWAN System Feature Profile. - - Returns: - UUID: The ID of the created SDWAN System Feature Profile. - - Raises: - ManagerHTTPError: If the SDWAN System Feature Profile cannot be created. - """ - system_name = f"MIGRATION_SDWAN_SYSTEM_FEATURE_PROFILE_{self.created_at}" - profile_system = FeatureProfileCreationPayload( - name=system_name, description="Profile for migrating UX1 Templates to UX2" - ) - system_id = self.session.endpoints.configuration_feature_profile.create_sdwan_system_feature_profile( - profile_system - ).id - self.logger.info(f"Created SDWAN System Feature Profile {system_name} with ID: {system_id}") - self.profile_ids.append(system_id) - - def _create_sdwan_policy_objects_feature_profile(self): - """ - Creates a SDWAN Policy Objects Feature Profile for migrating UX1 Policies to UX2. - - Args: - session (ManagerSession): A valid Manager API session. - name (str): The name of the SDWAN Policy Objects Feature Profile. - - Returns: - UUID: The ID of the created SDWAN Policy Objects Feature Profile. - - Raises: - ManagerHTTPError: If the SDWAN Policy Objects Feature Profile cannot be created. - """ - policy_objects_name = f"MIGRATION_SDWAN_POLICY_OBJECTS_FEATURE_PROFILE_{self.created_at}" - # TODO: Find a way to create a policy object profile - # for now there is no API or UI for creating a policy object profile - profile_policy_objects = FeatureProfileCreationPayload( # noqa: F841 - name=policy_objects_name, description="Profile for migrating UX1 Policies to UX2" - ) - - # Using default profile name for SDWAN Policy Objects Feature Profile - policy_object_id = ( - ConfigurationFeatureProfile(self.session) - .get_sdwan_feature_profiles() - .filter(profile_name="Default_Policy_Object_Profile") - .single_or_default() - ).profile_id - self.logger.info( - f"Created SDWAN Policy Object Feature Profile {policy_objects_name} with ID: {policy_object_id}" - ) - self.profile_ids.append(policy_object_id) - - def _create_configuration_group(self): - """ - Creates a configuration group and attach feature profiles for migrating UX1 templates to UX2. - - Args: - session (ManagerSession): A valid Manager API session. - name (str): The name of the configuration group. - profile_ids (List[UUID]): The IDs of the feature profiles to include in the configuration group. - - Returns: - UUID: The ID of the created configuration group. - - Raises: - ManagerHTTPError: If the configuration cannot be pushed. - """ - config_group_name = f"SDWAN_CONFIG_GROUP_{self.created_at}" - config_group_id = self.session.api.config_group.create( - name=config_group_name, - description="SDWAN Config Group created for migrating UX1 Templates to UX2", - solution="sdwan", - profile_ids=self.profile_ids, - ).id - self.logger.info(f"Created SDWAN Configuration Group {config_group_name} with ID: {config_group_id}") - return config_group_id diff --git a/catalystwan/utils/config_migration/creators/config_pusher.py b/catalystwan/utils/config_migration/creators/config_pusher.py new file mode 100644 index 000000000..0a8b0273c --- /dev/null +++ b/catalystwan/utils/config_migration/creators/config_pusher.py @@ -0,0 +1,80 @@ +from typing import Dict, List, Tuple, cast +from uuid import UUID +from venv import logger + +from pydantic import BaseModel, Field + +from catalystwan.endpoints.configuration_group import ProfileId +from catalystwan.exceptions import CatalystwanException +from catalystwan.models.configuration.config_migration import TransformedFeatureProfile, TransformedParcel, UX2Config +from catalystwan.models.configuration.feature_profile.common import ProfileType +from catalystwan.session import ManagerSession +from catalystwan.utils.config_migration.factories.feature_profile_api import FeatureProfileAPIFactory +from catalystwan.utils.config_migration.factories.parcel_pusher import ParcelPusherFactory + + +class ConfigurationMapping(BaseModel): + feature_profile_map: Dict[UUID, TransformedFeatureProfile] + parcel_map: Dict[UUID, TransformedParcel] + + +class UX2ConfigRollback(BaseModel): + config_groups_ids: List[UUID] = Field(default_factory=list) + feature_profiles_ids: List[Tuple[UUID, ProfileType]] = Field(default_factory=list) + + def add_config_group(self, config_group_id: UUID) -> None: + self.config_groups_ids.append(config_group_id) + + def add_feature_profile(self, feature_profile_id: UUID, profile_type: ProfileType) -> None: + self.feature_profiles_ids.append((feature_profile_id, profile_type)) + + +class UX2ConfigPusher: + def __init__(self, session: ManagerSession, ux2_config: UX2Config) -> None: + self._session = session + self._config_map = self._create_config_map(ux2_config) + self._config_rollback = UX2ConfigRollback() + self._ux2_config = ux2_config + + def _create_config_map(self, ux2_config: UX2Config) -> ConfigurationMapping: + return ConfigurationMapping( + feature_profile_map={item.header.origin: item for item in ux2_config.feature_profiles}, + parcel_map={item.header.origin: item for item in ux2_config.profile_parcels}, + ) + + def push(self) -> UX2ConfigRollback: + try: + self._create_config_groups() + except CatalystwanException as e: + logger.error(f"Error occured during config push: {e}") + return self._config_rollback + + def _create_config_groups(self): + for transformed_config_group in self._ux2_config.config_groups: + config_group_payload = transformed_config_group.config_group + config_group_payload.profiles = self._create_feature_profile_and_parcels( + transformed_config_group.header.subelements + ) + cg_id = self._session.endpoints.configuration_group.create_config_group(config_group_payload).id + self._config_rollback.add_config_group(cg_id) + + def _create_feature_profile_and_parcels(self, feature_profiles_ids: List[UUID]) -> List[ProfileId]: + config_group_profiles = [] + for feature_profile_id in feature_profiles_ids: + transformed_feature_profile = self._config_map.feature_profile_map[feature_profile_id] + profile_type = cast(ProfileType, transformed_feature_profile.header.type) + api = FeatureProfileAPIFactory.get_api(profile_type, self._session) + name = transformed_feature_profile.feature_profile.name + description = transformed_feature_profile.feature_profile.description + if profile_type == "policy-object": + # TODO: Get default policy profile + continue + created_profile_id = api.create_profile(name, description).id # type: ignore + config_group_profiles.append(ProfileId(id=created_profile_id)) + self._create_parcels(api, created_profile_id, profile_type, transformed_feature_profile.header.subelements) + self._config_rollback.add_feature_profile(created_profile_id, profile_type) + return config_group_profiles + + def _create_parcels(self, api, profile_uuid, profile_type, parcels_uuids): + pusher = ParcelPusherFactory.get_pusher(profile_type, api) + pusher.push(profile_uuid, parcels_uuids, self._config_map.parcel_map) diff --git a/catalystwan/utils/config_migration/creators/strategy/parcels.py b/catalystwan/utils/config_migration/creators/strategy/parcels.py new file mode 100644 index 000000000..077f949d9 --- /dev/null +++ b/catalystwan/utils/config_migration/creators/strategy/parcels.py @@ -0,0 +1,37 @@ +from typing import Dict, List +from uuid import UUID + +from catalystwan.models.configuration.config_migration import TransformedParcel +from catalystwan.utils.config_migration.factories.feature_profile_api import FeatureProfile + + +class ParcelPusher: + """ + Base class for pushing parcels to a feature profile. + """ + + def __init__(self, api: FeatureProfile): + self.api = api + + def push(self, profile_uuid: UUID, parcel_uuids: List[UUID], mapping: Dict[UUID, TransformedParcel]): + """ + Push parcels to the given feature profile. + + Args: + profile_uuid (UUID): The UUID of the feature profile. + parcel_uuids (List[UUID]): The list of parcel UUIDs to push. + mapping (Dict[UUID, TransformedParcel]): The mapping of parcel UUIDs to transformed parcels. + """ + raise NotImplementedError + + +class SimpleParcelPusher(ParcelPusher): + """ + Simple implementation of ParcelPusher that creates parcels directly. + """ + + def push(self, profile_uuid: UUID, parcel_uuids: List[UUID], mapping: Dict[UUID, TransformedParcel]): + # Parcels don't have references to other parcels, so we can create them directly + for parcel_uuid in parcel_uuids: + transformed_parcel = mapping[parcel_uuid] + self.api.create_parcel(profile_uuid, transformed_parcel.parcel) # type: ignore diff --git a/catalystwan/utils/config_migration/factories/feature_profile_api.py b/catalystwan/utils/config_migration/factories/feature_profile_api.py new file mode 100644 index 000000000..532004568 --- /dev/null +++ b/catalystwan/utils/config_migration/factories/feature_profile_api.py @@ -0,0 +1,48 @@ +from typing import Callable, Mapping, Union + +from pydantic import Field +from typing_extensions import Annotated + +from catalystwan.api.feature_profile_api import ( + OtherFeatureProfileAPI, + PolicyObjectFeatureProfileAPI, + ServiceFeatureProfileAPI, + SystemFeatureProfileAPI, +) +from catalystwan.models.configuration.feature_profile.common import ProfileType +from catalystwan.session import ManagerSession + +FEATURE_PROFILE_API_MAPPING: Mapping[ProfileType, Callable] = { + "system": SystemFeatureProfileAPI, + "other": OtherFeatureProfileAPI, + "policy-object": PolicyObjectFeatureProfileAPI, + "service": ServiceFeatureProfileAPI, +} + +FeatureProfile = Annotated[ + Union[SystemFeatureProfileAPI, OtherFeatureProfileAPI, PolicyObjectFeatureProfileAPI, ServiceFeatureProfileAPI], + Field(discriminator="type"), +] + + +class FeatureProfileAPIFactory: + """ + Factory class for creating FeatureProfileAPI instances. + """ + + @staticmethod + def get_api(profile_type: ProfileType, session: ManagerSession) -> FeatureProfile: + """ + Get the appropriate FeatureProfileAPI instance based on the profile type. + + Args: + profile_type (ProfileType): The type of the feature profile. + session (ManagerSession): The session object. + + Returns: + FeatureProfileAPI: The appropriate FeatureProfileAPI instance. + """ + api_class = FEATURE_PROFILE_API_MAPPING.get(profile_type) + if api_class is None: + raise ValueError(f"Invalid profile type: {profile_type}") + return api_class(session) diff --git a/catalystwan/utils/config_migration/factories/parcel_pusher.py b/catalystwan/utils/config_migration/factories/parcel_pusher.py new file mode 100644 index 000000000..6fb45a17e --- /dev/null +++ b/catalystwan/utils/config_migration/factories/parcel_pusher.py @@ -0,0 +1,33 @@ +from typing import Callable, Mapping + +from catalystwan.api.feature_profile_api import FeatureProfileAPI +from catalystwan.models.configuration.feature_profile.common import ProfileType +from catalystwan.utils.config_migration.creators.strategy.parcels import ParcelPusher, SimpleParcelPusher + +PARCEL_PUSHER_MAPPING: Mapping[ProfileType, Callable] = { + "other": SimpleParcelPusher, + "system": SimpleParcelPusher, +} + + +class ParcelPusherFactory: + """ + Factory class for creating ParcelPusher instances. + """ + + @staticmethod + def get_pusher(profile_type: ProfileType, api: FeatureProfileAPI) -> ParcelPusher: + """ + Get the appropriate ParcelPusher instance based on the profile type. + + Args: + profile_type (ProfileType): The type of the feature profile. + api (FeatureProfileAPI): The API for interacting with feature profiles. + + Returns: + ParcelPusher: The appropriate ParcelPusher instance. + """ + pusher_class = PARCEL_PUSHER_MAPPING.get(profile_type) + if pusher_class is None: + raise ValueError(f"Invalid profile type: {profile_type}") + return pusher_class(api) diff --git a/catalystwan/utils/config_migration/reverters/config_reverter.py b/catalystwan/utils/config_migration/reverters/config_reverter.py new file mode 100644 index 000000000..4469fd933 --- /dev/null +++ b/catalystwan/utils/config_migration/reverters/config_reverter.py @@ -0,0 +1,24 @@ +from venv import logger + +from catalystwan.exceptions import CatalystwanException +from catalystwan.utils.config_migration.creators.config_pusher import UX2ConfigRollback +from catalystwan.utils.config_migration.factories.feature_profile_api import FeatureProfileAPIFactory + + +class UX2ConfigReverter: + def __init__(self, session) -> None: + self._session = session + + def rollback(self, rollback_config: UX2ConfigRollback) -> bool: + try: + for cg_id in rollback_config.config_groups_ids: + self._session.endpoints.configuration_group.delete_config_group(cg_id) + for feature_profile_id, type_ in rollback_config.feature_profiles_ids: + api = FeatureProfileAPIFactory.get_api(type_, self._session) + if type_ == "policy-object": + continue + api.delete_profile(feature_profile_id) # type: ignore + except CatalystwanException as e: + logger.error(f"Error occured during config revert: {e}") + return False + return True diff --git a/catalystwan/workflows/config_migration.py b/catalystwan/workflows/config_migration.py index 7b6cc71a1..e7fe38d2c 100644 --- a/catalystwan/workflows/config_migration.py +++ b/catalystwan/workflows/config_migration.py @@ -3,7 +3,7 @@ from uuid import UUID, uuid4 from catalystwan.api.policy_api import POLICY_LIST_ENDPOINTS_MAP -from catalystwan.endpoints.configuration_group import ConfigGroup, ConfigGroupCreationPayload +from catalystwan.endpoints.configuration_group import ConfigGroupCreationPayload from catalystwan.models.configuration.config_migration import ( TransformedConfigGroup, TransformedFeatureProfile, @@ -16,8 +16,9 @@ from catalystwan.session import ManagerSession from catalystwan.utils.config_migration.converters.feature_template import create_parcel_from_template from catalystwan.utils.config_migration.converters.policy.policy_lists import convert as convert_policy_list -from catalystwan.utils.config_migration.creators.config_group import ConfigGroupCreator +from catalystwan.utils.config_migration.creators.config_pusher import UX2ConfigPusher, UX2ConfigRollback from catalystwan.utils.config_migration.device_templates import flatten_general_templates +from catalystwan.utils.config_migration.reverters.config_reverter import UX2ConfigReverter logger = logging.getLogger(__name__) @@ -43,8 +44,8 @@ "omp-vsmart", "cisco_ntp", "ntp", - "bgp", - "cisco_bgp", + # "bgp", + # "cisco_bgp", "cisco_thousandeyes", "ucse", "dhcp", @@ -73,8 +74,8 @@ "omp-vsmart", "cisco_ntp", "ntp", - "bgp", - "cisco_bgp", + # "bgp", + # "cisco_bgp", ] FEATURE_PROFILE_TRANSPORT = ["dhcp", "cisco_dhcp_server", "dhcp-server"] @@ -107,17 +108,6 @@ def transform(ux1: UX1Config) -> UX2Config: description="system", ), ) - fp_transport_uuid = uuid4() - transformed_fp_transport = TransformedFeatureProfile( - header=TransformHeader( - type="transport", - origin=fp_transport_uuid, - ), - feature_profile=FeatureProfileCreationPayload( - name=f"{dt.template_name}_transport", - description="transport", - ), - ) fp_other_uuid = uuid4() transformed_fp_other = TransformedFeatureProfile( header=TransformHeader( @@ -133,17 +123,15 @@ def transform(ux1: UX1Config) -> UX2Config: for template in templates: # Those feature templates IDs are real UUIDs and are used to map to the feature profiles if template.templateType in FEATURE_PROFILE_SYSTEM: - transformed_fp_system.header.subelements.append(UUID(template.templateId)) - elif template.templateType in FEATURE_PROFILE_TRANSPORT: - transformed_fp_transport.header.subelements.append(UUID(template.templateId)) + transformed_fp_system.header.subelements.add(UUID(template.templateId)) elif template.templateType in FEATURE_PROFILE_OTHER: - transformed_fp_other.header.subelements.append(UUID(template.templateId)) + transformed_fp_other.header.subelements.add(UUID(template.templateId)) transformed_cg = TransformedConfigGroup( header=TransformHeader( type="config_group", origin=uuid4(), - subelements=[fp_system_uuid, fp_transport_uuid, fp_other_uuid], + subelements=set([fp_system_uuid, fp_other_uuid]), ), config_group=ConfigGroupCreationPayload( name=dt.template_name, @@ -154,7 +142,6 @@ def transform(ux1: UX1Config) -> UX2Config: ) # Add to UX2 ux2.feature_profiles.append(transformed_fp_system) - ux2.feature_profiles.append(transformed_fp_transport) ux2.feature_profiles.append(transformed_fp_other) ux2.config_groups.append(transformed_cg) @@ -230,27 +217,15 @@ def collect_ux1_config(session: ManagerSession, progress: Callable[[str, int, in return ux1 -def push_ux2_config(session: ManagerSession, config: UX2Config) -> ConfigGroup: - """ - Creates configuration group and pushes a UX2 configuration to the Cisco vManage. - - Args: - session (ManagerSession): A valid Manager API session. - config (UX2Config): The UX2 configuration to push. - - Returns: - UX2ConfigPushResult - - Raises: - ManagerHTTPError: If the configuration cannot be pushed. - """ +def push_ux2_config( + session: ManagerSession, config: UX2Config, progress: Callable[[str, int, int], None] = log_progress +) -> UX2ConfigRollback: + config_pusher = UX2ConfigPusher(session, config) + rollback = config_pusher.push() + return rollback - config_group_creator = ConfigGroupCreator(session, config, logger) - config_group = config_group_creator.create() - feature_profiles = config_group.profiles # noqa: F841 - for parcels in config.profile_parcels: - # TODO: Create API that supports parcel creation on feature profiles - # Example: session.api.parcels.create(parcels=parcels, feature_profiles=feature_profiles) - pass - return config_group +def rollback_ux2_config(session: ManagerSession, rollback_config: UX2ConfigRollback) -> bool: + config_reverter = UX2ConfigReverter(session) + status = config_reverter.rollback(rollback_config) + return status