From 84b322671bc3ab73da5ec460a696ac74b8c3145a Mon Sep 17 00:00:00 2001 From: Jakub Krajewski <95274389+jpkrajewski@users.noreply.github.com> Date: Mon, 15 Apr 2024 11:34:26 +0200 Subject: [PATCH] Dev/wirelesslan (#19) * Add tests. Fix model * Add unit test * Add converter * Add model. integration tests unit test. fix model. mode Subnetmask to common * Add converter to migration list * Fix bad autocomplete --- .../feature_profile/sdwan/test_service.py | 54 +++++- catalystwan/models/common.py | 35 ++++ .../sdwan/other/thousandeyes.py | 37 +--- .../sdwan/service/dhcp_server.py | 38 +--- .../sdwan/service/wireless_lan.py | 16 +- catalystwan/tests/test_feature_profile_api.py | 2 + .../converters/feature_template/dhcp.py | 6 +- .../converters/feature_template/normalizer.py | 13 +- .../feature_template/parcel_factory.py | 2 + .../feature_template/wireless_lan.py | 178 ++++++++++++++++++ catalystwan/workflows/config_migration.py | 2 + 11 files changed, 300 insertions(+), 83 deletions(-) create mode 100644 catalystwan/utils/config_migration/converters/feature_template/wireless_lan.py diff --git a/catalystwan/integration_tests/feature_profile/sdwan/test_service.py b/catalystwan/integration_tests/feature_profile/sdwan/test_service.py index 8c8ca305..7e903bbd 100644 --- a/catalystwan/integration_tests/feature_profile/sdwan/test_service.py +++ b/catalystwan/integration_tests/feature_profile/sdwan/test_service.py @@ -1,14 +1,15 @@ from ipaddress import IPv4Address +from secrets import token_hex from uuid import UUID from catalystwan.api.configuration_groups.parcel import Global, as_global, as_variable from catalystwan.integration_tests.feature_profile.sdwan.base import TestFeatureProfileModels +from catalystwan.models.common import SubnetMask from catalystwan.models.configuration.feature_profile.common import Prefix from catalystwan.models.configuration.feature_profile.sdwan.service.acl import Ipv4AclParcel, Ipv6AclParcel from catalystwan.models.configuration.feature_profile.sdwan.service.dhcp_server import ( AddressPool, LanVpnDhcpServerParcel, - SubnetMask, ) from catalystwan.models.configuration.feature_profile.sdwan.service.eigrp import ( AddressFamily, @@ -44,6 +45,17 @@ SwitchportMode, SwitchportParcel, ) +from catalystwan.models.configuration.feature_profile.sdwan.service.wireless_lan import ( + SSID, + CountryCode, + MeIpConfig, + MeStaticIpConfig, + QosProfile, + RadioType, + SecurityConfig, + SecurityType, + WirelessLanParcel, +) class TestServiceFeatureProfileModels(TestFeatureProfileModels): @@ -229,6 +241,46 @@ def test_when_fully_specified_values_switchport_expect_successful_post(self): # Assert assert parcel_id + def test_when_fully_specified_values_wireless_lan_expect_successful_post(self): + # Arrange + wireless_lan_parcel = WirelessLanParcel( + parcel_name="TestWirelessLanParcel", + parcel_description="Test Wireless Lan Parcel", + enable_2_4G=as_global(True), + enable_5G=as_global(True), + country=as_global("US", CountryCode), + username=as_global("admin"), + password=as_global(token_hex(16) + "TEST!@#"), + ssid=[ + SSID( + name=as_global("TestSSID"), + admin_state=as_global(True), + vlan_id=as_global(1), + broadcast_ssid=as_global(True), + radio_type=as_global("all", RadioType), + qos_profile=as_global("platinum", QosProfile), + security_config=SecurityConfig( + security_type=as_global("enterprise", SecurityType), + radius_server_ip=as_global(IPv4Address("1.1.1.1")), + radius_server_port=as_global(1884), + radius_server_secret=as_global("23452345245"), + ), + ) + ], + me_ip_config=MeIpConfig( + me_dynamic_ip_enabled=as_global(False), + me_static_ip_config=MeStaticIpConfig( + me_ipv4_address=as_global(IPv4Address("10.2.3.2")), + netmask=as_global("255.255.255.0", SubnetMask), + default_gateway=as_global(IPv4Address("10.0.0.1")), + ), + ), + ) + # Act + parcel_id = self.api.create_parcel(self.profile_uuid, wireless_lan_parcel).id + # Assert + assert parcel_id + @classmethod def tearDownClass(cls) -> None: cls.api.delete_profile(cls.profile_uuid) diff --git a/catalystwan/models/common.py b/catalystwan/models/common.py index f5fa0e97..b8f7cc29 100644 --- a/catalystwan/models/common.py +++ b/catalystwan/models/common.py @@ -181,3 +181,38 @@ def str_as_str_list(val: Union[str, Sequence[str]]) -> Sequence[str]: ] MetricType = Literal["type1", "type2"] + +SubnetMask = Literal[ + "255.255.255.255", + "255.255.255.254", + "255.255.255.252", + "255.255.255.248", + "255.255.255.240", + "255.255.255.224", + "255.255.255.192", + "255.255.255.128", + "255.255.255.0", + "255.255.254.0", + "255.255.252.0", + "255.255.248.0", + "255.255.240.0", + "255.255.224.0", + "255.255.192.0", + "255.255.128.0", + "255.255.0.0", + "255.254.0.0", + "255.252.0.0", + "255.240.0.0", + "255.224.0.0", + "255.192.0.0", + "255.128.0.0", + "255.0.0.0", + "254.0.0.0", + "252.0.0.0", + "248.0.0.0", + "240.0.0.0", + "224.0.0.0", + "192.0.0.0", + "128.0.0.0", + "0.0.0.0", +] diff --git a/catalystwan/models/configuration/feature_profile/sdwan/other/thousandeyes.py b/catalystwan/models/configuration/feature_profile/sdwan/other/thousandeyes.py index cc96d54a..4a7a17e2 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/other/thousandeyes.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/other/thousandeyes.py @@ -6,44 +6,11 @@ from pydantic import AliasPath, BaseModel, ConfigDict, Field from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase, as_global, as_variable +from catalystwan.models.common import SubnetMask ProxyTypeStatic = Literal["static"] ProxyTypePac = Literal["pac"] ProxyTypeNone = Literal["none"] -TeMgmtSubnetMask = Literal[ - "255.255.255.255", - "255.255.255.254", - "255.255.255.252", - "255.255.255.248", - "255.255.255.240", - "255.255.255.224", - "255.255.255.192", - "255.255.255.128", - "255.255.255.0", - "255.255.254.0", - "255.255.252.0", - "255.255.248.0", - "255.255.240.0", - "255.255.224.0", - "255.255.192.0", - "255.255.128.0", - "255.255.0.0", - "255.254.0.0", - "255.252.0.0", - "255.240.0.0", - "255.224.0.0", - "255.192.0.0", - "255.128.0.0", - "255.0.0.0", - "254.0.0.0", - "252.0.0.0", - "248.0.0.0", - "240.0.0.0", - "224.0.0.0", - "192.0.0.0", - "128.0.0.0", - "0.0.0.0", -] class ProxyConfigStatic(BaseModel): @@ -135,7 +102,7 @@ class VirtualApplicationItem(BaseModel): validation_alias="teMgmtIp", description="Set the Agent IP Address", ) - te_mgmt_subnet_mask: Optional[Union[Variable, Global[TeMgmtSubnetMask]]] = Field( + te_mgmt_subnet_mask: Optional[Union[Variable, Global[SubnetMask]]] = Field( default=None, serialization_alias="teMgmtSubnetMask", validation_alias="teMgmtSubnetMask", diff --git a/catalystwan/models/configuration/feature_profile/sdwan/service/dhcp_server.py b/catalystwan/models/configuration/feature_profile/sdwan/service/dhcp_server.py index 1bee6075..c045ee4e 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/service/dhcp_server.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/service/dhcp_server.py @@ -9,42 +9,8 @@ from pydantic import AliasPath, BaseModel, ConfigDict, Field, field_validator, model_validator from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, _ParcelBase -from catalystwan.models.common import check_fields_exclusive - -SubnetMask = Literal[ - "255.255.255.255", - "255.255.255.254", - "255.255.255.252", - "255.255.255.248", - "255.255.255.240", - "255.255.255.224", - "255.255.255.192", - "255.255.255.128", - "255.255.255.0", - "255.255.254.0", - "255.255.252.0", - "255.255.248.0", - "255.255.240.0", - "255.255.224.0", - "255.255.192.0", - "255.255.128.0", - "255.255.0.0", - "255.254.0.0", - "255.252.0.0", - "255.240.0.0", - "255.224.0.0", - "255.192.0.0", - "255.128.0.0", - "255.0.0.0", - "254.0.0.0", - "252.0.0.0", - "248.0.0.0", - "240.0.0.0", - "224.0.0.0", - "192.0.0.0", - "128.0.0.0", - "0.0.0.0", -] +from catalystwan.models.common import SubnetMask, check_fields_exclusive + MAC_PATTERN_1 = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$") MAC_PATTERN_2 = re.compile(r"^[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}$") diff --git a/catalystwan/models/configuration/feature_profile/sdwan/service/wireless_lan.py b/catalystwan/models/configuration/feature_profile/sdwan/service/wireless_lan.py index 55431ce4..db2d227a 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/service/wireless_lan.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/service/wireless_lan.py @@ -1,10 +1,12 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates +from ipaddress import IPv4Address 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 +from catalystwan.models.common import SubnetMask CountryCode = Literal[ "AE", @@ -137,9 +139,11 @@ class MeStaticIpConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") - me_ipv4_address: Union[Global[str], Variable] - netmask: Union[Global[str], Variable] - default_gateway: Union[Global[str], Variable] = Field( + me_ipv4_address: Union[Global[IPv4Address], Variable] = Field( + serialization_alias="meIpv4Address", validation_alias="meIpv4Address" + ) + netmask: Union[Global[SubnetMask], Variable] + default_gateway: Union[Global[IPv4Address], Variable] = Field( serialization_alias="defaultGateway", validation_alias="defaultGateway" ) @@ -152,14 +156,16 @@ class MeIpConfig(BaseModel): validation_alias="meDynamicIpEnabled", default=Default[bool](value=True), ) - me_static_ip_config: Optional[MeStaticIpConfig] = None + me_static_ip_config: Optional[MeStaticIpConfig] = Field( + default=None, serialization_alias="meStaticIpCfg", validation_alias="meStaticIpCfg" + ) class SecurityConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True, extra="forbid") security_type: Global[SecurityType] = Field(serialization_alias="securityType", validation_alias="securityType") - radius_server_ip: Optional[Union[Global[str], Variable]] = Field( + radius_server_ip: Optional[Union[Global[IPv4Address], Variable]] = Field( serialization_alias="radiusServerIp", validation_alias="radiusServerIp", default=None ) radius_server_port: Optional[Union[Global[int], Variable, Default[int]]] = Field( diff --git a/catalystwan/tests/test_feature_profile_api.py b/catalystwan/tests/test_feature_profile_api.py index d955a1e3..7007a063 100644 --- a/catalystwan/tests/test_feature_profile_api.py +++ b/catalystwan/tests/test_feature_profile_api.py @@ -26,6 +26,7 @@ from catalystwan.models.configuration.feature_profile.sdwan.service.lan.ipsec import IpsecAddress, IpsecTunnelMode from catalystwan.models.configuration.feature_profile.sdwan.service.ospfv3 import Ospfv3IPv4Parcel, Ospfv3IPv6Parcel from catalystwan.models.configuration.feature_profile.sdwan.service.route_policy import RoutePolicyParcel +from catalystwan.models.configuration.feature_profile.sdwan.service.wireless_lan import WirelessLanParcel from catalystwan.models.configuration.feature_profile.sdwan.system import ( AAAParcel, BannerParcel, @@ -117,6 +118,7 @@ def test_update_method_with_valid_arguments(self, parcel, expected_path): Ipv6AclParcel: "ipv6-acl", Ipv4AclParcel: "ipv4-acl", SwitchportParcel: "switchport", + WirelessLanParcel: "wirelesslan", } service_interface_parcels = [ diff --git a/catalystwan/utils/config_migration/converters/feature_template/dhcp.py b/catalystwan/utils/config_migration/converters/feature_template/dhcp.py index 5c28e7a5..8ca1f529 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/dhcp.py +++ b/catalystwan/utils/config_migration/converters/feature_template/dhcp.py @@ -4,10 +4,8 @@ from typing import List from catalystwan.api.configuration_groups.parcel import Global, Variable, as_global, as_variable -from catalystwan.models.configuration.feature_profile.sdwan.service.dhcp_server import ( - LanVpnDhcpServerParcel, - SubnetMask, -) +from catalystwan.models.common import SubnetMask +from catalystwan.models.configuration.feature_profile.sdwan.service.dhcp_server import LanVpnDhcpServerParcel logger = logging.getLogger(__name__) diff --git a/catalystwan/utils/config_migration/converters/feature_template/normalizer.py b/catalystwan/utils/config_migration/converters/feature_template/normalizer.py index 3c318685..1c9743c6 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/normalizer.py +++ b/catalystwan/utils/config_migration/converters/feature_template/normalizer.py @@ -2,8 +2,7 @@ from typing import List, Optional, Union, get_args from catalystwan.api.configuration_groups.parcel import Global, as_global -from catalystwan.models.common import MetricType, TLOCColor -from catalystwan.models.configuration.feature_profile.sdwan.service.dhcp_server import SubnetMask +from catalystwan.models.common import MetricType, SubnetMask, TLOCColor from catalystwan.models.configuration.feature_profile.sdwan.service.lan.common import ( IkeCiphersuite, IkeMode, @@ -30,6 +29,12 @@ PortControl, SwitchportMode, ) +from catalystwan.models.configuration.feature_profile.sdwan.service.wireless_lan import ( + CountryCode, + QosProfile, + RadioType, + SecurityType, +) from catalystwan.models.configuration.feature_profile.sdwan.system.logging_parcel import ( AuthType, CypherSuite, @@ -71,6 +76,10 @@ PortControl, HostMode, ControlDirection, + CountryCode, + RadioType, + QosProfile, + SecurityType, ] CastedTypes = Union[ diff --git a/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py b/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py index b62ecaa2..94b058a0 100644 --- a/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py +++ b/catalystwan/utils/config_migration/converters/feature_template/parcel_factory.py @@ -9,6 +9,7 @@ from catalystwan.utils.config_migration.converters.feature_template.ethernet import InterfaceEthernetTemplateConverter from catalystwan.utils.config_migration.converters.feature_template.snmp import SNMPTemplateConverter from catalystwan.utils.config_migration.converters.feature_template.switchport import SwitchportTemplateConverter +from catalystwan.utils.config_migration.converters.feature_template.wireless_lan import WirelessLanTemplateConverter from catalystwan.utils.feature_template.find_template_values import find_template_values from .aaa import AAATemplateConverter @@ -59,6 +60,7 @@ OspfTemplateConverter, Ospfv3TemplateConverter, SwitchportTemplateConverter, + WirelessLanTemplateConverter, ] diff --git a/catalystwan/utils/config_migration/converters/feature_template/wireless_lan.py b/catalystwan/utils/config_migration/converters/feature_template/wireless_lan.py new file mode 100644 index 00000000..86baa144 --- /dev/null +++ b/catalystwan/utils/config_migration/converters/feature_template/wireless_lan.py @@ -0,0 +1,178 @@ +from copy import deepcopy +from ipaddress import IPv4Address +from typing import Literal, Union + +from catalystwan.api.configuration_groups.parcel import Default, Global, OptionType, as_default, as_global +from catalystwan.models.common import SubnetMask +from catalystwan.models.configuration.feature_profile.sdwan.service.wireless_lan import ( + SSID, + CountryCode, + MeIpConfig, + MeStaticIpConfig, + QosProfile, + RadioType, + SecurityConfig, + WirelessLanParcel, +) +from catalystwan.utils.config_migration.converters.exceptions import CatalystwanConverterCantConvertException + + +class WirelessLanTemplateConverter: + supported_template_types = ("cisco_wireless_lan",) + + delete_keys = ("radio", "mgmt") + + def create_parcel(self, name: str, description: str, template_values: dict) -> WirelessLanParcel: + values = self.prepare_values(template_values) + self.configure_enable_radio(values) + self.configure_username_and_password(values) + self.configure_ssid(values) + self.configure_me_ip_address(values) + self.cleanup_keys(values) + return WirelessLanParcel(parcel_name=name, parcel_description=description, **values) + + def prepare_values(self, template_values: dict) -> dict: + return deepcopy(template_values) + + def configure_enable_radio(self, values: dict) -> None: + self._configure_enable(values, "shutdown_2.4ghz", "enable_2_4G") + self._configure_enable(values, "shutdown_5ghz", "enable_5G") + + def _configure_enable(self, values: dict, feature_template_key: str, ux2_model_field: str) -> None: + """Logic in the Feature Template is inverted, so we need to invert it here""" + shutdown = values.get("radio", {}).get(feature_template_key, as_default(True)) + if shutdown.option_type == OptionType.GLOBAL: + shutdown.value = not shutdown.value + values[ux2_model_field] = shutdown + + def configure_username_and_password(self, values: dict) -> None: + values["username"] = values.get("mgmt", {}).get("username") + values["password"] = values.get("mgmt", {}).get("password") + + def configure_ssid(self, values: dict) -> None: + ssid = values.get("ssid", []) + ssid_list = [] + for entry in ssid: + ssid_list.append( + SSID( + name=entry.get("name"), + admin_state=entry.get("admin_state", as_default(True)), + broadcast_ssid=entry.get("broadcast_ssid", as_default(True)), + vlan_id=entry.get("vlan_id"), + radio_type=entry.get("radio_type", as_default("all", RadioType)), + security_config=self._prepare_security_config(entry), + qos_profile=self._get_qos_profile(entry), + ) + ) + values["ssid"] = ssid_list + + def configure_me_ip_address(self, values: dict) -> None: + address = values.get("mgmt", {}).get("address") + netmask = values.get("mgmt", {}).get("netmask") + default_gateway = values.get("mgmt", {}).get("default_gateway") + if not address or not netmask or not default_gateway: + values["me_ip_config"] = MeIpConfig(me_dynamic_ip_enabled=as_default(True)) + else: + values["me_ip_config"] = MeIpConfig( + me_dynamic_ip_enabled=as_default(False), + me_static_ip_config=MeStaticIpConfig( + me_ipv4_address=address, + netmask=netmask, + default_gateway=default_gateway, + ), + ) + + def _prepare_security_config(self, entry: dict) -> SecurityConfig: + security_type = entry.get("security_type") + if not security_type: + # This field is required and model can't be created without it + raise CatalystwanConverterCantConvertException("Security type is required for SSID configuration") + return SecurityConfig( + security_type=security_type, + passphrase=entry.get("passphrase"), + radius_server_ip=entry.get("radius_server_ip"), + radius_server_port=entry.get("radius_server_port"), + radius_server_secret=entry.get("radius_server_secret"), + ) + + def _get_qos_profile(self, values: dict) -> Union[Global[QosProfile], Default[QosProfile]]: + qos_profile = values.get("qos_profile", as_default("silver", QosProfile)) + if qos_profile.option_type == OptionType.GLOBAL: + qos_profile = as_global(qos_profile.value, QosProfile) + return qos_profile + + def cleanup_keys(self, values: dict) -> None: + for key in self.delete_keys: + values.pop(key, None) + + @staticmethod + def get_example_payload() -> dict: + payload = { + "radio": { + "shutdown_2.4ghz": Global[bool](value=True), + "shutdown_5ghz": Global[bool](value=True), + }, + "country": Global[CountryCode](value="BB"), + "mgmt": { + "username": Global[str](value="23452345"), + "password": Global[str](value="42$qsSdas!321"), + "address": Global[IPv4Address](value=IPv4Address("2.3.2.1")), + "netmask": Global[SubnetMask](value="255.255.255.0"), + "default_gateway": Global[IPv4Address](value=IPv4Address("10.0.0.1")), + }, + "ssid": [ + { + "name": Global[str](value="5345"), + "admin_state": Global[bool](value=True), + "broadcast_ssid": Global[bool](value=True), + "vlan_id": Global[int](value=44), + "radio_type": Global[Literal["all", "24ghz", "5ghz"]](value="24ghz"), + "security_type": Global[Literal["open", "personal", "enterprise"]](value="enterprise"), + "radius_server_ip": Global[IPv4Address](value=IPv4Address("43.3.1.2")), + "radius_server_port": Global[int](value=333), + "radius_server_secret": Global[str](value="2323232323"), + "qos_profile": Global[Literal["platinum", "gold", "silver", "bronze"]](value="platinum"), + }, + { + "name": Global[str](value="555"), + "admin_state": Global[bool](value=False), + "broadcast_ssid": Global[bool](value=False), + "vlan_id": Global[int](value=234), + "radio_type": Global[Literal["all", "24ghz", "5ghz"]](value="5ghz"), + "security_type": Global[Literal["open", "personal", "enterprise"]](value="personal"), + "passphrase": Global[str](value="33123123123"), + "qos_profile": Global[ + Literal[ + "default", + "mpls", + "metro-ethernet", + "biz-internet", + "public-internet", + "lte", + "3g", + "red", + "green", + "blue", + "gold", + "silver", + "bronze", + "custom1", + "custom2", + "custom3", + "private1", + "private2", + "private3", + "private4", + "private5", + "private6", + ] + ](value="gold"), + }, + { + "name": Global[str](value="12312"), + "vlan_id": Global[int](value=123), + "security_type": Global[Literal["open", "personal", "enterprise"]](value="open"), + }, + ], + } + return payload diff --git a/catalystwan/workflows/config_migration.py b/catalystwan/workflows/config_migration.py index 889a62b9..4b83a6d8 100644 --- a/catalystwan/workflows/config_migration.py +++ b/catalystwan/workflows/config_migration.py @@ -60,6 +60,7 @@ "vpn-interface-svi", "cisco_ospf", "switchport", + "cisco_wireless_lan", ] FEATURE_PROFILE_SYSTEM = [ @@ -107,6 +108,7 @@ "vpn-interface-svi", "cisco_ospf", "switchport", + "cisco_wireless_lan", ]