From 1ecfc0da13b49342e0f2282856bbd8d71b871780 Mon Sep 17 00:00:00 2001 From: Jakub Krajewski <95274389+jpkrajewski@users.noreply.github.com> Date: Mon, 26 Feb 2024 13:56:10 +0100 Subject: [PATCH] Push UX2 - Create config group and feature profiles (#492) * Push UX2 - Create config group and feature profiles * Simplify push_ux2_config * UX2Config rework * Refactor code for template definition normalization This commit refactors the code responsible for normalizing template definitions by implementing improved type annotations, better function organization, and enhanced error handling. Key changes include: 1. Refactoring the to_snake_case function to use the str.replace method for kebab-case to snake_case conversion. 2. Improving the cast_value_to_global function to handle different types of input values, including lists and IP addresses, and utilizing IPv4Address and IPv6Address from the ipaddress module. 3. Enhancing the transform_dict function to handle nested dictionaries and lists, casting leaf values to global types recursively. 4. Updating the template_definition_normalization function to utilize the refactored transform_dict function for key transformation and value normalization. These changes improve code readability, maintainability, and robustness, providing a more efficient and reliable solution for template definition normalization. * Add tests for normalization and Literal casting --------- Co-authored-by: Kuba Co-authored-by: sbasan Co-authored-by: Jakub Krajewski --- catalystwan/api/config_group_api.py | 16 ++- catalystwan/endpoints/configuration_group.py | 19 ++- .../models/configuration/config_migration.py | 26 ++-- .../converters/feature_template/logging.py | 27 ---- .../feature_template/normalizator.py | 80 ------------ .../feature_profile/sdwan/system/__init__.py | 21 ++- .../feature_profile/sdwan/system/aaa.py | 38 ++---- .../feature_profile/sdwan/system/bfd.py | 7 +- .../feature_profile/sdwan/system/literals.py | 7 + .../feature_profile/sdwan/system/logging.py | 52 -------- .../sdwan/system/logging_parcel.py | 82 ++++++++++++ .../test_converter_chooser.py | 26 ++++ .../tests/config_migration/test_normalizer.py | 98 ++++++++++++++ .../converters/feature_template/__init__.py | 10 ++ .../converters/feature_template/aaa.py | 6 +- .../converters/feature_template/base.py | 0 .../converters/feature_template/bfd.py | 6 +- .../feature_template/factory_method.py} | 32 +++-- .../converters/feature_template/logging_.py | 25 ++++ .../converters/feature_template/normalizer.py | 67 ++++++++++ .../config_migration}/converters/recast.py | 0 .../config_migration/creators/config_group.py | 123 ++++++++++++++++++ catalystwan/workflows/config_migration.py | 39 +++++- 23 files changed, 567 insertions(+), 240 deletions(-) delete mode 100644 catalystwan/models/configuration/feature_profile/converters/feature_template/logging.py delete mode 100644 catalystwan/models/configuration/feature_profile/converters/feature_template/normalizator.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/literals.py delete mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/logging.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/logging_parcel.py create mode 100644 catalystwan/tests/config_migration/test_converter_chooser.py create mode 100644 catalystwan/tests/config_migration/test_normalizer.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/__init__.py rename catalystwan/{models/configuration/feature_profile => utils/config_migration}/converters/feature_template/aaa.py (90%) rename catalystwan/{models/configuration/feature_profile => utils/config_migration}/converters/feature_template/base.py (100%) rename catalystwan/{models/configuration/feature_profile => utils/config_migration}/converters/feature_template/bfd.py (87%) rename catalystwan/{models/configuration/feature_profile/converters/feature_template/__init__.py => utils/config_migration/converters/feature_template/factory_method.py} (60%) create mode 100644 catalystwan/utils/config_migration/converters/feature_template/logging_.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/normalizer.py rename catalystwan/{models/configuration/feature_profile => utils/config_migration}/converters/recast.py (100%) create mode 100644 catalystwan/utils/config_migration/creators/config_group.py diff --git a/catalystwan/api/config_group_api.py b/catalystwan/api/config_group_api.py index 1d7555bfe..80aaa4345 100644 --- a/catalystwan/api/config_group_api.py +++ b/catalystwan/api/config_group_api.py @@ -1,11 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Union +from uuid import UUID + +from catalystwan.typed_list import DataSequence if TYPE_CHECKING: from catalystwan.session import ManagerSession from catalystwan.endpoints.configuration_group import ( + ConfigGroup, ConfigGroupAssociatePayload, ConfigGroupCreationPayload, ConfigGroupCreationResponse, @@ -14,7 +18,6 @@ ConfigGroupDisassociateResponse, ConfigGroupEditPayload, ConfigGroupEditResponse, - ConfigGroupResponsePayload, ConfigGroupVariablesCreatePayload, ConfigGroupVariablesCreateResponse, ConfigGroupVariablesEditPayload, @@ -108,11 +111,14 @@ def edit( return self.endpoint.edit_config_group(config_group_id=cg_id, payload=payload) - def get(self) -> ConfigGroupResponsePayload: + def get(self, group_id: Optional[UUID] = None) -> Union[DataSequence[ConfigGroup], ConfigGroup, None]: """ - Gets list of existing config-groups + Gets list of existing config-groups or single config-group with given ID + If given ID is not correct return None """ - return self.endpoint.get() + if group_id is None: + return self.endpoint.get() + return self.endpoint.get().filter(id=group_id).single_or_default() def update_variables(self, cg_id: str, solution: Solution, device_variables: list) -> None: """ diff --git a/catalystwan/endpoints/configuration_group.py b/catalystwan/endpoints/configuration_group.py index 75412b16c..f792a3923 100644 --- a/catalystwan/endpoints/configuration_group.py +++ b/catalystwan/endpoints/configuration_group.py @@ -1,6 +1,7 @@ # mypy: disable-error-code="empty-body" from datetime import datetime from typing import List, Optional +from uuid import UUID from pydantic import BaseModel, Field @@ -11,7 +12,7 @@ class ProfileId(BaseModel): - id: str + id: UUID # TODO Get mode from schema @@ -35,10 +36,24 @@ class FeatureProfile(BaseModel): class ConfigGroup(BaseModel): + id: UUID name: str description: Optional[str] solution: Solution profiles: Optional[List[FeatureProfile]] + source: Optional[str] = None + state: Optional[str] = None + devices: List = Field(default=[]) + created_by: Optional[str] = Field(alias="createdBy") + last_updated_by: Optional[str] = Field(alias="lastUpdatedBy") + created_on: Optional[datetime] = Field(alias="createdOn") + last_updated_on: Optional[datetime] = Field(alias="lastUpdatedOn") + version: int + number_of_devices: int = Field(alias="numberOfDevices") + number_of_devices_up_to_date: int = Field(alias="numberOfDevicesUpToDate") + origin: Optional[str] + topology: Optional[str] = None + full_config_cli: bool = Field(alias="fullConfigCli") class ConfigGroupResponsePayload(BaseModel): @@ -104,7 +119,7 @@ class ConfigGroupDisassociateResponse(BaseModel): class ConfigGroupCreationResponse(BaseModel): - id: str + id: UUID class EditedProfileId(BaseModel): diff --git a/catalystwan/models/configuration/config_migration.py b/catalystwan/models/configuration/config_migration.py index 36cf8673f..39d63ef15 100644 --- a/catalystwan/models/configuration/config_migration.py +++ b/catalystwan/models/configuration/config_migration.py @@ -1,11 +1,9 @@ -from typing import List, Union +from typing import List, Literal, Union from pydantic import BaseModel, ConfigDict, Field from typing_extensions import Annotated from catalystwan.api.template_api import DeviceTemplateInformation, FeatureTemplateInformation -from catalystwan.endpoints.configuration_group import ConfigGroup -from catalystwan.models.configuration.feature_profile.common import FeatureProfileCreationPayload from catalystwan.models.configuration.feature_profile.sdwan.policy_object import AnyPolicyObjectParcel from catalystwan.models.configuration.feature_profile.sdwan.system import AnySystemParcel from catalystwan.models.policy import ( @@ -49,6 +47,14 @@ class UX1Templates(BaseModel): devices: List[DeviceTemplateInformation] = Field(default=[]) +class ConfigGroupPreset(BaseModel): + config_group_name: str = Field(serialization_alias="name", validation_alias="name") + solution: Literal["sdwan"] = "sdwan" + profile_parcels: List[AnyParcel] = Field( + default=[], serialization_alias="profileParcels", validation_alias="profileParcels" + ) + + class UX1Config(BaseModel): # All UX1 Configuration items - Mega Model model_config = ConfigDict(populate_by_name=True) @@ -59,15 +65,7 @@ class UX1Config(BaseModel): class UX2Config(BaseModel): # All UX2 Configuration items - Mega Model model_config = ConfigDict(populate_by_name=True) - config_groups: List[ConfigGroup] = Field( - default=[], serialization_alias="configurationGroups", validation_alias="configurationGroups" - ) - policy_groups: List[ConfigGroup] = Field( - default=[], serialization_alias="policyGroups", validation_alias="policyGroups" - ) - feature_profiles: List[FeatureProfileCreationPayload] = Field( - default=[], serialization_alias="featureProfiles", validation_alias="featureProfiles" - ) - profile_parcels: List[AnyParcel] = Field( - default=[], serialization_alias="profileParcels", validation_alias="profileParcels" + # TODO: config group name + config_group_presets: List[ConfigGroupPreset] = Field( + default=[], serialization_alias="configGroupPresets", validation_alias="configGroupPresets" ) diff --git a/catalystwan/models/configuration/feature_profile/converters/feature_template/logging.py b/catalystwan/models/configuration/feature_profile/converters/feature_template/logging.py deleted file mode 100644 index 4eaf95ffc..000000000 --- a/catalystwan/models/configuration/feature_profile/converters/feature_template/logging.py +++ /dev/null @@ -1,27 +0,0 @@ -# from catalystwan.models.configuration.feature_profile.sdwan.system import Logging - -# class LoggingTemplateConverter: - -# @staticmethod -# def create_parcel(name, description, template_values: dict): -# """ -# Creates an Logging object based on the provided template values. - -# Returns: -# Logging: An Logging object with the provided template values. -# """ -# template_values["name"] = name -# template_values["description"] = description - -# template_values["disk"] = { -# "disk_enable": template_values["enable"], -# "file": { -# "disk_file_size": template_values["size"], -# "disk_file_rotate": template_values["rotate"] -# } -# } -# del template_values["enable"] -# del template_values["size"] -# del template_values["rotate"] - -# return Logging(**template_values) diff --git a/catalystwan/models/configuration/feature_profile/converters/feature_template/normalizator.py b/catalystwan/models/configuration/feature_profile/converters/feature_template/normalizator.py deleted file mode 100644 index 21e3bf734..000000000 --- a/catalystwan/models/configuration/feature_profile/converters/feature_template/normalizator.py +++ /dev/null @@ -1,80 +0,0 @@ -import json -from typing import cast - -from catalystwan.api.configuration_groups.parcel import as_global -from catalystwan.utils.feature_template import find_template_values - - -def template_definition_normalization(template_definition): - """ - Normalizes a template definition by changing keys to snake_case and casting all leafs values to global types. - - Args: - template_definition (str): The template definition in JSON format. - - Returns: - dict: The normalized template values. - - """ - - def to_snake_case(s: str): - """ - Converts a string from kebab-case to snake_case. - - Args: - s (str): The string to be converted. - - Returns: - str: The converted string. - - """ - if "-" in s: - temp = s.split("-") - return "_".join(ele for ele in temp) - return s - - def transform_dict(d): - """ - Transforms a nested dictionary into a normalized form. - - Args: - d (dict): The nested dictionary to be transformed. - - Returns: - dict: The transformed dictionary. - - """ - if isinstance(d, list): - return [transform_dict(i) if isinstance(i, (dict, list)) else i for i in d] - return {to_snake_case(a): transform_dict(b) if isinstance(b, (dict, list)) else b for a, b in d.items()} - - def cast_leafs_to_global(node: dict): - """ - Recursively casts all leaf values in a nested dictionary or list to the global configuration type. - - Args: - node (dict): The nested dictionary or list to be processed. - - Returns: - None - - """ - for key, item in node.items(): - if isinstance(item, dict): - cast_leafs_to_global(item) - elif isinstance(item, list): - for i in item: - if isinstance(i, dict): - cast_leafs_to_global(i) - else: - node[key] = as_global(item) - - template_definition_as_dict = json.loads(cast(str, template_definition)) - - template_values = find_template_values(template_definition_as_dict) - - template_values = transform_dict(template_values) - - cast_leafs_to_global(template_values) - - return template_values diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py b/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py index f06f24ee0..92ee265dc 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py @@ -1,16 +1,25 @@ from typing import List, Mapping, Union -from .aaa import AAA -from .bfd import BFD +from .aaa import AAAParcel +from .bfd import BFDParcel +from .literals import SYSTEM_LITERALS +from .logging_parcel import LoggingParcel SYSTEM_PAYLOAD_ENDPOINT_MAPPING: Mapping[type, str] = { - AAA: "aaa", - BFD: "bfd", + AAAParcel: "aaa", + BFDParcel: "bfd", } -AnySystemParcel = Union[AAA, BFD] +AnySystemParcel = Union[AAAParcel, BFDParcel, LoggingParcel] -__all__ = ["AAA", "BFD", "AnySystemParcel", "SYSTEM_PAYLOAD_ENDPOINT_MAPPING"] +__all__ = [ + "AAAParcel", + "BFDParcel", + "LoggingParcel", + "AnySystemParcel", + "SYSTEM_LITERALS", + "SYSTEM_PAYLOAD_ENDPOINT_MAPPING", +] def __dir__() -> "List[str]": diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py b/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py index 21b4afdc2..54929929f 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py @@ -4,12 +4,6 @@ from pydantic import AliasPath, BaseModel, ConfigDict, Field from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default, as_global -from catalystwan.models.configuration.feature_profile.converters.recast import ( - DefaultGlobalBool, - DefaultGlobalIPAddress, - DefaultGlobalList, - DefaultGlobalStr, -) class PubkeyChainItem(BaseModel): @@ -67,9 +61,7 @@ def add_pubkey_chain_item(self, key: str) -> PubkeyChainItem: class RadiusServerItem(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) - address: Union[DefaultGlobalIPAddress, Global[IPv4Address], Global[IPv6Address]] = Field( - description="Set IP address of Radius server" - ) + address: Union[Global[IPv4Address], Global[IPv6Address]] = Field(description="Set IP address of Radius server") auth_port: Union[Global[int], Default[int], Variable, None] = Field( default=as_default(1812), validation_alias="authPort", @@ -101,7 +93,7 @@ class RadiusServerItem(BaseModel): description="Set the TACACS server shared type 7 encrypted key", ) # Literal["6", "7"] - key_enum: Union[DefaultGlobalStr, Global[str], Default[None], None] = Field( + key_enum: Union[Global[str], Default[None], None] = Field( default=None, validation_alias="keyEnum", serialization_alias="keyEnum", @@ -164,9 +156,7 @@ def generate_radius_server( class TacacsServerItem(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) - address: Union[DefaultGlobalIPAddress, Global[IPv4Address], Global[IPv6Address]] = Field( - description="Set IP address of TACACS server" - ) + address: Union[Global[IPv4Address], Global[IPv6Address]] = Field(description="Set IP address of TACACS server") port: Union[Variable, Global[int], Default[int], None] = Field(default=None, description="TACACS Port") timeout: Union[Variable, Global[int], Default[int], None] = Field( default=None, @@ -186,7 +176,7 @@ class TacacsServerItem(BaseModel): description="Set the TACACS server shared type 7 encrypted key", ) # Literal["6", "7"] - key_enum: Union[DefaultGlobalStr, Global[str], Default[None], None] = Field( + key_enum: Union[Global[str], Default[None], None] = Field( default=None, validation_alias="keyEnum", serialization_alias="keyEnum", @@ -241,13 +231,13 @@ class AccountingRuleItem(BaseModel): method: Global[str] = Field(description="Configure Accounting Method") # Literal['1', '15'] level: Union[Global[str], Default[None], None] = Field(None, description="Privilege level when method is commands") - start_stop: Union[DefaultGlobalBool, Variable, Global[bool], Default[bool], None] = Field( + start_stop: Union[Variable, Global[bool], Default[bool], None] = Field( default=None, validation_alias="startStop", serialization_alias="startStop", description="Record start and stop without waiting", ) - group: Union[DefaultGlobalList, Global[List[str]]] = Field(description="Use Server-group") + group: Global[List[str]] = Field(description="Use Server-group") class AuthorizationRuleItem(BaseModel): @@ -259,8 +249,8 @@ class AuthorizationRuleItem(BaseModel): method: Global[str] # Literal['1', '15'] level: Global[str] = Field(description="Privilege level when method is commands") - group: Union[DefaultGlobalList, Global[List[str]]] = Field(description="Use Server-group") - if_authenticated: Union[DefaultGlobalBool, Global[bool], Default[bool], None] = Field( + group: Global[List[str]] = Field(description="Use Server-group") + if_authenticated: Union[Global[bool], Default[bool], None] = Field( default=None, validation_alias="ifAuthenticated", serialization_alias="ifAuthenticated", @@ -268,20 +258,20 @@ class AuthorizationRuleItem(BaseModel): ) -class AAA(_ParcelBase): +class AAAParcel(_ParcelBase): type_: Literal["aaa"] = Field(default="aaa", exclude=True) - authentication_group: Union[DefaultGlobalBool, Variable, Global[bool], Default[bool]] = Field( + authentication_group: Union[Variable, Global[bool], Default[bool]] = Field( default=as_default(False), validation_alias=AliasPath("data", "authenticationGroup"), description="Authentication configurations parameters", ) - accounting_group: Union[DefaultGlobalBool, Variable, Global[bool], Default[bool]] = Field( + accounting_group: Union[Variable, Global[bool], Default[bool]] = Field( default=as_default(False), validation_alias=AliasPath("data", "accountingGroup"), description="Accounting configurations parameters", ) # local, radius, tacacs - server_auth_order: Union[DefaultGlobalList, Global[List[str]]] = Field( + server_auth_order: Global[List[str]] = Field( validation_alias=AliasPath("data", "serverAuthOrder"), min_length=1, max_length=4, @@ -299,12 +289,12 @@ class AAA(_ParcelBase): accounting_rule: Optional[List[AccountingRuleItem]] = Field( default=None, validation_alias=AliasPath("data", "accountingRule"), description="Configure the accounting rules" ) - authorization_console: Union[DefaultGlobalBool, Variable, Global[bool], Default[bool]] = Field( + authorization_console: Union[Variable, Global[bool], Default[bool]] = Field( default=as_default(False), validation_alias=AliasPath("data", "authorizationConsole"), description="For enabling console authorization", ) - authorization_config_commands: Union[DefaultGlobalBool, Variable, Global[bool], Default[bool]] = Field( + authorization_config_commands: Union[Variable, Global[bool], Default[bool]] = Field( default=as_default(False), validation_alias=AliasPath("data", "authorizationConfigCommands"), description="For configuration mode commands.", diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py b/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py index 123e9ccff..bf2247e3b 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py @@ -4,10 +4,7 @@ from catalystwan.api.configuration_groups.parcel import Global, _ParcelBase, as_global from catalystwan.models.common import TLOCColor -from catalystwan.models.configuration.feature_profile.converters.recast import ( - DefaultGlobalBool, - DefaultGlobalColorLiteral, -) +from catalystwan.utils.config_migration.converters.recast import DefaultGlobalBool, DefaultGlobalColorLiteral DEFAULT_BFD_COLOR_MULTIPLIER = as_global(7) DEFAULT_BFD_DSCP = as_global(48) @@ -29,7 +26,7 @@ class Color(BaseModel): model_config = ConfigDict(populate_by_name=True) -class BFD(_ParcelBase): +class BFDParcel(_ParcelBase): type_: Literal["bfd"] = Field(default="bfd", exclude=True) model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/literals.py b/catalystwan/models/configuration/feature_profile/sdwan/system/literals.py new file mode 100644 index 000000000..a92724f35 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/literals.py @@ -0,0 +1,7 @@ +from typing import Literal + +Priority = Literal["information", "debugging", "notice", "warn", "error", "critical", "alert", "emergency"] +Version = Literal["TLSv1.1", "TLSv1.2"] +AuthType = Literal["Server", "Mutual"] + +SYSTEM_LITERALS = [Priority, Version, AuthType] diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/logging.py b/catalystwan/models/configuration/feature_profile/sdwan/system/logging.py deleted file mode 100644 index 64ac7033b..000000000 --- a/catalystwan/models/configuration/feature_profile/sdwan/system/logging.py +++ /dev/null @@ -1,52 +0,0 @@ -# flake8: noqa - -# import enum -# from typing import List, Literal, Optional, Union -# from catalystwan.api.configuration_groups.parcel import _ParcelBase, Global, as_global -# from catalystwan.models.configuration.common import AuthType, Priority, Version -# from pydantic import AliasPath, BaseModel, ConfigDict, Field -# from catalystwan.models.configuration.feature_profile.converters.recast import ( -# DefaultGlobalBool, -# DefaultGlobalStr, -# ) - -# class Server(BaseModel): -# name: Global[str] -# vpn: Optional[Union[DefaultGlobalStr, Global[str]]] = None -# source_interface: Optional[Global[str]] = Field(default=None, serialization_alias="sourceInterface", validation_alias="sourceInterface") -# priority: Optional[Union[DefaultGlobalLiteral, Global[Priority]]] = "information" -# enable_tls: Optional[Union[DefaultGlobalBool, Global[bool]]] = Field(default=as_global(False), serialization_alias="tlsEnable", validation_alias="tlsEnable") -# custom_profile: Optional[Union[DefaultGlobalBool, Global[bool]]] = Field( -# default=as_global(False), serialization_alias="tlsPropertiesCustomProfile", validation_alias="tlsPropertiesCustomProfile" -# ) -# profile_properties: Optional[Global[str]] = Field(default=None, serialization_alias="tlsPropertiesProfile", validation_alias="tlsPropertiesProfile") -# model_config = ConfigDict(populate_by_name=True) - - -# class Ipv6Server(BaseModel): -# name: Global[str] -# vpn: Optional[Union[DefaultGlobalStr, Global[str]]] = None -# source_interface: Optional[Global[str]] = Field(default=None, serialization_alias="sourceInterface", validation_alias="sourceInterface") -# priority: Optional[Union[DefaultGlobalLiteral, Global[Priority]]] = "information" -# enable_tls: Optional[Union[DefaultGlobalBool, Global[bool]]] = Field(default=as_global(False), serialization_alias="tlsEnable", validation_alias="tlsEnable") -# custom_profile: Optional[Union[DefaultGlobalBool, Global[bool]]] = Field( -# default=as_global(False), serialization_alias="tlsPropertiesCustomProfile", validation_alias="tlsPropertiesCustomProfile" -# ) -# profile_properties: Optional[Global[str]] = Field(default=None, serialization_alias="tlsPropertiesProfile", validation_alias="tlsPropertiesProfile") -# model_config = ConfigDict(populate_by_name=True) - -# # -# class File(BaseModel): -# disk_file_size: Optional[Global[int]] = Field(default=None, serialization_alias="diskFileSize", validation_alias="diskFileSize") -# disk_file_rotate: Optional[Global[int]] = Field(default=None, serialization_alias="diskFileRotate", validation_alias="diskFileRotate") - - -# class Disk(BaseModel): -# disk_enable: Optional[Global[bool]] = Field(default=False, serialization_alias="diskEnable", validation_alias="diskEnable") -# file: File - -# class Logging(_ParcelBase): -# disk: Optional[Disk] = Field(default=None, validation_alias=AliasPath("data", "disk")) -# tls_profile: Optional[List[TlsProfile]] = Field(default=None, validation_alias=AliasPath("data", "tlsProfile")) -# server: Optional[List[Server]] = Field(default=None, validation_alias=AliasPath("data", "server")) -# ipv6_server: Optional[List[Ipv6Server]] = Field(default=None, validation_alias=AliasPath("data", "ipv6Server")) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/logging_parcel.py b/catalystwan/models/configuration/feature_profile/sdwan/system/logging_parcel.py new file mode 100644 index 000000000..d0a726863 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/logging_parcel.py @@ -0,0 +1,82 @@ +from typing import List, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, _ParcelBase, as_global +from catalystwan.models.configuration.feature_profile.sdwan.system.literals import AuthType, Priority, Version +from catalystwan.utils.pydantic_validators import ConvertBoolToStringModel + + +class TlsProfile(ConvertBoolToStringModel): + profile: str + version: Optional[Version] = Field(default="TLSv1.1", json_schema_extra={"data_path": ["tls-version"]}) + auth_type: AuthType = Field(json_schema_extra={"vmanage_key": "auth-type"}) + ciphersuite_list: Optional[List] = Field( + default=None, json_schema_extra={"data_path": ["ciphersuite"], "vmanage_key": "ciphersuite-list"} + ) + model_config = ConfigDict(populate_by_name=True) + + +class Server(BaseModel): + name: Global[str] + vpn: Optional[Global[str]] = None + source_interface: Optional[Global[str]] = Field( + default=None, serialization_alias="sourceInterface", validation_alias="sourceInterface" + ) + priority: Optional[Global[Priority]] = Field(default="information") + enable_tls: Optional[Global[bool]] = Field( + default=as_global(False), serialization_alias="tlsEnable", validation_alias="tlsEnable" + ) + custom_profile: Optional[Global[bool]] = Field( + default=as_global(False), + serialization_alias="tlsPropertiesCustomProfile", + validation_alias="tlsPropertiesCustomProfile", + ) + profile_properties: Optional[Global[str]] = Field( + default=None, serialization_alias="tlsPropertiesProfile", validation_alias="tlsPropertiesProfile" + ) + model_config = ConfigDict(populate_by_name=True) + + +class Ipv6Server(BaseModel): + name: Global[str] + vpn: Optional[Global[str]] = None + source_interface: Optional[Global[str]] = Field( + default=None, serialization_alias="sourceInterface", validation_alias="sourceInterface" + ) + priority: Optional[Global[Priority]] = Field(default="information") + enable_tls: Optional[Global[bool]] = Field( + default=as_global(False), serialization_alias="tlsEnable", validation_alias="tlsEnable" + ) + custom_profile: Optional[Global[bool]] = Field( + default=as_global(False), + serialization_alias="tlsPropertiesCustomProfile", + validation_alias="tlsPropertiesCustomProfile", + ) + profile_properties: Optional[Global[str]] = Field( + default=None, serialization_alias="tlsPropertiesProfile", validation_alias="tlsPropertiesProfile" + ) + model_config = ConfigDict(populate_by_name=True) + + +class File(BaseModel): + disk_file_size: Optional[Union[Global[int], Default[int]]] = Field( + default=Default[int](value=10), serialization_alias="diskFileSize", validation_alias="diskFileSize" + ) + disk_file_rotate: Optional[Union[Global[int], Default[int]]] = Field( + default=Default[int](value=10), serialization_alias="diskFileRotate", validation_alias="diskFileRotate" + ) + + +class Disk(BaseModel): + disk_enable: Optional[Global[bool]] = Field( + default=False, serialization_alias="diskEnable", validation_alias="diskEnable" + ) + file: File + + +class LoggingParcel(_ParcelBase): + disk: Optional[Disk] = Field(default=None, validation_alias=AliasPath("data", "disk")) + tls_profile: Optional[List[TlsProfile]] = Field(default=[], validation_alias=AliasPath("data", "tlsProfile")) + server: Optional[List[Server]] = Field(default=[], validation_alias=AliasPath("data", "server")) + ipv6_server: Optional[List[Ipv6Server]] = Field(default=[], validation_alias=AliasPath("data", "ipv6Server")) diff --git a/catalystwan/tests/config_migration/test_converter_chooser.py b/catalystwan/tests/config_migration/test_converter_chooser.py new file mode 100644 index 000000000..0e2935d0a --- /dev/null +++ b/catalystwan/tests/config_migration/test_converter_chooser.py @@ -0,0 +1,26 @@ +import unittest + +from parameterized import parameterized # type: ignore + +from catalystwan.exceptions import CatalystwanException +from catalystwan.utils.config_migration.converters.feature_template import choose_parcel_converter +from catalystwan.utils.config_migration.converters.feature_template.aaa import AAATemplateConverter +from catalystwan.utils.config_migration.converters.feature_template.bfd import BFDTemplateConverter + + +class TestParcelConverterChooser(unittest.TestCase): + @parameterized.expand( + [("cisco_aaa", AAATemplateConverter), ("cedge_aaa", AAATemplateConverter), ("cisco_bfd", BFDTemplateConverter)] + ) + def test_choose_parcel_converter_returns_correct_converter_when_supported(self, template_type, expected): + # Arrange, Act + converter = choose_parcel_converter(template_type) + # Assert + self.assertEqual(converter, expected) + + def test_choose_parcel_converter_throws_exception_when_template_type_not_supported(self): + # Arrange + not_supported_type = "!@#$%^&*()" + # Act, Assert + with self.assertRaises(CatalystwanException, msg=f"Template type {not_supported_type} not supported"): + choose_parcel_converter(not_supported_type) diff --git a/catalystwan/tests/config_migration/test_normalizer.py b/catalystwan/tests/config_migration/test_normalizer.py new file mode 100644 index 000000000..96b521585 --- /dev/null +++ b/catalystwan/tests/config_migration/test_normalizer.py @@ -0,0 +1,98 @@ +import unittest +from ipaddress import IPv4Address, IPv6Address +from typing import List, Literal +from unittest.mock import patch + +from catalystwan.api.configuration_groups.parcel import Global +from catalystwan.utils.config_migration.converters.feature_template import template_definition_normalization + +TestLiteral = Literal["castable_literal"] + + +class TestNormalizer(unittest.TestCase): + def setUp(self): + self.template_values = { + "key-one": "Simple string !@#$%^&*()-=[';/.,`~]", + "keyone": "Simplestring!@#$%^&*()-=[';/.,`~]", + "bool-value-as-string": "true", + "boolvalueasstring": "false", + "simple-int": 1, + "simpleint": 333333331231, + "simple-ints-in-list": [1, 2, 4, 5, 6, 7, 8, 9], + "simple-int-in-list": [1], + "simplestringsinlist": ["1232132", "!@#$%^&*()-=[';/.,`~]", ""], + "objects-in-list": [ + {"color": "lte", "hello-interval": 300000, "pmtu-discovery": "false"}, + {"color": "mpls", "pmtu-discovery": "false"}, + {"color": "biz-internet"}, + {"color": "public-internet"}, + ], + "nested-objects": [{"next-hop": [{"distance": 1}]}], + "ipv4-address": "10.0.0.2", + "ipv6addr": "2000:0:2:3::", + } + self.expected_result = { + "key_one": Global[str](value="Simple string !@#$%^&*()-=[';/.,`~]"), + "keyone": Global[str](value="Simplestring!@#$%^&*()-=[';/.,`~]"), + "bool_value_as_string": Global[bool](value=True), + "boolvalueasstring": Global[bool](value=False), + "simple_int": Global[int](value=1), + "simpleint": Global[int](value=333333331231), + "simple_ints_in_list": Global[List[int]](value=[1, 2, 4, 5, 6, 7, 8, 9]), + "simple_int_in_list": Global[List[int]](value=[1]), + "simplestringsinlist": Global[List[str]](value=["1232132", "!@#$%^&*()-=[';/.,`~]", ""]), + "objects_in_list": [ + { + "color": Global[str](value="lte"), + "hello_interval": Global[int](value=300000), + "pmtu_discovery": Global[bool](value=False), + }, + {"color": Global[str](value="mpls"), "pmtu_discovery": Global[bool](value=False)}, + {"color": Global[str](value="biz-internet")}, + {"color": Global[str](value="public-internet")}, + ], + "nested_objects": [{"next_hop": [{"distance": Global[int](value=1)}]}], + "ipv4_address": Global[IPv4Address](value=IPv4Address("10.0.0.2")), + "ipv6addr": Global[IPv6Address](value=IPv6Address("2000:0:2:3::")), + } + + def test_normalizer_handles_various_types_of_input(self): + # Arrange + expected_result = self.expected_result + # Act + returned_result = template_definition_normalization(self.template_values) + # Assert + self.assertDictEqual(expected_result, returned_result) + + def test_normalizer_handles_super_nested_input(self): + # Arrange + super_nested_input = { + "super_nested": {"level1": {"level2": {"level3": {"key_one": "value_one", "key_two": "value_two"}}}} + } + expected_result = { + "super_nested": { + "level1": { + "level2": { + "level3": {"key_one": Global[str](value="value_one"), "key_two": Global[str](value="value_two")} + } + } + } + } + + # Act + returned_result = template_definition_normalization(super_nested_input) + + # Assert + self.assertDictEqual(expected_result, returned_result) + + @patch("catalystwan.models.configuration.feature_profile.sdwan.system.literals.SYSTEM_LITERALS", [TestLiteral]) + def test_normalizer_literal_casting_when_literal_in_system_literals(self): + # Arrange + simple_input = {"in": "castable_literal"} + expected_result = {"in": Global[TestLiteral](value="castable_literal")} + + # Act + returned_result = template_definition_normalization(simple_input) + + # Assert + self.assertDictEqual(expected_result, returned_result) diff --git a/catalystwan/utils/config_migration/converters/feature_template/__init__.py b/catalystwan/utils/config_migration/converters/feature_template/__init__.py new file mode 100644 index 000000000..477bbd793 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/__init__.py @@ -0,0 +1,10 @@ +from typing import List + +from .factory_method import choose_parcel_converter, create_parcel_from_template +from .normalizer import template_definition_normalization + +__all__ = ["create_parcel_from_template", "choose_parcel_converter", "template_definition_normalization"] + + +def __dir__() -> "List[str]": + return list(__all__) diff --git a/catalystwan/models/configuration/feature_profile/converters/feature_template/aaa.py b/catalystwan/utils/config_migration/converters/feature_template/aaa.py similarity index 90% rename from catalystwan/models/configuration/feature_profile/converters/feature_template/aaa.py rename to catalystwan/utils/config_migration/converters/feature_template/aaa.py index cf25340a1..0cd08aad3 100644 --- a/catalystwan/models/configuration/feature_profile/converters/feature_template/aaa.py +++ b/catalystwan/utils/config_migration/converters/feature_template/aaa.py @@ -1,9 +1,9 @@ -from catalystwan.models.configuration.feature_profile.sdwan.system import AAA +from catalystwan.models.configuration.feature_profile.sdwan.system import AAAParcel class AAATemplateConverter: @staticmethod - def create_parcel(name: str, description: str, template_values: dict) -> AAA: + def create_parcel(name: str, description: str, template_values: dict) -> AAAParcel: """ Creates an AAA object based on the provided template values. @@ -27,4 +27,4 @@ def create_parcel(name: str, description: str, template_values: dict) -> AAA: if template_values.get(prop) is not None: del template_values[prop] - return AAA(**template_values) + return AAAParcel(**template_values) diff --git a/catalystwan/models/configuration/feature_profile/converters/feature_template/base.py b/catalystwan/utils/config_migration/converters/feature_template/base.py similarity index 100% rename from catalystwan/models/configuration/feature_profile/converters/feature_template/base.py rename to catalystwan/utils/config_migration/converters/feature_template/base.py diff --git a/catalystwan/models/configuration/feature_profile/converters/feature_template/bfd.py b/catalystwan/utils/config_migration/converters/feature_template/bfd.py similarity index 87% rename from catalystwan/models/configuration/feature_profile/converters/feature_template/bfd.py rename to catalystwan/utils/config_migration/converters/feature_template/bfd.py index d4c0bc6a1..ce1b6711f 100644 --- a/catalystwan/models/configuration/feature_profile/converters/feature_template/bfd.py +++ b/catalystwan/utils/config_migration/converters/feature_template/bfd.py @@ -1,9 +1,9 @@ -from catalystwan.models.configuration.feature_profile.sdwan.system import BFD +from catalystwan.models.configuration.feature_profile.sdwan.system import BFDParcel class BFDTemplateConverter: @staticmethod - def create_parcel(name: str, description: str, template_values: dict) -> BFD: + def create_parcel(name: str, description: str, template_values: dict) -> BFDParcel: """ Creates an BFD object based on the provided template values. @@ -17,4 +17,4 @@ def create_parcel(name: str, description: str, template_values: dict) -> BFD: template_values["colors"] = template_values["color"] del template_values["color"] - return BFD(**template_values) + return BFDParcel(**template_values) diff --git a/catalystwan/models/configuration/feature_profile/converters/feature_template/__init__.py b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py similarity index 60% rename from catalystwan/models/configuration/feature_profile/converters/feature_template/__init__.py rename to catalystwan/utils/config_migration/converters/feature_template/factory_method.py index c241269e4..ca6ac196c 100644 --- a/catalystwan/models/configuration/feature_profile/converters/feature_template/__init__.py +++ b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py @@ -1,16 +1,24 @@ -from typing import Any, Dict, List +import json +import logging +from typing import Any, Dict, cast from catalystwan.api.template_api import FeatureTemplateInformation +from catalystwan.exceptions import CatalystwanException from catalystwan.models.configuration.feature_profile.sdwan.system import AnySystemParcel +from catalystwan.utils.feature_template import find_template_values from .aaa import AAATemplateConverter from .base import FeatureTemplateConverter from .bfd import BFDTemplateConverter -from .normalizator import template_definition_normalization +from .logging_ import LoggingTemplateConverter +from .normalizer import template_definition_normalization + +logger = logging.getLogger(__name__) supported_parcel_converters: Dict[Any, FeatureTemplateConverter] = { ("cisco_aaa", "cedge_aaa"): AAATemplateConverter, # type: ignore[dict-item] ("cisco_bfd",): BFDTemplateConverter, # type: ignore[dict-item] + ("cisco_logging", "logging"): LoggingTemplateConverter, } @@ -29,8 +37,10 @@ def choose_parcel_converter(template_type: str) -> FeatureTemplateConverter: """ for key in supported_parcel_converters.keys(): if template_type in key: - return supported_parcel_converters[key] - raise ValueError(f"Template type {template_type} not supported") + converter = supported_parcel_converters[key] + logger.debug(f"Choosen converter {converter} based on template type {template_type}") + return converter + raise CatalystwanException(f"Template type {template_type} not supported") def create_parcel_from_template(template: FeatureTemplateInformation) -> AnySystemParcel: @@ -47,12 +57,8 @@ def create_parcel_from_template(template: FeatureTemplateInformation) -> AnySyst ValueError: If the given template type is not supported. """ converter = choose_parcel_converter(template.template_type) - template_values = template_definition_normalization(template.template_definiton) - return converter.create_parcel(template.name, template.description, template_values) - - -__all__ = ["create_parcel_from_template"] - - -def __dir__() -> "List[str]": - return list(__all__) + template_definition_as_dict = json.loads(cast(str, template.template_definiton)) + template_values = find_template_values(template_definition_as_dict) + template_values_normalized = template_definition_normalization(template_values) + logger.debug(f"Normalized template {template.name}: {template_values_normalized}") + return converter.create_parcel(template.name, template.description, template_values_normalized) diff --git a/catalystwan/utils/config_migration/converters/feature_template/logging_.py b/catalystwan/utils/config_migration/converters/feature_template/logging_.py new file mode 100644 index 000000000..eb2c777da --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/logging_.py @@ -0,0 +1,25 @@ +from catalystwan.models.configuration.feature_profile.sdwan.system import LoggingParcel + + +class LoggingTemplateConverter: + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> LoggingParcel: + """ + Creates an Logging object based on the provided template values. + + Returns: + Logging: An Logging object with the provided template values. + """ + template_values["name"] = name + template_values["description"] = description + + if template_values.get("disk_enable"): + template_values["disk"] = { + "disk_enable": template_values["enable"], + "file": {"disk_file_size": template_values["size"], "disk_file_rotate": template_values["rotate"]}, + } + del template_values["enable"] + del template_values["size"] + del template_values["rotate"] + + return LoggingParcel(**template_values) diff --git a/catalystwan/utils/config_migration/converters/feature_template/normalizer.py b/catalystwan/utils/config_migration/converters/feature_template/normalizer.py new file mode 100644 index 000000000..5adbb777a --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/normalizer.py @@ -0,0 +1,67 @@ +from ipaddress import AddressValueError, IPv4Address, IPv6Address +from typing import List, Union, get_args + +from catalystwan.api.configuration_groups.parcel import Global, as_global +from catalystwan.models.configuration.feature_profile.sdwan.system import SYSTEM_LITERALS + +CastedTypes = Union[ + Global[bool], + Global[str], + Global[int], + Global[List[str]], + Global[List[int]], + Global[IPv4Address], + Global[IPv6Address], +] + + +def to_snake_case(s: str) -> str: + """Converts a string from kebab-case to snake_case.""" + return s.replace("-", "_") + + +def cast_value_to_global(value: Union[str, int, List[str], List[int]]) -> CastedTypes: + """Casts value to Global.""" + if isinstance(value, list): + value_type = Global[List[int]] if isinstance(value[0], int) else Global[List[str]] + return value_type(value=value) # type: ignore + + if isinstance(value, str): + if value.lower() == "true": + return Global[bool](value=True) + elif value.lower() == "false": + return Global[bool](value=False) + try: + ipv4_address = IPv4Address(value) + return Global[IPv4Address](value=ipv4_address) + except AddressValueError: + pass + try: + ipv6_address = IPv6Address(value) + return Global[IPv6Address](value=ipv6_address) + except AddressValueError: + pass + for literal in SYSTEM_LITERALS: + if value in get_args(literal): + return Global[literal](value=value) # type: ignore + + return as_global(value) # type: ignore + + +def transform_dict(d: dict) -> dict: + """Transforms a nested dictionary into a normalized form.""" + + def transform_value(value: Union[dict, list, str, int]) -> Union[CastedTypes, dict, list]: + if isinstance(value, dict): + return transform_dict(value) + elif isinstance(value, list): + if all(isinstance(v, dict) for v in value): + return [transform_value(item) for item in value] + return cast_value_to_global(value) + + return {to_snake_case(key): transform_value(val) for key, val in d.items()} + + +def template_definition_normalization(template_definition: dict) -> dict: + """Normalizes a template definition by changing keys to snake_case and casting all leafs values to global types.""" + return transform_dict(template_definition) diff --git a/catalystwan/models/configuration/feature_profile/converters/recast.py b/catalystwan/utils/config_migration/converters/recast.py similarity index 100% rename from catalystwan/models/configuration/feature_profile/converters/recast.py rename to catalystwan/utils/config_migration/converters/recast.py diff --git a/catalystwan/utils/config_migration/creators/config_group.py b/catalystwan/utils/config_migration/creators/config_group.py new file mode 100644 index 000000000..4294b5460 --- /dev/null +++ b/catalystwan/utils/config_migration/creators/config_group.py @@ -0,0 +1,123 @@ +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/workflows/config_migration.py b/catalystwan/workflows/config_migration.py index 3ae015ed3..abac5fcc5 100644 --- a/catalystwan/workflows/config_migration.py +++ b/catalystwan/workflows/config_migration.py @@ -2,9 +2,11 @@ from typing import Callable from catalystwan.api.policy_api import POLICY_LIST_ENDPOINTS_MAP -from catalystwan.models.configuration.config_migration import UX1Config, UX2Config -from catalystwan.models.configuration.feature_profile.converters.feature_template import create_parcel_from_template +from catalystwan.endpoints.configuration_group import ConfigGroup +from catalystwan.models.configuration.config_migration import ConfigGroupPreset, UX1Config, UX2Config from catalystwan.session import ManagerSession +from catalystwan.utils.config_migration.converters.feature_template import create_parcel_from_template +from catalystwan.utils.config_migration.creators.config_group import ConfigGroupCreator logger = logging.getLogger(__name__) @@ -17,13 +19,16 @@ def log_progress(task: str, completed: int, total: int) -> None: def transform(ux1: UX1Config) -> UX2Config: ux2 = UX2Config() + ux2.config_group_presets.append(ConfigGroupPreset(config_group_name="Default_Config_Group")) + profile_parcels = ux2.config_group_presets[0].profile_parcels + # Feature Templates for ft in ux1.templates.features: if ft.template_type in SUPPORTED_TEMPLATE_TYPES: - ux2.profile_parcels.append(create_parcel_from_template(ft)) + profile_parcels.append(create_parcel_from_template(ft)) # Policy Lists for policy_list in ux1.policies.policy_lists: if (parcel := policy_list.to_policy_object_parcel()) is not None: - ux2.profile_parcels.append(parcel) + profile_parcels.append(parcel) return ux2 @@ -75,5 +80,27 @@ def collect_ux1_config(session: ManagerSession, progress: Callable[[str, int, in return ux1 -def push_ux2_config(session: ManagerSession) -> None: - pass +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. + """ + + config_group_creator = ConfigGroupCreator(session, config, logger) + config_group = config_group_creator.create() + feature_profiles = config_group.profiles # noqa: F841 + for parcels in config.config_group_presets: + # 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