From 5e6238a3eef1789c957ff6ff31701faa83f3e7a4 Mon Sep 17 00:00:00 2001 From: Kuba Date: Thu, 8 Feb 2024 20:38:55 +0100 Subject: [PATCH 1/5] Migration - Software and Versions --- catalystwan/api/partition_manager_api.py | 46 ++++++------ catalystwan/api/software_action_api.py | 70 ++++++++----------- catalystwan/api/versions_utils.py | 48 ++++++++----- .../tests/test_partition_manager_api.py | 9 +++ catalystwan/tests/test_version_utils.py | 11 ++- 5 files changed, 99 insertions(+), 85 deletions(-) diff --git a/catalystwan/api/partition_manager_api.py b/catalystwan/api/partition_manager_api.py index 6299d8b5..36d42c62 100644 --- a/catalystwan/api/partition_manager_api.py +++ b/catalystwan/api/partition_manager_api.py @@ -70,20 +70,14 @@ def set_default_partition( else: payload_devices = self.device_version.get_devices_current_version(devices) - for device in payload_devices: - if not device.version: - raise EmptyVersionPayloadError("PartitionDevice payload contains entry with empty version field.") - - device_type = get_install_specification(devices.first()).device_type.value - partition_payload = PartitionActionPayload( - action="defaultpartition", devices=[dev for dev in payload_devices], device_type=device_type - ) - - partition_action = self.session.endpoints.configuration_device_actions.process_mark_default_partition( - payload=partition_payload - ) - - return Task(self.session, partition_action.id) + url = "/dataservice/device/action/defaultpartition" + payload = { + "action": "defaultpartition", + "devices": [device.model_dump() for device in payload_devices], # type: ignore + "deviceType": get_install_specification(devices.first()).device_type.value, + } + set_default = dict(self.session.post(url, json=payload).json()) + return Task(self.session, set_default["id"]) def remove_partition( self, devices: DataSequence[DeviceDetailsResponse], partition: Optional[str] = None, force: bool = False @@ -106,17 +100,19 @@ def remove_partition( else: payload_devices = self.device_version.get_devices_available_versions(devices) - for device in payload_devices: - if not device.version: - raise EmptyVersionPayloadError("PartitionDevice payload contains entry with empty version field.") - - device_type = get_install_specification(devices.first()).device_type.value - partition_payload = RemovePartitionActionPayload( - action="removepartition", - devices=[RemovePartitionDevice(**dev.model_dump()) for dev in payload_devices], - device_type=device_type, - ) - + remove_partition_payload = [ + RemovePartitionPayload( + device_id=device.device_id, device_ip=device.device_id, version=device.version + ) # type: ignore + for device in payload_devices + ] + + url = "/dataservice/device/action/removepartition" + payload = { + "action": "removepartition", + "devices": [device.model_dump() for device in remove_partition_payload], # type: ignore + "deviceType": get_install_specification(devices.first()).device_type.value, + } if force is False: self._check_remove_partition_possibility(cast(list, partition_payload.devices)) diff --git a/catalystwan/api/software_action_api.py b/catalystwan/api/software_action_api.py index b7d1502c..22d01b2a 100644 --- a/catalystwan/api/software_action_api.py +++ b/catalystwan/api/software_action_api.py @@ -85,26 +85,14 @@ def activate( else: raise ValueError("You can not provide software_image and image version at the same time!") - if not version: - raise ImageNotInRepositoryError( - "Based on provided arguments, software version to activate on device(s) cannot be detected." - ) - - payload_devices = self.device_versions.get_device_available(version, devices) - for device in payload_devices: - if not device.version: - raise EmptyVersionPayloadError("PartitionDevice payload contains entry with empty version field.") - - device_type = get_install_specification(devices.first()).device_type.value - partition_payload = PartitionActionPayload( - action="changepartition", devices=[dev for dev in payload_devices], device_type=device_type - ) - - partition_action = self.session.endpoints.configuration_device_actions.process_mark_change_partition( - payload=partition_payload - ) - - return Task(self.session, partition_action.id) + url = "/dataservice/device/action/changepartition" + payload = { + "action": "changepartition", + "devices": [device.model_dump() for device in self.device_versions.get_device_available(version, devices)], + "deviceType": get_install_specification(devices.first()).device_type.value, + } + activate = dict(self.session.post(url, json=payload).json()) + return Task(self.session, activate["id"]) def install( self, @@ -197,27 +185,27 @@ def install( sync=sync, ) else: - input = InstallInput( - v_edge_vpn=v_edge_vpn, - v_smart_vpn=v_smart_vpn, - data=[ - InstallData( - family=install_specification.family.value, - version=remote_image_details.version_id, # type: ignore - remote_server_id=remote_image_details.remote_server_id, # type: ignore - version_id=remote_image_details.version_id, # type: ignore - ) - ], - version_type=install_specification.version_type.value, - reboot=reboot, - sync=sync, - ) - - device_type = install_specification.device_type.value - install_payload = InstallActionPayload( - action="install", input=input, devices=install_devices, device_type=device_type - ) - + raise VersionDeclarationError("You can not provide image and image version at the same time") + install_specification = get_install_specification(devices.first()) + + url = "/dataservice/device/action/install" + payload: Dict[str, Any] = { + "action": "install", + "input": { + "vEdgeVPN": 0, + "vSmartVPN": 0, + "family": install_specification.family.value, + "version": version, + "versionType": install_specification.version_type.value, + "reboot": reboot, + "sync": sync, + }, + "devices": [ + {"deviceId": device.device_id, "deviceIP": device.device_ip} + for device in self.device_versions.get_device_list(devices) + ], # type: ignore + "deviceType": install_specification.device_type.value, + } if downgrade_check and devices.first().personality in (Personality.VMANAGE, Personality.EDGE): self._downgrade_check( install_payload.devices, diff --git a/catalystwan/api/versions_utils.py b/catalystwan/api/versions_utils.py index 0c5377a3..7b5d70c3 100644 --- a/catalystwan/api/versions_utils.py +++ b/catalystwan/api/versions_utils.py @@ -6,14 +6,15 @@ from pathlib import PurePath from typing import TYPE_CHECKING, Dict, List, Union +from clint.textui.progress import Bar as ProgressBar # type: ignore from pydantic import BaseModel, ConfigDict, Field +from pydantic.functional_validators import BeforeValidator +from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor # type: ignore +from typing_extensions import Annotated -from catalystwan.endpoints.configuration.software_actions import SoftwareImageDetails -from catalystwan.endpoints.configuration_device_actions import PartitionDevice -from catalystwan.endpoints.configuration_device_inventory import DeviceDetailsResponse +from catalystwan.dataclasses import Device from catalystwan.exceptions import ImageNotInRepositoryError from catalystwan.typed_list import DataSequence -from catalystwan.utils.upgrades_helper import SoftwarePackageUploadPayload if TYPE_CHECKING: from catalystwan.session import ManagerSession @@ -38,6 +39,25 @@ class DeviceSoftwareRepository(BaseModel): device_id: str = Field(default="", serialization_alias="uuid", validation_alias="uuid") +class DeviceVersionPayload(BaseModel): + device_id: str = Field(serialization_alias="deviceId") + device_ip: str = Field(serialization_alias="deviceIP") + version: Union[str, List[str]] = Field(default="") + + +def convert_to_list(element: Union[str, List[str]]) -> List[str]: + return [element] if isinstance(element, str) else element + + +VersionList = Annotated[Union[str, List[str]], BeforeValidator(convert_to_list)] + + +class RemovePartitionPayload(BaseModel): + device_id: str = Field(serialization_alias="deviceId") + device_ip: str = Field(serialization_alias="deviceIP") + version: VersionList + + class RepositoryAPI: """ API methods to get information about images and devices software versions @@ -87,8 +107,8 @@ def get_devices_versions_repository(self) -> Dict[str, DeviceSoftwareRepository] device_type="vmanage" ) devices_versions_repository = {} - for device in controllers_versions_info + edges_versions_info + vmanages_versions_info: - device_software_repository = DeviceSoftwareRepository(**device.model_dump(by_alias=True)) + for device in controllers_versions_info + edges_versions_info: + device_software_repository = DeviceSoftwareRepository(**device) device_software_repository.installed_versions = [a for a in device_software_repository.available_versions] device_software_repository.installed_versions.append(device_software_repository.current_version) devices_versions_repository[device_software_repository.device_id] = device_software_repository @@ -226,8 +246,8 @@ def _get_device_list_in( """ self._validate_devices_required_fields(devices) devices_payload = DataSequence( - PartitionDevice, - [PartitionDevice(device_id=device.uuid, device_ip=device.device_ip) for device in devices], # type: ignore + DeviceVersionPayload, + [DeviceVersionPayload(device_id=device.uuid, device_ip=device.id) for device in devices], ) all_dev_versions = self.repository.get_devices_versions_repository() for device in devices_payload: @@ -294,8 +314,8 @@ def _get_devices_chosen_version( self._validate_devices_required_fields(devices) devices_payload = DataSequence( - PartitionDevice, - [PartitionDevice(device_id=device.uuid, device_ip=device.device_ip) for device in devices], # type: ignore + DeviceVersionPayload, + [DeviceVersionPayload(device_id=device.uuid, device_ip=device.id) for device in devices], ) all_dev_versions = self.repository.get_devices_versions_repository() for device in devices_payload: @@ -335,9 +355,5 @@ def get_devices_available_versions( return self._get_devices_chosen_version(devices, "available_versions") - def get_device_list(self, devices: DataSequence[DeviceDetailsResponse]) -> List[PartitionDevice]: - self._validate_devices_required_fields(devices) - - return [ - PartitionDevice(device_id=device.uuid, device_ip=device.device_ip) for device in devices # type: ignore - ] + def get_device_list(self, devices: DataSequence[Device]) -> List[DeviceVersionPayload]: + return [DeviceVersionPayload(device_id=device.uuid, device_ip=device.id) for device in devices] # type: ignore diff --git a/catalystwan/tests/test_partition_manager_api.py b/catalystwan/tests/test_partition_manager_api.py index 5203537e..8057fdc1 100644 --- a/catalystwan/tests/test_partition_manager_api.py +++ b/catalystwan/tests/test_partition_manager_api.py @@ -36,12 +36,21 @@ def setUp(self): version="curr_ver", defaultVersion="def_ver", uuid="mock_uuid", + installed_versions=["ver1", "ver2", "curr_ver"], + availableVersions=["ver1", "ver2"], + version="curr_ver", + defaultVersion="def_ver", + uuid="mock_uuid", ), } self.mock_devices = [{"deviceId": "mock_uuid", "deviceIP": "mock_ip", "version": "ver1"}] self.mock_device_version_payload = DataSequence( +<<<<<<< HEAD RemovePartitionDevice, [RemovePartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] +======= + DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] +>>>>>>> cb90331 (Migration - Software and Versions) ) mock_session = Mock() self.mock_repository_object = RepositoryAPI(mock_session) diff --git a/catalystwan/tests/test_version_utils.py b/catalystwan/tests/test_version_utils.py index 842be872..1c4dd44d 100644 --- a/catalystwan/tests/test_version_utils.py +++ b/catalystwan/tests/test_version_utils.py @@ -31,6 +31,11 @@ def setUp(self): version="curr_ver", defaultVersion="def_ver", uuid="mock_uuid", + installed_versions=["ver1", "ver2", "curr_ver"], + availableVersions=["ver1", "ver2"], + version="curr_ver", + defaultVersion="def_ver", + uuid="mock_uuid", ) } mock_session = Mock() @@ -193,7 +198,7 @@ def test_get_device_available(self, mock_get_devices_versions_repository): mock_get_devices_versions_repository.return_value = self.DeviceSoftwareRepository_obj answer = mock_device_versions.get_device_available("ver1", [self.device]) expected_result = DataSequence( - PartitionDevice, [PartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] + DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] ) # Assert @@ -213,7 +218,7 @@ def test_get_device_list_if_in_installed(self, mock_get_devices_versions_reposit mock_get_devices_versions_repository.return_value = self.DeviceSoftwareRepository_obj answer = mock_device_versions.get_device_list_in_installed("ver1", [self.device]) expected_result = DataSequence( - PartitionDevice, [PartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] + DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] ) # Assert @@ -235,6 +240,6 @@ def test_get_devices_current_version(self, mock_get_devices_versions_repository) answer = mock_device_versions.get_devices_current_version([self.device]) # Answer proper_answer = DataSequence( - PartitionDevice, [PartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="curr_ver")] + DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="curr_ver")] ) self.assertEqual(answer, proper_answer) From 9fee3e68323ac5682921468678365959e96ab3af Mon Sep 17 00:00:00 2001 From: Jakub Krajewski Date: Thu, 7 Mar 2024 14:29:27 +0100 Subject: [PATCH 2/5] Main rebase to ux migration --- catalystwan/api/api_container.py | 3 +- catalystwan/api/config_group_api.py | 16 +- .../api/configuration_groups/parcel.py | 36 +- catalystwan/api/feature_profile_api.py | 439 ++++++++- catalystwan/api/partition_manager_api.py | 46 +- catalystwan/api/policy_api.py | 14 +- catalystwan/api/software_action_api.py | 70 +- catalystwan/api/template_api.py | 50 +- catalystwan/api/versions_utils.py | 48 +- catalystwan/endpoints/__init__.py | 67 +- .../feature_profile/sdwan/system.py | 49 +- catalystwan/endpoints/configuration_group.py | 21 +- .../sdwan/system/test_models.py | 217 +++++ catalystwan/models/common.py | 30 +- .../models/configuration/config_migration.py | 77 +- .../sdwan/policy_object/__init__.py | 65 +- .../sdwan/policy_object/policy/app_probe.py | 3 +- .../policy_object/policy/application_list.py | 3 +- .../sdwan/policy_object/policy/color_list.py | 3 +- .../sdwan/policy_object/policy/data_prefix.py | 17 +- .../policy/expanded_community_list.py | 3 + .../policy_object/policy/fowarding_class.py | 3 +- .../policy_object/policy/ipv6_data_prefix.py | 3 +- .../policy_object/policy/ipv6_prefix_list.py | 3 +- .../sdwan/policy_object/policy/policier.py | 9 +- .../policy/prefered_group_color.py | 10 +- .../sdwan/policy_object/policy/prefix_list.py | 3 +- .../sdwan/policy_object/policy/sla_class.py | 1 + .../policy/standard_community.py | 3 +- .../sdwan/policy_object/policy/tloc_list.py | 9 +- .../security/application_list.py | 3 +- .../policy_object/security/data_prefix.py | 3 +- .../sdwan/policy_object/security/fqdn.py | 3 +- .../security/geolocation_list.py | 3 +- .../policy_object/security/ips_signature.py | 3 +- .../policy_object/security/local_domain.py | 3 +- .../policy_object/security/protocol_list.py | 3 +- .../policy_object/security/security_port.py | 3 +- .../sdwan/policy_object/security/url.py | 13 +- .../sdwan/policy_object/security/zone.py | 5 +- .../feature_profile/sdwan/system/__init__.py | 52 ++ .../feature_profile/sdwan/system/aaa.py | 15 +- .../feature_profile/sdwan/system/banner.py | 29 + .../feature_profile/sdwan/system/basic.py | 289 ++++++ .../feature_profile/sdwan/system/bfd.py | 66 ++ .../sdwan/system/global_parcel.py | 148 +++ .../sdwan/system/logging_parcel.py | 159 ++++ .../feature_profile/sdwan/system/mrf.py | 74 ++ .../feature_profile/sdwan/system/ntp.py | 101 +++ .../feature_profile/sdwan/system/omp.py | 110 +++ .../feature_profile/sdwan/system/security.py | 177 ++++ .../feature_profile/sdwan/system/snmp.py | 172 ++++ .../models/configuration/topology_group.py | 13 + catalystwan/models/policy/centralized.py | 35 +- .../policy/definitions/access_control_list.py | 4 +- .../policy/definitions/device_access.py | 4 +- .../models/policy/definitions/qos_map.py | 57 +- .../models/policy/definitions/traffic_data.py | 35 +- .../policy/definitions/zone_based_firewall.py | 21 +- catalystwan/models/policy/lists.py | 48 +- catalystwan/models/policy/lists_entries.py | 23 +- catalystwan/models/policy/policy.py | 4 +- .../models/policy/policy_definition.py | 45 +- .../test_converter_chooser.py | 26 + .../tests/config_migration/test_normalizer.py | 98 ++ catalystwan/tests/test_feature_profile_api.py | 110 +++ .../tests/test_partition_manager_api.py | 9 - catalystwan/tests/test_version_utils.py | 11 +- .../converters/feature_template/__init__.py | 10 + .../converters/feature_template/aaa.py | 62 ++ .../converters/feature_template/banner.py | 20 + .../converters/feature_template/base.py | 9 + .../converters/feature_template/basic.py | 74 ++ .../converters/feature_template/bfd.py | 25 + .../feature_template/factory_method.py | 81 ++ .../converters/feature_template/global_.py | 20 + .../converters/feature_template/logging_.py | 50 ++ .../converters/feature_template/normalizer.py | 76 ++ .../converters/feature_template/ntp.py | 15 + .../converters/feature_template/omp.py | 35 + .../converters/feature_template/security.py | 42 + .../config_migration/creators/config_group.py | 123 +++ catalystwan/utils/timezone.py | 840 +++++++++--------- catalystwan/workflows/config_migration.py | 104 +++ endpoints-md.py | 2 +- examples/policies_configuration_guide.py | 14 +- pyproject.toml | 2 +- 87 files changed, 4128 insertions(+), 774 deletions(-) create mode 100644 catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/banner.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/basic.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/global_parcel.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/logging_parcel.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/mrf.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/ntp.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/omp.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/security.py create mode 100644 catalystwan/models/configuration/feature_profile/sdwan/system/snmp.py create mode 100644 catalystwan/models/configuration/topology_group.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/tests/test_feature_profile_api.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/__init__.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/aaa.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/banner.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/base.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/basic.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/bfd.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/factory_method.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/global_.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/logging_.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/normalizer.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/ntp.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/omp.py create mode 100644 catalystwan/utils/config_migration/converters/feature_template/security.py create mode 100644 catalystwan/utils/config_migration/creators/config_group.py create mode 100644 catalystwan/workflows/config_migration.py diff --git a/catalystwan/api/api_container.py b/catalystwan/api/api_container.py index 290a0b26..d9ea675f 100644 --- a/catalystwan/api/api_container.py +++ b/catalystwan/api/api_container.py @@ -18,7 +18,7 @@ from catalystwan.api.config_device_inventory_api import ConfigurationDeviceInventoryAPI from catalystwan.api.config_group_api import ConfigGroupAPI from catalystwan.api.dashboard_api import DashboardAPI -from catalystwan.api.feature_profile_api import SDRoutingFeatureProfilesAPI +from catalystwan.api.feature_profile_api import SDRoutingFeatureProfilesAPI, SDWANFeatureProfilesAPI from catalystwan.api.logs_api import LogsAPI from catalystwan.api.omp_api import OmpAPI from catalystwan.api.packet_capture_api import PacketCaptureAPI @@ -66,3 +66,4 @@ def __init__(self, session: ManagerSession): self.sessions = SessionsAPI(session) self.policy = PolicyAPI(session) self.sd_routing_feature_profiles = SDRoutingFeatureProfilesAPI(session) + self.sdwan_feature_profiles = SDWANFeatureProfilesAPI(session) diff --git a/catalystwan/api/config_group_api.py b/catalystwan/api/config_group_api.py index 9e3d9fba..f653ea22 100644 --- a/catalystwan/api/config_group_api.py +++ b/catalystwan/api/config_group_api.py @@ -2,12 +2,16 @@ 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, @@ -16,7 +20,6 @@ ConfigGroupDisassociateResponse, ConfigGroupEditPayload, ConfigGroupEditResponse, - ConfigGroupResponsePayload, ConfigGroupVariablesCreatePayload, ConfigGroupVariablesCreateResponse, ConfigGroupVariablesEditPayload, @@ -110,11 +113,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/api/configuration_groups/parcel.py b/catalystwan/api/configuration_groups/parcel.py index df4c20a5..46bede28 100644 --- a/catalystwan/api/configuration_groups/parcel.py +++ b/catalystwan/api/configuration_groups/parcel.py @@ -3,13 +3,25 @@ from enum import Enum from typing import Any, Dict, Generic, Literal, Optional, TypeVar, get_origin -from pydantic import AliasPath, BaseModel, ConfigDict, Field, PrivateAttr, model_serializer +from pydantic import ( + AliasPath, + BaseModel, + ConfigDict, + Field, + PrivateAttr, + SerializerFunctionWrapHandler, + model_serializer, +) + +from catalystwan.exceptions import CatalystwanException T = TypeVar("T") class _ParcelBase(BaseModel): - model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True, populate_by_name=True) + model_config = ConfigDict( + extra="allow", arbitrary_types_allowed=True, populate_by_name=True, json_schema_mode_override="validation" + ) parcel_name: str = Field( min_length=1, max_length=128, @@ -23,11 +35,18 @@ class _ParcelBase(BaseModel): validation_alias="description", description="Set the parcel description", ) - data: Optional[Any] = None _parcel_data_key: str = PrivateAttr(default="data") @model_serializer(mode="wrap") - def envelope_parcel_data(self, handler) -> Dict[str, Any]: + def envelope_parcel_data(self, handler: SerializerFunctionWrapHandler) -> Dict[str, Any]: + """ + serializes model fields with respect to field validation_alias, + sub-classing parcel fields can be defined like following: + >>> entries: List[SecurityZoneListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) + + "data" is default _parcel_data_key which must match validation_alias prefix, + this attribute can be overriden in sub-class when needed + """ model_dict = handler(self) model_dict[self._parcel_data_key] = {} remove_keys = [] @@ -43,6 +62,13 @@ def envelope_parcel_data(self, handler) -> Dict[str, Any]: del model_dict[key] return model_dict + @classmethod + def _get_parcel_type(cls) -> str: + field_info = cls.model_fields.get("type_") + if field_info is not None: + return str(field_info.default) + raise CatalystwanException("Field parcel type is not set.") + class OptionType(str, Enum): GLOBAL = "global" @@ -51,7 +77,7 @@ class OptionType(str, Enum): class ParcelAttribute(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", populate_by_name=True) option_type: OptionType = Field(serialization_alias="optionType", validation_alias="optionType") diff --git a/catalystwan/api/feature_profile_api.py b/catalystwan/api/feature_profile_api.py index bd14d392..7eaf4acd 100644 --- a/catalystwan/api/feature_profile_api.py +++ b/catalystwan/api/feature_profile_api.py @@ -2,9 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Protocol, Type, Union, overload +from typing import TYPE_CHECKING, Any, Optional, Protocol, Type, Union, overload from uuid import UUID +from pydantic import Json + +from catalystwan.endpoints.configuration.feature_profile.sdwan.system import SystemFeatureProfile from catalystwan.typed_list import DataSequence if TYPE_CHECKING: @@ -16,11 +19,12 @@ from catalystwan.models.configuration.feature_profile.common import ( FeatureProfileCreationPayload, FeatureProfileCreationResponse, + FeatureProfileInfo, + GetFeatureProfilesPayload, Parcel, ParcelCreationResponse, ) from catalystwan.models.configuration.feature_profile.sdwan.policy_object import ( - POLICY_OBJECT_PAYLOAD_ENDPOINT_MAPPING, AnyPolicyObjectParcel, ApplicationListParcel, AppProbeParcel, @@ -44,15 +48,32 @@ SecurityZoneListParcel, StandardCommunityParcel, TlocParcel, - URLAllowParcel, - URLBlockParcel, +) +from catalystwan.models.configuration.feature_profile.sdwan.system import ( + AAAParcel, + AnySystemParcel, + BannerParcel, + BasicParcel, + BFDParcel, + GlobalParcel, + LoggingParcel, + MRFParcel, + NTPParcel, + OMPParcel, + SecurityParcel, + SNMPParcel, ) class SDRoutingFeatureProfilesAPI: def __init__(self, session: ManagerSession): self.cli = SDRoutingCLIFeatureProfileAPI(session=session) + + +class SDWANFeatureProfilesAPI: + def __init__(self, session: ManagerSession): self.policy_object = PolicyObjectFeatureProfileAPI(session=session) + self.system = SystemFeatureProfileAPI(session=session) class FeatureProfileAPI(Protocol): @@ -105,6 +126,372 @@ def delete(self, fp_id: str) -> None: self.endpoint.delete_cli_feature_profile(cli_fp_id=fp_id) +class SystemFeatureProfileAPI: + """ + SDWAN Feature Profile System APIs + """ + + def __init__(self, session: ManagerSession): + self.session = session + self.endpoint = SystemFeatureProfile(session) + + def get_profiles( + self, limit: Optional[int] = None, offset: Optional[int] = None + ) -> DataSequence[FeatureProfileInfo]: + """ + Get all System Feature Profiles + """ + payload = GetFeatureProfilesPayload(limit=limit if limit else None, offset=offset if offset else None) + + return self.endpoint.get_sdwan_system_feature_profiles(payload) + + def create_profile(self, name: str, description: str) -> FeatureProfileCreationResponse: + """ + Create System Feature Profile + """ + payload = FeatureProfileCreationPayload(name=name, description=description) + return self.endpoint.create_sdwan_system_feature_profile(payload) + + def delete_profile(self, profile_id: UUID) -> None: + """ + Delete System Feature Profile + """ + self.endpoint.delete_sdwan_system_feature_profile(profile_id) + + def get_schema( + self, + profile_id: UUID, + parcel_type: Type[AnySystemParcel], + ) -> Json: + """ + Get all System Parcels for selected profile_id and selected type or get one Policy Object given parcel id + """ + + return self.endpoint.get_schema(profile_id, parcel_type._get_parcel_type()) + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[AAAParcel], + ) -> DataSequence[Parcel[AAAParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[BFDParcel], + ) -> DataSequence[Parcel[BFDParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[LoggingParcel], + ) -> DataSequence[Parcel[LoggingParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[BannerParcel], + ) -> DataSequence[Parcel[BannerParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[BasicParcel], + ) -> DataSequence[Parcel[BasicParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[GlobalParcel], + ) -> DataSequence[Parcel[GlobalParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[NTPParcel], + ) -> DataSequence[Parcel[NTPParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[MRFParcel], + ) -> DataSequence[Parcel[MRFParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[OMPParcel], + ) -> DataSequence[Parcel[OMPParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[SecurityParcel], + ) -> DataSequence[Parcel[SecurityParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[SNMPParcel], + ) -> DataSequence[Parcel[SNMPParcel]]: + ... + + # get by id + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[AAAParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[AAAParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[BFDParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[BFDParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[LoggingParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[LoggingParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[BannerParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[BannerParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[BasicParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[BasicParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[GlobalParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[GlobalParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[NTPParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[NTPParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[MRFParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[MRFParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[OMPParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[OMPParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[SecurityParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[SecurityParcel]]: + ... + + @overload + def get( + self, + profile_id: UUID, + parcel_type: Type[SNMPParcel], + parcel_id: UUID, + ) -> DataSequence[Parcel[SNMPParcel]]: + ... + + def get( + self, + profile_id: UUID, + parcel_type: Type[AnySystemParcel], + parcel_id: Union[UUID, None] = None, + ) -> DataSequence[Parcel[Any]]: + """ + Get all System Parcels for selected profile_id and selected type or get one System Parcel given parcel id + """ + + if not parcel_id: + 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: + """ + Create System 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: AnySystemParcel, parcel_id: UUID) -> ParcelCreationResponse: + """ + Update System Parcel for selected profile_id based on payload type + """ + + return self.endpoint.update(profile_id, payload._get_parcel_type(), parcel_id, payload) + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[AAAParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[BFDParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[LoggingParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[BannerParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[BasicParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[GlobalParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[NTPParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[MRFParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[OMPParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[SecurityParcel], + parcel_id: UUID, + ) -> None: + ... + + @overload + def delete( + self, + profile_id: UUID, + parcel_type: Type[SNMPParcel], + parcel_id: UUID, + ) -> None: + ... + + def delete(self, profile_id: UUID, parcel_type: Type[AnySystemParcel], parcel_id: UUID) -> None: + """ + Delete System Parcel for selected profile_id based on payload type + """ + return self.endpoint.delete(profile_id, parcel_type._get_parcel_type(), parcel_id) + + class PolicyObjectFeatureProfileAPI: """ SDWAN Feature Profile Policy Object APIs @@ -202,13 +589,13 @@ def get(self, profile_id: UUID, parcel_type: Type[StandardCommunityParcel]) -> D def get(self, profile_id: UUID, parcel_type: Type[TlocParcel]) -> DataSequence[Parcel[Any]]: ... - @overload - def get(self, profile_id: UUID, parcel_type: Type[URLAllowParcel]) -> DataSequence[Parcel[Any]]: - ... + # @overload + # def get(self, profile_id: UUID, parcel_type: Type[URLAllowParcel]) -> DataSequence[Parcel[Any]]: + # ... - @overload - def get(self, profile_id: UUID, parcel_type: Type[URLBlockParcel]) -> DataSequence[Parcel[Any]]: - ... + # @overload + # def get(self, profile_id: UUID, parcel_type: Type[URLBlockParcel]) -> DataSequence[Parcel[Any]]: + # ... # get by id @@ -328,13 +715,13 @@ def get( def get(self, profile_id: UUID, parcel_type: Type[TlocParcel], parcel_id: UUID) -> DataSequence[Parcel[Any]]: ... - @overload - def get(self, profile_id: UUID, parcel_type: Type[URLAllowParcel], parcel_id: UUID) -> DataSequence[Parcel[Any]]: - ... + # @overload + # def get(self, profile_id: UUID, parcel_type: Type[URLAllowParcel], parcel_id: UUID) -> DataSequence[Parcel[Any]]: + # ... - @overload - def get(self, profile_id: UUID, parcel_type: Type[URLBlockParcel], parcel_id: UUID) -> DataSequence[Parcel[Any]]: - ... + # @overload + # def get(self, profile_id: UUID, parcel_type: Type[URLBlockParcel], parcel_id: UUID) -> DataSequence[Parcel[Any]]: + # ... def get( self, @@ -346,7 +733,7 @@ def get( Get all Policy Objects for selected profile_id and selected type or get one Policy Object given parcel id """ - policy_object_list_type = POLICY_OBJECT_PAYLOAD_ENDPOINT_MAPPING[parcel_type] + policy_object_list_type = parcel_type._get_parcel_type() if not parcel_id: return self.endpoint.get_all(profile_id=profile_id, policy_object_list_type=policy_object_list_type) parcel = self.endpoint.get_by_id( @@ -359,7 +746,7 @@ def create(self, profile_id: UUID, payload: AnyPolicyObjectParcel) -> ParcelCrea Create Policy Object for selected profile_id based on payload type """ - policy_object_list_type = POLICY_OBJECT_PAYLOAD_ENDPOINT_MAPPING[type(payload)] + policy_object_list_type = payload._get_parcel_type() return self.endpoint.create( profile_id=profile_id, policy_object_list_type=policy_object_list_type, payload=payload ) @@ -369,7 +756,7 @@ def update(self, profile_id: UUID, payload: AnyPolicyObjectParcel, list_object_i Update Policy Object for selected profile_id based on payload type """ - policy_type = POLICY_OBJECT_PAYLOAD_ENDPOINT_MAPPING[type(payload)] + policy_type = payload._get_parcel_type() return self.endpoint.update( profile_id=profile_id, policy_object_list_type=policy_type, list_object_id=list_object_id, payload=payload ) @@ -462,20 +849,20 @@ def delete(self, profile_id: UUID, parcel_type: Type[StandardCommunityParcel], l def delete(self, profile_id: UUID, parcel_type: Type[TlocParcel], list_object_id: UUID) -> None: ... - @overload - def delete(self, profile_id: UUID, parcel_type: Type[URLAllowParcel], list_object_id: UUID) -> None: - ... + # @overload + # def delete(self, profile_id: UUID, parcel_type: Type[URLAllowParcel], list_object_id: UUID) -> None: + # ... - @overload - def delete(self, profile_id: UUID, parcel_type: Type[URLBlockParcel], list_object_id: UUID) -> None: - ... + # @overload + # def delete(self, profile_id: UUID, parcel_type: Type[URLBlockParcel], list_object_id: UUID) -> None: + # ... def delete(self, profile_id: UUID, parcel_type: Type[AnyPolicyObjectParcel], list_object_id: UUID) -> None: """ Delete Policy Object for selected profile_id based on payload type """ - policy_object_list_type = POLICY_OBJECT_PAYLOAD_ENDPOINT_MAPPING[parcel_type] + policy_object_list_type = parcel_type._get_parcel_type() return self.endpoint.delete( profile_id=profile_id, policy_object_list_type=policy_object_list_type, list_object_id=list_object_id ) diff --git a/catalystwan/api/partition_manager_api.py b/catalystwan/api/partition_manager_api.py index 36d42c62..6299d8b5 100644 --- a/catalystwan/api/partition_manager_api.py +++ b/catalystwan/api/partition_manager_api.py @@ -70,14 +70,20 @@ def set_default_partition( else: payload_devices = self.device_version.get_devices_current_version(devices) - url = "/dataservice/device/action/defaultpartition" - payload = { - "action": "defaultpartition", - "devices": [device.model_dump() for device in payload_devices], # type: ignore - "deviceType": get_install_specification(devices.first()).device_type.value, - } - set_default = dict(self.session.post(url, json=payload).json()) - return Task(self.session, set_default["id"]) + for device in payload_devices: + if not device.version: + raise EmptyVersionPayloadError("PartitionDevice payload contains entry with empty version field.") + + device_type = get_install_specification(devices.first()).device_type.value + partition_payload = PartitionActionPayload( + action="defaultpartition", devices=[dev for dev in payload_devices], device_type=device_type + ) + + partition_action = self.session.endpoints.configuration_device_actions.process_mark_default_partition( + payload=partition_payload + ) + + return Task(self.session, partition_action.id) def remove_partition( self, devices: DataSequence[DeviceDetailsResponse], partition: Optional[str] = None, force: bool = False @@ -100,19 +106,17 @@ def remove_partition( else: payload_devices = self.device_version.get_devices_available_versions(devices) - remove_partition_payload = [ - RemovePartitionPayload( - device_id=device.device_id, device_ip=device.device_id, version=device.version - ) # type: ignore - for device in payload_devices - ] - - url = "/dataservice/device/action/removepartition" - payload = { - "action": "removepartition", - "devices": [device.model_dump() for device in remove_partition_payload], # type: ignore - "deviceType": get_install_specification(devices.first()).device_type.value, - } + for device in payload_devices: + if not device.version: + raise EmptyVersionPayloadError("PartitionDevice payload contains entry with empty version field.") + + device_type = get_install_specification(devices.first()).device_type.value + partition_payload = RemovePartitionActionPayload( + action="removepartition", + devices=[RemovePartitionDevice(**dev.model_dump()) for dev in payload_devices], + device_type=device_type, + ) + if force is False: self._check_remove_partition_possibility(cast(list, partition_payload.devices)) diff --git a/catalystwan/api/policy_api.py b/catalystwan/api/policy_api.py index ff6d40cb..dd282bcb 100644 --- a/catalystwan/api/policy_api.py +++ b/catalystwan/api/policy_api.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type, overload +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple, Type, overload from uuid import UUID from catalystwan.api.task_status_api import Task @@ -639,6 +639,12 @@ def get(self, type: Type[AnyPolicyList], id: Optional[UUID] = None) -> Any: return endpoints.get_lists_by_id(id=id) return endpoints.get_policy_lists() + def get_all(self) -> List[AnyPolicyList]: + infos: List[AnyPolicyList] = [] + for list_type, _ in POLICY_LIST_ENDPOINTS_MAP.items(): + infos.extend(self.get(list_type)) + return infos + class PolicyDefinitionsAPI: def __init__(self, session: ManagerSession): @@ -782,6 +788,12 @@ def get(self, type: Type[AnyPolicyDefinition], id: Optional[UUID] = None) -> Any return endpoints.get_policy_definition(id=id) return endpoints.get_definitions() + def get_all(self) -> List[Tuple[type, PolicyDefinitionInfo]]: + all_items: List[Tuple[type, PolicyDefinitionInfo]] = [] + for definition_type, _ in POLICY_DEFINITION_ENDPOINTS_MAP.items(): + all_items.extend([(definition_type, info) for info in self.get(definition_type)]) + return all_items + class PolicyAPI: """This is exposing so called 'UX 1.0' API""" diff --git a/catalystwan/api/software_action_api.py b/catalystwan/api/software_action_api.py index 22d01b2a..b7d1502c 100644 --- a/catalystwan/api/software_action_api.py +++ b/catalystwan/api/software_action_api.py @@ -85,14 +85,26 @@ def activate( else: raise ValueError("You can not provide software_image and image version at the same time!") - url = "/dataservice/device/action/changepartition" - payload = { - "action": "changepartition", - "devices": [device.model_dump() for device in self.device_versions.get_device_available(version, devices)], - "deviceType": get_install_specification(devices.first()).device_type.value, - } - activate = dict(self.session.post(url, json=payload).json()) - return Task(self.session, activate["id"]) + if not version: + raise ImageNotInRepositoryError( + "Based on provided arguments, software version to activate on device(s) cannot be detected." + ) + + payload_devices = self.device_versions.get_device_available(version, devices) + for device in payload_devices: + if not device.version: + raise EmptyVersionPayloadError("PartitionDevice payload contains entry with empty version field.") + + device_type = get_install_specification(devices.first()).device_type.value + partition_payload = PartitionActionPayload( + action="changepartition", devices=[dev for dev in payload_devices], device_type=device_type + ) + + partition_action = self.session.endpoints.configuration_device_actions.process_mark_change_partition( + payload=partition_payload + ) + + return Task(self.session, partition_action.id) def install( self, @@ -185,27 +197,27 @@ def install( sync=sync, ) else: - raise VersionDeclarationError("You can not provide image and image version at the same time") - install_specification = get_install_specification(devices.first()) - - url = "/dataservice/device/action/install" - payload: Dict[str, Any] = { - "action": "install", - "input": { - "vEdgeVPN": 0, - "vSmartVPN": 0, - "family": install_specification.family.value, - "version": version, - "versionType": install_specification.version_type.value, - "reboot": reboot, - "sync": sync, - }, - "devices": [ - {"deviceId": device.device_id, "deviceIP": device.device_ip} - for device in self.device_versions.get_device_list(devices) - ], # type: ignore - "deviceType": install_specification.device_type.value, - } + input = InstallInput( + v_edge_vpn=v_edge_vpn, + v_smart_vpn=v_smart_vpn, + data=[ + InstallData( + family=install_specification.family.value, + version=remote_image_details.version_id, # type: ignore + remote_server_id=remote_image_details.remote_server_id, # type: ignore + version_id=remote_image_details.version_id, # type: ignore + ) + ], + version_type=install_specification.version_type.value, + reboot=reboot, + sync=sync, + ) + + device_type = install_specification.device_type.value + install_payload = InstallActionPayload( + action="install", input=input, devices=install_devices, device_type=device_type + ) + if downgrade_check and devices.first().personality in (Personality.VMANAGE, Personality.EDGE): self._downgrade_check( install_payload.devices, diff --git a/catalystwan/api/template_api.py b/catalystwan/api/template_api.py index 31b5050e..b1b68b89 100644 --- a/catalystwan/api/template_api.py +++ b/catalystwan/api/template_api.py @@ -2,12 +2,14 @@ from __future__ import annotations +import datetime as dt import json import logging from enum import Enum -from typing import TYPE_CHECKING, Any, Optional, Type, overload +from typing import TYPE_CHECKING, Any, List, Optional, Type, overload from ciscoconfparse import CiscoConfParse # type: ignore +from pydantic import BaseModel, ConfigDict, Field from catalystwan.api.task_status_api import Task from catalystwan.api.templates.cli_template import CLITemplate @@ -69,6 +71,41 @@ class DeviceTemplateFeature(Enum): ALL = "all" +class TemplateInformation(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + last_updated_by: str = Field(serialization_alias="lastUpdatedBy", validation_alias="lastUpdatedBy") + id: str = Field(serialization_alias="templateId", validation_alias="templateId") + factory_default: bool = Field(serialization_alias="factoryDefault", validation_alias="factoryDefault") + name: str = Field(serialization_alias="templateName", validation_alias="templateName") + devices_attached: int = Field(serialization_alias="devicesAttached", validation_alias="devicesAttached") + description: str = Field(serialization_alias="templateDescription", validation_alias="templateDescription") + last_updated_on: dt.datetime = Field(serialization_alias="lastUpdatedOn", validation_alias="lastUpdatedOn") + resource_group: Optional[str] = Field(None, serialization_alias="resourceGroup", validation_alias="resourceGroup") + + +class FeatureTemplateInformation(TemplateInformation): + model_config = ConfigDict(populate_by_name=True) + + template_type: str = Field(serialization_alias="templateType", validation_alias="templateType") + device_type: List[str] = Field(serialization_alias="deviceType", validation_alias="deviceType") + version: str = Field(serialization_alias="templateMinVersion", validation_alias="templateMinVersion") + template_definiton: Optional[str] = Field( + None, serialization_alias="templateDefinition", validation_alias="templateDefinition" + ) + + +class DeviceTemplateInformation(TemplateInformation): + model_config = ConfigDict(populate_by_name=True) + + device_type: str = Field(serialization_alias="deviceType", validation_alias="deviceType") + template_class: str = Field(serialization_alias="templateClass", validation_alias="templateClass") + config_type: str = Field(serialization_alias="configType", validation_alias="configType") + template_attached: int = Field(serialization_alias="templateAttached", validation_alias="templateAttached") + draft_mode: Optional[str] = Field(None, serialization_alias="draftMode", validation_alias="draftMode") + device_role: Optional[str] = Field(None, serialization_alias="deviceRole", validation_alias="deviceRole") + + class TemplatesAPI: def __init__(self, session: ManagerSession) -> None: self.session = session @@ -698,3 +735,14 @@ def load_running(self, device: Device) -> CiscoConfParse: config = CiscoConfParse(response["config"].splitlines()) logger.debug(f"Template loaded from {device.hostname}.") return config + + def get_feature_templates(self) -> DataSequence[FeatureTemplateInformation]: + endpoint = "/dataservice/template/feature" + fr_templates = self.session.get(endpoint) + return fr_templates.dataseq(FeatureTemplateInformation) + + def get_device_templates(self) -> DataSequence[DeviceTemplateInformation]: + endpoint = "/dataservice/template/device" + params = {"feature": "all"} + templates = self.session.get(url=endpoint, params=params) + return templates.dataseq(DeviceTemplateInformation) diff --git a/catalystwan/api/versions_utils.py b/catalystwan/api/versions_utils.py index 7b5d70c3..0c5377a3 100644 --- a/catalystwan/api/versions_utils.py +++ b/catalystwan/api/versions_utils.py @@ -6,15 +6,14 @@ from pathlib import PurePath from typing import TYPE_CHECKING, Dict, List, Union -from clint.textui.progress import Bar as ProgressBar # type: ignore from pydantic import BaseModel, ConfigDict, Field -from pydantic.functional_validators import BeforeValidator -from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor # type: ignore -from typing_extensions import Annotated -from catalystwan.dataclasses import Device +from catalystwan.endpoints.configuration.software_actions import SoftwareImageDetails +from catalystwan.endpoints.configuration_device_actions import PartitionDevice +from catalystwan.endpoints.configuration_device_inventory import DeviceDetailsResponse from catalystwan.exceptions import ImageNotInRepositoryError from catalystwan.typed_list import DataSequence +from catalystwan.utils.upgrades_helper import SoftwarePackageUploadPayload if TYPE_CHECKING: from catalystwan.session import ManagerSession @@ -39,25 +38,6 @@ class DeviceSoftwareRepository(BaseModel): device_id: str = Field(default="", serialization_alias="uuid", validation_alias="uuid") -class DeviceVersionPayload(BaseModel): - device_id: str = Field(serialization_alias="deviceId") - device_ip: str = Field(serialization_alias="deviceIP") - version: Union[str, List[str]] = Field(default="") - - -def convert_to_list(element: Union[str, List[str]]) -> List[str]: - return [element] if isinstance(element, str) else element - - -VersionList = Annotated[Union[str, List[str]], BeforeValidator(convert_to_list)] - - -class RemovePartitionPayload(BaseModel): - device_id: str = Field(serialization_alias="deviceId") - device_ip: str = Field(serialization_alias="deviceIP") - version: VersionList - - class RepositoryAPI: """ API methods to get information about images and devices software versions @@ -107,8 +87,8 @@ def get_devices_versions_repository(self) -> Dict[str, DeviceSoftwareRepository] device_type="vmanage" ) devices_versions_repository = {} - for device in controllers_versions_info + edges_versions_info: - device_software_repository = DeviceSoftwareRepository(**device) + for device in controllers_versions_info + edges_versions_info + vmanages_versions_info: + device_software_repository = DeviceSoftwareRepository(**device.model_dump(by_alias=True)) device_software_repository.installed_versions = [a for a in device_software_repository.available_versions] device_software_repository.installed_versions.append(device_software_repository.current_version) devices_versions_repository[device_software_repository.device_id] = device_software_repository @@ -246,8 +226,8 @@ def _get_device_list_in( """ self._validate_devices_required_fields(devices) devices_payload = DataSequence( - DeviceVersionPayload, - [DeviceVersionPayload(device_id=device.uuid, device_ip=device.id) for device in devices], + PartitionDevice, + [PartitionDevice(device_id=device.uuid, device_ip=device.device_ip) for device in devices], # type: ignore ) all_dev_versions = self.repository.get_devices_versions_repository() for device in devices_payload: @@ -314,8 +294,8 @@ def _get_devices_chosen_version( self._validate_devices_required_fields(devices) devices_payload = DataSequence( - DeviceVersionPayload, - [DeviceVersionPayload(device_id=device.uuid, device_ip=device.id) for device in devices], + PartitionDevice, + [PartitionDevice(device_id=device.uuid, device_ip=device.device_ip) for device in devices], # type: ignore ) all_dev_versions = self.repository.get_devices_versions_repository() for device in devices_payload: @@ -355,5 +335,9 @@ def get_devices_available_versions( return self._get_devices_chosen_version(devices, "available_versions") - def get_device_list(self, devices: DataSequence[Device]) -> List[DeviceVersionPayload]: - return [DeviceVersionPayload(device_id=device.uuid, device_ip=device.id) for device in devices] # type: ignore + def get_device_list(self, devices: DataSequence[DeviceDetailsResponse]) -> List[PartitionDevice]: + self._validate_devices_required_fields(devices) + + return [ + PartitionDevice(device_id=device.uuid, device_ip=device.device_ip) for device in devices # type: ignore + ] diff --git a/catalystwan/endpoints/__init__.py b/catalystwan/endpoints/__init__.py index 06e25fa9..f4534174 100644 --- a/catalystwan/endpoints/__init__.py +++ b/catalystwan/endpoints/__init__.py @@ -52,6 +52,7 @@ Sequence, Set, Tuple, + Type, TypeVar, Union, runtime_checkable, @@ -116,6 +117,47 @@ def json(cls) -> TypeSpecifier: def model_union(cls, models: Sequence[type]) -> TypeSpecifier: return TypeSpecifier(present=True, payload_union_model_types=models) + @classmethod + def resolve_nested_base_model_unions( + cls, annotation: Any, models_types: List[Union[Type[BaseModelV1], Type[BaseModelV2]]] + ) -> List[Union[Type[BaseModelV1], Type[BaseModelV2]]]: + type_origin = get_origin(annotation) + if isclass(annotation): + try: + if issubclass(annotation, (BaseModelV1, BaseModelV2)): + return [annotation] + raise APIEndpointError(f"Expected: {PayloadType}") + except TypeError: + raise APIEndpointError(f"Expected: {PayloadType}") + # Check if Annnotated[Union[PayloadModelType, ...]], only unions of pydantic models allowed + elif type_origin == Annotated: + if annotated_origin := get_args(annotation): + if (len(annotated_origin) >= 1) and get_origin(annotated_origin[0]) == Union: + type_args = get_args(annotated_origin[0]) + if all(isclass(t) for t in type_args) and all( + issubclass(t, (BaseModelV1, BaseModelV2)) for t in type_args + ): + models_types.extend(list(type_args)) + return models_types + else: + non_models = [t for t in type_args if not isclass(t)] + for non_model in non_models: + models_types.extend(cls.resolve_nested_base_model_unions(non_model, models_types)) + return models_types + + # Check if Union[PayloadModelType, ...], only unions of pydantic models allowed + elif type_origin == Union: + type_args = get_args(annotation) + if all(isclass(t) for t in type_args) and all(issubclass(t, (BaseModelV1, BaseModelV2)) for t in type_args): + models_types.extend(list(type_args)) + return models_types + else: + non_models = [t for t in type_args if not isclass(t)] + for non_model in non_models: + models_types.extend(cls.resolve_nested_base_model_unions(non_model, models_types)) + return models_types + raise APIEndpointError(f"Expected: {PayloadType}") + @dataclass class APIEndpointRequestMeta: @@ -453,27 +495,10 @@ def specify_payload_type(self) -> TypeSpecifier: and issubclass(type_args[0], (BaseModelV1, BaseModelV2)) ): return TypeSpecifier(True, type_origin, type_args[0], None, False, is_optional) - # Check if Annnotated[Union[PayloadModelType, ...]], only unions of pydantic models allowed - elif type_origin == Annotated: - if annotated_origin := get_args(annotation): - if (len(annotated_origin) >= 1) and get_origin(annotated_origin[0]) == Union: - if ( - (type_args := get_args(annotated_origin[0])) - and all(isclass(t) for t in type_args) - and all(issubclass(t, (BaseModelV1, BaseModelV2)) for t in type_args) - ): - return TypeSpecifier.model_union(models=list(type_args)) - # Check if Union[PayloadModelType, ...], only unions of pydantic models allowed - elif type_origin == Union: - if ( - (type_args := get_args(annotation)) - and all(isclass(t) for t in type_args) - and all(issubclass(t, (BaseModelV1, BaseModelV2)) for t in type_args) - ): - return TypeSpecifier.model_union(models=list(type_args)) - raise APIEndpointError(f"Expected: {PayloadType} but found payload {annotation}") - else: - raise APIEndpointError(f"Expected: {PayloadType} but found payload {annotation}") + else: + models = TypeSpecifier.resolve_nested_base_model_unions(annotation, []) + return TypeSpecifier.model_union(models) + raise APIEndpointError(f"'payload' param must be annotated with supported type: {PayloadType}") def check_params(self): """Checks params in decorated method definition diff --git a/catalystwan/endpoints/configuration/feature_profile/sdwan/system.py b/catalystwan/endpoints/configuration/feature_profile/sdwan/system.py index 6c34b1fd..d9243200 100644 --- a/catalystwan/endpoints/configuration/feature_profile/sdwan/system.py +++ b/catalystwan/endpoints/configuration/feature_profile/sdwan/system.py @@ -2,6 +2,7 @@ # mypy: disable-error-code="empty-body" from typing import Optional +from uuid import UUID from catalystwan.api.configuration_groups.parcel import _ParcelBase from catalystwan.endpoints import JSON, APIEndpoints, delete, get, post, put, versions @@ -10,16 +11,18 @@ FeatureProfileCreationResponse, FeatureProfileInfo, GetFeatureProfilesPayload, + Parcel, ParcelId, SchemaTypeQuery, ) +from catalystwan.models.configuration.feature_profile.sdwan.system import AnySystemParcel from catalystwan.typed_list import DataSequence class SystemFeatureProfile(APIEndpoints): @versions(supported_versions=(">=20.9"), raises=False) - @get("/v1/feature-profile/sdwan/system/aaa/schema", resp_json_key="request") - def get_sdwan_system_aaa_parcel_schema(self, params: SchemaTypeQuery) -> JSON: + @get("/v1/feature-profile/sdwan/system/{parcel_type}/schema", resp_json_key="request") + def get_schema(self, parcel_type: str, params: SchemaTypeQuery) -> JSON: ... @versions(supported_versions=(">=20.9"), raises=False) @@ -37,16 +40,46 @@ def create_sdwan_system_feature_profile( ... @versions(supported_versions=(">=20.9"), raises=False) - @delete("/v1/feature-profile/sdwan/system/{system_id}") - def delete_sdwan_system_feature_profile(self, system_id: str) -> None: + @delete("/v1/feature-profile/sdwan/system/{profile_id}") + def delete_sdwan_system_feature_profile(self, profile_id: UUID) -> None: ... @versions(supported_versions=(">=20.9"), raises=False) - @post("/v1/feature-profile/sdwan/system/{system_id}/aaa") - def create_aaa_profile_parcel_for_system(self, system_id: str, payload: _ParcelBase) -> ParcelId: + @post("/v1/feature-profile/sdwan/system/{profile_id}/aaa") + def create_aaa_profile_parcel_for_system(self, profile_id: UUID, payload: _ParcelBase) -> ParcelId: ... @versions(supported_versions=(">=20.9"), raises=False) - @put("/v1/feature-profile/sdwan/system/{system_id}/aaa/{parcel_id}") - def edit_aaa_profile_parcel_for_system(self, system_id: str, parcel_id: str, payload: _ParcelBase) -> ParcelId: + @put("/v1/feature-profile/sdwan/system/{profile_id}/aaa/{parcel_id}") + def edit_aaa_profile_parcel_for_system(self, profile_id: UUID, parcel_id: UUID, payload: _ParcelBase) -> ParcelId: + ... + + @versions(supported_versions=(">=20.9"), raises=False) + @get("/v1/feature-profile/sdwan/system/{profile_id}/{parcel_type}") + def get_all(self, profile_id: UUID, parcel_type: UUID) -> DataSequence[Parcel]: + ... + + @versions(supported_versions=(">=20.9"), raises=False) + @get("/v1/feature-profile/sdwan/system/{profile_id}/{parcel_type}/{parcel_id}") + def get_by_id(self, profile_id: UUID, parcel_type: str, parcel_id: UUID) -> Parcel: + ... + + @versions(supported_versions=(">=20.9"), raises=False) + @put("/v1/feature-profile/sdwan/system/{profile_id}/{parcel_type}/{parcel_id}") + def update(self, profile_id: UUID, parcel_type: str, parcel_id: UUID, payload: AnySystemParcel) -> ParcelId: + ... + + @versions(supported_versions=(">=20.9"), raises=False) + @delete("/v1/feature-profile/sdwan/system/{profile_id}/{parcel_type}/{parcel_id}") + def delete(self, profile_id: UUID, parcel_type: str, parcel_id: UUID) -> None: + ... + + @versions(supported_versions=(">=20.9"), raises=False) + @post("/v1/feature-profile/sdwan/system/{system_id}/bfd") + def create_bfd_profile_parcel_for_system(self, system_id: str, payload: _ParcelBase) -> ParcelId: + ... + + @versions(supported_versions=(">=20.9"), raises=False) + @post("/v1/feature-profile/sdwan/system/{profile_id}/{parcel_type}") + def create(self, profile_id: UUID, parcel_type: str, payload: AnySystemParcel) -> ParcelId: ... diff --git a/catalystwan/endpoints/configuration_group.py b/catalystwan/endpoints/configuration_group.py index ef499304..b8467639 100644 --- a/catalystwan/endpoints/configuration_group.py +++ b/catalystwan/endpoints/configuration_group.py @@ -3,8 +3,9 @@ # mypy: disable-error-code="empty-body" from datetime import datetime from typing import List, Optional +from uuid import UUID -from pydantic.v1 import BaseModel, Field +from pydantic import BaseModel, Field from catalystwan.endpoints import APIEndpoints, delete, get, post, put, versions from catalystwan.models.configuration.common import Solution @@ -13,7 +14,7 @@ class ProfileId(BaseModel): - id: str + id: UUID # TODO Get mode from schema @@ -37,10 +38,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): @@ -106,7 +121,7 @@ class ConfigGroupDisassociateResponse(BaseModel): class ConfigGroupCreationResponse(BaseModel): - id: str + id: UUID class EditedProfileId(BaseModel): diff --git a/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py b/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py new file mode 100644 index 00000000..9bc5aad6 --- /dev/null +++ b/catalystwan/integration_tests/feature_profile/sdwan/system/test_models.py @@ -0,0 +1,217 @@ +import os +import unittest +from typing import cast + +from catalystwan.models.configuration.feature_profile.sdwan.system import ( + BannerParcel, + BasicParcel, + BFDParcel, + GlobalParcel, + LoggingParcel, + MRFParcel, + NTPParcel, + OMPParcel, + SecurityParcel, + SNMPParcel, +) +from catalystwan.session import create_manager_session + + +class TestSystemFeatureProfileModels(unittest.TestCase): + def setUp(self) -> None: + self.session = create_manager_session( + url=cast(str, os.environ.get("TEST_VMANAGE_URL")), + port=cast(int, int(os.environ.get("TEST_VMANAGE_PORT"))), # type: ignore + username=cast(str, os.environ.get("TEST_VMANAGE_USERNAME")), + password=cast(str, os.environ.get("TEST_VMANAGE_PASSWORD")), + ) + self.profile_id = self.session.api.sdwan_feature_profiles.system.create_profile("TestProfile", "Description").id + + def test_when_default_values_banner_parcel_expect_successful_post(self): + # Arrange + banner_parcel = BannerParcel( + parcel_name="BannerDefault", + parcel_description="Banner Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, banner_parcel).id + # Assert + assert parcel_id + + def test_when_fully_specified_banner_parcel_expect_successful_post(self): + # Arrange + banner_parcel = BannerParcel( + parcel_name="BannerFullySpecified", + parcel_description="Banner Parcel", + ) + 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 + # Assert + assert parcel_id + + def test_when_default_values_logging_parcel_expect_successful_post(self): + # Arrange + logging_parcel = LoggingParcel( + parcel_name="LoggingDefault", + parcel_description="Logging Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, logging_parcel).id + # Assert + assert parcel_id + + def test_when_fully_specified_logging_parcel_expect_successful_post(self): + # Arrange + logging_parcel = LoggingParcel( + parcel_name="LoggingFullySpecified", + parcel_description="Logging Parcel", + ) + logging_parcel.set_disk( + enable=True, + disk_file_rotate=10, + disk_file_size=10, + ) + logging_parcel.add_tls_profile( + profile="TLSProfile", + version="TLSv1.2", + ciphersuite_list=[ + "aes-256-cbc-sha", + "aes-128-cbc-sha", + "ecdhe-ecdsa-aes-gcm-sha2", + "ecdhe-rsa-aes-cbc-sha2", + ], + ) + logging_parcel.add_ipv4_server( + name="Server1", + vpn=0, + source_interface="fastethernet1/0", + priority="debugging", + enable_tls=True, + custom_profile=True, + profile_properties="TLSProfile", + ) + logging_parcel.add_ipv6_server( + name="Server2", + vpn=0, + source_interface="fastethernet1/1", + priority="debugging", + enable_tls=True, + custom_profile=True, + profile_properties="TLSProfile", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, logging_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_bfd_parcel_expect_successful_post(self): + # Arrange + bfd_parcel = BFDParcel( + parcel_name="BFDDefault", + parcel_description="BFD Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, bfd_parcel).id + # Assert + assert parcel_id + + def test_when_fully_specified_bfd_parcel_expect_successful_post(self): + # Arrange + bfd_parcel = BFDParcel( + parcel_name="BFDFullySpecified", + parcel_description="BFD Parcel", + ) + bfd_parcel.set_muliplier(1) + bfd_parcel.set_poll_interval(700000) + bfd_parcel.set_default_dscp(51) + bfd_parcel.add_color(color="lte", hello_interval=300000, multiplier=7, pmtu_discovery=False) + bfd_parcel.add_color(color="mpls", pmtu_discovery=False) + 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 + # Assert + assert parcel_id + + def test_when_default_values_basic_parcel_expect_successful_post(self): + # Arrange + basic_parcel = BasicParcel( + parcel_name="BasicDefault", + parcel_description="Basic Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, basic_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_security_parcel_expect_successful_post(self): + # Arrange + security_parcel = SecurityParcel( + parcel_name="SecurityDefault", + parcel_description="Security Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, security_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_ntp_parcel_expect_successful_post(self): + # Arrange + ntp_parcel = NTPParcel( + parcel_name="NTPDefault", + parcel_description="NTP Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, ntp_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_global_parcel_expect_successful_post(self): + # Arrange + global_parcel = GlobalParcel( + parcel_name="GlobalDefault", + parcel_description="Global Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, global_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_mrf_parcel_expect_successful_post(self): + # Arrange + mrf_parcel = MRFParcel( + parcel_name="MRFDefault", + parcel_description="MRF Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, mrf_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_snmp_parcel_expect_successful_post(self): + # Arrange + snmp_parcel = SNMPParcel( + parcel_name="SNMPDefault", + parcel_description="SNMP Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, snmp_parcel).id + # Assert + assert parcel_id + + def test_when_default_values_omp_parcel_expect_successful_post(self): + # Arrange + omp_parcel = OMPParcel( + parcel_name="OMPDefault", + parcel_description="OMP Parcel", + ) + # Act + parcel_id = self.session.api.sdwan_feature_profiles.system.create(self.profile_id, omp_parcel).id + # Assert + assert parcel_id + + def tearDown(self) -> None: + self.session.api.sdwan_feature_profiles.system.delete_profile(self.profile_id) + self.session.close() diff --git a/catalystwan/models/common.py b/catalystwan/models/common.py index 481886f4..cd97b740 100644 --- a/catalystwan/models/common.py +++ b/catalystwan/models/common.py @@ -1,6 +1,11 @@ # Copyright 2023 Cisco Systems, Inc. and its affiliates -from typing import Dict, List, Literal, Set, Tuple +from typing import Dict, List, Literal, Sequence, Set, Tuple, Union +from uuid import UUID + +from pydantic import PlainSerializer +from pydantic.functional_validators import BeforeValidator +from typing_extensions import Annotated def check_fields_exclusive(values: Dict, field_names: Set[str], at_least_one: bool = False) -> bool: @@ -48,6 +53,25 @@ def check_any_of_exclusive_field_sets(values: Dict, field_sets: List[Tuple[Set[s raise ValueError(f"One of {all_sets_field_names} must be assigned") +IntStr = Annotated[ + int, + PlainSerializer(lambda x: str(x), return_type=str, when_used="json-unless-none"), + BeforeValidator(lambda x: int(x)), +] + + +def str_as_uuid_list(val: Union[str, Sequence[UUID]]) -> Sequence[UUID]: + if isinstance(val, str): + return [UUID(uuid_) for uuid_ in val.split()] + return val + + +def str_as_str_list(val: Union[str, Sequence[str]]) -> Sequence[str]: + if isinstance(val, str): + return [s for s in val.split()] + return val + + InterfaceType = Literal[ "Ethernet", "FastEthernet", @@ -116,3 +140,7 @@ def check_any_of_exclusive_field_sets(values: Dict, field_sets: List[Tuple[Set[s "SC15", "SC16", ] + +ICMPMessageType = Literal[ + "echo", "echo-reply", "unreachable", "net-unreachable", "host-unreachable", "protocol-unreachable" +] diff --git a/catalystwan/models/configuration/config_migration.py b/catalystwan/models/configuration/config_migration.py index 631b4b69..f68558d9 100644 --- a/catalystwan/models/configuration/config_migration.py +++ b/catalystwan/models/configuration/config_migration.py @@ -1,9 +1,16 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal, Union -from pydantic import BaseModel, Field +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.configuration.topology_group import TopologyGroup from catalystwan.models.policy import ( AnyPolicyDefinition, AnyPolicyList, @@ -12,25 +19,73 @@ SecurityPolicy, ) +AnyParcel = Annotated[ + Union[ + AnySystemParcel, + AnyPolicyObjectParcel, + ], + Field(discriminator="type_"), +] + class UX1Policies(BaseModel): - centralized_policies: List[CentralizedPolicy] = Field(default=[], serialization_alias="centralizedPolicies") - localized_policies: List[LocalizedPolicy] = Field(default=[], serialization_alias="localizedPolicies") - security_policies: List[SecurityPolicy] = Field(default=[], serialization_alias="securityPolicies") - policy_definitions: List[AnyPolicyDefinition] = Field(default=[], serialization_alias="policyDefinitions") - policy_lists: List[AnyPolicyList] = Field(default=[], serialization_alias="policyLists") + model_config = ConfigDict(populate_by_name=True) + centralized_policies: List[CentralizedPolicy] = Field( + default=[], serialization_alias="centralizedPolicies", validation_alias="centralizedPolicies" + ) + localized_policies: List[LocalizedPolicy] = Field( + default=[], serialization_alias="localizedPolicies", validation_alias="localizedPolicies" + ) + security_policies: List[SecurityPolicy] = Field( + default=[], serialization_alias="securityPolicies", validation_alias="securityPolicies" + ) + policy_definitions: List[AnyPolicyDefinition] = Field( + default=[], serialization_alias="policyDefinitions", validation_alias="policyDefinitions" + ) + policy_lists: List[AnyPolicyList] = Field( + default=[], serialization_alias="policyLists", validation_alias="policyLists" + ) class UX1Templates(BaseModel): - pass + feature_templates: List[FeatureTemplateInformation] = Field( + default=[], serialization_alias="featureTemplates", validation_alias="featureTemplates" + ) + device_templates: List[DeviceTemplateInformation] = Field( + default=[], serialization_alias="deviceTemplates", validation_alias="deviceTemplates" + ) + + +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 - policies: UX1Policies - templates: UX1Templates + model_config = ConfigDict(populate_by_name=True) + policies: UX1Policies = UX1Policies() + templates: UX1Templates = UX1Templates() class UX2Config(BaseModel): # All UX2 Configuration items - Mega Model - pass + model_config = ConfigDict(populate_by_name=True) + topology_groups: List[TopologyGroup] = Field( + default=[], serialization_alias="topologyGroups", validation_alias="topologyGroups" + ) + 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" + ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/__init__.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/__init__.py index 38cdb079..bcf0d6bf 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/__init__.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/__init__.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Mapping, Union +from typing import List, Union from pydantic import Field from typing_extensions import Annotated @@ -34,65 +34,44 @@ from .security.url import BaseURLListEntry, URLAllowParcel, URLBlockParcel from .security.zone import SecurityZoneListEntry, SecurityZoneListParcel +AnyURLParcel = Annotated[ + Union[ + URLAllowParcel, + URLBlockParcel, + ], + Field(discriminator="parcel_type"), +] + AnyPolicyObjectParcel = Annotated[ Union[ - AppProbeParcel, + # AnyURLParcel, ApplicationListParcel, + AppProbeParcel, ColorParcel, DataPrefixParcel, ExpandedCommunityParcel, FowardingClassParcel, + FQDNDomainParcel, + GeoLocationListParcel, + IPSSignatureParcel, IPv6DataPrefixParcel, IPv6PrefixListParcel, - PrefixListParcel, + LocalDomainParcel, PolicierParcel, PreferredColorGroupParcel, - SLAClassParcel, - TlocParcel, - StandardCommunityParcel, - LocalDomainParcel, - FQDNDomainParcel, - IPSSignatureParcel, - URLAllowParcel, - URLBlockParcel, - SecurityPortParcel, + PrefixListParcel, ProtocolListParcel, - GeoLocationListParcel, - SecurityZoneListParcel, SecurityApplicationListParcel, SecurityDataPrefixParcel, + SecurityPortParcel, + SecurityZoneListParcel, + SLAClassParcel, + StandardCommunityParcel, + TlocParcel, ], - Field(discriminator="type"), + Field(discriminator="type_"), ] -POLICY_OBJECT_PAYLOAD_ENDPOINT_MAPPING: Mapping[type, str] = { - AppProbeParcel: "app-probe", - ApplicationListParcel: "app-list", - ColorParcel: "color", - DataPrefixParcel: "data-prefix", - ExpandedCommunityParcel: "expanded-community", - FowardingClassParcel: "class", - IPv6DataPrefixParcel: "data-ipv6-prefix", - IPv6PrefixListParcel: "ipv6-prefix", - PrefixListParcel: "prefix", - PolicierParcel: "policer", - PreferredColorGroupParcel: "preferred-color-group", - SLAClassParcel: "sla-class", - TlocParcel: "tloc", - StandardCommunityParcel: "standard-community", - LocalDomainParcel: "security-localdomain", - FQDNDomainParcel: "security-fqdn", - IPSSignatureParcel: "security-ipssignature", - URLAllowParcel: "security-urllist", - URLBlockParcel: "security-urllist", - SecurityPortParcel: "security-port", - ProtocolListParcel: "security-protocolname", - GeoLocationListParcel: "security-geolocation", - SecurityZoneListParcel: "security-zone", - SecurityApplicationListParcel: "security-localapp", - SecurityDataPrefixParcel: "security-data-ip-prefix", -} - __all__ = ( "AnyPolicyObjectParcel", "ApplicationFamilyListEntry", diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/app_probe.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/app_probe.py index b889d648..1f358e9a 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/app_probe.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/app_probe.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field, field_validator @@ -28,6 +28,7 @@ class AppProbeEntry(BaseModel): class AppProbeParcel(_ParcelBase): + type_: Literal["app-probe"] = Field(default="app-probe", exclude=True) entries: List[AppProbeEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_fowarding_class(self, forwarding_class_name: str): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/application_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/application_list.py index 85f1c703..355f5316 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/application_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/application_list.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Union +from typing import List, Literal, Union from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -18,6 +18,7 @@ class ApplicationFamilyListEntry(BaseModel): class ApplicationListParcel(_ParcelBase): + type_: Literal["app-list"] = Field(default="app-list", exclude=True) entries: List[Union[ApplicationListEntry, ApplicationFamilyListEntry]] = Field( default=[], validation_alias=AliasPath("data", "entries") ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/color_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/color_list.py index a988881d..ae0e7bbd 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/color_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/color_list.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, Field @@ -13,6 +13,7 @@ class ColorEntry(BaseModel): class ColorParcel(_ParcelBase): + type_: Literal["color"] = Field(default="color", exclude=True) entries: List[ColorEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_color(self, color: TLOCColor): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/data_prefix.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/data_prefix.py index dfe8ec6f..dadc1ce9 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/data_prefix.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/data_prefix.py @@ -1,7 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Address, IPv4Network -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -13,14 +13,17 @@ class DataPrefixEntry(BaseModel): ipv4_address: Global[IPv4Address] = Field(serialization_alias="ipv4Address", validation_alias="ipv4Address") ipv4_prefix_length: Global[int] = Field(serialization_alias="ipv4PrefixLength", validation_alias="ipv4PrefixLength") + @staticmethod + def from_ipv4_network(ipv4_network: IPv4Network) -> "DataPrefixEntry": + return DataPrefixEntry( + ipv4_address=as_global(ipv4_network.network_address), + ipv4_prefix_length=as_global(ipv4_network.prefixlen), + ) + class DataPrefixParcel(_ParcelBase): + type_: Literal["data-prefix"] = Field(default="data-prefix", exclude=True) entries: List[DataPrefixEntry] = Field(default_factory=list, validation_alias=AliasPath("data", "entries")) def add_data_prefix(self, ipv4_network: IPv4Network): - self.entries.append( - DataPrefixEntry( - ipv4_address=as_global(ipv4_network.network_address), - ipv4_prefix_length=as_global(ipv4_network.prefixlen), - ) - ) + self.entries.append(DataPrefixEntry.from_ipv4_network(ipv4_network)) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/expanded_community_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/expanded_community_list.py index 0fd79245..01edff73 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/expanded_community_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/expanded_community_list.py @@ -1,11 +1,14 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates +from typing import Literal + from pydantic import AliasPath, ConfigDict, Field, field_validator from catalystwan.api.configuration_groups.parcel import Global, _ParcelBase, as_global class ExpandedCommunityParcel(_ParcelBase): + type_: Literal["expanded-community"] = Field(default="expanded-community", exclude=True) model_config = ConfigDict(populate_by_name=True) expandedCommunityList: Global[list] = Field( default=as_global([]), diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/fowarding_class.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/fowarding_class.py index 58ef5040..428a1806 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/fowarding_class.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/fowarding_class.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, Field, field_validator @@ -18,6 +18,7 @@ def check_burst(cls, queue: Global): class FowardingClassParcel(_ParcelBase): + type_: Literal["class"] = Field(default="class", exclude=True) entries: List[FowardingClassQueueEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_queue(self, queue: int): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_data_prefix.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_data_prefix.py index 861edfe1..87757295 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_data_prefix.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_data_prefix.py @@ -1,7 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv6Address, IPv6Network -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -15,6 +15,7 @@ class IPv6DataPrefixEntry(BaseModel): class IPv6DataPrefixParcel(_ParcelBase): + type_: Literal["data-ipv6-prefix"] = Field(default="data-ipv6-prefix", exclude=True) entries: List[IPv6DataPrefixEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_prefix(self, ipv6_network: IPv6Network): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_prefix_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_prefix_list.py index d01dd457..7608ef2b 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_prefix_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/ipv6_prefix_list.py @@ -1,7 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv6Address, IPv6Network -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -15,6 +15,7 @@ class IPv6PrefixListEntry(BaseModel): class IPv6PrefixListParcel(_ParcelBase): + type_: Literal["ipv6-prefix"] = Field(default="ipv6-prefix", exclude=True) entries: List[IPv6PrefixListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_prefix(self, ipv6_network: IPv6Network): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/policier.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/policier.py index 0eac93e9..d375446f 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/policier.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/policier.py @@ -1,11 +1,15 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field, field_validator from catalystwan.api.configuration_groups.parcel import Global, _ParcelBase, as_global -from catalystwan.models.policy.lists_entries import PolicerExceedAction + +PolicerExceedAction = Literal[ + "drop", + "remark", +] class PolicierEntry(BaseModel): @@ -28,6 +32,7 @@ def check_rate(cls, rate_str: Global): class PolicierParcel(_ParcelBase): + type_: Literal["policer"] = Field(default="policer", exclude=True) entries: List[PolicierEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_entry(self, burst: int, exceed: PolicerExceedAction, rate: int): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefered_group_color.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefered_group_color.py index 8ea933c0..ad725de6 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefered_group_color.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefered_group_color.py @@ -1,12 +1,17 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import AliasPath, BaseModel, ConfigDict, Field, model_validator from catalystwan.api.configuration_groups.parcel import Global, _ParcelBase, as_global from catalystwan.models.common import TLOCColor -from catalystwan.models.policy.lists_entries import PathPreference + +PathPreference = Literal[ + "direct-path", + "multi-hop-path", + "all-paths", +] class Preference(BaseModel): @@ -37,6 +42,7 @@ def check_passwords_match(self) -> "PreferredColorGroupEntry": class PreferredColorGroupParcel(_ParcelBase): + type_: Literal["preferred-color-group"] = Field(default="preferred-color-group", exclude=True) entries: List[PreferredColorGroupEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_primary(self, color_preference: List[TLOCColor], path_preference: PathPreference): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefix_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefix_list.py index ba05f99c..14fd01df 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefix_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/prefix_list.py @@ -1,7 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Address, IPv4Network -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -15,6 +15,7 @@ class PrefixListEntry(BaseModel): class PrefixListParcel(_ParcelBase): + type_: Literal["prefix"] = Field(default="prefix", exclude=True) entries: List[PrefixListEntry] = Field(default_factory=list, validation_alias=AliasPath("data", "entries")) def add_prefix(self, ipv4_network: IPv4Network): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/sla_class.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/sla_class.py index e40f79ab..d4056f85 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/sla_class.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/sla_class.py @@ -115,6 +115,7 @@ class SLAClassListEntry(BaseModel): class SLAClassParcel(_ParcelBase): + type_: Literal["sla-class"] = Field(default="sla-class", exclude=True) entries: List[SLAClassListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_entry( diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/standard_community.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/standard_community.py index f42eefa6..ae7d1f72 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/standard_community.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/standard_community.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -16,6 +16,7 @@ class StandardCommunityEntry(BaseModel): class StandardCommunityParcel(_ParcelBase): + type_: Literal["standard-community"] = Field(default="standard-community", exclude=True) entries: List[StandardCommunityEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_community(self, standard_community: WellKnownBGPCommunities): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/tloc_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/tloc_list.py index 8ba90518..b608e815 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/tloc_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/policy/tloc_list.py @@ -1,13 +1,17 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Address -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import AliasPath, BaseModel, ConfigDict, Field, field_validator from catalystwan.api.configuration_groups.parcel import Global, _ParcelBase, as_global from catalystwan.models.common import TLOCColor -from catalystwan.models.policy.lists_entries import EncapType + +EncapType = Literal[ + "ipsec", + "gre", +] class TlocEntry(BaseModel): @@ -28,6 +32,7 @@ def ensure_correct_preference_value(cls, v: Global): class TlocParcel(_ParcelBase): + type_: Literal["tloc"] = Field(default="tloc", exclude=True) entries: List[TlocEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_entry( diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/application_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/application_list.py index ad242e1c..49eee2a0 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/application_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/application_list.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Union +from typing import List, Literal, Union from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -18,6 +18,7 @@ class SecurityApplicationFamilyListEntry(BaseModel): class SecurityApplicationListParcel(_ParcelBase): + type_: Literal["security-localapp"] = Field(default="security-localapp", exclude=True) entries: List[Union[SecurityApplicationFamilyListEntry, SecurityApplicationListEntry]] = Field( default=[], validation_alias=AliasPath("data", "entries") ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/data_prefix.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/data_prefix.py index 3d93c80d..5f5fab00 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/data_prefix.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/data_prefix.py @@ -1,7 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Network -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -14,6 +14,7 @@ class SecurityDataPrefixEntry(BaseModel): class SecurityDataPrefixParcel(_ParcelBase): + type_: Literal["security-data-ip-prefix"] = Field(default="security-data-ip-prefix", exclude=True) entries: List[SecurityDataPrefixEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_prefix(self, ip_prefix: IPv4Network): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/fqdn.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/fqdn.py index 3de298b3..aaf0b817 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/fqdn.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/fqdn.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -15,6 +15,7 @@ class FQDNListEntry(BaseModel): class FQDNDomainParcel(_ParcelBase): + type_: Literal["security-fqdn"] = Field(default="security-fqdn", exclude=True) entries: List[FQDNListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def from_fqdns(self, fqdns: List[str]): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/geolocation_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/geolocation_list.py index 81e6b8a3..95a59fc8 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/geolocation_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/geolocation_list.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import AliasPath, BaseModel, Field, model_validator @@ -21,6 +21,7 @@ def check_country_xor_continent(self): class GeoLocationListParcel(_ParcelBase): + type_: Literal["security-geolocation"] = Field(default="security-geolocation", exclude=True) entries: List[GeoLocationListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_country(self, country: str): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/ips_signature.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/ips_signature.py index 06ae65d0..9d4f5bd6 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/ips_signature.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/ips_signature.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field, field_validator @@ -30,6 +30,7 @@ def check_signature_id(cls, signature_id: Global): class IPSSignatureParcel(_ParcelBase): + type_: Literal["security-ipssignature"] = Field(default="security-ipssignature", exclude=True) entries: List[IPSSignatureListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_signature(self, signature: str): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/local_domain.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/local_domain.py index 5a53a8f4..27b2e931 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/local_domain.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/local_domain.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -15,6 +15,7 @@ class LocalDomainListEntry(BaseModel): class LocalDomainParcel(_ParcelBase): + type_: Literal["security-localdomain"] = Field(default="security-localdomain", exclude=True) entries: List[LocalDomainListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def from_local_domains(self, domains: List[str]): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/protocol_list.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/protocol_list.py index fccc5698..179034c5 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/protocol_list.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/protocol_list.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -13,6 +13,7 @@ class ProtocolListEntry(BaseModel): class ProtocolListParcel(_ParcelBase): + type_: Literal["security-protocolname"] = Field(default="security-protocolname", exclude=True) entries: List[ProtocolListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_protocol(self, protocol: str): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/security_port.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/security_port.py index 346b6814..a06147eb 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/security_port.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/security_port.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field, field_validator @@ -29,6 +29,7 @@ def check_port(cls, port: Global[str]): class SecurityPortParcel(_ParcelBase): + type_: Literal["security-port"] = Field(default="security-port", exclude=True) entries: List[SecurityPortListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_port(self, port: str): diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/url.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/url.py index b16eb0a1..5371c6f9 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/url.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/url.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List +from typing import List, Literal from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -13,6 +13,7 @@ class BaseURLListEntry(BaseModel): class BaseURLParcel(_ParcelBase): + type_: Literal["security-urllist"] = Field(default="security-urllist", exclude=True) entries: List[BaseURLListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_url(self, pattern: str): @@ -20,8 +21,14 @@ def add_url(self, pattern: str): class URLAllowParcel(BaseURLParcel): - parcel_type: str = Field(default="urlallowed", validation_alias="type", serialization_alias="type") + type_: Literal["security-urllist"] = Field(default="security-urllist", exclude=True) + parcel_type: Literal["urlallowed"] = Field( + default="urlallowed", validation_alias="type", serialization_alias="type" + ) class URLBlockParcel(BaseURLParcel): - parcel_type: str = Field(default="urlblocked", validation_alias="type", serialization_alias="type") + type_: Literal["security-urllist"] = Field(default="security-urllist", exclude=True) + parcel_type: Literal["urlblocked"] = Field( + default="urlblocked", validation_alias="type", serialization_alias="type" + ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/zone.py b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/zone.py index 33b7f5f9..5af30f52 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/zone.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/policy_object/security/zone.py @@ -1,6 +1,6 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import AliasPath, BaseModel, Field, field_validator, model_validator @@ -25,6 +25,7 @@ def check_vpn_xor_interface(self): class SecurityZoneListParcel(_ParcelBase): + type_: Literal["security-zone"] = Field(default="security-zone", exclude=True) entries: List[SecurityZoneListEntry] = Field(default=[], validation_alias=AliasPath("data", "entries")) def add_interface(self, interface: InterfaceType): @@ -37,6 +38,6 @@ def add_interface(self, interface: InterfaceType): def add_vpn(self, vpn: str): self.entries.append( SecurityZoneListEntry( - vpn=as_global(vpn, InterfaceType), + vpn=as_global(vpn), ) ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py b/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py new file mode 100644 index 00000000..b4edc5b3 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/__init__.py @@ -0,0 +1,52 @@ +from typing import List, Union + +from pydantic import Field +from typing_extensions import Annotated + +from .aaa import AAAParcel +from .banner import BannerParcel +from .basic import BasicParcel +from .bfd import BFDParcel +from .global_parcel import GlobalParcel +from .logging_parcel import LoggingParcel +from .mrf import MRFParcel +from .ntp import NTPParcel +from .omp import OMPParcel +from .security import SecurityParcel +from .snmp import SNMPParcel + +AnySystemParcel = Annotated[ + Union[ + AAAParcel, + BFDParcel, + LoggingParcel, + BannerParcel, + BasicParcel, + GlobalParcel, + NTPParcel, + MRFParcel, + OMPParcel, + SecurityParcel, + SNMPParcel, + ], + Field(discriminator="type_"), +] + +__all__ = [ + "AAAParcel", + "BFDParcel", + "LoggingParcel", + "BannerParcel", + "BasicParcel", + "GlobalParcel", + "NTPParcel", + "MRFParcel", + "OMPParcel", + "SecurityParcel", + "SNMPParcel", + "AnySystemParcel", +] + + +def __dir__() -> "List[str]": + return list(__all__) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py b/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py index 07515f00..a30479fa 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py @@ -1,7 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Address, IPv6Address -from typing import List, Optional, Union +from typing import List, Literal, Optional, Union from pydantic import AliasPath, BaseModel, ConfigDict, Field @@ -29,7 +29,8 @@ class PubkeyChainItem(BaseModel): class UserItem(BaseModel): - model_config = ConfigDict(extra="forbid", populate_by_name=True) + model_config = ConfigDict(extra="ignore", populate_by_name=True) + model_config = ConfigDict(extra="ignore", populate_by_name=True) name: Union[Global[str], Variable] = Field(description="Set the username") password: Union[Global[str], Variable] = Field( @@ -112,7 +113,8 @@ class RadiusServerItem(BaseModel): class Radius(BaseModel): - model_config = ConfigDict(extra="forbid", populate_by_name=True) + model_config = ConfigDict(extra="ignore", populate_by_name=True) + model_config = ConfigDict(extra="ignore", populate_by_name=True) group_name: Global[str] = Field( validation_alias="groupName", serialization_alias="groupName", description="Set Radius server Group Name" ) @@ -260,7 +262,8 @@ class AuthorizationRuleItem(BaseModel): ) -class AAA(_ParcelBase): +class AAAParcel(_ParcelBase): + type_: Literal["aaa"] = Field(default="aaa", exclude=True) authentication_group: Union[Variable, Global[bool], Default[bool]] = Field( default=as_default(False), validation_alias=AliasPath("data", "authenticationGroup"), @@ -278,7 +281,9 @@ class AAA(_ParcelBase): max_length=4, description="ServerGroups priority order", ) - user: Optional[List[UserItem]] = Field(default=None, description="Create local login account", min_length=1) + user: Optional[List[UserItem]] = Field( + default=None, validation_alias=AliasPath("data", "user"), description="Create local login account", min_length=1 + ) radius: Optional[List[Radius]] = Field( default=None, validation_alias=AliasPath("data", "radius"), description="Configure the Radius serverGroup" ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/banner.py b/catalystwan/models/configuration/feature_profile/sdwan/system/banner.py new file mode 100644 index 00000000..0bdbcd68 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/banner.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Literal, Union + +from pydantic import AliasPath, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default, as_global + +EmptyString = Literal[""] + + +class BannerParcel(_ParcelBase): + type_: Literal["banner"] = Field(default="banner", exclude=True) + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + login: Union[Variable, Global[str], Default[EmptyString], str] = Field( + default=as_default("", EmptyString), validation_alias=AliasPath("data", "login") + ) + motd: Union[Variable, Global[str], Default[EmptyString]] = Field( + default=as_default("", EmptyString), + validation_alias=AliasPath("data", "motd"), + description="Message of the day", + ) + + def add_login(self, value: str): + self.login = as_global(value) + + def add_motd(self, value: str): + self.motd = as_global(value) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/basic.py b/catalystwan/models/configuration/feature_profile/sdwan/system/basic.py new file mode 100644 index 00000000..e840d5e7 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/basic.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default +from catalystwan.utils.timezone import Timezone + +ConsoleBaudRate = Literal["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"] +DefaultConsoleBaudRate = Literal["9600"] +Epfr = Literal["disabled", "aggressive", "moderate", "conservative"] +DefaultEpfr = Literal["disabled"] +SiteType = Literal["type-1", "type-2", "type-3", "cloud", "branch", "br", "spoke"] +DefaultTimezone = Literal["UTC"] + + +class Clock(BaseModel): + timezone: Union[Variable, Global[Timezone], Default[DefaultTimezone]] = Field( + default=as_default("UTC", DefaultTimezone), description="Set the timezone" + ) + + +class MobileNumberItem(BaseModel): + number: Union[Global[str], Variable] = Field(..., description="Mobile number, ex: 1231234414") + + +class Sms(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + enable: Union[Global[bool], Default[bool]] = Field( + default=as_default(False), description="Enable device’s geo fencing SMS" + ) + mobile_number: Optional[List[MobileNumberItem]] = Field( + None, + serialization_alias="MobileNumber", + validation_alias="MobileNumber", + description="Set device’s geo fencing SMS phone number", + ) + + +class GeoFencing(BaseModel): + enable: Union[Global[bool], Default[bool]] = Field(default=as_default(False), description="Enable Geo fencing") + range: Union[Global[int], Variable, Default[int]] = Field( + default=as_default(100), description="Set the device’s geo fencing range" + ) + sms: Sms = Field(default_factory=Sms, description="Set device’s geo fencing SMS") # type: ignore + + +class GpsVariable(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + longitude: Union[Variable, Global[float], Default[None]] = Field( + default=Default[None](value=None), description="Set the device physical longitude" + ) + latitude: Union[Variable, Global[float], Default[None]] = Field( + default=Default[None](value=None), description="Set the device physical latitude" + ) + geo_fencing: GeoFencing = Field( + default_factory=GeoFencing, + serialization_alias="geoFencing", + validation_alias="geoFencing", + ) + + +class OnDemand(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + on_demand_enable: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="onDemandEnable", + validation_alias="onDemandEnable", + description="Enable or disable On-demand Tunnel", + ) + on_demand_idle_timeout: Union[ + Variable, + Global[int], + Default[int], + ] = Field( + default=as_default(10), + serialization_alias="onDemandIdleTimeout", + validation_alias="onDemandIdleTimeout", + description="Set the idle timeout for on-demand tunnels", + ) + + +class AffinityPerVrfItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + affinity_group_number: Union[ + Variable, + Global[int], + Default[None], + ] = Field( + default=Default[None](value=None), + serialization_alias="affinityGroupNumber", + validation_alias="affinityGroupNumber", + description="Affinity Group Number", + ) + vrf_range: Union[Variable, Global[str], Default[None]] = Field( + default=Default[None](value=None), + serialization_alias="vrfRange", + validation_alias="vrfRange", + description="Range of VRFs", + ) + + +class BasicParcel(_ParcelBase): + type_: Literal["basic"] = Field(default="basic", exclude=True) + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + clock: Clock = Field(default_factory=Clock, validation_alias=AliasPath("data", "clock")) + description: Union[Variable, Global[str], Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "description"), + description="Set a text description of the device", + ) + location: Union[Variable, Global[str], Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "location"), + description="Set the location of the device", + ) + gps_location: GpsVariable = Field( + default_factory=GpsVariable, + validation_alias=AliasPath("data", "gpsLocation"), + ) + device_groups: Union[Variable, Global[List[str]], Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "deviceGroups"), + description="Device groups", + ) + controller_group_list: Optional[ + Union[ + Variable, + Global[List[int]], + Default[None], + ] + ] = Field( + None, + validation_alias=AliasPath("data", "controllerGroupList"), + description="Configure a list of comma-separated controller groups", + ) + overlay_id: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(1), + validation_alias=AliasPath("data", "overlayId"), + description="Set the Overlay ID", + ) + port_offset: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(0), + validation_alias=AliasPath("data", "portOffset"), + description="Set the TLOC port offset when multiple devices are behind a NAT", + ) + port_hop: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + validation_alias=AliasPath("data", "portHop"), + description="Enable port hopping", + ) + control_session_pps: Optional[Union[Variable, Global[int], Default[int]]] = Field( + None, + validation_alias=AliasPath("data", "controlSessionPps"), + description="Set the policer rate for control sessions", + ) + track_transport: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + None, + validation_alias=AliasPath("data", "trackTransport"), + description="Configure tracking of transport", + ) + track_interface_tag: Optional[Union[Variable, Global[int], Default[None]]] = Field( + None, + validation_alias=AliasPath("data", "trackInterfaceTag"), + description="OMP Tag attached to routes based on interface tracking", + ) + console_baud_rate: Union[Variable, Global[ConsoleBaudRate], Default[DefaultConsoleBaudRate]] = Field( + default=as_default("9600", DefaultConsoleBaudRate), + validation_alias=AliasPath("data", "consoleBaudRate"), + description="Set the console baud rate", + ) + max_omp_sessions: Union[Variable, Global[int], Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "maxOmpSessions"), + description="Set the maximum number of OMP sessions <1..100> the device can have", + ) + multi_tenant: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + None, + validation_alias=AliasPath("data", "multiTenant"), + description="Device is multi-tenant", + ) + track_default_gateway: Optional[ + Union[ + Variable, + Global[bool], + Default[bool], + ] + ] = Field( + None, + validation_alias=AliasPath("data", "trackDefaultGateway"), + description="Enable or disable default gateway tracking", + ) + tracker_dia_stabilize_status: Optional[ + Union[ + Variable, + Global[bool], + Default[bool], + ] + ] = Field( + None, + validation_alias=AliasPath("data", "trackerDiaStabilizeStatus"), + description="Enable or disable endpoint tracker diaStabilize status", + ) + admin_tech_on_failure: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + validation_alias=AliasPath("data", "adminTechOnFailure"), + description="Collect admin-tech before reboot due to daemon failure", + ) + idle_timeout: Optional[Union[Variable, Global[int], Default[None]]] = Field( + None, + validation_alias=AliasPath("data", "idleTimeout"), + description="Idle CLI timeout in minutes", + ) + on_demand: OnDemand = Field( + default_factory=OnDemand, + validation_alias=AliasPath("data", "onDemand"), + ) + transport_gateway: Optional[Union[Global[bool], Variable, Default[bool]]] = Field( + None, + validation_alias=AliasPath("data", "transportGateway"), + description="Enable transport gateway", + ) + epfr: Optional[Union[Global[Epfr], Default[DefaultEpfr], Variable]] = Field( + None, + validation_alias=AliasPath("data", "epfr"), + description="Enable SLA Dampening and Enhanced App Routing.", + ) + site_type: Optional[Union[Variable, Global[List[SiteType]], Default[None]]] = Field( + None, + validation_alias=AliasPath("data", "siteType"), + description="Site Type", + ) + affinity_group_number: Optional[ + Union[ + Variable, + Global[int], + Default[None], + ] + ] = Field( + None, + validation_alias=AliasPath("data", "affinityGroupNumber"), + description="Affinity Group Number", + ) + affinity_group_preference: Optional[ + Union[ + Variable, + Global[List[int]], + Default[None], + ] + ] = Field( + None, + validation_alias=AliasPath("data", "affinityGroupPreference"), + description="Affinity Group Preference", + ) + affinity_preference_auto: Optional[ + Union[ + Variable, + Global[bool], + Default[bool], + ] + ] = Field( + None, + validation_alias=AliasPath("data", "affinityPreferenceAuto"), + description="Affinity Group Preference Auto", + ) + affinity_per_vrf: Optional[List[AffinityPerVrfItem]] = Field( + None, + validation_alias=AliasPath("data", "affinityPerVrf"), + description="Affinity Group Range for VRFs", + max_length=4, + min_length=0, + ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py b/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py new file mode 100644 index 00000000..0e47e66e --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/bfd.py @@ -0,0 +1,66 @@ +from typing import List, Literal, Optional + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Global, _ParcelBase, as_global +from catalystwan.models.common import TLOCColor + + +class Color(BaseModel): + color: Global[TLOCColor] + hello_interval: Optional[Global[int]] = Field( + default=as_global(1000), validation_alias="helloInterval", serialization_alias="helloInterval" + ) + multiplier: Optional[Global[int]] = as_global(7) + pmtu_discovery: Optional[Global[bool]] = Field( + default=as_global(True), validation_alias="pmtuDiscovery", serialization_alias="pmtuDiscovery" + ) + dscp: Optional[Global[int]] = as_global(48) + model_config = ConfigDict(populate_by_name=True) + + +class BFDParcel(_ParcelBase): + type_: Literal["bfd"] = Field(default="bfd", exclude=True) + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + + multiplier: Optional[Global[int]] = Field(default=as_global(6), validation_alias=AliasPath("data", "multiplier")) + poll_interval: Optional[Global[int]] = Field( + default=as_global(600000), + validation_alias=AliasPath("data", "pollInterval"), + description="Poll Interval (In Millisecond)", + ) + default_dscp: Optional[Global[int]] = Field( + default=as_global(48), + validation_alias=AliasPath("data", "defaultDscp"), + description="DSCP Values for BFD Packets (decimal)", + ) + colors: Optional[List[Color]] = Field(default=None, validation_alias=AliasPath("data", "colors")) + + def set_muliplier(self, value: int): + self.multiplier = as_global(value) + + def set_poll_interval(self, value: int): + self.poll_interval = as_global(value) + + def set_default_dscp(self, value: int): + self.default_dscp = as_global(value) + + def add_color( + self, + color: TLOCColor, + hello_interval: int = 1000, + multiplier: int = 7, + pmtu_discovery: bool = True, + dscp: int = 48, + ): + if not self.colors: + self.colors = [] + self.colors.append( + Color( + color=Global[TLOCColor](value=color), + hello_interval=as_global(hello_interval), + multiplier=as_global(multiplier), + pmtu_discovery=as_global(pmtu_discovery), + dscp=as_global(dscp), + ) + ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/global_parcel.py b/catalystwan/models/configuration/feature_profile/sdwan/system/global_parcel.py new file mode 100644 index 00000000..00b93833 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/global_parcel.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import Literal, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default + + +class ServicesIp(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + http_server: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpHttpServer", + validation_alias="servicesGlobalServicesIpHttpServer", + ) + https_server: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpHttpsServer", + validation_alias="servicesGlobalServicesIpHttpsServer", + ) + ftp_passive: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpFtpPassive", + validation_alias="servicesGlobalServicesIpFtpPassive", + ) + domain_lookup: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpDomainLookup", + validation_alias="servicesGlobalServicesIpDomainLookup", + ) + arp_proxy: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpArpProxy", + validation_alias="servicesGlobalServicesIpArpProxy", + ) + rcmd: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpRcmd", + validation_alias="servicesGlobalServicesIpRcmd", + ) + line_vty: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="servicesGlobalServicesIpLineVty", + validation_alias="servicesGlobalServicesIpLineVty", + ) + cdp: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + serialization_alias="servicesGlobalServicesIpCdp", + validation_alias="servicesGlobalServicesIpCdp", + ) + lldp: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + serialization_alias="servicesGlobalServicesIpLldp", + validation_alias="servicesGlobalServicesIpLldp", + ) + source_intrf: Union[Variable, Global[str], Default[None]] = Field( + default=Default[None](value=None), + serialization_alias="servicesGlobalServicesIpSourceIntrf", + validation_alias="servicesGlobalServicesIpSourceIntrf", + ) + tcp_keepalives_in: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + serialization_alias="globalOtherSettingsTcpKeepalivesIn", + validation_alias="globalOtherSettingsTcpKeepalivesIn", + ) + keepalives_out: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + serialization_alias="globalOtherSettingsTcpKeepalivesOut", + validation_alias="globalOtherSettingsTcpKeepalivesOut", + ) + small_servers: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="globalOtherSettingsTcpSmallServers", + validation_alias="globalOtherSettingsTcpSmallServers", + ) + udp_small_servers: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="globalOtherSettingsUdpSmallServers", + validation_alias="globalOtherSettingsUdpSmallServers", + ) + console_logging: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + serialization_alias="globalOtherSettingsConsoleLogging", + validation_alias="globalOtherSettingsConsoleLogging", + ) + ip_source_route: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="globalOtherSettingsIPSourceRoute", + validation_alias="globalOtherSettingsIPSourceRoute", + ) + vty_line_logging: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + serialization_alias="globalOtherSettingsVtyLineLogging", + validation_alias="globalOtherSettingsVtyLineLogging", + ) + snmp_ifindex_persist: (Union[Variable, Global[bool], Default[bool]]) = Field( + default=as_default(True), + serialization_alias="globalOtherSettingsSnmpIfindexPersist", + validation_alias="globalOtherSettingsSnmpIfindexPersist", + ) + ignore_bootp: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + serialization_alias="globalOtherSettingsIgnoreBootp", + validation_alias="globalOtherSettingsIgnoreBootp", + ) + nat64_udp_timeout: Union[Variable, Global[bool], Default[int]] = Field( + default=as_default(300), + serialization_alias="globalSettingsNat64UdpTimeout", + validation_alias="globalSettingsNat64UdpTimeout", + ) + nat64_tcp_timeout: Union[Variable, Global[bool], Default[int]] = Field( + default=as_default(3600), + serialization_alias="globalSettingsNat64TcpTimeout", + validation_alias="globalSettingsNat64TcpTimeout", + ) + http_authentication: Union[Variable, Global[bool], Default[None]] = Field( + default=Default[None](value=None), + serialization_alias="globalSettingsHttpAuthentication", + validation_alias="globalSettingsHttpAuthentication", + ) + ssh_version: Union[Variable, Global[bool], Default[None]] = Field( + default=Default[None](value=None), + serialization_alias="globalSettingsSSHVersion", + validation_alias="globalSettingsSSHVersion", + ) + + +class ServicesGlobal(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + services_ip: ServicesIp = Field(default_factory=ServicesIp) + + +class GlobalParcel(_ParcelBase): + type_: Literal["global"] = Field(default="global", exclude=True) + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + services_global: ServicesGlobal = Field( + default_factory=ServicesGlobal, validation_alias=AliasPath("data", "services_global") + ) 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 00000000..a2f2b409 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/logging_parcel.py @@ -0,0 +1,159 @@ +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, _ParcelBase, as_default, as_global + +Priority = Literal["information", "debugging", "notice", "warn", "error", "critical", "alert", "emergency"] +TlsVersion = Literal["TLSv1.1", "TLSv1.2"] +AuthType = Literal["Server", "Mutual"] +CypherSuite = Literal[ + "rsa-aes-cbc-sha2", + "rsa-aes-gcm-sha2", + "ecdhe-rsa-aes-gcm-sha2", + "aes-128-cbc-sha", + "aes-256-cbc-sha", + "dhe-aes-cbc-sha2", + "dhe-aes-gcm-sha2", + "ecdhe-ecdsa-aes-gcm-sha2", + "ecdhe-rsa-aes-cbc-sha2", +] + + +class TlsProfile(BaseModel): + profile: Global[str] + version: Union[Global[TlsVersion], Default[TlsVersion]] = Field( + default=as_default("TLSv1.1", TlsVersion), serialization_alias="tlsVersion", validation_alias="tlsVersion" + ) + auth_type: Default[AuthType] = Field( + default=as_default("Server", AuthType), serialization_alias="authType", validation_alias="authType" + ) # Value can't be changed in the UI + ciphersuite_list: Union[Global[List[CypherSuite]], Default[None]] = Field( + default=Default[None](value=None), serialization_alias="cipherSuiteList", validation_alias="cipherSuiteList" + ) + model_config = ConfigDict(populate_by_name=True) + + +class Server(BaseModel): + name: Global[str] + vpn: Union[Global[int], Default[int]] = Field(default=as_default(0)) + source_interface: Union[Global[str], Default[None]] = Field( + default=Default[None](value=None), serialization_alias="sourceInterface", validation_alias="sourceInterface" + ) + priority: Union[Global[Priority], Default[Priority]] = Field(default=as_default("information", Priority)) + enable_tls: Union[Global[bool], Default[bool]] = Field( + default=as_default(False), serialization_alias="tlsEnable", validation_alias="tlsEnable" + ) + custom_profile: Optional[Union[Global[bool], Default[bool]]] = Field( + default=None, + 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=as_default(10), serialization_alias="diskFileSize", validation_alias="diskFileSize" + ) + disk_file_rotate: Optional[Union[Global[int], Default[int]]] = Field( + default=as_default(10), serialization_alias="diskFileRotate", validation_alias="diskFileRotate" + ) + + +class Disk(BaseModel): + disk_enable: Optional[Global[bool]] = Field( + default=None, serialization_alias="diskEnable", validation_alias="diskEnable" + ) + file: File = Field(default_factory=File) + + +class LoggingParcel(_ParcelBase): + type_: Literal["logging"] = Field(default="logging", exclude=True) + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + disk: Disk = Field(default_factory=Disk, 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[Server]] = Field(default=[], validation_alias=AliasPath("data", "ipv6Server")) + + def set_disk(self, enable: bool, disk_file_size: int = 10, disk_file_rotate: int = 10): + self.disk.disk_enable = as_global(enable) + self.disk.file.disk_file_size = as_global(disk_file_size) + self.disk.file.disk_file_rotate = as_global(disk_file_rotate) + + def add_tls_profile( + self, profile: str, version: TlsVersion = "TLSv1.1", ciphersuite_list: Optional[List[CypherSuite]] = None + ): + if not self.tls_profile: + self.tls_profile = [] + self.tls_profile.append( + TlsProfile( + profile=as_global(profile), + version=as_global(version, TlsVersion), + ciphersuite_list=Global[List[CypherSuite]](value=ciphersuite_list) + if ciphersuite_list + else Default[None](value=None), + ) + ) + + def add_ipv4_server( + self, + name: str, + vpn: int = 0, + source_interface: Optional[str] = None, + priority: Priority = "information", + enable_tls: bool = False, + custom_profile: bool = False, + profile_properties: Optional[str] = None, + ): + item = self._create_server_item( + name, vpn, source_interface, priority, enable_tls, custom_profile, profile_properties + ) + if not self.server: + self.server = [] + self.server.append(item) + return item + + def add_ipv6_server( + self, + name: str, + vpn: int = 0, + source_interface: Optional[str] = None, + priority: Priority = "information", + enable_tls: bool = False, + custom_profile: bool = False, + profile_properties: Optional[str] = None, + ): + item = self._create_server_item( + name, vpn, source_interface, priority, enable_tls, custom_profile, profile_properties + ) + if not self.ipv6_server: + self.ipv6_server = [] + self.ipv6_server.append(item) + return item + + def _create_server_item( + self, + name: str, + vpn: int, + source_interface: Optional[str] = None, + priority: Priority = "information", + enable_tls: bool = False, + custom_profile: bool = False, + profile_properties: Optional[str] = None, + ): + return Server( + name=as_global(name), + vpn=as_global(vpn), + source_interface=as_global(source_interface) if source_interface else Default[None](value=None), + priority=as_global(priority, Priority), + enable_tls=as_global(enable_tls), + custom_profile=as_global(custom_profile) if custom_profile else None, + profile_properties=as_global(profile_properties) if profile_properties else None, + ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/mrf.py b/catalystwan/models/configuration/feature_profile/sdwan/system/mrf.py new file mode 100644 index 00000000..cd49d0fe --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/mrf.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default + +EnableMrfMigration = Literal["enabled", "enabled-from-bgp-core"] +Role = Literal["edge-router", "border-router"] + + +class ManagementRegion(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + vrf_id: Union[Global[int], Default[None], Variable] = Field( + default=Default[None](value=None), + serialization_alias="vrfId", + validation_alias="vrfId", + description="VRF name for management region", + ) + gateway_preference: Optional[Union[Global[List[int]], Default[None], Variable]] = Field( + default=Default[None](value=None), + serialization_alias="gatewayPreference", + validation_alias="gatewayPreference", + description="List of affinity group preferences for VRF", + ) + management_gateway: Union[Global[bool], Default[bool], Variable] = Field( + default=as_default(False), + serialization_alias="managementGateway", + validation_alias="managementGateway", + description="Enable management gateway", + ) + + +class MRFParcel(_ParcelBase): + type_: Literal["mrf"] = Field(default="mrf", exclude=True) + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + secondary_region: Union[Global[int], Variable, Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "secondaryRegion"), + description="Set secondary region ID", + ) + role: Union[Global[Role], Variable, Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "role"), + description="Set the role for router", + ) + enable_mrf_migration: Union[Global[EnableMrfMigration], Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "enableMrfMigration"), + description="Enable migration mode to Multi-Region Fabric", + ) + migration_bgp_community: Optional[Union[Global[int], Default[None]]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "migrationBgpCommunity"), + description="Set BGP community during migration from BGP-core based network", + ) + enable_management_region: Union[Global[bool], Default[bool], Variable] = Field( + default=as_default(False), + validation_alias=AliasPath("data", "enableManagementRegion"), + description="Enable management region", + ) + management_region: ManagementRegion = Field( + default_factory=ManagementRegion, + validation_alias=AliasPath("data", "managementRegion"), + description="Management Region", + ) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/ntp.py b/catalystwan/models/configuration/feature_profile/sdwan/system/ntp.py new file mode 100644 index 00000000..c5b0eac7 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/ntp.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default + + +class ServerItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + name: Union[Variable, Global[str], Global[IPv6Address], Global[IPv4Address]] = Field( + ..., description="Set hostname or IP address of server" + ) + key: Optional[Union[Variable, Global[int], Default[None]]] = Field( + None, description="Set authentication key for the server" + ) + vpn: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(0), description="Set VPN in which NTP server is located" + ) + version: Union[Variable, Global[int], Default[int]] = Field(default=as_default(4), description="Set NTP version") + source_interface: Optional[Union[Variable, Global[str], Default[None]]] = Field( + None, + serialization_alias="sourceInterface", + validation_alias="sourceInterface", + description="Set interface to use to reach NTP server", + ) + prefer: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), description="Variable this NTP server" + ) + + +class AuthenticationVariable(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + key_id: Union[Variable, Global[int]] = Field( + ..., serialization_alias="keyId", validation_alias="keyId", description="MD5 authentication key ID" + ) + md5_value: Union[Variable, Global[str]] = Field( + ..., + alias="md5Value", + description="Enter cleartext or AES-encrypted MD5 authentication key" + "[Note: Catalyst SD-WAN Manager will encrypt this field before saving." + "Cleartext strings will not be returned back to the user in GET responses for sensitive fields.]", + ) + + +class Authentication(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + authentication_keys: List[AuthenticationVariable] = Field( + default=[], + serialization_alias="authenticationKeys", + validation_alias="authenticationKeys", + description="Set MD5 authentication key", + ) + trusted_keys: Optional[Union[Variable, Global[List[int]], Default[None]]] = Field( + None, + serialization_alias="trustedKeys", + validation_alias="trustedKeys", + description="Designate authentication key as trustworthy", + ) + + +class Leader(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + enable: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), description="Variable device as NTP Leader" + ) + stratum: Optional[Union[Variable, Global[int], Default[None]]] = Field( + None, description="Variable device as NTP Leader" + ) + source: Optional[Union[Variable, Global[str], Default[None]]] = Field( + None, description="Variable device as NTP Leader" + ) + + +class NTPParcel(_ParcelBase): + type_: Literal["ntp"] = Field(default="ntp", exclude=True) + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + server: List[ServerItem] = Field( + default=[], validation_alias=AliasPath("data", "server"), description="Configure NTP servers" + ) + authentication: Authentication = Field( + default_factory=Authentication, validation_alias=AliasPath("data", "authentication") # type: ignore + ) + leader: Leader = Field(default_factory=Leader, validation_alias=AliasPath("data", "leader")) # type: ignore diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/omp.py b/catalystwan/models/configuration/feature_profile/sdwan/system/omp.py new file mode 100644 index 00000000..e1ef82b4 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/omp.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default + +SiteTypesForTransportGateway = Literal["type-1", "type-2", "type-3", "cloud", "branch", "br", "spoke"] +TransportGateway = Literal["prefer", "ecmp-with-direct-path"] + + +class AdvertiseIp(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + bgp: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="BGP") + ospf: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="OSPF") + connected: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="Variable") + static: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="Variable") + eigrp: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="EIGRP") + lisp: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="LISP") + isis: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="ISIS") + + +class AdvertiseIpv6(AdvertiseIp): + pass + + +class AdvertiseIpv4(AdvertiseIp): + ospfv3: Union[Variable, Global[bool], Default[bool]] = Field(default=as_default(False), description="OSPF") + + +class OMPParcel(_ParcelBase): + type_: Literal["omp"] = Field(default="omp", exclude=True) + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + graceful_restart: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), + validation_alias=AliasPath("data", "gracefulRestart"), + description="Graceful Restart for OMP", + ) + overlay_as: Union[Variable, Global[float], Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "overlayAs"), + description="Overlay AS Number", + ) + send_path_limit: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(4), + validation_alias=AliasPath("data", "sendPathLimit"), + description="Number of Paths Advertised per Prefix", + ) + ecmp_limit: Union[Variable, Global[float], Default[int]] = Field( + default=as_default(4), + validation_alias=AliasPath("data", "ecmpLimit"), + description="Set maximum number of OMP paths to install in cEdge route table", + ) + shutdown: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), validation_alias=AliasPath("data", "shutdown"), description="Variable" + ) + omp_admin_distance_ipv4: Union[Variable, Global[int], Default[Optional[int]]] = Field( + default=as_default(251), + validation_alias=AliasPath("data", "ompAdminDistanceIpv4"), + description="OMP Admin Distance IPv4", + ) + omp_admin_distance_ipv6: Union[Variable, Global[int], Default[Optional[int]]] = Field( + default=as_default(251), + validation_alias=AliasPath("data", "ompAdminDistanceIpv6"), + description="OMP Admin Distance IPv6", + ) + advertisement_interval: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(1), + validation_alias=AliasPath("data", "advertisementInterval"), + description="Advertisement Interval (seconds)", + ) + graceful_restart_timer: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(43200), + validation_alias=AliasPath("data", "gracefulRestartTimer"), + description="Graceful Restart Timer (seconds)", + ) + eor_timer: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(300), validation_alias=AliasPath("data", "eorTimer"), description="EOR Timer" + ) + holdtime: Union[Variable, Global[int], Default[int]] = Field( + default=as_default(60), validation_alias=AliasPath("data", "holdtime"), description="Hold Time (seconds)" + ) + advertise_ipv4: AdvertiseIpv4 = Field( + default_factory=AdvertiseIpv4, validation_alias=AliasPath("data", "advertiseIpv4") + ) + advertise_ipv6: AdvertiseIpv6 = Field( + default_factory=AdvertiseIpv6, validation_alias=AliasPath("data", "advertiseIpv6") + ) + ignore_region_path_length: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + None, + validation_alias=AliasPath("data", "ignoreRegionPathLength"), + description="Treat hierarchical and direct (secondary region) paths equally", + ) + transport_gateway: Optional[Union[Variable, Global[TransportGateway], Default[None]]] = Field( + None, validation_alias=AliasPath("data", "transportGateway"), description="Transport Gateway Path Behavior" + ) + site_types_for_transport_gateway: Optional[ + Union[ + Variable, + Global[List[SiteTypesForTransportGateway]], + Default[None], + ] + ] = Field(None, validation_alias=AliasPath("data", "siteTypesForVariable"), description="Site Types") diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/security.py b/catalystwan/models/configuration/feature_profile/sdwan/system/security.py new file mode 100644 index 00000000..7b82402d --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/security.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default, as_variable + +IntegrityType = Literal["esp", "ip-udp-esp", "none", "ip-udp-esp-no-id"] +ReplayWindow = Literal["64", "128", "256", "512", "1024", "2048", "4096", "8192"] +DefaultReplayWindow = Literal["512"] +Tcp = Literal["aes-128-cmac", "hmac-sha-1", "hmac-sha-256"] + + +class KeychainItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + name: Global[str] = Field(..., description="Specify the name of the Keychain") + id: Global[int] = Field(..., description="Specify the Key ID") + + +class OneOfendChoice1(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + infinite: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(True), description="Infinite lifetime" + ) + + +class OneOfendChoice2(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + duration: Union[Global[int], Variable] = Field(..., description="Send lifetime Duration (seconds)") + + +class OneOfendChoice3(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + exact: Global[float] = Field(..., description="Configure Key lifetime end time") + + +class SendLifetime(BaseModel): + """ + Send Lifetime Settings + """ + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + local: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + default=None, description="Configure Send lifetime Local" + ) + start_epoch: Global[float] = Field( + ..., + serialization_alias="startEpoch", + validation_alias="startEpoch", + description="Configure Key lifetime start time", + ) + one_ofend_choice: Optional[Union[OneOfendChoice1, OneOfendChoice2, OneOfendChoice3]] = Field( + default=None, serialization_alias="oneOfendChoice", validation_alias="oneOfendChoice" + ) + + +class AcceptLifetime(BaseModel): + """ + Accept Lifetime Settings + """ + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + local: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + default=None, description="Configure Send lifetime Local" + ) + start_epoch: Global[float] = Field( + ..., + serialization_alias="startEpoch", + validation_alias="startEpoch", + description="Configure Key lifetime start time", + ) + one_ofend_choice: Optional[Union[OneOfendChoice1, OneOfendChoice2, OneOfendChoice3]] = Field( + default=None, serialization_alias="oneOfendChoice", validation_alias="oneOfendChoice" + ) + + +class KeyItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + id: Global[int] = Field(..., description="Select the Key ID") + name: Global[str] = Field(..., description="Select the chain name") + send_id: Union[Global[int], Variable] = Field( + ..., serialization_alias="recvId", validation_alias="srecvId", description="Specify the Send ID" + ) + recv_id: Union[Global[int], Variable] = Field( + ..., serialization_alias="recvId", validation_alias="recvId", description="Specify the Receiver ID" + ) + include_tcp_options: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + default=None, + serialization_alias="includeTcpOptions", + validation_alias="includeTcpOptions", + description="Configure Include TCP Options", + ) + accept_ao_mismatch: Optional[Union[Variable, Global[bool], Default[bool]]] = Field( + default=None, + serialization_alias="acceptAoMismatch", + validation_alias="acceptAoMismatch", + description="Configure Accept AO Mismatch", + ) + tcp: Global[Tcp] = Field(..., description="Crypto Algorithm") + key_string: Union[Global[str], Variable] = Field( + ..., + serialization_alias="keyString", + validation_alias="keyString", + description="Specify the Key String [Note: Catalyst SD-WAN Manager will encrypt this field before saving." + "Cleartext strings will not be returned back to the user in GET responses for sensitive fields.]", + ) + send_lifetime: Optional[SendLifetime] = Field( + default=None, + serialization_alias="sendLifetime", + validation_alias="sendLifetime", + description="Send Lifetime Settings", + ) + accept_lifetime: Optional[AcceptLifetime] = Field( + default=None, + serialization_alias="acceptLifetime", + validation_alias="acceptLifetime", + description="Accept Lifetime Settings", + ) + + +class SecurityParcel(_ParcelBase): + type_: Literal["security"] = Field(default="security", exclude=True) + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + rekey: Union[Global[int], Variable, Default[int]] = Field( + default=as_default(86400), + validation_alias=AliasPath("data", "rekey"), + description="Set how often to change the AES key for DTLS connections", + ) + replay_window: Optional[Union[Global[ReplayWindow], Variable, Default[DefaultReplayWindow]]] = Field( + default=as_default("512", DefaultReplayWindow), + validation_alias=AliasPath("data", "replayWindow"), + description="Set the sliding replay window size", + ) + extended_ar_window: Optional[Union[Global[int], Variable, Default[int]]] = Field( + default=None, + validation_alias=AliasPath("data", "extendedArWindow"), + description="Extended Anti-Replay Window", + ) + integrity_type: Union[Global[List[IntegrityType]], Variable] = Field( + default=as_variable("{{security_auth_type_inte}}"), + validation_alias=AliasPath("data", "integrityType"), + description="Set the authentication type for DTLS connections", + ) + pairwise_keying: Union[Variable, Global[bool], Default[bool]] = Field( + default=as_default(False), + validation_alias=AliasPath("data", "pairwiseKeying"), + description="Enable or disable IPsec pairwise-keying", + ) + keychain: List[KeychainItem] = Field( + default=[], validation_alias=AliasPath("data", "keychain"), description="Configure a Keychain" + ) + key: List[KeyItem] = Field(default=[], validation_alias=AliasPath("data", "key"), description="Configure a Key") diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/snmp.py b/catalystwan/models/configuration/feature_profile/sdwan/system/snmp.py new file mode 100644 index 00000000..bbddffa0 --- /dev/null +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/snmp.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address +from typing import List, Literal, Optional, Union + +from pydantic import AliasPath, BaseModel, ConfigDict, Field + +from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_default + +Authorization = Literal["read-only", "read-write"] +Priv = Literal["aes-cfb-128", "aes-256-cfb-128"] +SecurityLevel = Literal["no-auth-no-priv", "auth-no-priv", "auth-priv"] + + +class OidItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + id: Union[Global[str], Variable] = Field(..., description="Configure identifier of subtree of MIB objects") + exclude: Optional[Union[Global[bool], Variable]] = Field(default=None, description="Exclude the OID") + + +class ViewItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + name: Global[str] = Field(..., description="Set the name of the SNMP view") + oid: Optional[List[OidItem]] = Field(default=None, description="Configure SNMP object identifier") + + +class CommunityItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + name: Global[str] = Field( + ..., + description="Set name of the SNMP community" + "[Note: Catalyst SD-WAN Manager will encrypt this field before saving." + "Cleartext strings will not be returned back to the user in GET responses for sensitive fields.]", + ) + user_label: Optional[Global[str]] = Field( + default=None, + serialization_alias="userLabel", + validation_alias="userLabel", + description="Set user label of the SNMP community", + ) + view: Union[Global[str], Variable] = Field(..., description="Set name of the SNMP view") + authorization: Optional[Union[Global[Authorization], Variable]] = Field( + default=None, description="Configure access permissions" + ) + + +class GroupItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + name: Global[str] = Field(..., description="Name of the SNMP group") + security_level: Global[SecurityLevel] = Field( + ..., + serialization_alias="securityLevel", + validation_alias="securityLevel", + description="Configure security level", + ) + view: Union[Global[str], Variable] = Field(..., description="Name of the SNMP view") + + +class UserItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + name: Global[str] = Field(..., description="Name of the SNMP user") + auth: Optional[Union[Global[Literal["sha"]], Variable, Default[None]]] = Field( + default=None, description="Configure authentication protocol" + ) + auth_password: Optional[Union[Global[str], Variable, Default[None]]] = Field( + default=None, + serialization_alias="authPassword", + validation_alias="authPassword", + description="Specify authentication protocol password", + ) + priv: Optional[Union[Global[Priv], Variable, Default[None]]] = Field( + default=None, description="Configure privacy protocol" + ) + priv_password: Optional[Union[Global[str], Variable, Default[None]]] = Field( + default=None, + serialization_alias="privPassword", + validation_alias="privPassword", + description="Specify privacy protocol password", + ) + group: Union[Global[str], Variable] = Field(..., description="Name of the SNMP group") + + +class TargetItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + vpn_id: Union[Global[int], Variable] = Field( + ..., + serialization_alias="vpnId", + validation_alias="vpnId", + description="Set VPN in which SNMP server is located", + ) + ip: Union[Global[Union[IPv4Address, IPv6Address]], Variable] = Field( + ..., description="Set IPv4/IPv6 address of SNMP server" + ) + port: Union[Global[int], Variable] = Field(..., description="Set UDP port number to connect to SNMP server") + user_label: Optional[Global[str]] = Field( + default=None, + serialization_alias="userLabel", + validation_alias="userLabel", + description="Set user label of the SNMP community", + ) + community_name: Optional[Union[Global[str], Variable]] = Field( + default=None, + serialization_alias="communityName", + validation_alias="communityName", + description="Set name of the SNMP community" + "[Note: Catalyst SD-WAN Manager will encrypt this field before saving." + "Cleartext strings will not be returned back to the user in GET responses for sensitive fields.]." + "DEPRECATED. Use userLabel field instead", + ) + user: Optional[Union[Global[str], Variable]] = Field(default=None, description="Set name of the SNMP user") + source_interface: Optional[Union[Global[str], Variable]] = Field( + default=None, + serialization_alias="sourceInterface", + validation_alias="sourceInterface", + description="Source interface for outgoing SNMP traps", + ) + + +class SNMPParcel(_ParcelBase): + type_: Literal["snmp"] = Field(default="snmp", exclude=True) + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + shutdown: Union[Global[bool], Variable, Default[bool]] = Field( + default=as_default(False), validation_alias=AliasPath("data", "shutdown"), description="Enable or disable SNMP" + ) + contact: Union[Global[str], Variable, Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "contact"), + description="Set the contact for this managed node", + ) + location: Union[Global[str], Variable, Default[None]] = Field( + default=Default[None](value=None), + validation_alias=AliasPath("data", "location"), + description="Set the physical location of this managed node", + ) + view: List[ViewItem] = Field( + default=[], validation_alias=AliasPath("data", "view"), description="Configure a view record" + ) + community: List[CommunityItem] = Field( + default=[], validation_alias=AliasPath("data", "community"), description="Configure SNMP community" + ) + group: List[GroupItem] = Field( + default=[], validation_alias=AliasPath("data", "group"), description="Configure an SNMP group" + ) + user: List[UserItem] = Field( + default=[], validation_alias=AliasPath("data", "user"), description="Configure an SNMP user" + ) + target: List[TargetItem] = Field( + default=[], + validation_alias=AliasPath("data", "target"), + description="Configure SNMP server to receive SNMP traps", + ) diff --git a/catalystwan/models/configuration/topology_group.py b/catalystwan/models/configuration/topology_group.py new file mode 100644 index 00000000..47ecddb7 --- /dev/null +++ b/catalystwan/models/configuration/topology_group.py @@ -0,0 +1,13 @@ +from typing import List, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class TopologyGroup(BaseModel): + name: str + solution: Literal["sdwan"] = "sdwan" + profiles: List[UUID] = [] + from_topology_group: Optional[UUID] = Field( + default=None, serialization_alias="fromTopologyGroup", validation_alias="fromTopologyGroup" + ) diff --git a/catalystwan/models/policy/centralized.py b/catalystwan/models/policy/centralized.py index 50ad91b0..3f494bb7 100644 --- a/catalystwan/models/policy/centralized.py +++ b/catalystwan/models/policy/centralized.py @@ -3,7 +3,7 @@ from typing import List, Literal, Optional, Union, overload from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from typing_extensions import Annotated from catalystwan.models.policy.policy import ( @@ -161,12 +161,27 @@ class MeshPolicyItem(AssemblyItemBase): type: Literal["mesh"] = "mesh" +class AppRoutePolicyItem(AssemblyItemBase): + type: Literal["appRoute"] = "appRoute" + + +class CFlowDPolicyItem(AssemblyItemBase): + type: Literal["cflowd"] = "cflowd" + + +class VpnMembershipGroupPolicyItem(AssemblyItemBase): + type: Literal["vpnMembershipGroup"] = "vpnMembershipGroup" + + AnyAssemblyItem = Annotated[ Union[ TrafficDataPolicyItem, ControlPolicyItem, MeshPolicyItem, HubAndSpokePolicyItem, + AppRoutePolicyItem, + CFlowDPolicyItem, + VpnMembershipGroupPolicyItem, ], Field(discriminator="type"), ] @@ -186,7 +201,7 @@ class CentralizedPolicy(PolicyCreationPayload): serialization_alias="policyDefinition", validation_alias="policyDefinition", ) - policy_type: Literal["feature"] = Field( + policy_type: Literal["feature", "cli"] = Field( default="feature", serialization_alias="policyType", validation_alias="policyType" ) @@ -206,19 +221,23 @@ def add_mesh_policy(self, mesh_policy_id: UUID) -> None: def add_hub_and_spoke_policy(self, hub_and_spoke_policy_id: UUID) -> None: self.policy_definition.assembly.append(HubAndSpokePolicyItem(definition_id=hub_and_spoke_policy_id)) - @field_validator("policy_definition", mode="before") + @model_validator(mode="before") @classmethod - def try_parse(cls, policy_definition): + def try_parse_policy_definition_string(cls, values): # this is needed because GET /template/policy/vsmart contains string in policyDefinition field # while POST /template/policy/vsmart requires a regular object # it makes sense to reuse that model for both requests and present parsed data to the user - if isinstance(policy_definition, str): - return CentralizedPolicyDefinition.parse_raw(policy_definition) - return policy_definition + # TODO: this is workaround, probably it is better to provide separate models for "cli" and "feature" + if policy_definition := values.get("policyDefinition") and values.get("policyType") != "cli": + if isinstance(policy_definition, str): + values["policyDefinition"] = CentralizedPolicyDefinition.model_validate_json(policy_definition) + else: + values["policyDefinition"] = CentralizedPolicyDefinition() + return values class CentralizedPolicyEditPayload(PolicyEditPayload, CentralizedPolicy): - rid: Optional[str] = Field(default=None, serialization_alias="@rid", validation_alias="@rid") + rid: Optional[int] = Field(default=None, serialization_alias="@rid", validation_alias="@rid") class CentralizedPolicyInfo(PolicyInfo, CentralizedPolicyEditPayload): diff --git a/catalystwan/models/policy/definitions/access_control_list.py b/catalystwan/models/policy/definitions/access_control_list.py index d05b4f2a..1fab4280 100644 --- a/catalystwan/models/policy/definitions/access_control_list.py +++ b/catalystwan/models/policy/definitions/access_control_list.py @@ -91,8 +91,8 @@ def match_high_plp(self) -> None: def match_protocols(self, protocols: Set[int]) -> None: self._insert_match(ProtocolEntry.from_protocol_set(protocols)) - def match_source_data_prefix_list(self, data_prefix_list_id: UUID) -> None: - self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_list_id)) + def match_source_data_prefix_list(self, data_prefix_lists: List[UUID]) -> None: + self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_lists)) def match_source_ip(self, networks: List[IPv4Network]) -> None: self._insert_match(SourceIPEntry.from_ipv4_networks(networks)) diff --git a/catalystwan/models/policy/definitions/device_access.py b/catalystwan/models/policy/definitions/device_access.py index 10e00ec7..817c0803 100644 --- a/catalystwan/models/policy/definitions/device_access.py +++ b/catalystwan/models/policy/definitions/device_access.py @@ -61,8 +61,8 @@ class DeviceAccessPolicySequence(PolicyDefinitionSequenceBase): def match_device_access_protocol(self, port: DeviceAccessProtocol) -> None: self._insert_match(DestinationPortEntry.from_port_set_and_ranges(ports={port})) - def match_source_data_prefix_list(self, data_prefix_list_id: UUID) -> None: - self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_list_id)) + def match_source_data_prefix_list(self, data_prefix_lists: List[UUID]) -> None: + self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_lists)) def match_source_ip(self, networks: List[IPv4Network]) -> None: self._insert_match(SourceIPEntry.from_ipv4_networks(networks)) diff --git a/catalystwan/models/policy/definitions/qos_map.py b/catalystwan/models/policy/definitions/qos_map.py index 01ac6271..2576b62b 100644 --- a/catalystwan/models/policy/definitions/qos_map.py +++ b/catalystwan/models/policy/definitions/qos_map.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from catalystwan.models.common import IntStr from catalystwan.models.policy.policy_definition import PolicyDefinitionBase QoSScheduling = Literal[ @@ -19,11 +20,17 @@ class QoSScheduler(BaseModel): - queue: str - class_map_ref: Union[UUID, Literal[""]] = Field(serialization_alias="classMapRef", validation_alias="classMapRef") - bandwidth_percent: str = Field("1", serialization_alias="bandwidthPercent", validation_alias="bandwidthPercent") - buffer_percent: str = Field("1", serialization_alias="bufferPercent", validation_alias="bufferPercent") - burst: Optional[str] = None + queue: IntStr = Field(ge=0, le=8) + class_map_ref: Optional[UUID] = Field( + default=None, serialization_alias="classMapRef", validation_alias="classMapRef" + ) + bandwidth_percent: IntStr = Field( + default=1, ge=1, le=100, serialization_alias="bandwidthPercent", validation_alias="bandwidthPercent" + ) + buffer_percent: IntStr = Field( + default=1, ge=1, le=100, serialization_alias="bufferPercent", validation_alias="bufferPercent" + ) + burst: Optional[IntStr] = Field(default=None, ge=5000, le=10_000_000) scheduling: QoSScheduling = "wrr" drops: QoSDropType = "tail-drop" temp_key_values: Optional[str] = Field( @@ -33,35 +40,23 @@ class QoSScheduler(BaseModel): @staticmethod def get_default_control_scheduler() -> "QoSScheduler": return QoSScheduler( - queue="0", - class_map_ref="", - bandwidth_percent="100", - buffer_percent="100", - burst="15000", + queue=0, + bandwidth_percent=100, + buffer_percent=100, + burst=15000, scheduling="llq", drops="tail-drop", ) model_config = ConfigDict(populate_by_name=True) - @field_validator("queue") - @classmethod - def check_queue(cls, queue_str: str): - assert 0 <= int(queue_str) <= 7 - return queue_str - - @field_validator("bandwidth_percent", "buffer_percent") - @classmethod - def check_bandwidth_and_buffer_percent(cls, percent_str: str): - assert 1 <= int(percent_str) <= 100 - return percent_str - - @field_validator("burst") + @field_validator("class_map_ref", mode="before") @classmethod - def check_burst(cls, burst_val: Union[str, None]): - if burst_val is not None: - assert 5000 <= int(burst_val) <= 10_000_000 - return burst_val + def check_optional_class_map_ref(cls, class_map_ref: Union[str, None]): + # None and "" indicates missing value, both can be found in server responses + if not class_map_ref: + return None + return class_map_ref class QoSMapDefinition(BaseModel): @@ -86,11 +81,11 @@ def add_scheduler( ) -> None: self.definition.qos_schedulers.append( QoSScheduler( - queue=str(queue), + queue=queue, class_map_ref=class_map_ref, - bandwidth_percent=str(bandwidth), - buffer_percent=str(buffer), - burst=str(burst) if burst is not None else None, + bandwidth_percent=bandwidth, + buffer_percent=buffer, + burst=burst, scheduling=scheduling, drops=drops, ) diff --git a/catalystwan/models/policy/definitions/traffic_data.py b/catalystwan/models/policy/definitions/traffic_data.py index b5e63d4a..2d3ffbee 100644 --- a/catalystwan/models/policy/definitions/traffic_data.py +++ b/catalystwan/models/policy/definitions/traffic_data.py @@ -7,7 +7,7 @@ from pydantic import ConfigDict, Field from typing_extensions import Annotated -from catalystwan.models.common import ServiceChainNumber, TLOCColor +from catalystwan.models.common import ICMPMessageType, ServiceChainNumber, TLOCColor from catalystwan.models.policy.lists_entries import EncapType from catalystwan.models.policy.policy_definition import ( AppListEntry, @@ -26,6 +26,7 @@ DSCPEntry, FallBackToRoutingAction, ForwardingClassEntry, + ICMPMessageEntry, LocalTLOCListEntry, LocalTLOCListEntryValue, LogAction, @@ -66,24 +67,25 @@ TrafficDataPolicySequenceEntry = Annotated[ Union[ + AppListEntry, + DestinationDataIPv6PrefixListEntry, + DestinationDataPrefixListEntry, + DestinationIPEntry, + DestinationPortEntry, + DestinationRegionEntry, + DNSAppListEntry, + DNSEntry, + DSCPEntry, + ICMPMessageEntry, PacketLengthEntry, PLPEntry, ProtocolEntry, - DSCPEntry, + SourceDataIPv6PrefixListEntry, + SourceDataPrefixListEntry, SourceIPEntry, SourcePortEntry, - DestinationIPEntry, - DestinationPortEntry, TCPEntry, - DNSEntry, TrafficToEntry, - SourceDataPrefixListEntry, - DestinationDataPrefixListEntry, - SourceDataIPv6PrefixListEntry, - DestinationDataIPv6PrefixListEntry, - DestinationRegionEntry, - DNSAppListEntry, - AppListEntry, ], Field(discriminator="field"), ] @@ -100,7 +102,7 @@ class TrafficDataPolicySequenceMatch(Match): class TrafficDataPolicySequence(PolicyDefinitionSequenceBase): - sequence_type: Literal["data"] = Field( + sequence_type: Literal["applicationFirewall", "qos", "serviceChaining", "trafficEngineering", "data"] = Field( default="data", serialization_alias="sequenceType", validation_alias="sequenceType" ) match: TrafficDataPolicySequenceMatch = TrafficDataPolicySequenceMatch() @@ -122,6 +124,9 @@ def match_dns_response(self) -> None: def match_dscp(self, dscp: int) -> None: self._insert_match(DSCPEntry(value=str(dscp))) + def match_icmp(self, icmp_message_types: List[ICMPMessageType]) -> None: + self._insert_match(ICMPMessageEntry(value=icmp_message_types)) + def match_packet_length(self, packet_lengths: Tuple[int, int]) -> None: self._insert_match(PacketLengthEntry.from_range(packet_lengths)) @@ -134,8 +139,8 @@ def match_high_plp(self) -> None: def match_protocols(self, protocols: Set[int]) -> None: self._insert_match(ProtocolEntry.from_protocol_set(protocols)) - def match_source_data_prefix_list(self, data_prefix_list_id: UUID) -> None: - self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_list_id)) + def match_source_data_prefix_list(self, data_prefix_lists: List[UUID]) -> None: + self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_lists)) def match_source_ip(self, networks: List[IPv4Network]) -> None: self._insert_match(SourceIPEntry.from_ipv4_networks(networks)) diff --git a/catalystwan/models/policy/definitions/zone_based_firewall.py b/catalystwan/models/policy/definitions/zone_based_firewall.py index 4a7fe5a5..cd2500bf 100644 --- a/catalystwan/models/policy/definitions/zone_based_firewall.py +++ b/catalystwan/models/policy/definitions/zone_based_firewall.py @@ -9,7 +9,10 @@ from catalystwan.models.misc.application_protocols import ApplicationProtocol from catalystwan.models.policy.policy_definition import ( + AdvancedInspectionProfileAction, AppListEntry, + AppListFlatEntry, + ConnectionEventsAction, DefinitionWithSequencesCommonBase, DestinationDataPrefixListEntry, DestinationFQDNEntry, @@ -40,6 +43,7 @@ ZoneBasedFWPolicySequenceEntry = Annotated[ Union[ AppListEntry, + AppListFlatEntry, DestinationDataPrefixListEntry, DestinationFQDNEntry, DestinationGeoLocationEntry, @@ -71,6 +75,15 @@ Field(discriminator="field"), ] +ZoneBasedFWPolicyActions = Annotated[ + Union[ + AdvancedInspectionProfileAction, + ConnectionEventsAction, + LogAction, + ], + Field(discriminator="type"), +] + class ZoneBasedFWPolicyMatches(Match): entries: List[ZoneBasedFWPolicySequenceEntry] = [] @@ -82,7 +95,7 @@ class ZoneBasedFWPolicySequenceWithRuleSets(PolicyDefinitionSequenceBase): ) match: ZoneBasedFWPolicyMatches ruleset: bool = True - actions: List[LogAction] = [] + actions: List[ZoneBasedFWPolicyActions] = [] model_config = ConfigDict(populate_by_name=True) def match_rule_set_lists(self, rule_set_ids: Set[UUID]) -> None: @@ -145,8 +158,8 @@ def match_protocol_names(self, names: Set[str], protocol_map: Dict[str, Applicat def match_protocol_name_list(self, protocol_name_list_id: UUID) -> None: self._insert_match(ProtocolNameListEntry(ref=protocol_name_list_id)) - def match_source_data_prefix_list(self, data_prefix_list_id: UUID) -> None: - self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_list_id)) + def match_source_data_prefix_list(self, data_prefix_lists: List[UUID]) -> None: + self._insert_match(SourceDataPrefixListEntry(ref=data_prefix_lists)) def match_source_fqdn(self, fqdn: str) -> None: self._insert_match(SourceFQDNEntry(value=fqdn)) @@ -191,7 +204,7 @@ class ZoneBasedFWPolicyDefinition(DefinitionWithSequencesCommonBase): class ZoneBasedFWPolicy(ZoneBasedFWPolicyHeader): type: Literal["zoneBasedFW"] = "zoneBasedFW" - mode: Literal["security"] = "security" + mode: Literal["security", "unified"] = "security" definition: ZoneBasedFWPolicyDefinition = ZoneBasedFWPolicyDefinition() def add_ipv4_rule( diff --git a/catalystwan/models/policy/lists.py b/catalystwan/models/policy/lists.py index 50570717..0b5972e1 100644 --- a/catalystwan/models/policy/lists.py +++ b/catalystwan/models/policy/lists.py @@ -1,12 +1,19 @@ # Copyright 2023 Cisco Systems, Inc. and its affiliates -from ipaddress import IPv4Address, IPv4Network, IPv6Network +from ipaddress import IPv4Address, IPv4Network, IPv6Interface from typing import Any, List, Literal, Optional, Set, Tuple from uuid import UUID from pydantic import BaseModel, Field from catalystwan.models.common import InterfaceType, TLOCColor, WellKnownBGPCommunities +from catalystwan.models.configuration.feature_profile.sdwan.policy_object import AnyPolicyObjectParcel +from catalystwan.models.configuration.feature_profile.sdwan.policy_object.policy.data_prefix import ( + DataPrefixEntry, + DataPrefixParcel, +) +from catalystwan.models.configuration.feature_profile.sdwan.policy_object.policy.tloc_list import TlocParcel +from catalystwan.models.configuration.feature_profile.sdwan.policy_object.security.zone import SecurityZoneListParcel from catalystwan.models.policy.lists_entries import ( AppListEntry, AppProbeClassListEntry, @@ -57,6 +64,9 @@ def _add_entry(self, entry: Any, single: bool = False) -> None: else: self.entries.append(entry) + def to_policy_object_parcel(self) -> Optional[AnyPolicyObjectParcel]: + return None + class DataPrefixList(PolicyListBase): type: Literal["dataPrefix"] = "dataPrefix" @@ -65,6 +75,13 @@ class DataPrefixList(PolicyListBase): def add_prefix(self, ip_prefix: IPv4Network) -> None: self._add_entry(DataPrefixListEntry(ip_prefix=ip_prefix)) + def to_policy_object_parcel(self) -> DataPrefixParcel: + return DataPrefixParcel( + parcel_name=self.name, + parcel_description=self.description, + entries=[DataPrefixEntry.from_ipv4_network(i.ip_prefix) for i in self.entries], + ) + class SiteList(PolicyListBase): type: Literal["site"] = "site" @@ -78,6 +95,9 @@ def add_site_range(self, site_range: Tuple[int, int]): entry = SiteListEntry(site_id=f"{site_range[0]}-{site_range[1]}") self._add_entry(entry) + def to_policy_object_parcel(self) -> None: + return None + class VPNList(PolicyListBase): type: Literal["vpn"] = "vpn" @@ -91,6 +111,9 @@ def add_vpn_range(self, vpn_range: Tuple[int, int]): entry = VPNListEntry(vpn=f"{vpn_range[0]}-{vpn_range[1]}") self._add_entry(entry) + def to_policy_object_parcel(self) -> None: + return None + class ZoneList(PolicyListBase): type: Literal["zone"] = "zone" @@ -102,6 +125,18 @@ def assign_vpns(self, vpns: Set[int]) -> None: def assign_interfaces(self, ifs: Set[InterfaceType]) -> None: self.entries = [ZoneListEntry(interface=interface) for interface in ifs] + def to_policy_object_parcel(self) -> SecurityZoneListParcel: + parcel = SecurityZoneListParcel( + parcel_name=self.name, + parcel_description=self.description, + ) + for e in self.entries: + if e.vpn is not None: + parcel.add_vpn(e.vpn) + if e.interface is not None: + parcel.add_interface(e.interface) + return parcel + class FQDNList(PolicyListBase): type: Literal["fqdn"] = "fqdn" @@ -151,7 +186,7 @@ class DataIPv6PrefixList(PolicyListBase): type: Literal["dataIpv6Prefix"] = "dataIpv6Prefix" entries: List[DataIPv6PrefixListEntry] = [] - def add_prefix(self, ipv6_prefix: IPv6Network) -> None: + def add_prefix(self, ipv6_prefix: IPv6Interface) -> None: self._add_entry(DataIPv6PrefixListEntry(ipv6_prefix=ipv6_prefix)) @@ -274,6 +309,15 @@ def add_tloc(self, tloc: IPv4Address, color: TLOCColor, encap: EncapType, prefer _preference = str(preference) if preference is not None else None self.entries.append(TLOCListEntry(tloc=tloc, color=color, encap=encap, preference=_preference)) + def to_policy_object_parcel(self) -> TlocParcel: + parcel = TlocParcel( + parcel_name=self.name, + parcel_description=self.description, + ) + for i in self.entries: + parcel.add_entry(i.tloc, i.color, i.encap, i.preference) + return parcel + class PreferredColorGroupList(PolicyListBase): type: Literal["preferredColorGroup"] = "preferredColorGroup" diff --git a/catalystwan/models/policy/lists_entries.py b/catalystwan/models/policy/lists_entries.py index 8eba5358..9c610e60 100644 --- a/catalystwan/models/policy/lists_entries.py +++ b/catalystwan/models/policy/lists_entries.py @@ -1,6 +1,6 @@ # Copyright 2023 Cisco Systems, Inc. and its affiliates -from ipaddress import IPv4Address, IPv4Network, IPv6Network +from ipaddress import IPv4Address, IPv4Network, IPv6Interface, IPv6Network from typing import List, Literal, Optional, Set from uuid import UUID @@ -9,18 +9,21 @@ from catalystwan.models.common import InterfaceType, TLOCColor, check_fields_exclusive -def check_jitter_ms(jitter_str: str) -> str: - assert 1 <= int(jitter_str) <= 1000 +def check_jitter_ms(jitter_str: Optional[str]) -> Optional[str]: + if jitter_str is not None: + assert 1 <= int(jitter_str) <= 1000 return jitter_str -def check_latency_ms(latency_str: str) -> str: - assert 1 <= int(latency_str) <= 1000 +def check_latency_ms(latency_str: Optional[str]) -> Optional[str]: + if latency_str is not None: + assert 1 <= int(latency_str) <= 1000 return latency_str -def check_loss_percent(loss_str: str) -> str: - assert 0 <= int(loss_str) <= 100 +def check_loss_percent(loss_str: Optional[str]) -> Optional[str]: + if loss_str is not None: + assert 0 <= int(loss_str) <= 100 return loss_str @@ -234,7 +237,7 @@ class ColorListEntry(BaseModel): class DataIPv6PrefixListEntry(BaseModel): model_config = ConfigDict(populate_by_name=True) - ipv6_prefix: IPv6Network = Field(serialization_alias="ipv6Prefix", validation_alias="ipv6Prefix") + ipv6_prefix: IPv6Interface = Field(serialization_alias="ipv6Prefix", validation_alias="ipv6Prefix") class LocalDomainListEntry(BaseModel): @@ -328,7 +331,9 @@ class SLAClassListEntry(BaseModel): latency: Optional[str] = None loss: Optional[str] = None jitter: Optional[str] = None - app_probe_class: Optional[UUID] = Field(serialization_alias="appProbeClass", validation_alias="appProbeClass") + app_probe_class: Optional[UUID] = Field( + default=None, serialization_alias="appProbeClass", validation_alias="appProbeClass" + ) fallback_best_tunnel: Optional[FallbackBestTunnel] = Field( default=None, serialization_alias="fallbackBestTunnel", validation_alias="fallbackBestTunnel" ) diff --git a/catalystwan/models/policy/policy.py b/catalystwan/models/policy/policy.py index 05a1296e..874995e4 100644 --- a/catalystwan/models/policy/policy.py +++ b/catalystwan/models/policy/policy.py @@ -1,7 +1,7 @@ # Copyright 2023 Cisco Systems, Inc. and its affiliates import datetime -from typing import List, Literal, Optional, Sequence +from typing import List, Literal, Optional, Sequence, Union from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -70,7 +70,7 @@ class PolicyCreationPayload(BaseModel): default="default description", serialization_alias="policyDescription", validation_alias="policyDescription" ) policy_type: str = Field(serialization_alias="policyType", validation_alias="policyType") - policy_definition: PolicyDefinition = Field( + policy_definition: Union[PolicyDefinition, str] = Field( serialization_alias="policyDefinition", validation_alias="policyDefinition" ) is_policy_activated: bool = Field( diff --git a/catalystwan/models/policy/policy_definition.py b/catalystwan/models/policy/policy_definition.py index 362bd7a3..dcdcd339 100644 --- a/catalystwan/models/policy/policy_definition.py +++ b/catalystwan/models/policy/policy_definition.py @@ -6,10 +6,17 @@ from typing import Any, Dict, List, MutableSequence, Optional, Protocol, Sequence, Set, Tuple, Union from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, RootModel, model_validator +from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator from typing_extensions import Annotated, Literal -from catalystwan.models.common import ServiceChainNumber, TLOCColor, check_fields_exclusive +from catalystwan.models.common import ( + ICMPMessageType, + ServiceChainNumber, + TLOCColor, + check_fields_exclusive, + str_as_str_list, + str_as_uuid_list, +) from catalystwan.models.misc.application_protocols import ApplicationProtocol from catalystwan.models.policy.lists_entries import EncapType from catalystwan.typed_list import DataSequence @@ -487,9 +494,18 @@ def from_nat_vpn(fallback: bool, vpn: int = 0) -> "NATVPNEntry": return NATVPNEntry(root=[UseVPNEntry(value=str(vpn))]) +class ICMPMessageEntry(BaseModel): + field: Literal["icmpMessage"] = "icmpMessage" + value: List[ICMPMessageType] + + _value = field_validator("value", mode="before")(str_as_str_list) + + class SourceDataPrefixListEntry(BaseModel): field: Literal["sourceDataPrefixList"] = "sourceDataPrefixList" - ref: UUID + ref: List[UUID] + + _ref = field_validator("ref", mode="before")(str_as_uuid_list) class SourceDataIPv6PrefixListEntry(BaseModel): @@ -517,6 +533,11 @@ class AppListEntry(BaseModel): ref: UUID +class AppListFlatEntry(BaseModel): + field: Literal["appListFlat"] = "appListFlat" + ref: UUID + + class SourceFQDNListEntry(BaseModel): field: Literal["sourceFqdnList"] = "sourceFqdnList" ref: UUID @@ -752,6 +773,16 @@ class PolicerAction(BaseModel): parameter: Reference +class ConnectionEventsAction(BaseModel): + type: Literal["connectionEvents"] = "connectionEvents" + parameter: str = "" + + +class AdvancedInspectionProfileAction(BaseModel): + type: Literal["advancedInspectionProfile"] = "advancedInspectionProfile" + parameter: Reference + + ActionSetEntry = Annotated[ Union[ AffinityEntry, @@ -786,8 +817,10 @@ class ActionSet(BaseModel): ActionEntry = Annotated[ Union[ ActionSet, + AdvancedInspectionProfileAction, CFlowDAction, ClassMapAction, + ConnectionEventsAction, CountAction, DREOptimizationAction, FallBackToRoutingAction, @@ -810,6 +843,7 @@ class ActionSet(BaseModel): MatchEntry = Annotated[ Union[ AppListEntry, + AppListFlatEntry, CarrierEntry, ClassMapListEntry, ColorListEntry, @@ -831,6 +865,7 @@ class ActionSet(BaseModel): DSCPEntry, ExpandedCommunityListEntry, GroupIDEntry, + ICMPMessageEntry, NextHeaderEntry, OMPTagEntry, OriginatorEntry, @@ -911,7 +946,9 @@ class PolicyDefinitionSequenceBase(BaseModel): default="drop", serialization_alias="baseAction", validation_alias="baseAction" ) sequence_type: SequenceType = Field(serialization_alias="sequenceType", validation_alias="sequenceType") - sequence_ip_type: SequenceIpType = Field(serialization_alias="sequenceIpType", validation_alias="sequenceIpType") + sequence_ip_type: Optional[SequenceIpType] = Field( + default="ipv4", serialization_alias="sequenceIpType", validation_alias="sequenceIpType" + ) ruleset: Optional[bool] = None match: Match actions: Sequence[ActionEntry] 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 00000000..0e2935d0 --- /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 00000000..00ad48da --- /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.utils.config_migration.converters.feature_template.normalizer.CastableLiterals", [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/tests/test_feature_profile_api.py b/catalystwan/tests/test_feature_profile_api.py new file mode 100644 index 00000000..2174f077 --- /dev/null +++ b/catalystwan/tests/test_feature_profile_api.py @@ -0,0 +1,110 @@ +import unittest +from unittest.mock import patch +from uuid import UUID + +from parameterized import parameterized # type: ignore + +from catalystwan.api.feature_profile_api import SystemFeatureProfileAPI +from catalystwan.models.configuration.feature_profile.sdwan.system import ( + AAAParcel, + BannerParcel, + BasicParcel, + BFDParcel, + GlobalParcel, + LoggingParcel, + MRFParcel, + NTPParcel, + OMPParcel, + SecurityParcel, + SNMPParcel, +) + +endpoint_mapping = { + AAAParcel: "aaa", + BannerParcel: "banner", + BasicParcel: "basic", + BFDParcel: "bfd", + GlobalParcel: "global", + LoggingParcel: "logging", + MRFParcel: "mrf", + NTPParcel: "ntp", + OMPParcel: "omp", + SecurityParcel: "security", + SNMPParcel: "snmp", +} + + +class TestSystemFeatureProfileAPI(unittest.TestCase): + def setUp(self): + self.profile_uuid = UUID("054d1b82-9fa7-43c6-98fb-4355da0d77ff") + self.parcel_uuid = UUID("7113505f-8cec-4420-8799-1a209357ba7e") + + @parameterized.expand(endpoint_mapping.items()) + @patch("catalystwan.session.ManagerSession") + @patch("catalystwan.endpoints.configuration.feature_profile.sdwan.system.SystemFeatureProfile") + def test_delete_method_with_valid_arguments(self, parcel, expected_path, mock_endpoint, mock_session): + # Arrange + api = SystemFeatureProfileAPI(mock_session) + api.endpoint = mock_endpoint + + # Act + api.delete(self.profile_uuid, parcel, self.parcel_uuid) + + # Assert + mock_endpoint.delete.assert_called_once_with(self.profile_uuid, expected_path, self.parcel_uuid) + + @parameterized.expand(endpoint_mapping.items()) + @patch("catalystwan.session.ManagerSession") + @patch("catalystwan.endpoints.configuration.feature_profile.sdwan.system.SystemFeatureProfile") + def test_get_method_with_valid_arguments(self, parcel, expected_path, mock_endpoint, mock_session): + # Arrange + api = SystemFeatureProfileAPI(mock_session) + api.endpoint = mock_endpoint + + # Act + api.get(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) + + @parameterized.expand(endpoint_mapping.items()) + @patch("catalystwan.session.ManagerSession") + @patch("catalystwan.endpoints.configuration.feature_profile.sdwan.system.SystemFeatureProfile") + def test_get_all_method_with_valid_arguments(self, parcel, expected_path, mock_endpoint, mock_session): + # Arrange + api = SystemFeatureProfileAPI(mock_session) + api.endpoint = mock_endpoint + + # Act + api.get(self.profile_uuid, parcel) + + # Assert + mock_endpoint.get_all.assert_called_once_with(self.profile_uuid, expected_path) + + @parameterized.expand(endpoint_mapping.items()) + @patch("catalystwan.session.ManagerSession") + @patch("catalystwan.endpoints.configuration.feature_profile.sdwan.system.SystemFeatureProfile") + def test_create_method_with_valid_arguments(self, parcel, expected_path, mock_endpoint, mock_session): + # Arrange + api = SystemFeatureProfileAPI(mock_session) + api.endpoint = mock_endpoint + + # Act + api.create(self.profile_uuid, parcel) + + # Assert + mock_endpoint.create.assert_called_once_with(self.profile_uuid, expected_path, parcel) + + @parameterized.expand(endpoint_mapping.items()) + @patch("catalystwan.session.ManagerSession") + @patch("catalystwan.endpoints.configuration.feature_profile.sdwan.system.SystemFeatureProfile") + def test_update_method_with_valid_arguments(self, parcel, expected_path, mock_endpoint, mock_session): + # Arrange + api = SystemFeatureProfileAPI(mock_session) + api.endpoint = mock_endpoint + + # Act + api.update(self.profile_uuid, parcel, self.parcel_uuid) + + # Assert + mock_endpoint.update.assert_called_once_with(self.profile_uuid, expected_path, self.parcel_uuid, parcel) diff --git a/catalystwan/tests/test_partition_manager_api.py b/catalystwan/tests/test_partition_manager_api.py index 8057fdc1..5203537e 100644 --- a/catalystwan/tests/test_partition_manager_api.py +++ b/catalystwan/tests/test_partition_manager_api.py @@ -36,21 +36,12 @@ def setUp(self): version="curr_ver", defaultVersion="def_ver", uuid="mock_uuid", - installed_versions=["ver1", "ver2", "curr_ver"], - availableVersions=["ver1", "ver2"], - version="curr_ver", - defaultVersion="def_ver", - uuid="mock_uuid", ), } self.mock_devices = [{"deviceId": "mock_uuid", "deviceIP": "mock_ip", "version": "ver1"}] self.mock_device_version_payload = DataSequence( -<<<<<<< HEAD RemovePartitionDevice, [RemovePartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] -======= - DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] ->>>>>>> cb90331 (Migration - Software and Versions) ) mock_session = Mock() self.mock_repository_object = RepositoryAPI(mock_session) diff --git a/catalystwan/tests/test_version_utils.py b/catalystwan/tests/test_version_utils.py index 1c4dd44d..842be872 100644 --- a/catalystwan/tests/test_version_utils.py +++ b/catalystwan/tests/test_version_utils.py @@ -31,11 +31,6 @@ def setUp(self): version="curr_ver", defaultVersion="def_ver", uuid="mock_uuid", - installed_versions=["ver1", "ver2", "curr_ver"], - availableVersions=["ver1", "ver2"], - version="curr_ver", - defaultVersion="def_ver", - uuid="mock_uuid", ) } mock_session = Mock() @@ -198,7 +193,7 @@ def test_get_device_available(self, mock_get_devices_versions_repository): mock_get_devices_versions_repository.return_value = self.DeviceSoftwareRepository_obj answer = mock_device_versions.get_device_available("ver1", [self.device]) expected_result = DataSequence( - DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] + PartitionDevice, [PartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] ) # Assert @@ -218,7 +213,7 @@ def test_get_device_list_if_in_installed(self, mock_get_devices_versions_reposit mock_get_devices_versions_repository.return_value = self.DeviceSoftwareRepository_obj answer = mock_device_versions.get_device_list_in_installed("ver1", [self.device]) expected_result = DataSequence( - DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] + PartitionDevice, [PartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="ver1")] ) # Assert @@ -240,6 +235,6 @@ def test_get_devices_current_version(self, mock_get_devices_versions_repository) answer = mock_device_versions.get_devices_current_version([self.device]) # Answer proper_answer = DataSequence( - DeviceVersionPayload, [DeviceVersionPayload(device_id="mock_uuid", device_ip="mock_ip", version="curr_ver")] + PartitionDevice, [PartitionDevice(device_id="mock_uuid", device_ip="mock_ip", version="curr_ver")] ) self.assertEqual(answer, proper_answer) 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 00000000..477bbd79 --- /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/utils/config_migration/converters/feature_template/aaa.py b/catalystwan/utils/config_migration/converters/feature_template/aaa.py new file mode 100644 index 00000000..afe533c3 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/aaa.py @@ -0,0 +1,62 @@ +from copy import deepcopy +from typing import List + +from catalystwan.api.configuration_groups.parcel import Global +from catalystwan.models.configuration.feature_profile.sdwan.system import AAAParcel + + +class AAATemplateConverter: + supported_template_types = ("cisco_aaa", "cedge_aaa", "aaa") + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> AAAParcel: + """ + Creates an AAAParcel object based on the provided template values. + + Args: + name (str): The name of the AAAParcel. + description (str): The description of the AAAParcel. + template_values (dict): A dictionary containing the template values. + + Returns: + AAAParcel: An AAAParcel object with the provided template values. + """ + + def assign_authorization_servers(auth_server_list: List) -> None: + for auth_server in auth_server_list: + servers = auth_server.get("server", {}) + for server in servers: + key_enum = server.get("key_enum") + server["key_enum"] = Global[str](value=str(key_enum.value)) + + def assign_rules(rules: List) -> None: + for rule_item in rules: + rule_item["group"] = Global[List[str]](value=rule_item["group"].value.split(",")) + + parcel_values = deepcopy(template_values) + parcel_values["parcel_name"] = name + parcel_values["parcel_description"] = description + + if server_auth_order := template_values.get("server_auth_order"): + parcel_values["server_auth_order"] = Global[List[str]](value=server_auth_order.value.split(",")) + + for server in ["radius", "tacacs"]: + if auth_server_list := parcel_values.get(server): + assign_authorization_servers(auth_server_list) + + for rule in ["accounting_rule", "authorization_rule"]: + if existing_rule := parcel_values.get(rule): + assign_rules(existing_rule) + + for key in [ + "radius_client", + "radius_trustsec_group", + "rda_server_key", + "domain_stripping", + "auth_type", + "port", + "cts_auth_list", + ]: + parcel_values.pop(key, None) + + return AAAParcel(**parcel_values) diff --git a/catalystwan/utils/config_migration/converters/feature_template/banner.py b/catalystwan/utils/config_migration/converters/feature_template/banner.py new file mode 100644 index 00000000..5aa4550b --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/banner.py @@ -0,0 +1,20 @@ +from catalystwan.models.configuration.feature_profile.sdwan.system import BannerParcel + + +class BannerTemplateConverter: + supported_template_types = ("cisco_banner",) + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> BannerParcel: + """ + Creates a BannerParcel object based on the provided template values. + + Args: + name (str): The name of the BannerParcel. + description (str): The description of the BannerParcel. + template_values (dict): A dictionary containing the template values. + + Returns: + BannerParcel: A BannerParcel object with the provided template values. + """ + return BannerParcel(parcel_name=name, parcel_description=description, **template_values) diff --git a/catalystwan/utils/config_migration/converters/feature_template/base.py b/catalystwan/utils/config_migration/converters/feature_template/base.py new file mode 100644 index 00000000..c3144e72 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/base.py @@ -0,0 +1,9 @@ +from typing_extensions import Protocol + +from catalystwan.models.configuration.feature_profile.sdwan.system import AnySystemParcel + + +class FeatureTemplateConverter(Protocol): + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> AnySystemParcel: + ... diff --git a/catalystwan/utils/config_migration/converters/feature_template/basic.py b/catalystwan/utils/config_migration/converters/feature_template/basic.py new file mode 100644 index 00000000..968c50ae --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/basic.py @@ -0,0 +1,74 @@ +from copy import deepcopy + +from catalystwan.api.configuration_groups.parcel import Global, as_default, as_global +from catalystwan.models.configuration.feature_profile.sdwan.system import BasicParcel +from catalystwan.models.configuration.feature_profile.sdwan.system.basic import ConsoleBaudRate +from catalystwan.utils.timezone import Timezone + + +class SystemToBasicTemplateConverter: + supported_template_types = ("cisco_system", "system-vsmart", "system-vedge") + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> BasicParcel: + """ + Converts the provided template values into a BasicParcel object. + + Args: + name (str): The name of the BasicParcel. + description (str): The description of the BasicParcel. + template_values (dict): A dictionary containing the template values. + + Returns: + BasicParcel: A BasicParcel object with the provided template values. + """ + parcel_values = deepcopy(template_values) + parcel_values = { + "parcel_name": name, + "parcel_description": description, + } + + track_default_gateway = template_values.get("track_default_gateway", as_default(False)).value + if track_default_gateway == "": + track_default_gateway = False + parcel_values["track_default_gateway"] = as_global(track_default_gateway) + + clock_timezone = template_values.get("timezone", as_default("UTC")).value + parcel_values["clock"] = {"timezone": Global[Timezone](value=clock_timezone)} + + console_baud_rate = template_values.get("console_baud_rate", as_default("9600")).value + if console_baud_rate == "": + console_baud_rate = "9600" # Default value for console baud rate + parcel_values["console_baud_rate"] = Global[ConsoleBaudRate](value=console_baud_rate) + + parcel_values["gps_location"] = {} + + longitude = parcel_values.get("longitude", as_default("")).value + latitude = parcel_values.get("latitude", as_default("")).value + if longitude and latitude: + parcel_values["gps_location"]["longitude"] = longitude + parcel_values["gps_location"]["latitude"] = latitude + + if mobile_number := parcel_values.get("mobile_number", []): + parcel_values["gps_location"]["geo_fencing"] = { + "enable": as_global(True), + "range": parcel_values.get("range", as_default(100)), + "sms": {"enable": as_global(True), "mobile_number": mobile_number}, + } + + # Remove unnecessary keys from template_values + for key in [ + "timezone", + "longitude", + "latitude", + "mobile_number", + "range", + "site_id", + "system_ip", + "host_name", + "enable", + "tracker", + ]: + parcel_values.pop(key, None) + + return BasicParcel(**parcel_values) diff --git a/catalystwan/utils/config_migration/converters/feature_template/bfd.py b/catalystwan/utils/config_migration/converters/feature_template/bfd.py new file mode 100644 index 00000000..5b30dc85 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/bfd.py @@ -0,0 +1,25 @@ +from catalystwan.models.configuration.feature_profile.sdwan.system import BFDParcel + + +class BFDTemplateConverter: + supported_template_types = ("cisco_bfd", "bfd-vedge") + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> BFDParcel: + """ + Creates a BFDParcel object based on the provided template values. + + Args: + name (str): The name of the BFDParcel. + description (str): The description of the BFDParcel. + template_values (dict): A dictionary containing the template values. + + Returns: + BFDParcel: A BFDParcel object with the provided template values. + """ + parcel_values = { + "parcel_name": name, + "parcel_description": description, + "colors": template_values.get("color"), + } + return BFDParcel(**parcel_values) # type: ignore diff --git a/catalystwan/utils/config_migration/converters/feature_template/factory_method.py b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py new file mode 100644 index 00000000..afcb71ed --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py @@ -0,0 +1,81 @@ +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 .banner import BannerTemplateConverter +from .base import FeatureTemplateConverter +from .basic import SystemToBasicTemplateConverter +from .bfd import BFDTemplateConverter +from .global_ import GlobalTemplateConverter +from .logging_ import LoggingTemplateConverter +from .normalizer import template_definition_normalization +from .ntp import NTPTemplateConverter +from .omp import OMPTemplateConverter +from .security import SecurityTemplateConverter + +logger = logging.getLogger(__name__) + +available_converters = [ + AAATemplateConverter, + BannerTemplateConverter, + SecurityTemplateConverter, + SystemToBasicTemplateConverter, + BFDTemplateConverter, + GlobalTemplateConverter, + LoggingTemplateConverter, + OMPTemplateConverter, + NTPTemplateConverter, +] + + +supported_parcel_converters: Dict[Any, Any] = { + converter.supported_template_types: converter for converter in available_converters # type: ignore +} + + +def choose_parcel_converter(template_type: str) -> FeatureTemplateConverter: + """ + This function is used to choose the correct parcel factory based on the template type. + + Args: + template_type (str): The template type used to determine the correct factory. + + Returns: + BaseFactory: The chosen parcel factory. + + Raises: + ValueError: If the template type is not supported. + """ + for key in supported_parcel_converters.keys(): + if template_type in key: + 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: + """ + Creates a new instance of a _ParcelBase based on the given template. + + Args: + template (FeatureTemplateInformation): The template to use for creating the _ParcelBase instance. + + Returns: + _ParcelBase: The created _ParcelBase instance. + + Raises: + ValueError: If the given template type is not supported. + """ + converter = choose_parcel_converter(template.template_type) + 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/global_.py b/catalystwan/utils/config_migration/converters/feature_template/global_.py new file mode 100644 index 00000000..fed5222b --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/global_.py @@ -0,0 +1,20 @@ +from catalystwan.models.configuration.feature_profile.sdwan.system import GlobalParcel + + +class GlobalTemplateConverter: + supported_template_types = ("cedge_global",) + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> GlobalParcel: + """ + Creates an Logging object based on the provided template values. + + Returns: + GlobalParcel: A GlobalParcel object with the provided template values. + """ + parcel_values = { + "parcel_name": name, + "parcel_description": description, + "services_global": {"services_ip": {key: value for key, value in template_values.items()}}, + } + return GlobalParcel(**parcel_values) # type: ignore 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 00000000..399be09f --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/logging_.py @@ -0,0 +1,50 @@ +from copy import deepcopy +from typing import Dict, List + +from catalystwan.api.configuration_groups.parcel import Global +from catalystwan.models.configuration.feature_profile.sdwan.system import LoggingParcel +from catalystwan.models.configuration.feature_profile.sdwan.system.logging_parcel import CypherSuite + + +class LoggingTemplateConverter: + supported_template_types = ("cisco_logging", "logging") + + @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. + """ + + def parse_server_name(servers: List) -> None: + for server in servers: + server["name"] = Global[str](value=str(server["name"].value)) + + def set_disk(parcel_values: Dict) -> None: + parcel_values["disk"] = { + "disk_enable": parcel_values["enable"], + "file": {"disk_file_size": parcel_values["size"], "disk_file_rotate": parcel_values["rotate"]}, + } + for key in ["enable", "size", "rotate"]: + parcel_values.pop(key, None) + + parcel_values = deepcopy(template_values) + parcel_values["name"] = name + parcel_values["description"] = description + + if tls_profiles := parcel_values.get("tls_profile"): + for profile in tls_profiles: + del profile["auth_type"] + if profile.get("ciphersuite_list"): + profile["ciphersuite_list"] = Global[List[CypherSuite]](value=profile["ciphersuite_list"].value) + + for server in ["server", "ipv6_server"]: + if target_server := parcel_values.get(server): + parse_server_name(target_server) + + if parcel_values.get("enable"): + set_disk(parcel_values) + + return LoggingParcel(**parcel_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 00000000..ecc1aa74 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/normalizer.py @@ -0,0 +1,76 @@ +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.common import TLOCColor +from catalystwan.models.configuration.feature_profile.sdwan.system.logging_parcel import ( + AuthType, + CypherSuite, + Priority, + TlsVersion, +) +from catalystwan.models.configuration.feature_profile.sdwan.system.mrf import EnableMrfMigration, Role + +CastableLiterals = [Priority, TlsVersion, AuthType, CypherSuite, Role, EnableMrfMigration, TLOCColor] + +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 CastableLiterals: + 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/utils/config_migration/converters/feature_template/ntp.py b/catalystwan/utils/config_migration/converters/feature_template/ntp.py new file mode 100644 index 00000000..4905acd1 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/ntp.py @@ -0,0 +1,15 @@ +from catalystwan.models.configuration.feature_profile.sdwan.system import NTPParcel + + +class NTPTemplateConverter: + supported_template_types = ("cisco_ntp", "ntp") + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> NTPParcel: + """ + Creates an Logging object based on the provided template values. + + Returns: + Logging: An Logging object with the provided template values. + """ + return NTPParcel(parcel_name=name, parcel_description=description, **template_values) diff --git a/catalystwan/utils/config_migration/converters/feature_template/omp.py b/catalystwan/utils/config_migration/converters/feature_template/omp.py new file mode 100644 index 00000000..e3703f66 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/omp.py @@ -0,0 +1,35 @@ +from typing import Dict, List + +from catalystwan.api.configuration_groups.parcel import Global, as_default, as_global +from catalystwan.models.configuration.feature_profile.sdwan.system import OMPParcel + + +class OMPTemplateConverter: + supported_template_types = ("cisco_omp", "omp-vedge", "omp-vsmart") + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> OMPParcel: + """ + Creates an OMPParcel object based on the provided template values. + + Args: + name (str): The name of the OMPParcel. + description (str): The description of the OMPParcel. + template_values (dict): A dictionary containing the template values. + + Returns: + OMPParcel: An OMPParcel object with the provided template values. + """ + + def create_advertise_dict(advertise_list: List) -> Dict: + return {definition["protocol"].value: Global[bool](value=True) for definition in advertise_list} + + parcel_values = { + "parcel_name": name, + "parcel_description": description, + "ecmp_limit": as_global(float(template_values.get("ecmp_limit", as_default(4)).value)), + "advertise_ipv4": create_advertise_dict(template_values.get("advertise", [])), + "advertise_ipv6": create_advertise_dict(template_values.get("ipv6_advertise", [])), + } + + return OMPParcel(**parcel_values) diff --git a/catalystwan/utils/config_migration/converters/feature_template/security.py b/catalystwan/utils/config_migration/converters/feature_template/security.py new file mode 100644 index 00000000..c080efc1 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/security.py @@ -0,0 +1,42 @@ +from typing import List + +from catalystwan.api.configuration_groups.parcel import Global, as_default +from catalystwan.models.configuration.feature_profile.sdwan.system import SecurityParcel +from catalystwan.models.configuration.feature_profile.sdwan.system.security import IntegrityType + + +class SecurityTemplateConverter: + """ + A class for converting template values into a SecurityParcel object. + + Attributes: + supported_template_types (tuple): A tuple of supported template types. + """ + + supported_template_types = ( + "cisco_security", + "security", + "security-vsmart", + "security-vedge", + ) + + @staticmethod + def create_parcel(name: str, description: str, template_values: dict) -> SecurityParcel: + """ + Creates a SecurityParcel object based on the provided template values. + + Args: + name (str): The name of the SecurityParcel. + description (str): The description of the SecurityParcel. + template_values (dict): A dictionary containing the template values. + + Returns: + SecurityParcel: A SecurityParcel object with the provided template values. + """ + parcel_values = { + "parcel_name": name, + "parcel_description": description, + } + if integrity_type := template_values.get("integrity_type", as_default([])).value: + parcel_values["integrity_type"] = Global[List[IntegrityType]](value=integrity_type) # type: ignore + return SecurityParcel(**parcel_values) # type: ignore 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 00000000..4294b546 --- /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/utils/timezone.py b/catalystwan/utils/timezone.py index e210b891..a607a0d7 100644 --- a/catalystwan/utils/timezone.py +++ b/catalystwan/utils/timezone.py @@ -1,422 +1,420 @@ -# Copyright 2023 Cisco Systems, Inc. and its affiliates +from typing import Literal -from enum import Enum - - -class Timezone(str, Enum): - EUROPE_ANDORRA = "Europe/Andorra" - ASIA_DUBAI = "Asia/Dubai" - ASIA_KABUL_ = "Asia/Kabul" - AMERICA_ANTIGUA_ = "America/Antigua" - AMERICA_ANGUILLA_ = "America/Anguilla" - EUROPE_TIRANE_ = "Europe/Tirane" - ASIA_YEREVAN_ = "Asia/Yerevan" - AFRICA_LUANDA_ = "Africa/Luanda" - ANTARCTICA_MCMURDO = "Antarctica/McMurdo" - ANTARCTICA_ROTHERA = "Antarctica/Rothera" - ANTARCTICA_PALMER = "Antarctica/Palmer" - ANTARCTICA_MAWSON = "Antarctica/Mawson" - ANTARCTICA_DAVIS = "Antarctica/Davis" - ANTARCTICA_CASEY = "Antarctica/Casey" - ANTARCTICA_VOSTOK = "Antarctica/Vostok" - ANTARCTICA_DUMONTDURVILLE = "Antarctica/DumontDUrville" - ANTARCTICA_SYOWA = "Antarctica/Syowa" - AMERICA_ARGENTINA_BUENOS_AIRES = "America/Argentina/Buenos_Aires" - AMERICA_ARGENTINA_CORDOBA = "America/Argentina/Cordoba" - AMERICA_ARGENTINA_SALTA = "America/Argentina/Salta" - AMERICA_ARGENTINA_JUJUY = "America/Argentina/Jujuy" - AMERICA_ARGENTINA_TUCUMAN = "America/Argentina/Tucuman" - AMERICA_ARGENTINA_CATAMARCA = "America/Argentina/Catamarca" - AMERICA_ARGENTINA_LA_RIOJA = "America/Argentina/La_Rioja" - AMERICA_ARGENTINA_SAN_JUAN = "America/Argentina/San_Juan" - AMERICA_ARGENTINA_MENDOZA = "America/Argentina/Mendoza" - AMERICA_ARGENTINA_SAN_LUIS = "America/Argentina/San_Luis" - AMERICA_ARGENTINA_RIO_GALLEGOS = "America/Argentina/Rio_Gallegos" - AMERICA_ARGENTINA_USHUAIA = "America/Argentina/Ushuaia" - PACIFIC_PAGO_PAGO = "Pacific/Pago_Pago" - EUROPE_VIENNA = "Europe/Vienna" - AUSTRALIA_LORD_HOWE = "Australia/Lord_Howe" - ANTARCTICA_MACQUARIE = "Antarctica/Macquarie" - AUSTRALIA_HOBART = "Australia/Hobart" - AUSTRALIA_CURRIE = "Australia/Currie" - AUSTRALIA_MELBOURNE = "Australia/Melbourne" - AUSTRALIA_SYDNEY = "Australia/Sydney" - AUSTRALIA_BROKEN_HILL = "Australia/Broken_Hill" - AUSTRALIA_BRISBANE = "Australia/Brisbane" - AUSTRALIA_LINDEMAN = "Australia/Lindeman" - AUSTRALIA_ADELAIDE = "Australia/Adelaide" - AUSTRALIA_DARWIN = "Australia/Darwin" - AUSTRALIA_PERTH = "Australia/Perth" - AUSTRALIA_EUCLA = "Australia/Eucla" - AMERICA_ARUBA = "America/Aruba" - EUROPE_MARIEHAMN = "Europe/Mariehamn" - ASIA_BAKU = "Asia/Baku" - EUROPE_SARAJEVO = "Europe/Sarajevo" - AMERICA_BARBADOS = "America/Barbados" - ASIA_DHAKA = "Asia/Dhaka" - EUROPE_BRUSSELS = "Europe/Brussels" - AFRICA_OUAGADOUGOU = "Africa/Ouagadougou" - EUROPE_SOFIA = "Europe/Sofia" - ASIA_BAHRAIN = "Asia/Bahrain" - AFRICA_BUJUMBURA = "Africa/Bujumbura" - AFRICA_PORTO_NOVO = "Africa/Porto-Novo" - AMERICA_ST_BARTHELEMY = "America/St_Barthelemy" - ATLANTIC_BERMUDA = "Atlantic/Bermuda" - ASIA_BRUNEI = "Asia/Brunei" - AMERICA_LA_PAZ = "America/La_Paz" - AMERICA_KRALENDIJK = "America/Kralendijk" - AMERICA_NORONHA = "America/Noronha" - AMERICA_BELEM = "America/Belem" - AMERICA_FORTALEZA = "America/Fortaleza" - AMERICA_RECIFE = "America/Recife" - AMERICA_ARAGUAINA = "America/Araguaina" - AMERICA_MACEIO = "America/Maceio" - AMERICA_BAHIA = "America/Bahia" - AMERICA_SAO_PAULO = "America/Sao_Paulo" - AMERICA_CAMPO_GRANDE = "America/Campo_Grande" - AMERICA_CUIABA = "America/Cuiaba" - AMERICA_SANTAREM = "America/Santarem" - AMERICA_PORTO_VELHO = "America/Porto_Velho" - AMERICA_BOA_VISTA = "America/Boa_Vista" - AMERICA_MANAUS = "America/Manaus" - AMERICA_EIRUNEPE = "America/Eirunepe" - AMERICA_RIO_BRANCO = "America/Rio_Branco" - AMERICA_NASSAU = "America/Nassau" - ASIA_THIMPHU = "Asia/Thimphu" - AFRICA_GABORONE = "Africa/Gaborone" - EUROPE_MINSK = "Europe/Minsk" - AMERICA_BELIZE = "America/Belize" - AMERICA_ST_JOHNS = "America/St_Johns" - AMERICA_HALIFAX = "America/Halifax" - AMERICA_GLACE_BAY = "America/Glace_Bay" - AMERICA_MONCTON = "America/Moncton" - AMERICA_GOOSE_BAY = "America/Goose_Bay" - AMERICA_BLANC_SABLON = "America/Blanc-Sablon" - AMERICA_TORONTO = "America/Toronto" - AMERICA_NIPIGON = "America/Nipigon" - AMERICA_THUNDER_BAY = "America/Thunder_Bay" - AMERICA_IQALUIT = "America/Iqaluit" - AMERICA_PANGNIRTUNG = "America/Pangnirtung" - AMERICA_RESOLUTE = "America/Resolute" - AMERICA_ATIKOKAN = "America/Atikokan" - AMERICA_RANKIN_INLET = "America/Rankin_Inlet" - AMERICA_WINNIPEG = "America/Winnipeg" - AMERICA_RAINY_RIVER = "America/Rainy_River" - AMERICA_REGINA = "America/Regina" - AMERICA_SWIFT_CURRENT = "America/Swift_Current" - AMERICA_EDMONTON = "America/Edmonton" - AMERICA_CAMBRIDGE_BAY = "America/Cambridge_Bay" - AMERICA_YELLOWKNIFE = "America/Yellowknife" - AMERICA_INUVIK = "America/Inuvik" - AMERICA_CRESTON = "America/Creston" - AMERICA_DAWSON_CREEK = "America/Dawson_Creek" - AMERICA_VANCOUVER = "America/Vancouver" - AMERICA_WHITEHORSE = "America/Whitehorse" - AMERICA_DAWSON = "America/Dawson" - INDIAN_COCOS = "Indian/Cocos" - AFRICA_KINSHASA = "Africa/Kinshasa" - AFRICA_LUBUMBASHI = "Africa/Lubumbashi" - AFRICA_BANGUI = "Africa/Bangui" - AFRICA_BRAZZAVILLE = "Africa/Brazzaville" - EUROPE_ZURICH = "Europe/Zurich" - AFRICA_ABIDJAN = "Africa/Abidjan" - PACIFIC_RAROTONGA = "Pacific/Rarotonga" - AMERICA_SANTIAGO = "America/Santiago" - PACIFIC_EASTER = "Pacific/Easter" - AFRICA_DOUALA = "Africa/Douala" - ASIA_SHANGHAI = "Asia/Shanghai" - ASIA_HARBIN = "Asia/Harbin" - ASIA_CHONGQING = "Asia/Chongqing" - ASIA_URUMQI = "Asia/Urumqi" - ASIA_KASHGAR = "Asia/Kashgar" - AMERICA_BOGOTA = "America/Bogota" - AMERICA_COSTA_RICA = "America/Costa_Rica" - AMERICA_HAVANA = "America/Havana" - ATLANTIC_CAPE_VERDE = "Atlantic/Cape_Verde" - AMERICA_CURACAO = "America/Curacao" - INDIAN_CHRISTMAS = "Indian/Christmas" - ASIA_NICOSIA = "Asia/Nicosia" - EUROPE_PRAGUE = "Europe/Prague" - EUROPE_BERLIN = "Europe/Berlin" - EUROPE_BUSINGEN = "Europe/Busingen" - AFRICA_DJIBOUTI = "Africa/Djibouti" - EUROPE_COPENHAGEN = "Europe/Copenhagen" - AMERICA_DOMINICA = "America/Dominica" - AMERICA_SANTO_DOMINGO = "America/Santo_Domingo" - AFRICA_ALGIERS = "Africa/Algiers" - AMERICA_GUAYAQUIL = "America/Guayaquil" - PACIFIC_GALAPAGOS = "Pacific/Galapagos" - EUROPE_TALLINN = "Europe/Tallinn" - AFRICA_CAIRO = "Africa/Cairo" - AFRICA_EL_AAIUN = "Africa/El_Aaiun" - AFRICA_ASMARA = "Africa/Asmara" - EUROPE_MADRID = "Europe/Madrid" - AFRICA_CEUTA = "Africa/Ceuta" - ATLANTIC_CANARY = "Atlantic/Canary" - AFRICA_ADDIS_ABABA = "Africa/Addis_Ababa" - EUROPE_HELSINKI = "Europe/Helsinki" - PACIFIC_FIJI = "Pacific/Fiji" - ATLANTIC_STANLEY = "Atlantic/Stanley" - PACIFIC_CHUUK = "Pacific/Chuuk" - PACIFIC_POHNPEI = "Pacific/Pohnpei" - PACIFIC_KOSRAE = "Pacific/Kosrae" - ATLANTIC_FAROE = "Atlantic/Faroe" - EUROPE_PARIS = "Europe/Paris" - AFRICA_LIBREVILLE = "Africa/Libreville" - EUROPE_LONDON = "Europe/London" - AMERICA_GRENADA = "America/Grenada" - ASIA_TBILISI = "Asia/Tbilisi" - AMERICA_CAYENNE = "America/Cayenne" - EUROPE_GUERNSEY = "Europe/Guernsey" - AFRICA_ACCRA = "Africa/Accra" - EUROPE_GIBRALTAR = "Europe/Gibraltar" - AMERICA_GODTHAB = "America/Godthab" - AMERICA_DANMARKSHAVN = "America/Danmarkshavn" - AMERICA_SCORESBYSUND = "America/Scoresbysund" - AMERICA_THULE = "America/Thule" - AFRICA_BANJUL = "Africa/Banjul" - AFRICA_CONAKRY = "Africa/Conakry" - AMERICA_GUADELOUPE = "America/Guadeloupe" - AFRICA_MALABO = "Africa/Malabo" - EUROPE_ATHENS = "Europe/Athens" - ATLANTIC_SOUTH_GEORGIA = "Atlantic/South_Georgia" - AMERICA_GUATEMALA = "America/Guatemala" - PACIFIC_GUAM = "Pacific/Guam" - AFRICA_BISSAU = "Africa/Bissau" - AMERICA_GUYANA = "America/Guyana" - ASIA_HONG_KONG = "Asia/Hong_Kong" - AMERICA_TEGUCIGALPA = "America/Tegucigalpa" - EUROPE_ZAGREB = "Europe/Zagreb" - AMERICA_PORT_AU_PRINCE = "America/Port-au-Prince" - EUROPE_BUDAPEST = "Europe/Budapest" - ASIA_JAKARTA = "Asia/Jakarta" - ASIA_PONTIANAK = "Asia/Pontianak" - ASIA_MAKASSAR = "Asia/Makassar" - ASIA_JAYAPURA = "Asia/Jayapura" - EUROPE_DUBLIN = "Europe/Dublin" - ASIA_JERUSALEM = "Asia/Jerusalem" - EUROPE_ISLE_OF_MAN = "Europe/Isle_of_Man" - ASIA_KOLKATA = "Asia/Kolkata" - INDIAN_CHAGOS = "Indian/Chagos" - ASIA_BAGHDAD = "Asia/Baghdad" - ASIA_TEHRAN = "Asia/Tehran" - ATLANTIC_REYKJAVIK = "Atlantic/Reykjavik" - EUROPE_ROME = "Europe/Rome" - EUROPE_JERSEY = "Europe/Jersey" - AMERICA_JAMAICA = "America/Jamaica" - ASIA_AMMAN = "Asia/Amman" - ASIA_TOKYO = "Asia/Tokyo" - AFRICA_NAIROBI = "Africa/Nairobi" - ASIA_BISHKEK = "Asia/Bishkek" - ASIA_PHNOM_PENH = "Asia/Phnom_Penh" - PACIFIC_TARAWA = "Pacific/Tarawa" - PACIFIC_ENDERBURY = "Pacific/Enderbury" - PACIFIC_KIRITIMATI = "Pacific/Kiritimati" - INDIAN_COMORO = "Indian/Comoro" - AMERICA_ST_KITTS = "America/St_Kitts" - ASIA_PYONGYANG = "Asia/Pyongyang" - ASIA_SEOUL = "Asia/Seoul" - ASIA_KUWAIT = "Asia/Kuwait" - AMERICA_CAYMAN = "America/Cayman" - ASIA_ALMATY = "Asia/Almaty" - ASIA_QYZYLORDA = "Asia/Qyzylorda" - ASIA_AQTOBE = "Asia/Aqtobe" - ASIA_AQTAU = "Asia/Aqtau" - ASIA_ORAL = "Asia/Oral" - ASIA_VIENTIANE = "Asia/Vientiane" - ASIA_BEIRUT = "Asia/Beirut" - AMERICA_ST_LUCIA = "America/St_Lucia" - EUROPE_VADUZ = "Europe/Vaduz" - ASIA_COLOMBO = "Asia/Colombo" - AFRICA_MONROVIA = "Africa/Monrovia" - AFRICA_MASERU = "Africa/Maseru" - EUROPE_VILNIUS = "Europe/Vilnius" - EUROPE_LUXEMBOURG = "Europe/Luxembourg" - EUROPE_RIGA = "Europe/Riga" - AFRICA_TRIPOLI = "Africa/Tripoli" - AFRICA_CASABLANCA = "Africa/Casablanca" - EUROPE_MONACO = "Europe/Monaco" - EUROPE_CHISINAU = "Europe/Chisinau" - EUROPE_PODGORICA = "Europe/Podgorica" - AMERICA_MARIGOT = "America/Marigot" - INDIAN_ANTANANARIVO = "Indian/Antananarivo" - PACIFIC_MAJURO = "Pacific/Majuro" - PACIFIC_KWAJALEIN = "Pacific/Kwajalein" - EUROPE_SKOPJE = "Europe/Skopje" - AFRICA_BAMAKO = "Africa/Bamako" - ASIA_RANGOON = "Asia/Rangoon" - ASIA_ULAANBAATAR = "Asia/Ulaanbaatar" - ASIA_HOVD = "Asia/Hovd" - ASIA_CHOIBALSAN = "Asia/Choibalsan" - ASIA_MACAU = "Asia/Macau" - PACIFIC_SAIPAN = "Pacific/Saipan" - AMERICA_MARTINIQUE = "America/Martinique" - AFRICA_NOUAKCHOTT = "Africa/Nouakchott" - AMERICA_MONTSERRAT = "America/Montserrat" - EUROPE_MALTA = "Europe/Malta" - INDIAN_MAURITIUS = "Indian/Mauritius" - INDIAN_MALDIVES = "Indian/Maldives" - AFRICA_BLANTYRE = "Africa/Blantyre" - AMERICA_MEXICO_CITY = "America/Mexico_City" - AMERICA_CANCUN = "America/Cancun" - AMERICA_MERIDA = "America/Merida" - AMERICA_MONTERREY = "America/Monterrey" - AMERICA_MATAMOROS = "America/Matamoros" - AMERICA_MAZATLAN = "America/Mazatlan" - AMERICA_CHIHUAHUA = "America/Chihuahua" - AMERICA_OJINAGA = "America/Ojinaga" - AMERICA_HERMOSILLO = "America/Hermosillo" - AMERICA_TIJUANA = "America/Tijuana" - AMERICA_SANTA_ISABEL = "America/Santa_Isabel" - AMERICA_BAHIA_BANDERAS = "America/Bahia_Banderas" - ASIA_KUALA_LUMPUR = "Asia/Kuala_Lumpur" - ASIA_KUCHING = "Asia/Kuching" - AFRICA_MAPUTO = "Africa/Maputo" - AFRICA_WINDHOEK = "Africa/Windhoek" - PACIFIC_NOUMEA = "Pacific/Noumea" - AFRICA_NIAMEY = "Africa/Niamey" - PACIFIC_NORFOLK = "Pacific/Norfolk" - AFRICA_LAGOS = "Africa/Lagos" - AMERICA_MANAGUA = "America/Managua" - EUROPE_AMSTERDAM = "Europe/Amsterdam" - EUROPE_OSLO = "Europe/Oslo" - ASIA_KATHMANDU = "Asia/Kathmandu" - PACIFIC_NAURU = "Pacific/Nauru" - PACIFIC_NIUE = "Pacific/Niue" - PACIFIC_AUCKLAND = "Pacific/Auckland" - PACIFIC_CHATHAM = "Pacific/Chatham" - ASIA_MUSCAT = "Asia/Muscat" - AMERICA_PANAMA = "America/Panama" - AMERICA_LIMA = "America/Lima" - PACIFIC_TAHITI = "Pacific/Tahiti" - PACIFIC_MARQUESAS = "Pacific/Marquesas" - PACIFIC_GAMBIER = "Pacific/Gambier" - PACIFIC_PORT_MORESBY = "Pacific/Port_Moresby" - ASIA_MANILA = "Asia/Manila" - ASIA_KARACHI = "Asia/Karachi" - EUROPE_WARSAW = "Europe/Warsaw" - AMERICA_MIQUELON = "America/Miquelon" - PACIFIC_PITCAIRN = "Pacific/Pitcairn" - AMERICA_PUERTO_RICO = "America/Puerto_Rico" - ASIA_GAZA = "Asia/Gaza" - ASIA_HEBRON = "Asia/Hebron" - EUROPE_LISBON = "Europe/Lisbon" - ATLANTIC_MADEIRA = "Atlantic/Madeira" - ATLANTIC_AZORES = "Atlantic/Azores" - PACIFIC_PALAU = "Pacific/Palau" - AMERICA_ASUNCION = "America/Asuncion" - ASIA_QATAR = "Asia/Qatar" - INDIAN_REUNION = "Indian/Reunion" - EUROPE_BUCHAREST = "Europe/Bucharest" - EUROPE_BELGRADE = "Europe/Belgrade" - EUROPE_KALININGRAD = "Europe/Kaliningrad" - EUROPE_MOSCOW = "Europe/Moscow" - EUROPE_VOLGOGRAD = "Europe/Volgograd" - EUROPE_SAMARA = "Europe/Samara" - ASIA_YEKATERINBURG = "Asia/Yekaterinburg" - ASIA_OMSK = "Asia/Omsk" - ASIA_NOVOSIBIRSK = "Asia/Novosibirsk" - ASIA_NOVOKUZNETSK = "Asia/Novokuznetsk" - ASIA_KRASNOYARSK = "Asia/Krasnoyarsk" - ASIA_IRKUTSK = "Asia/Irkutsk" - ASIA_YAKUTSK = "Asia/Yakutsk" - ASIA_KHANDYGA = "Asia/Khandyga" - ASIA_VLADIVOSTOK = "Asia/Vladivostok" - ASIA_SAKHALIN = "Asia/Sakhalin" - ASIA_UST_NERA = "Asia/Ust-Nera" - ASIA_MAGADAN = "Asia/Magadan" - ASIA_KAMCHATKA = "Asia/Kamchatka" - ASIA_ANADYR = "Asia/Anadyr" - AFRICA_KIGALI = "Africa/Kigali" - ASIA_RIYADH = "Asia/Riyadh" - PACIFIC_GUADALCANAL = "Pacific/Guadalcanal" - INDIAN_MAHE = "Indian/Mahe" - AFRICA_KHARTOUM = "Africa/Khartoum" - EUROPE_STOCKHOLM = "Europe/Stockholm" - ASIA_SINGAPORE = "Asia/Singapore" - ATLANTIC_ST_HELENA = "Atlantic/St_Helena" - EUROPE_LJUBLJANA = "Europe/Ljubljana" - ARCTIC_LONGYEARBYEN = "Arctic/Longyearbyen" - EUROPE_BRATISLAVA = "Europe/Bratislava" - AFRICA_FREETOWN = "Africa/Freetown" - EUROPE_SAN_MARINO = "Europe/San_Marino" - AFRICA_DAKAR = "Africa/Dakar" - AFRICA_MOGADISHU = "Africa/Mogadishu" - AMERICA_PARAMARIBO = "America/Paramaribo" - AFRICA_JUBA = "Africa/Juba" - AFRICA_SAO_TOME = "Africa/Sao_Tome" - AMERICA_EL_SALVADOR = "America/El_Salvador" - AMERICA_LOWER_PRINCES = "America/Lower_Princes" - ASIA_DAMASCUS = "Asia/Damascus" - AFRICA_MBABANE = "Africa/Mbabane" - AMERICA_GRAND_TURK = "America/Grand_Turk" - AFRICA_NDJAMENA = "Africa/Ndjamena" - INDIAN_KERGUELEN = "Indian/Kerguelen" - AFRICA_LOME = "Africa/Lome" - ASIA_BANGKOK = "Asia/Bangkok" - ASIA_DUSHANBE = "Asia/Dushanbe" - PACIFIC_FAKAOFO = "Pacific/Fakaofo" - ASIA_DILI = "Asia/Dili" - ASIA_ASHGABAT = "Asia/Ashgabat" - AFRICA_TUNIS = "Africa/Tunis" - PACIFIC_TONGATAPU = "Pacific/Tongatapu" - EUROPE_ISTANBUL = "Europe/Istanbul" - AMERICA_PORT_OF_SPAIN = "America/Port_of_Spain" - PACIFIC_FUNAFUTI = "Pacific/Funafuti" - ASIA_TAIPEI = "Asia/Taipei" - AFRICA_DAR_ES_SALAAM = "Africa/Dar_es_Salaam" - EUROPE_KIEV = "Europe/Kiev" - EUROPE_UZHGOROD = "Europe/Uzhgorod" - EUROPE_ZAPOROZHYE = "Europe/Zaporozhye" - EUROPE_SIMFEROPOL = "Europe/Simferopol" - AFRICA_KAMPALA = "Africa/Kampala" - PACIFIC_JOHNSTON = "Pacific/Johnston" - PACIFIC_MIDWAY = "Pacific/Midway" - PACIFIC_WAKE = "Pacific/Wake" - AMERICA_NEW_YORK = "America/New_York" - AMERICA_DETROIT = "America/Detroit" - AMERICA_KENTUCKY_LOUISVILLE = "America/Kentucky/Louisville" - AMERICA_KENTUCKY_MONTICELLO = "America/Kentucky/Monticello" - AMERICA_INDIANA_INDIANAPOLIS = "America/Indiana/Indianapolis" - AMERICA_INDIANA_VINCENNES = "America/Indiana/Vincennes" - AMERICA_INDIANA_WINAMAC = "America/Indiana/Winamac" - AMERICA_INDIANA_MARENGO = "America/Indiana/Marengo" - AMERICA_INDIANA_PETERSBURG = "America/Indiana/Petersburg" - AMERICA_INDIANA_VEVAY = "America/Indiana/Vevay" - AMERICA_CHICAGO = "America/Chicago" - AMERICA_INDIANA_TELL_CITY = "America/Indiana/Tell_City" - AMERICA_INDIANA_KNOX = "America/Indiana/Knox" - AMERICA_MENOMINEE = "America/Menominee" - AMERICA_NORTH_DAKOTA_CENTER = "America/North_Dakota/Center" - AMERICA_NORTH_DAKOTA_NEW_SALEM = "America/North_Dakota/New_Salem" - AMERICA_NORTH_DAKOTA_BEULAH = "America/North_Dakota/Beulah" - AMERICA_DENVER = "America/Denver" - AMERICA_BOISE = "America/Boise" - AMERICA_PHOENIX = "America/Phoenix" - AMERICA_LOS_ANGELES = "America/Los_Angeles" - AMERICA_ANCHORAGE = "America/Anchorage" - AMERICA_JUNEAU = "America/Juneau" - AMERICA_SITKA = "America/Sitka" - AMERICA_YAKUTAT = "America/Yakutat" - AMERICA_NOME = "America/Nome" - AMERICA_ADAK = "America/Adak" - AMERICA_METLAKATLA = "America/Metlakatla" - PACIFIC_HONOLULU = "Pacific/Honolulu" - AMERICA_MONTEVIDEO = "America/Montevideo" - ASIA_SAMARKAND = "Asia/Samarkand" - ASIA_TASHKENT = "Asia/Tashkent" - EUROPE_VATICAN = "Europe/Vatican" - AMERICA_ST_VINCENT = "America/St_Vincent" - AMERICA_CARACAS = "America/Caracas" - AMERICA_TORTOLA = "America/Tortola" - AMERICA_ST_THOMAS = "America/St_Thomas" - ASIA_HO_CHI_MINH = "Asia/Ho_Chi_Minh" - PACIFIC_EFATE = "Pacific/Efate" - PACIFIC_WALLIS = "Pacific/Wallis" - PACIFIC_APIA = "Pacific/Apia" - ASIA_ADEN = "Asia/Aden" - INDIAN_MAYOTTE = "Indian/Mayotte" - AFRICA_JOHANNESBURG = "Africa/Johannesburg" - AFRICA_LUSAKA = "Africa/Lusaka" - AFRICA_HARARE = "Africa/Harare" - UTC = "UTC" +Timezone = Literal[ + "Europe/Andorra", + "Asia/Dubai", + "Asia/Kabul", + "America/Antigua", + "America/Anguilla", + "Europe/Tirane", + "Asia/Yerevan", + "Africa/Luanda", + "Antarctica/McMurdo", + "Antarctica/Rothera", + "Antarctica/Palmer", + "Antarctica/Mawson", + "Antarctica/Davis", + "Antarctica/Casey", + "Antarctica/Vostok", + "Antarctica/DumontDUrville", + "Antarctica/Syowa", + "America/Argentina/Buenos_Aires", + "America/Argentina/Cordoba", + "America/Argentina/Salta", + "America/Argentina/Jujuy", + "America/Argentina/Tucuman", + "America/Argentina/Catamarca", + "America/Argentina/La_Rioja", + "America/Argentina/San_Juan", + "America/Argentina/Mendoza", + "America/Argentina/San_Luis", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Ushuaia", + "Pacific/Pago_Pago", + "Europe/Vienna", + "Australia/Lord_Howe", + "Antarctica/Macquarie", + "Australia/Hobart", + "Australia/Currie", + "Australia/Melbourne", + "Australia/Sydney", + "Australia/Broken_Hill", + "Australia/Brisbane", + "Australia/Lindeman", + "Australia/Adelaide", + "Australia/Darwin", + "Australia/Perth", + "Australia/Eucla", + "America/Aruba", + "Europe/Mariehamn", + "Asia/Baku", + "Europe/Sarajevo", + "America/Barbados", + "Asia/Dhaka", + "Europe/Brussels", + "Africa/Ouagadougou", + "Europe/Sofia", + "Asia/Bahrain", + "Africa/Bujumbura", + "Africa/Porto-Novo", + "America/St_Barthelemy", + "Atlantic/Bermuda", + "Asia/Brunei", + "America/La_Paz", + "America/Kralendijk", + "America/Noronha", + "America/Belem", + "America/Fortaleza", + "America/Recife", + "America/Araguaina", + "America/Maceio", + "America/Bahia", + "America/Sao_Paulo", + "America/Campo_Grande", + "America/Cuiaba", + "America/Santarem", + "America/Porto_Velho", + "America/Boa_Vista", + "America/Manaus", + "America/Eirunepe", + "America/Rio_Branco", + "America/Nassau", + "Asia/Thimphu", + "Africa/Gaborone", + "Europe/Minsk", + "America/Belize", + "America/St_Johns", + "America/Halifax", + "America/Glace_Bay", + "America/Moncton", + "America/Goose_Bay", + "America/Blanc-Sablon", + "America/Toronto", + "America/Nipigon", + "America/Thunder_Bay", + "America/Iqaluit", + "America/Pangnirtung", + "America/Resolute", + "America/Atikokan", + "America/Rankin_Inlet", + "America/Winnipeg", + "America/Rainy_River", + "America/Regina", + "America/Swift_Current", + "America/Edmonton", + "America/Cambridge_Bay", + "America/Yellowknife", + "America/Inuvik", + "America/Creston", + "America/Dawson_Creek", + "America/Vancouver", + "America/Whitehorse", + "America/Dawson", + "Indian/Cocos", + "Africa/Kinshasa", + "Africa/Lubumbashi", + "Africa/Bangui", + "Africa/Brazzaville", + "Europe/Zurich", + "Africa/Abidjan", + "Pacific/Rarotonga", + "America/Santiago", + "Pacific/Easter", + "Africa/Douala", + "Asia/Shanghai", + "Asia/Harbin", + "Asia/Chongqing", + "Asia/Urumqi", + "Asia/Kashgar", + "America/Bogota", + "America/Costa_Rica", + "America/Havana", + "Atlantic/Cape_Verde", + "America/Curacao", + "Indian/Christmas", + "Asia/Nicosia", + "Europe/Prague", + "Europe/Berlin", + "Europe/Busingen", + "Africa/Djibouti", + "Europe/Copenhagen", + "America/Dominica", + "America/Santo_Domingo", + "Africa/Algiers", + "America/Guayaquil", + "Pacific/Galapagos", + "Europe/Tallinn", + "Africa/Cairo", + "Africa/El_Aaiun", + "Africa/Asmara", + "Europe/Madrid", + "Africa/Ceuta", + "Atlantic/Canary", + "Africa/Addis_Ababa", + "Europe/Helsinki", + "Pacific/Fiji", + "Atlantic/Stanley", + "Pacific/Chuuk", + "Pacific/Pohnpei", + "Pacific/Kosrae", + "Atlantic/Faroe", + "Europe/Paris", + "Africa/Libreville", + "Europe/London", + "America/Grenada", + "Asia/Tbilisi", + "America/Cayenne", + "Europe/Guernsey", + "Africa/Accra", + "Europe/Gibraltar", + "America/Godthab", + "America/Danmarkshavn", + "America/Scoresbysund", + "America/Thule", + "Africa/Banjul", + "Africa/Conakry", + "America/Guadeloupe", + "Africa/Malabo", + "Europe/Athens", + "Atlantic/South_Georgia", + "America/Guatemala", + "Pacific/Guam", + "Africa/Bissau", + "America/Guyana", + "Asia/Hong_Kong", + "America/Tegucigalpa", + "Europe/Zagreb", + "America/Port-au-Prince", + "Europe/Budapest", + "Asia/Jakarta", + "Asia/Pontianak", + "Asia/Makassar", + "Asia/Jayapura", + "Europe/Dublin", + "Asia/Jerusalem", + "Europe/Isle_of_Man", + "Asia/Kolkata", + "Indian/Chagos", + "Asia/Baghdad", + "Asia/Tehran", + "Atlantic/Reykjavik", + "Europe/Rome", + "Europe/Jersey", + "America/Jamaica", + "Asia/Amman", + "Asia/Tokyo", + "Africa/Nairobi", + "Asia/Bishkek", + "Asia/Phnom_Penh", + "Pacific/Tarawa", + "Pacific/Enderbury", + "Pacific/Kiritimati", + "Indian/Comoro", + "America/St_Kitts", + "Asia/Pyongyang", + "Asia/Seoul", + "Asia/Kuwait", + "America/Cayman", + "Asia/Almaty", + "Asia/Qyzylorda", + "Asia/Aqtobe", + "Asia/Aqtau", + "Asia/Oral", + "Asia/Vientiane", + "Asia/Beirut", + "America/St_Lucia", + "Europe/Vaduz", + "Asia/Colombo", + "Africa/Monrovia", + "Africa/Maseru", + "Europe/Vilnius", + "Europe/Luxembourg", + "Europe/Riga", + "Africa/Tripoli", + "Africa/Casablanca", + "Europe/Monaco", + "Europe/Chisinau", + "Europe/Podgorica", + "America/Marigot", + "Indian/Antananarivo", + "Pacific/Majuro", + "Pacific/Kwajalein", + "Europe/Skopje", + "Africa/Bamako", + "Asia/Rangoon", + "Asia/Ulaanbaatar", + "Asia/Hovd", + "Asia/Choibalsan", + "Asia/Macau", + "Pacific/Saipan", + "America/Martinique", + "Africa/Nouakchott", + "America/Montserrat", + "Europe/Malta", + "Indian/Mauritius", + "Indian/Maldives", + "Africa/Blantyre", + "America/Mexico_City", + "America/Cancun", + "America/Merida", + "America/Monterrey", + "America/Matamoros", + "America/Mazatlan", + "America/Chihuahua", + "America/Ojinaga", + "America/Hermosillo", + "America/Tijuana", + "America/Santa_Isabel", + "America/Bahia_Banderas", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Africa/Maputo", + "Africa/Windhoek", + "Pacific/Noumea", + "Africa/Niamey", + "Pacific/Norfolk", + "Africa/Lagos", + "America/Managua", + "Europe/Amsterdam", + "Europe/Oslo", + "Asia/Kathmandu", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Auckland", + "Pacific/Chatham", + "Asia/Muscat", + "America/Panama", + "America/Lima", + "Pacific/Tahiti", + "Pacific/Marquesas", + "Pacific/Gambier", + "Pacific/Port_Moresby", + "Asia/Manila", + "Asia/Karachi", + "Europe/Warsaw", + "America/Miquelon", + "Pacific/Pitcairn", + "America/Puerto_Rico", + "Asia/Gaza", + "Asia/Hebron", + "Europe/Lisbon", + "Atlantic/Madeira", + "Atlantic/Azores", + "Pacific/Palau", + "America/Asuncion", + "Asia/Qatar", + "Indian/Reunion", + "Europe/Bucharest", + "Europe/Belgrade", + "Europe/Kaliningrad", + "Europe/Moscow", + "Europe/Volgograd", + "Europe/Samara", + "Asia/Yekaterinburg", + "Asia/Omsk", + "Asia/Novosibirsk", + "Asia/Novokuznetsk", + "Asia/Krasnoyarsk", + "Asia/Irkutsk", + "Asia/Yakutsk", + "Asia/Khandyga", + "Asia/Vladivostok", + "Asia/Sakhalin", + "Asia/Ust-Nera", + "Asia/Magadan", + "Asia/Kamchatka", + "Asia/Anadyr", + "Africa/Kigali", + "Asia/Riyadh", + "Pacific/Guadalcanal", + "Indian/Mahe", + "Africa/Khartoum", + "Europe/Stockholm", + "Asia/Singapore", + "Atlantic/St_Helena", + "Europe/Ljubljana", + "Arctic/Longyearbyen", + "Europe/Bratislava", + "Africa/Freetown", + "Europe/San_Marino", + "Africa/Dakar", + "Africa/Mogadishu", + "America/Paramaribo", + "Africa/Juba", + "Africa/Sao_Tome", + "America/El_Salvador", + "America/Lower_Princes", + "Asia/Damascus", + "Africa/Mbabane", + "America/Grand_Turk", + "Africa/Ndjamena", + "Indian/Kerguelen", + "Africa/Lome", + "Asia/Bangkok", + "Asia/Dushanbe", + "Pacific/Fakaofo", + "Asia/Dili", + "Asia/Ashgabat", + "Africa/Tunis", + "Pacific/Tongatapu", + "Europe/Istanbul", + "America/Port_of_Spain", + "Pacific/Funafuti", + "Asia/Taipei", + "Africa/Dar_es_Salaam", + "Europe/Kiev", + "Europe/Uzhgorod", + "Europe/Zaporozhye", + "Europe/Simferopol", + "Africa/Kampala", + "Pacific/Johnston", + "Pacific/Midway", + "Pacific/Wake", + "America/New_York", + "America/Detroit", + "America/Kentucky/Louisville", + "America/Kentucky/Monticello", + "America/Indiana/Indianapolis", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Vevay", + "America/Chicago", + "America/Indiana/Tell_City", + "America/Indiana/Knox", + "America/Menominee", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/North_Dakota/Beulah", + "America/Denver", + "America/Boise", + "America/Phoenix", + "America/Los_Angeles", + "America/Anchorage", + "America/Juneau", + "America/Sitka", + "America/Yakutat", + "America/Nome", + "America/Adak", + "America/Metlakatla", + "Pacific/Honolulu", + "America/Montevideo", + "Asia/Samarkand", + "Asia/Tashkent", + "Europe/Vatican", + "America/St_Vincent", + "America/Caracas", + "America/Tortola", + "America/St_Thomas", + "Asia/Ho_Chi_Minh", + "Pacific/Efate", + "Pacific/Wallis", + "Pacific/Apia", + "Asia/Aden", + "Indian/Mayotte", + "Africa/Johannesburg", + "Africa/Lusaka", + "Africa/Harare", + "UTC", +] diff --git a/catalystwan/workflows/config_migration.py b/catalystwan/workflows/config_migration.py new file mode 100644 index 00000000..68215f69 --- /dev/null +++ b/catalystwan/workflows/config_migration.py @@ -0,0 +1,104 @@ +import logging +from typing import Callable + +from catalystwan.api.policy_api import POLICY_LIST_ENDPOINTS_MAP +from catalystwan.endpoints.configuration_group import ConfigGroup +from catalystwan.models.configuration.config_migration import 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__) + +SUPPORTED_TEMPLATE_TYPES = ["cedge_aaa"] + + +def log_progress(task: str, completed: int, total: int) -> None: + logger.info(f"{task} {completed}/{total}") + + +def transform(ux1: UX1Config) -> UX2Config: + ux2 = UX2Config() + # Feature Templates + for ft in ux1.templates.feature_templates: + if ft.template_type in SUPPORTED_TEMPLATE_TYPES: + ux2.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) + return ux2 + + +def collect_ux1_config(session: ManagerSession, progress: Callable[[str, int, int], None] = log_progress) -> UX1Config: + ux1 = UX1Config() + + """Collect Policies""" + policy_api = session.api.policy + progress("Collecting Policy Info", 0, 3) + + centralized_policy_ids = [info.policy_id for info in policy_api.centralized.get()] + progress("Collecting Policy Info", 1, 3) + + localized_policy_ids = [info.policy_id for info in policy_api.localized.get()] + progress("Collecting Policy Info", 2, 3) + + policy_definition_types_and_ids = [ + (policy_type, info.definition_id) for policy_type, info in policy_api.definitions.get_all() + ] + progress("Collecting Policy Info", 3, 3) + + policy_list_types = POLICY_LIST_ENDPOINTS_MAP.keys() + for i, policy_list_type in enumerate(policy_list_types): + ux1.policies.policy_lists.extend(policy_api.lists.get(policy_list_type)) + progress("Collecting Policy Lists", i + 1, len(policy_list_types)) + + for i, type_and_id in enumerate(policy_definition_types_and_ids): + ux1.policies.policy_definitions.append(policy_api.definitions.get(*type_and_id)) + progress("Collecting Policy Definitions", i + 1, len(policy_definition_types_and_ids)) + + for i, cpid in enumerate(centralized_policy_ids): + ux1.policies.centralized_policies.append(policy_api.centralized.get(id=cpid)) + progress("Collecting Centralized Policies", i + 1, len(centralized_policy_ids)) + + for i, lpid in enumerate(localized_policy_ids): + ux1.policies.localized_policies.append(policy_api.localized.get(id=lpid)) + progress("Collecting Localized Policies", i + 1, len(localized_policy_ids)) + + """Collect Templates""" + template_api = session.api.templates + progress("Collecting Templates Info", 0, 2) + + ux1.templates.feature_templates = [t for t in template_api.get_feature_templates()] + progress("Collecting Templates Info", 1, 2) + + ux1.templates.device_templates = [t for t in template_api.get_device_templates()] + progress("Collecting Templates Info", 2, 2) + + 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. + """ + + 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 diff --git a/endpoints-md.py b/endpoints-md.py index 409c63d8..9625f280 100644 --- a/endpoints-md.py +++ b/endpoints-md.py @@ -201,7 +201,7 @@ def md(self) -> str: # this instantiates APIEndpoints classes triggering method decorators # endpoints not attached to container will be not documented ! - _ = APIEndpointContainter(MagicMock()) + APIEndpointContainter(MagicMock()) endpoint_registry = EndpointRegistry( meta_lookup=request.request_lookup, diff --git a/examples/policies_configuration_guide.py b/examples/policies_configuration_guide.py index 33919651..1024215e 100644 --- a/examples/policies_configuration_guide.py +++ b/examples/policies_configuration_guide.py @@ -1,5 +1,3 @@ -# Copyright 2024 Cisco Systems, Inc. and its affiliates - """ This example demonstrates usage of PolicyAPI in catalystwan Code below provides same results as obtained after executing workflow manually via WEB-UI according to: @@ -22,7 +20,7 @@ import logging import sys from dataclasses import dataclass -from ipaddress import IPv4Address, IPv4Network, IPv6Network +from ipaddress import IPv4Address, IPv4Network, IPv6Interface from typing import List, Optional, Sequence from uuid import UUID @@ -48,12 +46,12 @@ PrefixList, RegionList, SiteList, - SLAClassList, TLOCList, TrafficDataPolicy, VPNList, VPNMembershipPolicy, ) +from catalystwan.models.policy.lists import SLAClassList logger = logging.getLogger(__name__) @@ -129,8 +127,8 @@ def configure_groups_of_interest(api: PolicyAPI) -> List[ConfigItem]: configured_items.append(ConfigItem(DataPrefixList, data_prefix_list.name, data_prefix_list_id)) data_ipv6_prefix_list = DataIPv6PrefixList(name="MyDataIPv6Prefixes") - data_ipv6_prefix_list.add_prefix(IPv6Network("2001:db8::1000/124")) - data_ipv6_prefix_list.add_prefix(IPv6Network("2001:db9::1000/124")) + data_ipv6_prefix_list.add_prefix(IPv6Interface("2001:db8::1000/124")) + data_ipv6_prefix_list.add_prefix(IPv6Interface("2001:db9::1000/124")) data_ipv6_prefix_list_id = api.lists.create(data_ipv6_prefix_list) configured_items.append(ConfigItem(DataIPv6PrefixList, data_ipv6_prefix_list.name, data_ipv6_prefix_list_id)) @@ -186,7 +184,7 @@ def configure_groups_of_interest(api: PolicyAPI) -> List[ConfigItem]: configured_items.append(ConfigItem(ClassMapList, class_map.name, class_map_id)) app_probe_class = AppProbeClassList(name="MyAppProbeClass") - app_probe_class.assign_forwarding_class("MyClassMap").add_color_mapping("3g", 5) + app_probe_class.assign_forwarding_class("MyClassMap").add_color_mapping("green", 5) app_probe_class_id = api.lists.create(app_probe_class) configured_items.append(ConfigItem(AppProbeClassList, app_probe_class.name, app_probe_class_id)) @@ -367,7 +365,7 @@ def create_traffic_data_policy(api: PolicyAPI, items: List[ConfigItem]) -> Confi seq_2.match_dns_response() seq_2.match_low_plp() seq_2.match_secondary_destination_region() - seq_2.match_source_data_prefix_list(find_id(items, "MyDataPrefixes")) + seq_2.match_source_data_prefix_list([find_id(items, "MyDataPrefixes")]) seq_2.match_traffic_to_core() seq_2.match_destination_data_prefix_list(find_id(items, "MyDataPrefixes")) seq_2.associate_loss_correction_packet_duplication_action() diff --git a/pyproject.toml b/pyproject.toml index b5b39e7f..dd38dee3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "catalystwan" -version = "0.31.0" +version = "0.31.0dev4" description = "Cisco Catalyst WAN SDK for Python" authors = ["kagorski "] readme = "README.md" From b01c3077bdc01e4ada574da2e288f6ed9da468e3 Mon Sep 17 00:00:00 2001 From: Jakub Krajewski Date: Thu, 7 Mar 2024 15:03:59 +0100 Subject: [PATCH 3/5] Fix import for factory method, change converter AAA --- .../feature_profile/sdwan/system/aaa.py | 9 +++------ .../converters/feature_template/aaa.py | 12 ++++++++---- .../converters/feature_template/factory_method.py | 3 ++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py b/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py index a30479fa..476bae9a 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/system/aaa.py @@ -9,9 +9,7 @@ class PubkeyChainItem(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) + model_config = ConfigDict(extra="forbid", populate_by_name=True) key_string: Global[str] = Field( validation_alias="keyString", serialization_alias="keyString", @@ -30,7 +28,6 @@ class PubkeyChainItem(BaseModel): class UserItem(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) - model_config = ConfigDict(extra="ignore", populate_by_name=True) name: Union[Global[str], Variable] = Field(description="Set the username") password: Union[Global[str], Variable] = Field( @@ -113,8 +110,7 @@ class RadiusServerItem(BaseModel): class Radius(BaseModel): - model_config = ConfigDict(extra="ignore", populate_by_name=True) - model_config = ConfigDict(extra="ignore", populate_by_name=True) + model_config = ConfigDict(extra="forbid", populate_by_name=True) group_name: Global[str] = Field( validation_alias="groupName", serialization_alias="groupName", description="Set Radius server Group Name" ) @@ -264,6 +260,7 @@ class AuthorizationRuleItem(BaseModel): class AAAParcel(_ParcelBase): type_: Literal["aaa"] = Field(default="aaa", exclude=True) + model_config = ConfigDict(extra="forbid", populate_by_name=True) authentication_group: Union[Variable, Global[bool], Default[bool]] = Field( default=as_default(False), validation_alias=AliasPath("data", "authenticationGroup"), diff --git a/catalystwan/utils/config_migration/converters/feature_template/aaa.py b/catalystwan/utils/config_migration/converters/feature_template/aaa.py index afe533c3..e3503606 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/aaa.py +++ b/catalystwan/utils/config_migration/converters/feature_template/aaa.py @@ -33,12 +33,12 @@ def assign_rules(rules: List) -> None: for rule_item in rules: rule_item["group"] = Global[List[str]](value=rule_item["group"].value.split(",")) - parcel_values = deepcopy(template_values) + parcel_values = deepcopy(template_values["aaa"]) parcel_values["parcel_name"] = name parcel_values["parcel_description"] = description - - if server_auth_order := template_values.get("server_auth_order"): - parcel_values["server_auth_order"] = Global[List[str]](value=server_auth_order.value.split(",")) + print(parcel_values) + if server_auth_order := parcel_values.get("auth_order"): + parcel_values["server_auth_order"] = server_auth_order for server in ["radius", "tacacs"]: if auth_server_list := parcel_values.get(server): @@ -56,6 +56,10 @@ def assign_rules(rules: List) -> None: "auth_type", "port", "cts_auth_list", + "auth_order", + "usergroup", + "ciscotacro_user", + "ciscotacrw_user", ]: parcel_values.pop(key, None) diff --git a/catalystwan/utils/config_migration/converters/feature_template/factory_method.py b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py index afcb71ed..b42d1ba1 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/factory_method.py +++ b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py @@ -5,7 +5,7 @@ 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 catalystwan.utils.feature_template.find_template_values import find_template_values from .aaa import AAATemplateConverter from .banner import BannerTemplateConverter @@ -76,6 +76,7 @@ def create_parcel_from_template(template: FeatureTemplateInformation) -> AnySyst converter = choose_parcel_converter(template.template_type) template_definition_as_dict = json.loads(cast(str, template.template_definiton)) template_values = find_template_values(template_definition_as_dict) + print(template_values) 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) From 2f02f7ce49888ee7d10f9955bd8aea6ba3021bf4 Mon Sep 17 00:00:00 2001 From: Jakub Krajewski Date: Thu, 7 Mar 2024 17:00:11 +0100 Subject: [PATCH 4/5] Fix AAA, NTP, Global and supported templates to transform --- .../converters/feature_template/aaa.py | 17 +++++++++++-- .../feature_template/factory_method.py | 1 - .../converters/feature_template/global_.py | 7 +----- .../converters/feature_template/ntp.py | 14 ++++++++++- catalystwan/workflows/config_migration.py | 24 ++++++++++++++++++- 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/catalystwan/utils/config_migration/converters/feature_template/aaa.py b/catalystwan/utils/config_migration/converters/feature_template/aaa.py index e3503606..4e9ac4d1 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/aaa.py +++ b/catalystwan/utils/config_migration/converters/feature_template/aaa.py @@ -33,13 +33,23 @@ def assign_rules(rules: List) -> None: for rule_item in rules: rule_item["group"] = Global[List[str]](value=rule_item["group"].value.split(",")) - parcel_values = deepcopy(template_values["aaa"]) + parcel_values = deepcopy(template_values.get("aaa", template_values)) parcel_values["parcel_name"] = name parcel_values["parcel_description"] = description - print(parcel_values) + + # Templates "aaa" and "cedge_aaa" have "auth_order" key, while "cisco_aaa" has "server_auth_order" key + if server_auth_order := parcel_values.get("server_auth_order"): + parcel_values["server_auth_order"] = Global[List[str]](value=server_auth_order.value.split(",")) + if server_auth_order := parcel_values.get("auth_order"): parcel_values["server_auth_order"] = server_auth_order + if accounting := parcel_values.get("accounting"): + parcel_values["accounting_group"] = accounting["dot1x"]["default"]["start_stop"]["accounting_group"] + + if authorization := parcel_values.get("authentication"): + parcel_values["authentication_group"] = authorization["dot1x"]["default"]["authentication_group"] + for server in ["radius", "tacacs"]: if auth_server_list := parcel_values.get(server): assign_authorization_servers(auth_server_list) @@ -60,6 +70,9 @@ def assign_rules(rules: List) -> None: "usergroup", "ciscotacro_user", "ciscotacrw_user", + "accounting", + "authentication", + "radius_trustsec", ]: parcel_values.pop(key, None) diff --git a/catalystwan/utils/config_migration/converters/feature_template/factory_method.py b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py index b42d1ba1..ed87775f 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/factory_method.py +++ b/catalystwan/utils/config_migration/converters/feature_template/factory_method.py @@ -76,7 +76,6 @@ def create_parcel_from_template(template: FeatureTemplateInformation) -> AnySyst converter = choose_parcel_converter(template.template_type) template_definition_as_dict = json.loads(cast(str, template.template_definiton)) template_values = find_template_values(template_definition_as_dict) - print(template_values) 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/global_.py b/catalystwan/utils/config_migration/converters/feature_template/global_.py index fed5222b..ade77de6 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/global_.py +++ b/catalystwan/utils/config_migration/converters/feature_template/global_.py @@ -12,9 +12,4 @@ def create_parcel(name: str, description: str, template_values: dict) -> GlobalP Returns: GlobalParcel: A GlobalParcel object with the provided template values. """ - parcel_values = { - "parcel_name": name, - "parcel_description": description, - "services_global": {"services_ip": {key: value for key, value in template_values.items()}}, - } - return GlobalParcel(**parcel_values) # type: ignore + return GlobalParcel(parcel_name=name, parcel_description=description, **template_values) # type: ignore diff --git a/catalystwan/utils/config_migration/converters/feature_template/ntp.py b/catalystwan/utils/config_migration/converters/feature_template/ntp.py index 4905acd1..a766abcf 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/ntp.py +++ b/catalystwan/utils/config_migration/converters/feature_template/ntp.py @@ -12,4 +12,16 @@ def create_parcel(name: str, description: str, template_values: dict) -> NTPParc Returns: Logging: An Logging object with the provided template values. """ - return NTPParcel(parcel_name=name, parcel_description=description, **template_values) + parcel_values = { + "parcel_name": name, + "parcel_description": description, + "server": template_values.get("server", []), + } + + if keys := template_values.get("keys", {}): + parcel_values["authentication"] = { + "authentication_keys": keys.get("authentication_keys", []), + "trusted_keys": keys.get("trusted", None), + } + + return NTPParcel(**parcel_values) diff --git a/catalystwan/workflows/config_migration.py b/catalystwan/workflows/config_migration.py index 68215f69..cbb485aa 100644 --- a/catalystwan/workflows/config_migration.py +++ b/catalystwan/workflows/config_migration.py @@ -10,7 +10,29 @@ logger = logging.getLogger(__name__) -SUPPORTED_TEMPLATE_TYPES = ["cedge_aaa"] +SUPPORTED_TEMPLATE_TYPES = [ + "cisco_aaa", + "cedge_aaa", + "aaa", + "cisco_banner", + "cisco_security", + "security", + "security-vsmart", + "security-vedge", + "cisco_system", + "system-vsmart", + "system-vedge", + "cisco_bfd", + "bfd-vedge", + "cedge_global", + "cisco_logging", + "logging", + "cisco_omp", + "omp-vedge", + "omp-vsmart", + "cisco_ntp", + "ntp", +] def log_progress(task: str, completed: int, total: int) -> None: From a4916a82ac6f676c512fa43481d77fdb7e95ce1b Mon Sep 17 00:00:00 2001 From: Jakub Krajewski Date: Thu, 7 Mar 2024 20:35:46 +0100 Subject: [PATCH 5/5] Add integration test for fast check if transformation works --- .../test_config_migration.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 catalystwan/integration_tests/test_config_migration.py diff --git a/catalystwan/integration_tests/test_config_migration.py b/catalystwan/integration_tests/test_config_migration.py new file mode 100644 index 00000000..4deac363 --- /dev/null +++ b/catalystwan/integration_tests/test_config_migration.py @@ -0,0 +1,28 @@ +import os +import unittest +from typing import cast + +from catalystwan.session import create_manager_session +from catalystwan.workflows.config_migration import collect_ux1_config, transform + + +class TestConfigMigration(unittest.TestCase): + def setUp(self) -> None: + self.session = create_manager_session( + url=cast(str, os.environ.get("TEST_VMANAGE_URL")), + port=cast(int, int(os.environ.get("TEST_VMANAGE_PORT"))), # type: ignore + username=cast(str, os.environ.get("TEST_VMANAGE_USERNAME")), + password=cast(str, os.environ.get("TEST_VMANAGE_PASSWORD")), + ) + + def test_config_migration(self): + ux1_config = collect_ux1_config(self.session) + ux2_config = transform(ux1_config) + # push_ux2_config(self.session, ux2_config) + # This section will include the Feature profiles creation + # and pushing the configuration to the vManage + assert ux2_config + + def tearDown(self) -> None: + # This section will include the Feature profiles deletion + self.session.close()