diff --git a/catalystwan/api/configuration_groups/parcel.py b/catalystwan/api/configuration_groups/parcel.py index 6e1d2f6d..4c6d7bc3 100644 --- a/catalystwan/api/configuration_groups/parcel.py +++ b/catalystwan/api/configuration_groups/parcel.py @@ -1,4 +1,4 @@ -# Copyright 2023 Cisco Systems, Inc. and its affiliates +# Copyright 2024 Cisco Systems, Inc. and its affiliates from enum import Enum from typing import Any, Dict, Generic, List, Literal, Optional, Tuple, TypeVar, get_origin diff --git a/catalystwan/endpoints/configuration/network_hierarchy.py b/catalystwan/endpoints/configuration/network_hierarchy.py index bf65cf32..a67f2352 100644 --- a/catalystwan/endpoints/configuration/network_hierarchy.py +++ b/catalystwan/endpoints/configuration/network_hierarchy.py @@ -1,8 +1,12 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates # mypy: disable-error-code="empty-body" -from catalystwan.endpoints import APIEndpoints, get, versions -from catalystwan.models.configuration.network_hierarchy import NodeInfo +from uuid import UUID + +from catalystwan.endpoints import APIEndpoints, delete, get, post, versions +from catalystwan.models.configuration.feature_profile.parcel import Parcel, ParcelCreationResponse +from catalystwan.models.configuration.network_hierarchy.cflowd import CflowdParcel +from catalystwan.models.configuration.network_hierarchy.node import NodeInfo from catalystwan.typed_list import DataSequence @@ -11,3 +15,18 @@ class NetworkHierarchy(APIEndpoints): @versions(">=20.10") def list_nodes(self) -> DataSequence[NodeInfo]: ... + + @post("/v1/network-hierarchy/{node_id}/network-settings/cflowd") + @versions(">20.12") + def create_cflowd(self, node_id: UUID, payload: CflowdParcel) -> ParcelCreationResponse: + ... + + @get("/v1/network-hierarchy/{node_id}/network-settings/cflowd", resp_json_key="data") + @versions(">20.12") + def get_cflowd(self, node_id: UUID) -> DataSequence[Parcel[CflowdParcel]]: + ... + + @delete("/v1/network-hierarchy/{node_id}/network-settings/cflowd/{parcel_id}") + @versions(">20.12") + def delete_cflowd(self, node_id: UUID, parcel_id: UUID) -> None: + ... diff --git a/catalystwan/integration_tests/base.py b/catalystwan/integration_tests/base.py index d4fc66dd..44e0f54a 100644 --- a/catalystwan/integration_tests/base.py +++ b/catalystwan/integration_tests/base.py @@ -1,6 +1,5 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates import logging -import os import unittest from typing import Union from uuid import UUID, uuid4 @@ -25,21 +24,27 @@ RUN_ID: str = str(uuid4())[:4] -def create_session() -> ManagerSession: - """Try to create a session with the environment variables, if it fails, raise an exception""" +def load_config() -> dict: + """Load the configuration from the environment variables""" url = os.environ.get("VMANAGE_URL") port = os.environ.get("VMANAGE_PORT") username = os.environ.get("VMANAGE_USERNAME") password = os.environ.get("VMANAGE_PASSWORD") if url is None or port is None or username is None or password is None: raise CatalystwanException("Missing environment variables") + return dict( + url=url, + port=port, + username=username, + password=password, + ) + + + +def create_session() -> ManagerSession: + """Try to create a session with the environment variables, if it fails, raise an exception""" try: - session = create_manager_session( - url=url, - port=int(port), - username=username, - password=password, - ) + session = create_manager_session(**load_config()) session.is_for_testing = True return session except Exception: diff --git a/catalystwan/integration_tests/test_network_hierarchy.py b/catalystwan/integration_tests/test_network_hierarchy.py new file mode 100644 index 00000000..5e145eb9 --- /dev/null +++ b/catalystwan/integration_tests/test_network_hierarchy.py @@ -0,0 +1,82 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +import unittest +from typing import Optional +from uuid import UUID + +from catalystwan.integration_tests.base import IS_API_20_12, TestCaseBase +from catalystwan.models.configuration.network_hierarchy.cflowd import CflowdParcel + + +@unittest.skipIf(IS_API_20_12, "cflowd is not supported in 20.12") +class TestCflowd(TestCaseBase): + parcel_id: Optional[UUID] = None + global_node_id: UUID + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + nodes = cls.session.endpoints.network_hierarchy.list_nodes() + for node in nodes: + if node.data.label == "GLOBAL": + cls.global_node_id = UUID(node.id) + return + raise ValueError("Global node not found") + + def test_create_cflowd(self): + # Arrange + protocol = "ipv4" + active_timeout = 1000 + inactive_timeout = 3000 + refresh_time = 5000 + sampling_interval = 6000 + collect_tos = False + collect_dscp_output = True + vpn_id = 50 + address = "10.0.2.3" + port = 9900 + export_spread = True + bfd_metrics_export = True + export_interval = 1000 + cflowd = CflowdParcel() + cflowd.add_collector( + address=address, + bfd_metrics_export=bfd_metrics_export, + export_interval=export_interval, + export_spread=export_spread, + udp_port=port, + vpn_id=vpn_id, + ) + cflowd.set_customized_ipv4_record_fields(collect_tos=collect_tos, collect_dscp_output=collect_dscp_output) + cflowd.set_flow( + active_timeout=active_timeout, + inactive_timeout=inactive_timeout, + refresh_time=refresh_time, + sampling_interval=sampling_interval, + ) + cflowd.set_protocol(protocol) + # Act + self.parcel_id = self.session.endpoints.network_hierarchy.create_cflowd(self.global_node_id, cflowd).id + parcel = ( + self.session.endpoints.network_hierarchy.get_cflowd(self.global_node_id) + .find(parcel_id=self.parcel_id) + .payload + ) + # Assert + assert parcel.protocol.value == protocol + assert parcel.flow_active_timeout.value == active_timeout + assert parcel.flow_inactive_timeout.value == inactive_timeout + assert parcel.flow_refresh_time.value == refresh_time + assert parcel.flow_sampling_interval.value == sampling_interval + assert parcel.customized_ipv4_record_fields.collect_dscp_output.value == collect_dscp_output + assert parcel.customized_ipv4_record_fields.collect_tos.value == collect_tos + assert parcel.collectors[0].vpn_id.value == vpn_id + assert parcel.collectors[0].address.value == address + assert parcel.collectors[0].udp_port.value == port + assert parcel.collectors[0].bfd_metrics_export.value == bfd_metrics_export + assert parcel.collectors[0].export_interval.value == export_interval + assert parcel.collectors[0].export_spread.value == export_spread + + def tearDown(self) -> None: + if self.parcel_id: + self.session.endpoints.network_hierarchy.delete_cflowd(self.global_node_id, self.parcel_id) + super().tearDown() diff --git a/catalystwan/models/configuration/config_migration.py b/catalystwan/models/configuration/config_migration.py index 2420577f..8deff431 100644 --- a/catalystwan/models/configuration/config_migration.py +++ b/catalystwan/models/configuration/config_migration.py @@ -23,7 +23,7 @@ from catalystwan.models.configuration.feature_profile.parcel import AnyParcel, list_types from catalystwan.models.configuration.feature_profile.sdwan.policy_object import AnyPolicyObjectParcel from catalystwan.models.configuration.feature_profile.sdwan.service.lan.vpn import LanVpnParcel -from catalystwan.models.configuration.network_hierarchy import NodeInfo +from catalystwan.models.configuration.network_hierarchy.node import NodeInfo from catalystwan.models.configuration.topology_group import TopologyGroup from catalystwan.models.policy import AnyPolicyDefinitionInfo, AnyPolicyListInfo, URLAllowListInfo, URLBlockListInfo from catalystwan.models.policy.centralized import CentralizedPolicyInfo diff --git a/catalystwan/models/configuration/feature_profile/common.py b/catalystwan/models/configuration/feature_profile/common.py index 2d902d76..aca00314 100644 --- a/catalystwan/models/configuration/feature_profile/common.py +++ b/catalystwan/models/configuration/feature_profile/common.py @@ -1,6 +1,7 @@ # Copyright 2024 Cisco Systems, Inc. and its affiliates from datetime import datetime +from functools import wraps from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface from typing import List, Literal, Optional, Union from uuid import UUID @@ -8,7 +9,14 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from typing_extensions import Self -from catalystwan.api.configuration_groups.parcel import Default, Global, Variable, as_default, as_global +from catalystwan.api.configuration_groups.parcel import ( + Default, + Global, + Variable, + as_default, + as_global, + as_optional_global, +) from catalystwan.models.common import ( CableLengthLongValue, CableLengthShortValue, @@ -817,3 +825,14 @@ class MultilinkNimList(BaseModel): "time-exceeded", "unreachable", ] + + +def arguments_as_optional_global(func): + wraps(func) + + def wrapper(self, *args, **kwargs): + new_args = [as_optional_global(a) for a in args] + new_kwargs = {k: as_optional_global(v) for k, v in kwargs.items()} + return func(self, *new_args, **new_kwargs) + + return wrapper diff --git a/catalystwan/models/configuration/feature_profile/parcel.py b/catalystwan/models/configuration/feature_profile/parcel.py index bd55631f..f78601da 100644 --- a/catalystwan/models/configuration/feature_profile/parcel.py +++ b/catalystwan/models/configuration/feature_profile/parcel.py @@ -1,6 +1,6 @@ -# Copyright 2023 Cisco Systems, Inc. and its affiliates +# Copyright 2024 Cisco Systems, Inc. and its affiliates from functools import lru_cache -from typing import Generic, List, Literal, Sequence, TypeVar, Union, cast +from typing import Generic, List, Literal, Optional, Sequence, TypeVar, Union, cast from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -18,6 +18,7 @@ from catalystwan.models.configuration.feature_profile.sdwan.system import AnySystemParcel from catalystwan.models.configuration.feature_profile.sdwan.topology import AnyTopologyParcel from catalystwan.models.configuration.feature_profile.sdwan.transport import AnyTransportParcel +from catalystwan.models.configuration.network_hierarchy import AnyNetworkHierarchy from catalystwan.utils.model import resolve_nested_base_model_unions ParcelType = Literal[ @@ -31,6 +32,7 @@ "bfd", "bgp", "cellular-controller", + "cflowd", "class", "color", "config", @@ -128,6 +130,7 @@ AnyApplicationPriorityParcel, AnyTopologyParcel, AnyRoutingParcel, + AnyNetworkHierarchy, ], Field(discriminator="type_"), ] @@ -136,21 +139,31 @@ class Parcel(BaseModel, Generic[T]): - parcel_id: Union[str, UUID] = Field(alias="parcelId") + parcel_id: UUID = Field(alias="parcelId") parcel_type: ParcelType = Field(alias="parcelType") created_by: str = Field(alias="createdBy") - last_updated_by: str = Field(alias="lastUpdatedBy") + last_updated_by: Optional[str] = Field(default=None, alias="lastUpdatedBy") created_on: int = Field(alias="createdOn") - last_updated_on: int = Field(alias="lastUpdatedOn") + last_updated_on: Optional[int] = Field(default=None, alias="lastUpdatedOn") payload: T @model_validator(mode="before") def validate_payload(cls, data): if not isinstance(data, dict): return data - data["payload"]["type_"] = data["parcelType"] + type_ = data.get("parcelType") or data.get("type") + id_ = data.get("parcelId") or data.get("id") + assert type_ is not None + assert id_ is not None + data["parcelType"] = type_ + data["payload"]["type_"] = type_ + data["parcelId"] = id_ return data + def model_post_init(self, __context): + """We do not want to store the JSON as dict data in the 'data' field, as it is already deserialized""" + self.payload.data = None + class Header(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/catalystwan/models/configuration/feature_profile/sdwan/acl/ipv4acl.py b/catalystwan/models/configuration/feature_profile/sdwan/acl/ipv4acl.py index 762a751b..bbdd49bc 100644 --- a/catalystwan/models/configuration/feature_profile/sdwan/acl/ipv4acl.py +++ b/catalystwan/models/configuration/feature_profile/sdwan/acl/ipv4acl.py @@ -1,3 +1,4 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates from ipaddress import IPv4Address, IPv4Interface from typing import List, Literal, Optional, Tuple, Union from uuid import UUID @@ -313,7 +314,7 @@ class Ipv4AclParcel(_ParcelBase): default=Default[Literal["drop"]](value="drop"), validation_alias=AliasPath("data", "defaultAction") ) sequences: List[Sequence] = Field( - default=[], validation_alias=AliasPath("data", "sequences"), description="Access Control List" + default_factory=list, validation_alias=AliasPath("data", "sequences"), description="Access Control List" ) def set_default_action(self, action: AcceptDropActionType): diff --git a/catalystwan/models/configuration/network_hierarchy/__init__.py b/catalystwan/models/configuration/network_hierarchy/__init__.py new file mode 100644 index 00000000..fdd0fe3c --- /dev/null +++ b/catalystwan/models/configuration/network_hierarchy/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +from typing import List, Union + +from pydantic import Field +from typing_extensions import Annotated + +from .cflowd import CflowdParcel +from .node import NodeInfo + +AnyNetworkHierarchy = Annotated[ + Union[CflowdParcel], + Field(discriminator="type_"), +] + +__all__ = [ + "CflowdParcel", + "NodeInfo", +] + + +def __dir__() -> "List[str]": + return list(__all__) diff --git a/catalystwan/models/configuration/network_hierarchy/cflowd.py b/catalystwan/models/configuration/network_hierarchy/cflowd.py new file mode 100644 index 00000000..e62d24bf --- /dev/null +++ b/catalystwan/models/configuration/network_hierarchy/cflowd.py @@ -0,0 +1,122 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +import typing +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.configuration.feature_profile.common import arguments_as_optional_global + +Protocol = Literal[ + "both", + "ipv4", + "ipv6", +] + + +class CustomizedIpv4RecordFields(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + collect_dscp_output: Optional[Global[bool]] = Field( + default=Global[bool](value=False), validation_alias="collectDscpOutput", serialization_alias="collectDscpOutput" + ) + collect_tos: Optional[Global[bool]] = Field( + default=Global[bool](value=False), validation_alias="collectTos", serialization_alias="collectTos" + ) + + +class Collectors(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + address: Optional[Global[str]] = Field(default=None) + bfd_metrics_export: Optional[Global[bool]] = Field( + default=Global[bool](value=False), validation_alias="bfdMetricsExport", serialization_alias="bfdMetricsExport" + ) + export_interval: Optional[Global[int]] = Field( + default=Global[int](value=600), validation_alias="exportInterval", serialization_alias="exportInterval" + ) + export_spread: Optional[Global[bool]] = Field( + default=Global[bool](value=False), validation_alias="exportSpread", serialization_alias="exportSpread" + ) + udp_port: Optional[Global[int]] = Field( + default=Global[int](value=4739), validation_alias="udpPort", serialization_alias="udpPort" + ) + vpn_id: Optional[Global[int]] = Field(default=None, validation_alias="vpnId", serialization_alias="vpnId") + + +class CflowdParcel(_ParcelBase): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + type_: Literal["cflowd"] = Field(default="cflowd", exclude=True) + parcel_name: Optional[str] = Field(default=None, description="This parcel does not have this field") # type: ignore + collect_tloc_loopback: Optional[Global[bool]] = Field( + default=Global[bool](value=False), validation_alias=AliasPath("data", "collectTlocLoopback") + ) + collectors: Optional[List[Collectors]] = Field( + default=None, description="Collectors list", validation_alias=AliasPath("data", "collectors") + ) + customized_ipv4_record_fields: Optional[CustomizedIpv4RecordFields] = Field( + default=None, + validation_alias=AliasPath("data", "customizedIpv4RecordFields"), + description="Custom IPV4 flow record fields", + ) + flow_active_timeout: Optional[Global[int]] = Field( + default=Global[int](value=600), validation_alias=AliasPath("data", "flowActiveTimeout") + ) + flow_inactive_timeout: Optional[Global[int]] = Field( + default=Global[int](value=60), validation_alias=AliasPath("data", "flowInactiveTimeout") + ) + flow_refresh_time: Optional[Global[int]] = Field( + default=Global[int](value=600), validation_alias=AliasPath("data", "flowRefreshTime") + ) + flow_sampling_interval: Optional[Global[int]] = Field( + default=Global[int](value=1), validation_alias=AliasPath("data", "flowSamplingInterval") + ) + protocol: Optional[Global[Protocol]] = Field(default=None, validation_alias=AliasPath("data", "protocol")) + + @typing.no_type_check + @arguments_as_optional_global + def add_collector( + self, + address: Optional[str] = None, + bfd_metrics_export: Optional[bool] = False, + export_interval: Optional[int] = 600, + export_spread: Optional[bool] = False, + udp_port: Optional[int] = 4739, + vpn_id: Optional[int] = None, + ): + if self.collectors is None: + self.collectors = [] + self.collectors.append( + Collectors( + address=address, + udp_port=udp_port, + vpn_id=vpn_id, + export_spread=export_spread, + bfd_metrics_export=bfd_metrics_export, + export_interval=export_interval, + ) + ) + + @typing.no_type_check + @arguments_as_optional_global + def set_customized_ipv4_record_fields( + self, collect_dscp_output: Optional[bool] = False, collect_tos: Optional[bool] = False + ): + self.customized_ipv4_record_fields = CustomizedIpv4RecordFields( + collect_dscp_output=collect_dscp_output, collect_tos=collect_tos + ) + + @typing.no_type_check + @arguments_as_optional_global + def set_flow( + self, + active_timeout: Optional[int] = 600, + inactive_timeout: Optional[int] = 60, + refresh_time: Optional[int] = 600, + sampling_interval: Optional[int] = 1, + ): + self.flow_active_timeout = active_timeout + self.flow_inactive_timeout = inactive_timeout + self.flow_refresh_time = refresh_time + self.flow_sampling_interval = sampling_interval + + def set_protocol(self, protocol: Protocol): + self.protocol = as_global(protocol, Protocol) diff --git a/catalystwan/models/configuration/network_hierarchy.py b/catalystwan/models/configuration/network_hierarchy/node.py similarity index 100% rename from catalystwan/models/configuration/network_hierarchy.py rename to catalystwan/models/configuration/network_hierarchy/node.py diff --git a/catalystwan/tests/config_migration/policy_converters/test_cflowd.py b/catalystwan/tests/config_migration/policy_converters/test_cflowd.py new file mode 100644 index 00000000..efbc43de --- /dev/null +++ b/catalystwan/tests/config_migration/policy_converters/test_cflowd.py @@ -0,0 +1,84 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates + +import unittest +from uuid import uuid4 + +from catalystwan.models.configuration.config_migration import PolicyConvertContext +from catalystwan.models.configuration.network_hierarchy.cflowd import CflowdParcel +from catalystwan.models.policy.definition.cflowd import ( + CflowdDefinition, + CflowdPolicy, + Collector, + CustomizedIpv4RecordFields, + IpProtocol, +) +from catalystwan.utils.config_migration.converters.policy.policy_definitions import convert + + +class TestCflowdConverter(unittest.TestCase): + def setUp(self) -> None: + self.context = PolicyConvertContext() + + def test_cflowd_conversion(self): + # Arrange + protocol: IpProtocol = "ipv4" + flow_active_timeout = 1000 + flow_inactive_timeout = 3000 + template_refresh = 5000 + flow_sampling_interval = 6000 + collect_tos = False + collect_dscp_output = True + vpn_id = 50 + address = "0::" + port = 9900 + transport = "transport_udp" + source_interface = "eth0" + export_spread = None + bfd_metrics_export = "enable" + export_interval = 1000 + collector = Collector( + vpn=vpn_id, + address=address, + port=port, + transport=transport, + source_interface=source_interface, + export_spread=export_spread, + bfd_metrics_export=bfd_metrics_export, + export_interval=export_interval, + ) + customized_ipv4_record_fields = CustomizedIpv4RecordFields( + collect_tos=collect_tos, + collect_dscp_output=collect_dscp_output, + ) + definition = CflowdDefinition( + protocol=protocol, + flow_active_timeout=flow_active_timeout, + flow_inactive_timeout=flow_inactive_timeout, + template_refresh=template_refresh, + flow_sampling_interval=flow_sampling_interval, + customized_ipv4_record_fields=customized_ipv4_record_fields, + collectors=[collector], + ) + cflowd = CflowdPolicy( + name="cflowd_policy", + description="cflowd description", + definition=definition, + ) + uuid = uuid4() + # Act + parcel = convert(cflowd, uuid, context=self.context).output + # Assert + assert isinstance(parcel, CflowdParcel) + assert parcel.protocol.value == protocol + assert parcel.flow_active_timeout.value == flow_active_timeout + assert parcel.flow_inactive_timeout.value == flow_inactive_timeout + assert parcel.flow_refresh_time.value == template_refresh + assert parcel.flow_sampling_interval.value == flow_sampling_interval + assert parcel.customized_ipv4_record_fields.collect_dscp_output.value == collect_dscp_output + assert parcel.customized_ipv4_record_fields.collect_tos.value == collect_tos + assert parcel.collectors[0].vpn_id.value == vpn_id + assert parcel.collectors[0].address.value == address + assert parcel.collectors[0].udp_port.value == port + assert parcel.collectors[0].bfd_metrics_export.value == (bfd_metrics_export is not None) + assert parcel.collectors[0].export_interval.value == export_interval + assert parcel.collectors[0].export_spread.value == (export_spread is not None) diff --git a/catalystwan/utils/config_migration/converters/policy/policy_definitions.py b/catalystwan/utils/config_migration/converters/policy/policy_definitions.py index a4f29951..79897291 100644 --- a/catalystwan/utils/config_migration/converters/policy/policy_definitions.py +++ b/catalystwan/utils/config_migration/converters/policy/policy_definitions.py @@ -53,11 +53,13 @@ from catalystwan.models.configuration.feature_profile.sdwan.topology.custom_control import CustomControlParcel from catalystwan.models.configuration.feature_profile.sdwan.topology.hubspoke import HubSpokeParcel from catalystwan.models.configuration.feature_profile.sdwan.topology.mesh import MeshParcel +from catalystwan.models.configuration.network_hierarchy.cflowd import CflowdParcel from catalystwan.models.policy import AnyPolicyDefinition from catalystwan.models.policy.definition.access_control_list import AclPolicy from catalystwan.models.policy.definition.access_control_list_ipv6 import AclIPv6Policy from catalystwan.models.policy.definition.aip import AdvancedInspectionProfilePolicy from catalystwan.models.policy.definition.amp import AdvancedMalwareProtectionPolicy +from catalystwan.models.policy.definition.cflowd import CflowdPolicy from catalystwan.models.policy.definition.control import ControlPolicy from catalystwan.models.policy.definition.device_access import DeviceAccessPolicy from catalystwan.models.policy.definition.device_access_ipv6 import DeviceAccessIPv6Policy @@ -754,9 +756,46 @@ def intrusion_prevention( return ConvertResult[IntrusionPreventionParcel](output=out) +def cflowd(in_: CflowdPolicy, uuid: UUID, context: PolicyConvertContext) -> ConvertResult[CflowdParcel]: + out = CflowdParcel() + definition = in_.definition + customized = definition.customized_ipv4_record_fields + out.set_customized_ipv4_record_fields( + collect_dscp_output=customized.collect_dscp_output, + collect_tos=customized.collect_tos, + ) + out.set_flow( + active_timeout=definition.flow_active_timeout, + inactive_timeout=definition.flow_inactive_timeout, + refresh_time=definition.template_refresh, + sampling_interval=definition.flow_sampling_interval, + ) + out.set_protocol(definition.protocol) + for col in definition.collectors: + out.add_collector( + address=col.address, + udp_port=col.port, + vpn_id=col.vpn, + export_spread=col.export_spread is not None, + bfd_metrics_export=col.bfd_metrics_export is not None, + export_interval=col.export_interval, + # col.transport + ) + result = ConvertResult[CflowdParcel](output=out) + result.update_status( + "complete", + f"UX2 cflowd have one extra field 'collect_tloc_loopback' setting default: {out.collect_tloc_loopback}", + ) + result.update_status( + "partial", "UX2 cflowd do not have 'transport [transport_udp, transport_tcp]' field in collectors" + ) + return result + + CONVERTERS: Mapping[Type[Input], Callable[..., Output]] = { AclPolicy: ipv4acl, AclIPv6Policy: ipv6acl, + CflowdPolicy: cflowd, ControlPolicy: control, HubAndSpokePolicy: hubspoke, MeshPolicy: mesh,