diff --git a/plugins/module_utils/endpoints/v1/manage/lan_fabric_base_path.py b/plugins/module_utils/endpoints/v1/manage/lan_fabric_base_path.py new file mode 100644 index 000000000..31e8d8b4b --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/lan_fabric_base_path.py @@ -0,0 +1,37 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Centralized base paths for top-down and resource-manager NDFC endpoints. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + + +class TopDownBasePath: + """Base path helper for top-down lan-fabric APIs.""" + + API: "Final" = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down" + + @classmethod + def path(cls, *segments: str) -> str: + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" + + +class ResourceManagerBasePath: + """Base path helper for resource-manager APIs.""" + + API: "Final" = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/resource-manager" + + @classmethod + def path(cls, *segments: str) -> str: + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py new file mode 100644 index 000000000..934ce2255 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py @@ -0,0 +1,50 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint model for /api/v1/manage/fabrics/{fabric_name}/actions/configSave. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class EpFabricConfigSavePost(FabricNameMixin, NDEndpointBaseModel): + class_name: Literal["EpFabricConfigSavePost"] = Field( + default="EpFabricConfigSavePost", + frozen=True, + description="Class name for backward compatibility", + ) + + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "actions", "configSave") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +EpManageFabricsActionsConfigSavePost = EpFabricConfigSavePost diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py new file mode 100644 index 000000000..e86d8890e --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py @@ -0,0 +1,53 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint model for /api/v1/manage/fabrics/{fabric_name}/actions/deploy. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class EpFabricDeployPost(FabricNameMixin, NDEndpointBaseModel): + class_name: Literal["EpFabricDeployPost"] = Field( + default="EpFabricDeployPost", + frozen=True, + description="Class name for backward compatibility", + ) + + force_show_run: bool = Field(default=True, description="forceShowRun query parameter value.") + + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + base_path = BasePath.path("fabrics", quote(self.fabric_name, safe=""), "actions", "deploy") + return "{0}?forceShowRun={1}".format(base_path, str(self.force_show_run).lower()) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +EpManageFabricsActionsDeployPost = EpFabricDeployPost diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py new file mode 100644 index 000000000..295920f3b --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py @@ -0,0 +1,50 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint model for /api/v1/manage/fabrics/{fabric_name}/switches. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class EpFabricSwitchesGet(FabricNameMixin, NDEndpointBaseModel): + class_name: Literal["EpFabricSwitchesGet"] = Field( + default="EpFabricSwitchesGet", + frozen=True, + description="Class name for backward compatibility", + ) + + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "switches") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +EpManageFabricsSwitchesGet = EpFabricSwitchesGet diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs.py new file mode 100644 index 000000000..4d7deef7a --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs.py @@ -0,0 +1,66 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint models for /top-down/fabrics/{fabric_name}/vrfs. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.lan_fabric_base_path import ( + TopDownBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpFabricVrfsBase(FabricNameMixin, NDEndpointBaseModel): + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + + return TopDownBasePath.path("fabrics", quote(self.fabric_name, safe=""), "vrfs") + + +class EpFabricVrfsGet(_EpFabricVrfsBase): + class_name: Literal["EpFabricVrfsGet"] = Field( + default="EpFabricVrfsGet", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +class EpFabricVrfsPost(_EpFabricVrfsBase): + class_name: Literal["EpFabricVrfsPost"] = Field( + default="EpFabricVrfsPost", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +EpTopDownFabricsVrfsGet = EpFabricVrfsGet +EpTopDownFabricsVrfsPost = EpFabricVrfsPost diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_attachments.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_attachments.py new file mode 100644 index 000000000..f5aa9e151 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_attachments.py @@ -0,0 +1,78 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint models for /top-down/fabrics/{fabric_name}/vrfs/attachments. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.lan_fabric_base_path import ( + TopDownBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpFabricVrfsAttachmentsBase(FabricNameMixin, NDEndpointBaseModel): + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def _attachments_path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return TopDownBasePath.path("fabrics", quote(self.fabric_name, safe=""), "vrfs", "attachments") + + +class EpFabricVrfsAttachmentsGet(_EpFabricVrfsAttachmentsBase): + class_name: Literal["EpFabricVrfsAttachmentsGet"] = Field( + default="EpFabricVrfsAttachmentsGet", + frozen=True, + description="Class name for backward compatibility", + ) + + vrf_names: str = Field( + min_length=1, + description="Comma-separated VRF names for vrf-names query parameter.", + ) + + @property + def path(self) -> str: + return "{0}?vrf-names={1}".format(self._attachments_path, quote(self.vrf_names, safe="")) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +class EpFabricVrfsAttachmentsPost(_EpFabricVrfsAttachmentsBase): + class_name: Literal["EpFabricVrfsAttachmentsPost"] = Field( + default="EpFabricVrfsAttachmentsPost", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def path(self) -> str: + return self._attachments_path + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +EpTopDownFabricsVrfsAttachmentsGet = EpFabricVrfsAttachmentsGet +EpTopDownFabricsVrfsAttachmentsPost = EpFabricVrfsAttachmentsPost diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_deployments.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_deployments.py new file mode 100644 index 000000000..efd0702ba --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_deployments.py @@ -0,0 +1,51 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint model for /top-down/fabrics/{fabric_name}/vrfs/deployments. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.lan_fabric_base_path import ( + TopDownBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class EpFabricVrfsDeploymentsPost(FabricNameMixin, NDEndpointBaseModel): + class_name: Literal["EpFabricVrfsDeploymentsPost"] = Field( + default="EpFabricVrfsDeploymentsPost", + frozen=True, + description="Class name for backward compatibility", + ) + + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + + return TopDownBasePath.path("fabrics", quote(self.fabric_name, safe=""), "vrfs", "deployments") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +EpTopDownFabricsVrfsDeploymentsPost = EpFabricVrfsDeploymentsPost diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_switches.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_switches.py new file mode 100644 index 000000000..6f59f7baf --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_switches.py @@ -0,0 +1,65 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint model for /top-down/fabrics/{fabric_name}/vrfs/switches. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.lan_fabric_base_path import ( + TopDownBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class EpFabricVrfsSwitchesGet(FabricNameMixin, NDEndpointBaseModel): + class_name: Literal["EpFabricVrfsSwitchesGet"] = Field( + default="EpFabricVrfsSwitchesGet", + frozen=True, + description="Class name for backward compatibility", + ) + + vrf_names: str = Field( + min_length=1, + description="Comma-separated VRF names for vrf-names query parameter.", + ) + serial_numbers: str = Field( + min_length=1, + description="Comma-separated switch serial numbers for serial-numbers query parameter.", + ) + + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + + base_path = TopDownBasePath.path("fabrics", quote(self.fabric_name, safe=""), "vrfs", "switches") + return "{0}?vrf-names={1}&serial-numbers={2}".format( + base_path, + quote(self.vrf_names, safe=""), + quote(self.serial_numbers, safe=""), + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +EpTopDownFabricsVrfsSwitchesGet = EpFabricVrfsSwitchesGet diff --git a/plugins/module_utils/endpoints/v1/manage/manage_resource_manager_reserve_id.py b/plugins/module_utils/endpoints/v1/manage/manage_resource_manager_reserve_id.py new file mode 100644 index 000000000..a82d0b9d5 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_resource_manager_reserve_id.py @@ -0,0 +1,40 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Endpoint model for /resource-manager/reserve-id. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.lan_fabric_base_path import ( + ResourceManagerBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class EpResourceManagerReserveIdPost(NDEndpointBaseModel): + class_name: Literal["EpResourceManagerReserveIdPost"] = Field( + default="EpResourceManagerReserveIdPost", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def path(self) -> str: + return ResourceManagerBasePath.path("reserve-id") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +EpTopDownResourceManagerReserveIdPost = EpResourceManagerReserveIdPost diff --git a/plugins/module_utils/manage_vrf_lite/__init__.py b/plugins/module_utils/manage_vrf_lite/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py new file mode 100644 index 000000000..293f4ed6c --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +import json +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + _raise_vrf_lite_error, + _resolve_serial, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( + query_vrf_lite_state, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( + VrfLiteEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_payloads import ( + build_instance_values, + build_vrf_lite_extension_values, + parse_instance_values, + vrf_lite_items_to_config, +) +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender as NDSender + + +class _VrfLiteListPayloadRestSend(RestSend): + """RestSend variant for the NDFC VRF attachment API's top-level list body.""" + + @property + def payload(self) -> Any: + return self._payload + + @payload.setter + def payload(self, value: Any) -> None: + if not isinstance(value, (dict, list)): + msg = "{0}.payload: payload must be a dict or list. ".format(self.class_name) + msg += "Got {0}.".format(value) + raise TypeError(msg) + self._payload = value + + +class _VrfLiteListPayloadSender(NDSender): + """Sender variant for the NDFC VRF attachment API's top-level list body.""" + + @property + def payload(self) -> Any: + return self._payload + + @payload.setter + def payload(self, value: Any) -> None: + if value is not None and not isinstance(value, (dict, list)): + msg = "{0}.payload: payload must be a dict, list, or None. ".format(self.class_name) + msg += "Got type {0}, value {1}.".format(type(value).__name__, value) + raise TypeError(msg) + self._payload = value + + +def _request_vrf_lite_payload(nd_v2: Any, path: str, verb: HttpVerbEnum, payload: Any) -> Any: + """Send VRF Lite payloads without widening the shared REST sender contract.""" + if not isinstance(payload, list): + return nd_v2.request(path, verb, payload) + + if not hasattr(nd_v2, "_get_rest_send") or not hasattr(nd_v2, "module"): + return nd_v2.request(path, verb, payload) + + base_rest_send = nd_v2._get_rest_send() + rest_send = _VrfLiteListPayloadRestSend( + { + "check_mode": nd_v2.module.check_mode, + "state": nd_v2.params.get("state"), + } + ) + rest_send.timeout = base_rest_send.timeout + rest_send.send_interval = base_rest_send.send_interval + rest_send.unit_test = base_rest_send.unit_test + + sender = _VrfLiteListPayloadSender() + sender.ansible_module = nd_v2.module + rest_send.sender = sender + response_handler = ResponseHandler() + rest_send.response_handler = response_handler + + try: + rest_send.path = path + rest_send.verb = verb + rest_send.payload = payload + rest_send.commit() + except (TypeError, ValueError) as error: + raise ValueError("Error in VRF Lite request: {0}".format(error)) from error + + response = rest_send.response_current + result = rest_send.result_current + + nd_v2.method = verb.value + nd_v2.path = path + nd_v2.response = response.get("MESSAGE") + nd_v2.status = response.get("RETURN_CODE", -1) + nd_v2.url = response.get("REQUEST_PATH") + + if not result.get("success", False): + response_data = response.get("DATA") + raw = None + response_payload = None + if isinstance(response_data, dict): + if "raw_response" in response_data: + raw = response_data["raw_response"] + else: + response_payload = response_data + + error_msg = "Unknown error" + error_msg = response_handler.error_message or error_msg + + _raise_vrf_lite_error( + msg=error_msg, + status=nd_v2.status, + request_payload=payload, + response_payload=response_payload, + raw=raw, + ) + + return response.get("DATA", {}) + + +def _ensure_vrf_exists(module: Any, vrf_name: str) -> None: + known_vrfs = module.params.get("_known_vrfs") or [] + if vrf_name in known_vrfs: + return + + fabric_name = module.params.get("fabric_name") + refreshed = query_vrf_lite_state(module=module, fabric_name=fabric_name, filter_vrfs=set([vrf_name])) + if not refreshed: + _raise_vrf_lite_error( + msg="VRF '{0}' does not exist in fabric '{1}'.".format(vrf_name, fabric_name), + fabric=fabric_name, + vrf_name=vrf_name, + ) + + +def _reserve_dot1q_if_needed(nd_v2: Any, vrf_name: str, serial_number: str, lite_item: dict[str, Any]) -> dict[str, Any]: + if lite_item.get("dot1q") not in (None, ""): + return lite_item + + interface = lite_item.get("interface") + if not interface: + return lite_item + + payload = { + "scopeType": "DeviceInterface", + "usageType": "TOP_DOWN_L3_DOT1Q", + "serialNumber": serial_number, + "ifName": interface, + "allocatedTo": vrf_name, + } + + response = nd_v2.request(VrfLiteEndpoints.reserve_id(), HttpVerbEnum.POST, payload) + + dot1q_value = None + if isinstance(response, dict): + # Different controller versions return either a scalar DATA-equivalent + # or a dict-like body for reserve-id. + for key in ("dot1q", "id", "value", "allocatedId"): + if response.get(key) not in (None, ""): + dot1q_value = response.get(key) + break + else: + dot1q_value = response + + if dot1q_value in (None, ""): + _raise_vrf_lite_error( + msg="Failed to reserve dot1q for VRF Lite interface '{0}' on switch '{1}'.".format(interface, serial_number), + vrf_name=vrf_name, + serial_number=serial_number, + reserve_response=response, + ) + + updated = dict(lite_item) + updated["dot1q"] = dot1q_value + return updated + + +def _raw_attachment_entry(module: Any, vrf_name: str, serial_number: str) -> dict[str, Any]: + raw_vrf_attachment_map = module.params.get("_raw_vrf_attachment_map") or {} + raw_attachment_map = raw_vrf_attachment_map.get(vrf_name, {}) if isinstance(raw_vrf_attachment_map, dict) else {} + raw_attach = raw_attachment_map.get(serial_number) if isinstance(raw_attachment_map, dict) else None + return raw_attach if isinstance(raw_attach, dict) else {} + + +def _empty_vrf_lite_extension_values(existing_extension_values: Any) -> str: + rendered = build_vrf_lite_extension_values( + [], + existing_extension_values=existing_extension_values, + ) + if rendered: + return rendered + + return build_vrf_lite_extension_values( + [], + existing_extension_values={ + "VRF_LITE_CONN": json.dumps({"VRF_LITE_CONN": []}, separators=(",", ":")), + "MULTISITE_CONN": json.dumps({"MULTISITE_CONN": []}, separators=(",", ":")), + }, + ) + + +def _build_instance_values_for_payload(import_evpn_rt: Any, export_evpn_rt: Any, existing_instance_values: Any = None) -> str: + # Make a defensive copy: parse_instance_values may return the input dict directly + # when existing_instance_values is already a dict, and we must not mutate the caller's object. + existing = dict(parse_instance_values(existing_instance_values)) + if not existing: + return build_instance_values(import_evpn_rt, export_evpn_rt) + + existing["switchRouteTargetImportEvpn"] = import_evpn_rt or "" + existing["switchRouteTargetExportEvpn"] = export_evpn_rt or "" + for key in ("loopbackId", "loopbackIpAddress", "loopbackIpV6Address"): + existing.setdefault(key, "") + + return json.dumps(existing, separators=(",", ":")) + + +def _entry_extensions(entry: Any) -> list[dict[str, Any]]: + return entry.to_config().get("extensions", []) if hasattr(entry, "to_config") else list(getattr(entry, "extensions", None) or []) + + +def _entry_vlan_id(module: Any, entry: Any, raw_attach: Optional[dict[str, Any]] = None) -> int: + if getattr(entry, "vlan_id", None) is not None: + return int(entry.vlan_id) + + vlan_map = module.params.get("_vrf_lite_vrf_vlan_map") or {} + mapped_vlan = vlan_map.get(entry.vrf_name) + if mapped_vlan not in (None, ""): + try: + return int(mapped_vlan) + except (TypeError, ValueError): + pass + + if isinstance(raw_attach, dict) and raw_attach.get("vlan") not in (None, ""): + try: + return int(raw_attach.get("vlan")) + except (TypeError, ValueError): + pass + + _raise_vrf_lite_error( + msg=("vlan_id is required for VRF '{0}' because the current VRF VLAN " "could not be determined from the controller.").format(entry.vrf_name), + vrf_name=entry.vrf_name, + switch_ip=entry.switch_ip, + ) + raise RuntimeError("unreachable") # _raise_vrf_lite_error always raises + + +def build_attach_payload_for_entry(module: Any, nd_v2: Any, entry: Any) -> dict[str, Any]: + """Build one NDFC attachment row for one flat VRF Lite entry.""" + serial_number = _resolve_serial(module, entry.switch_ip) + raw_attach = _raw_attachment_entry(module, entry.vrf_name, serial_number) + vlan_id = _entry_vlan_id(module, entry, raw_attach) + + resolved_extensions = [] + for lite_item in vrf_lite_items_to_config(_entry_extensions(entry)): + resolved_extensions.append(_reserve_dot1q_if_needed(nd_v2, entry.vrf_name, serial_number, lite_item)) + + extension_values = build_vrf_lite_extension_values( + resolved_extensions, + existing_extension_values=raw_attach.get("extension_values") if isinstance(raw_attach, dict) else None, + ) + instance_values = _build_instance_values_for_payload( + getattr(entry, "import_evpn_rt", None), + getattr(entry, "export_evpn_rt", None), + raw_attach.get("instance_values") if isinstance(raw_attach, dict) else None, + ) + + return { + "fabric": module.params.get("fabric_name"), + "vrfName": entry.vrf_name, + "serialNumber": serial_number, + "vlan": vlan_id, + "deployment": True, + "isAttached": True, + "extensionValues": extension_values, + "instanceValues": instance_values, + "freeformConfig": "", + } + + +def _build_detach_payload( + module: Any, + vrf_name: str, + serial_number: str, + vlan_id: Any, + extension_values: Any = None, + instance_values: Any = None, +) -> dict[str, Any]: + # NDFC does not support a true "detach" on VRF Lite attachments. + # The correct way to remove VRF Lite config from a switch is to POST + # the attachment row with deployment=True, isAttached=True, and empty + # extensionValues/instanceValues. This clears the VRF Lite extension + # data while keeping the switch attachment itself intact. + return { + "fabric": module.params.get("fabric_name"), + "vrfName": vrf_name, + "serialNumber": serial_number, + "vlan": vlan_id if vlan_id is not None else 0, + "deployment": True, + "isAttached": True, + "extensionValues": _empty_vrf_lite_extension_values(extension_values), + "instanceValues": instance_values if instance_values not in (None, "") else build_instance_values("", ""), + "freeformConfig": "", + } + + +def build_detach_payload_for_entry(module: Any, entry: Any) -> dict[str, Any]: + """Build one NDFC row that removes VRF Lite data for one flat entry.""" + serial_number = _resolve_serial(module, entry.switch_ip) + raw_attach = _raw_attachment_entry(module, entry.vrf_name, serial_number) + vlan_id = getattr(entry, "vlan_id", None) + if vlan_id in (None, ""): + vlan_id = raw_attach.get("vlan") + if vlan_id in (None, ""): + vlan_id = (module.params.get("_vrf_lite_vrf_vlan_map") or {}).get(entry.vrf_name) + + return _build_detach_payload( + module=module, + vrf_name=entry.vrf_name, + serial_number=serial_number, + vlan_id=vlan_id, + extension_values=raw_attach.get("extension_values"), + instance_values=raw_attach.get("instance_values"), + ) + + +def _collect_attachment_failures(response: Any) -> list[str]: + failures: list[str] = [] + + if isinstance(response, str): + if "failed" in response.lower(): + failures.append(response) + return failures + + if isinstance(response, dict): + for value in response.values(): + failures.extend(_collect_attachment_failures(value)) + return failures + + if isinstance(response, list): + for item in response: + failures.extend(_collect_attachment_failures(item)) + + return failures + + +def _post_attachment_payload(nd_v2: Any, fabric_name: str, vrf_name: str, lan_attach_list: list[dict[str, Any]]) -> dict[str, Any]: + if not lan_attach_list: + return {} + + path = VrfLiteEndpoints.vrf_attachments_post(fabric_name) + payload = [{"vrfName": vrf_name, "lanAttachList": lan_attach_list}] + response = _request_vrf_lite_payload(nd_v2, path, HttpVerbEnum.POST, payload) + failures = _collect_attachment_failures(response) + if failures: + _raise_vrf_lite_error( + msg="VRF Lite attachment API reported failure: {0}".format("; ".join(failures)), + fabric=fabric_name, + vrf_name=vrf_name, + controller_failures=failures, + response=response, + ) + return response diff --git a/plugins/module_utils/manage_vrf_lite/common.py b/plugins/module_utils/manage_vrf_lite/common.py new file mode 100644 index 000000000..125eba700 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/common.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +import ipaddress +from typing import Any, NoReturn + +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( + VrfLiteResourceError, +) + +DEFAULT_VERIFY_TIMEOUT = 10 +DEFAULT_VERIFY_RETRIES = 5 +DEFAULT_CONFIG_ACTION_TYPE = "switch" +CONFIG_ACTION_TYPE_CHOICES = ("switch", "global") + + +def _params(source: Any) -> dict[str, Any]: + if isinstance(source, dict): + return source + params = getattr(source, "params", None) + return params if isinstance(params, dict) else {} + + +def _raise_vrf_lite_error(msg: str, **details: Any) -> NoReturn: + raise VrfLiteResourceError(msg=msg, **details) + + +def _is_ip_literal(value: Any) -> bool: + if not isinstance(value, str): + return False + + text = value.strip() + if not text: + return False + + try: + ipaddress.ip_address(text) + return True + except ValueError: + return False + + +def _resolve_serial(module: Any, switch_identifier: Any) -> str: + """Resolve a switch management IP to serial when the query cache has it.""" + if switch_identifier is None: + return "" + + text = str(switch_identifier).strip() + if not text: + return "" + + mapping = module.params.get("_ip_to_sn_mapping") or {} + if text in mapping: + return mapping[text] + + if _is_ip_literal(text): + _raise_vrf_lite_error( + msg=( + "Switch identifier '{0}' appears to be an IP, but it could not " + "be resolved to a fabric switch serial number." + ).format(text), + switch_id=text, + ) + + return text + + +def vrf_name_from_config_item(item: Any) -> str: + """Return a normalized VRF name from public or controller-style keys.""" + if not isinstance(item, dict): + return "" + + vrf_name = item.get("vrf_name") or item.get("vrfName") + return str(vrf_name).strip() if vrf_name else "" + + +def append_runtime_warning(source: Any, message: str) -> None: + """Collect runtime warnings without requiring direct Ansible dependencies.""" + params = _params(source) + warnings = params.get("_warnings") + if not isinstance(warnings, list): + warnings = [] + warnings.append(str(message)) + params["_warnings"] = warnings + + +def get_runtime_warnings(source: Any) -> list[str]: + params = _params(source) + warnings = params.get("_warnings") + if not isinstance(warnings, list): + return [] + + normalized: list[str] = [] + seen: set[str] = set() + for warning in warnings: + text = str(warning) + if text in seen: + continue + seen.add(text) + normalized.append(text) + return normalized + + +def get_verify_settings(source: Any) -> dict[str, Any]: + params = _params(source) + raw_verify = params.get("verify") + if isinstance(raw_verify, dict): + return { + "enabled": raw_verify.get("enabled", True), + "retries": raw_verify.get("retries", DEFAULT_VERIFY_RETRIES), + "timeout": raw_verify.get("timeout", DEFAULT_VERIFY_TIMEOUT), + } + + return { + "enabled": True, + "retries": DEFAULT_VERIFY_RETRIES, + "timeout": DEFAULT_VERIFY_TIMEOUT, + } + + +def request_with_verify_settings(module: Any, nd_v2: Any, path: str, verb: Any) -> Any: + """Run a controller read using the configured verify timeout/retry policy.""" + settings = get_verify_settings(module.params) + timeout = settings.get("timeout", DEFAULT_VERIFY_TIMEOUT) + retries = settings.get("retries", DEFAULT_VERIFY_RETRIES) + try: + retries = max(1, int(retries)) + except (TypeError, ValueError): + retries = DEFAULT_VERIFY_RETRIES + + last_error = None + for attempt in range(retries): + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + return nd_v2.request(path, verb) + except Exception as error: + last_error = error + if attempt + 1 >= retries: + raise + finally: + rest_send.restore_settings() + + +def get_config_actions(source: Any) -> dict[str, Any]: + params = _params(source) + raw_actions = params.get("config_actions") + if isinstance(raw_actions, dict): + action_type_raw = raw_actions.get("type", DEFAULT_CONFIG_ACTION_TYPE) + action_type = str(action_type_raw).strip().lower() if action_type_raw is not None else DEFAULT_CONFIG_ACTION_TYPE + if action_type not in CONFIG_ACTION_TYPE_CHOICES: + action_type = DEFAULT_CONFIG_ACTION_TYPE + + return { + "save": raw_actions.get("save", True), + "deploy": raw_actions.get("deploy", True), + "type": action_type, + } + + return { + "save": True, + "deploy": True, + "type": DEFAULT_CONFIG_ACTION_TYPE, + } diff --git a/plugins/module_utils/manage_vrf_lite/config_transform.py b/plugins/module_utils/manage_vrf_lite/config_transform.py new file mode 100644 index 000000000..c7efa7585 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/config_transform.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Any, Optional, Union + +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + _resolve_serial, + append_runtime_warning, + vrf_name_from_config_item, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_attachment_entry import ( + VrfLiteAttachmentEntry, +) + + +def _canonical_switch_id(module: Any, switch_identifier: Any) -> str: + if switch_identifier is None: + return "" + if module is None: + return str(switch_identifier).strip() + return _resolve_serial(module, switch_identifier) + + +def _entry_from_attach(module: Any, vrf_item: dict[str, Any], attach: dict[str, Any]) -> dict[str, Any]: + vrf_name = vrf_name_from_config_item(vrf_item) + switch_identifier = attach.get("ip_address") or attach.get("switch_ip") or attach.get("serial_number") + switch_ip = _canonical_switch_id(module, switch_identifier) + + entry: dict[str, Any] = { + "vrf_name": vrf_name, + "switch_ip": switch_ip, + } + + vlan_id = attach.get("vlan_id", vrf_item.get("vlan_id")) + if vlan_id is not None: + entry["vlan_id"] = vlan_id + + if "deploy" in attach: + entry["deploy"] = attach.get("deploy") + elif "deploy" in vrf_item: + entry["deploy"] = vrf_item.get("deploy") + + for key in ("import_evpn_rt", "export_evpn_rt"): + if attach.get(key) is not None: + entry[key] = attach.get(key) + + extensions = attach.get("extensions", attach.get("vrf_lite")) + if extensions is not None: + entry["extensions"] = extensions + + return {key: value for key, value in entry.items() if value is not None} + + +def _merge_current_entry(current: dict[str, Any], proposed: dict[str, Any]) -> dict[str, Any]: + """Build the effective merged attachment row before generic diffing.""" + return VrfLiteAttachmentEntry.from_config(current).merge(VrfLiteAttachmentEntry.from_config(proposed)).to_config() + + +def explode_playbook_to_entries( + config: list[dict[str, Any]], + module: Any, + state: str, + current_entries: Optional[list[dict[str, Any]]] = None, +) -> list[dict[str, Any]]: + """Flatten nested playbook config into attachment-level payload DTO data.""" + entries: list[dict[str, Any]] = [] + current_entries = current_entries or [] + current_by_key = {(item.get("vrf_name"), item.get("switch_ip")): item for item in current_entries} + current_keys = set(current_by_key) + for vrf_item in config or []: + if not isinstance(vrf_item, dict): + continue + + vrf_name = vrf_name_from_config_item(vrf_item) + if not vrf_name: + continue + + attachments = vrf_item.get("attach") + + if state == "deleted" and not attachments: + entries.extend([dict(item) for item in current_entries if item.get("vrf_name") == vrf_name]) + continue + + if not isinstance(attachments, list): + continue + + for attach in attachments: + if not isinstance(attach, dict): + continue + entry = _entry_from_attach(module, vrf_item, attach) + if not entry.get("switch_ip") or not entry.get("vrf_name"): + continue + if state == "merged" and (entry.get("vrf_name"), entry.get("switch_ip")) in current_by_key: + entry = _merge_current_entry(current_by_key[(entry.get("vrf_name"), entry.get("switch_ip"))], entry) + entries.append(entry) + + if state == "deleted" and (entry.get("vrf_name"), entry.get("switch_ip")) not in current_keys: + append_runtime_warning( + module.params, + "No matching VRF Lite attachment found to delete for VRF '{0}' switch '{1}'. " + "No detach payload was sent.".format(entry.get("vrf_name"), entry.get("switch_ip")), + ) + + return sorted(entries, key=lambda item: (item.get("vrf_name", ""), item.get("switch_ip", ""))) + + +def config_vrf_names(config: list[dict[str, Any]]) -> list[str]: + """Return unique VRF names from playbook-style config.""" + vrfs = [] + for item in config or []: + if not isinstance(item, dict): + continue + vrf_name = vrf_name_from_config_item(item) + if vrf_name: + vrfs.append(vrf_name) + return sorted(set(vrfs)) + + +def replacement_scope_vrfs(config: list[dict[str, Any]]) -> list[str]: + """Return VRF names whose attachment set is managed by replaced state.""" + return config_vrf_names(config) + + +def group_attachment_entries_to_vrfs( + entries: list[Any], + module: Any = None, + include_vrfs: Optional[Union[list[str], set[str]]] = None, +) -> list[dict[str, Any]]: + """Convert flat attachment DTOs back to the public nested VRF shape.""" + sn_to_ip = {} + vlan_map = {} + known_vrfs = set() + if module is not None and isinstance(getattr(module, "params", None), dict): + sn_to_ip = module.params.get("_sn_to_ip_mapping") or {} + vlan_map = module.params.get("_vrf_lite_vrf_vlan_map") or {} + known_vrfs = set(module.params.get("_known_vrfs") or []) + + grouped: dict[str, dict[str, Any]] = {} + for raw_vrf_name in include_vrfs or []: + if not raw_vrf_name: + continue + vrf_name = str(raw_vrf_name).strip() + if known_vrfs and vrf_name not in known_vrfs: + continue + grouped.setdefault(vrf_name, {"vrf_name": vrf_name, "attach": []}) + if vlan_map.get(vrf_name) is not None: + grouped[vrf_name]["vlan_id"] = vlan_map.get(vrf_name) + + for entry in entries or []: + data = entry.to_config() if hasattr(entry, "to_config") else dict(entry) + vrf_name = data.get("vrf_name") + if not vrf_name: + continue + + vrf = grouped.setdefault(str(vrf_name), {"vrf_name": str(vrf_name), "attach": []}) + if data.get("vlan_id") is not None and vrf.get("vlan_id") is None: + vrf["vlan_id"] = data.get("vlan_id") + + if data.get("deploy") is True: + vrf["deploy"] = True + elif data.get("deploy") is not None and "deploy" not in vrf: + vrf["deploy"] = data.get("deploy") + + switch_id = data.get("switch_ip") + attach: dict[str, Any] = { + "ip_address": sn_to_ip.get(switch_id, switch_id), + } + for key in ("deploy", "import_evpn_rt", "export_evpn_rt"): + if data.get(key) is not None: + attach[key] = data.get(key) + if data.get("extensions") is not None: + attach["vrf_lite"] = data.get("extensions") + + vrf["attach"].append(attach) + + result = [] + for vrf_name in sorted(grouped): + vrf = grouped[vrf_name] + vrf["attach"] = sorted(vrf.get("attach", []), key=lambda item: item.get("ip_address", "")) + result.append(vrf) + return result diff --git a/plugins/module_utils/manage_vrf_lite/deploy.py b/plugins/module_utils/manage_vrf_lite/deploy.py new file mode 100644 index 000000000..795311029 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/deploy.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + vrf_name_from_config_item, +) + + +def _target_vrfs_for_deploy(module: Any) -> list[str]: + target: set[str] = set() + for item in module.params.get("config") or []: + if not isinstance(item, dict): + continue + vrf_name = vrf_name_from_config_item(item) + if not vrf_name: + continue + + deploy = item.get("deploy") + if deploy is False: + continue + if deploy is True: + target.add(vrf_name) + continue + + attachments = item.get("attach") or [] + if not isinstance(attachments, list) or not attachments: + target.add(vrf_name) + continue + + if any(isinstance(attachment, dict) and attachment.get("deploy") is not False for attachment in attachments): + target.add(vrf_name) + continue + + if not any(isinstance(attachment, dict) for attachment in attachments): + target.add(vrf_name) + continue + + return sorted(target) + + +def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: + if not isinstance(error, NDModuleError): + return False + if error.status != 500: + return False + + message = (error.msg or "").lower() + signatures = ( + "vpc fabric peering is not supported", + "unexpected error generating vpc configuration", + "vpcsanitycheck", + ) + return any(signature in message for signature in signatures) + + +def _needs_deployment(result: dict[str, Any], module: Any) -> bool: + """Return True only when actual config changes were applied. + + Deploying against the controller when no configuration changed would + violate Ansible's idempotency contract. A VRF that is still PENDING + from a previous run is a controller-side timing concern; the module + re-deploys only when the user's intent has actually been modified. + """ + if result.get("changed"): + return True + + changed_vrfs = module.params.get("_changed_vrfs") or [] + return bool(changed_vrfs) diff --git a/plugins/module_utils/manage_vrf_lite/exceptions.py b/plugins/module_utils/manage_vrf_lite/exceptions.py new file mode 100644 index 000000000..b74c2c095 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/exceptions.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Any + + +class VrfLiteResourceError(Exception): + """Structured error for nd_manage_vrf_lite workflows.""" + + def __init__(self, msg: str, **details: Any) -> None: + super().__init__(msg) + self.msg = msg + self.details = details or {} diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py new file mode 100644 index 000000000..e6aa915a8 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +import json +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + request_with_verify_settings, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( + VrfLiteEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_payloads import ( + parse_instance_values, + parse_vrf_lite_extension_values, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule as NDModuleV2 + + +def _parse_vrf_template_vlan(vrf_object: dict[str, Any]) -> Optional[int]: + template_cfg = vrf_object.get("vrfTemplateConfig") + if not template_cfg: + return None + + if isinstance(template_cfg, dict): + parsed = template_cfg + else: + try: + parsed = json.loads(str(template_cfg)) + except Exception: + return None + + vlan = parsed.get("vrfVlanId") + if vlan in (None, "", 0): + return None + + try: + return int(vlan) + except Exception: + return None + + +def _result_list(data: Any, keys: tuple[str, ...]) -> list[Any]: + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if isinstance(value, list): + return value + return [] + + +def _query_fabric_switches(module: Any, nd_v2: Any, fabric_name: str) -> dict[str, str]: + """Return serial->mgmt-ip mapping from fabric switch inventory.""" + # TODO: Use a shared FabricContext/fabric inventory helper when available. + path = VrfLiteEndpoints.fabric_switches(fabric_name) + response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) + + switches = _result_list( + response if not isinstance(response, dict) else response.get("switches", response), + ("switches", "DATA", "data", "items"), + ) + + sn_to_ip = {} + for switch in switches: + if not isinstance(switch, dict): + continue + serial = switch.get("serialNumber") + mgmt_ip = switch.get("fabricManagementIp") + if serial and mgmt_ip: + sn_to_ip[str(serial).strip()] = str(mgmt_ip).strip() + + return sn_to_ip + + +def _query_vrfs(module: Any, nd_v2: Any, fabric_name: str) -> list[dict[str, Any]]: + path = VrfLiteEndpoints.vrfs(fabric_name) + response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) + + return [item for item in _result_list(response, ("DATA", "data", "vrfs", "items")) if isinstance(item, dict)] + + +def _query_vrf_attachments(module: Any, nd_v2: Any, fabric_name: str, vrf_names: list[str]) -> list[dict[str, Any]]: + if not vrf_names: + return [] + + path = VrfLiteEndpoints.vrf_attachments_query(fabric_name, ",".join(vrf_names)) + response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) + + return [item for item in _result_list(response, ("DATA", "data", "vrfs", "items")) if isinstance(item, dict)] + + +def _query_vrf_switch_details( + module: Any, + nd_v2: Any, + fabric_name: str, + vrf_name: str, + serial_numbers: list[str], +) -> dict[str, dict[str, Any]]: + """Return per-switch VRF attachment details keyed by serial number. + + This makes one additional API call per VRF that has attachments lacking + ``extensionValues`` in the bulk list response (e.g. PENDING state rows). + On fabrics with many VRFs this may add latency; it is skipped entirely + when all attachments already carry ``extensionValues``. + """ + if not serial_numbers: + return {} + + path = VrfLiteEndpoints.vrf_switch(fabric_name, vrf_name, ",".join(serial_numbers)) + response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) + + detail_map: dict[str, dict[str, Any]] = {} + for vrf_switch in _result_list(response, ("DATA", "data", "vrfs", "items")): + if not isinstance(vrf_switch, dict): + continue + for switch_detail in vrf_switch.get("switchDetailsList") or []: + if not isinstance(switch_detail, dict): + continue + + serial_number = switch_detail.get("serialNumber") + if not serial_number: + continue + + detail_map[str(serial_number).strip()] = switch_detail + + return detail_map + + +def _value_from_attachment_or_detail(attach: dict[str, Any], detail: dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in attach: + value = attach.get(key) + if value is not None: + return value + + for key in keys: + value = detail.get(key) + if value is not None: + return value + + return None + + +def _flatten_to_entries(nested: list[dict[str, Any]], module: Any = None) -> list[dict[str, Any]]: + """Convert nested VRF list into a flat list of per-(vrf, switch) entries. + + Each entry has the shape expected by ``VrfLiteAttachmentEntry``: + ``vrf_name``, ``switch_ip``, ``vlan_id``, ``deploy``, ``import_evpn_rt``, + ``export_evpn_rt``, and ``extensions``. VRFs without attachments produce + no entries; the state machine handles their absence via diff identifiers. + """ + entries: list[dict[str, Any]] = [] + for vrf in nested or []: + if not isinstance(vrf, dict): + continue + vrf_name = vrf.get("vrf_name") + if not vrf_name: + continue + vrf_level_vlan = vrf.get("vlan_id") + for attach in vrf.get("attach") or []: + if not isinstance(attach, dict): + continue + switch_identifier = attach.get("ip_address") + if not switch_identifier: + continue + switch_ip = switch_identifier + mapping = {} + if module is not None and isinstance(getattr(module, "params", None), dict): + mapping = module.params.get("_ip_to_sn_mapping") or {} + if switch_identifier in mapping: + switch_ip = mapping[switch_identifier] + entry: dict[str, Any] = { + "vrf_name": vrf_name, + "switch_ip": switch_ip, + "vlan_id": vrf_level_vlan, + "deploy": attach.get("deploy"), + "import_evpn_rt": attach.get("import_evpn_rt"), + "export_evpn_rt": attach.get("export_evpn_rt"), + } + extensions = attach.get("vrf_lite") + if extensions: + entry["extensions"] = extensions + entries.append({k: v for k, v in entry.items() if v is not None}) + + entries.sort(key=lambda item: (item.get("vrf_name", ""), item.get("switch_ip", ""))) + return entries + + +def query_vrf_lite_state( + module: Any, + fabric_name: str, + filter_vrfs: Optional[set[str]] = None, + flat: bool = False, +) -> list[dict[str, Any]]: + """ + Query controller state and return normalized vrf-lite config shape. + + When ``flat=True`` returns a flat list of per-(vrf_name, switch_ip) + attachment entries shaped for ``VrfLiteAttachmentEntry``. When + ``flat=False`` (default) returns the legacy nested list of VRFs with an + ``attach`` sub-list per VRF. + """ + nd_v2 = NDModuleV2(module) + + sn_to_ip = _query_fabric_switches(module, nd_v2, fabric_name) + ip_to_sn = {ip: sn for sn, ip in sn_to_ip.items()} + + vrf_objects = _query_vrfs(module, nd_v2, fabric_name) + + result_map: dict[str, dict[str, Any]] = {} + known_vrfs: set[str] = set() + for vrf in vrf_objects: + vrf_name = vrf.get("vrfName") or vrf.get("vrf_name") + if not vrf_name: + continue + + vrf_name = str(vrf_name).strip() + known_vrfs.add(vrf_name) + + if filter_vrfs and vrf_name not in filter_vrfs: + continue + + result_map[vrf_name] = { + "vrf_name": vrf_name, + "vlan_id": _parse_vrf_template_vlan(vrf), + "deploy": False, + "attach": [], + } + + attachment_objects = _query_vrf_attachments( + module=module, + nd_v2=nd_v2, + fabric_name=fabric_name, + vrf_names=sorted(result_map.keys()), + ) + + not_in_sync_vrfs: set[str] = set() + raw_vrf_attachment_map: dict[str, dict[str, dict[str, Any]]] = {} + switch_detail_map: dict[tuple[str, str], dict[str, Any]] = {} + + for vrf_attach in attachment_objects: + vrf_name = vrf_attach.get("vrfName") + if not vrf_name: + continue + + vrf_name = str(vrf_name).strip() + if filter_vrfs and vrf_name not in filter_vrfs: + continue + + serials_to_enrich: list[str] = [] + for attach in vrf_attach.get("lanAttachList") or []: + if not isinstance(attach, dict): + continue + + if "extensionValues" in attach: + continue + + attach_state = str(attach.get("lanAttachState") or attach.get("lanAttachedState") or "").upper() + attached_value = attach.get("isLanAttached", attach.get("isAttached", False)) + is_attached = attached_value is True or str(attached_value).strip().lower() in ("true", "1", "yes") + if not is_attached and attach_state not in ("PENDING", "OUT-OF-SYNC", "FAILED"): + continue + + serial_number = attach.get("switchSerialNo") or attach.get("serialNumber") + if serial_number: + serials_to_enrich.append(str(serial_number).strip()) + + for serial_number, switch_detail in _query_vrf_switch_details( + module=module, + nd_v2=nd_v2, + fabric_name=fabric_name, + vrf_name=vrf_name, + serial_numbers=sorted(set(serials_to_enrich)), + ).items(): + switch_detail_map[(vrf_name, serial_number)] = switch_detail + + for vrf_attach in attachment_objects: + vrf_name = vrf_attach.get("vrfName") + if not vrf_name: + continue + + vrf_name = str(vrf_name).strip() + if filter_vrfs and vrf_name not in filter_vrfs: + continue + + entry = result_map.get(vrf_name) + if entry is None: + entry = { + "vrf_name": vrf_name, + "vlan_id": None, + "deploy": False, + "attach": [], + } + result_map[vrf_name] = entry + + attach_list = vrf_attach.get("lanAttachList") or [] + if not isinstance(attach_list, list): + continue + + for attach in attach_list: + if not isinstance(attach, dict): + continue + + serial_number = attach.get("switchSerialNo") or attach.get("serialNumber") + serial_number = str(serial_number).strip() if serial_number else "" + switch_detail = switch_detail_map.get((vrf_name, serial_number), {}) + + ip_address = attach.get("ipAddress") + if ip_address: + ip_address = str(ip_address).strip() + elif serial_number: + ip_address = sn_to_ip.get(serial_number, serial_number) + else: + continue + + attach_state = str( + _value_from_attachment_or_detail(attach, switch_detail, "lanAttachState", "lanAttachedState") or "" + ).upper() + attached_value = attach.get( + "isLanAttached", + attach.get("isAttached", switch_detail.get("islanAttached", switch_detail.get("isLanAttached", False))), + ) + is_attached = attached_value is True or str(attached_value).strip().lower() in ("true", "1", "yes") + extension_values = _value_from_attachment_or_detail(attach, switch_detail, "extensionValues") + # For VRF Lite, include entries that have extension values even if + # not yet lan-attached (pending save/deploy state). + has_extension_values = bool( + extension_values and str(extension_values).strip() + and str(extension_values).strip() != "[]" + ) + if not is_attached and not has_extension_values: + continue + + instance_values_raw = _value_from_attachment_or_detail(attach, switch_detail, "instanceValues") + vrf_vlan_raw = _value_from_attachment_or_detail(attach, switch_detail, "vlanId", "vlan") + attachment_vlan_raw = _value_from_attachment_or_detail(switch_detail, attach, "vlan", "vlanId") + + if serial_number: + raw_vrf_attachment_map.setdefault(vrf_name, {})[serial_number] = { + "extension_values": extension_values, + "instance_values": instance_values_raw, + "vlan": attachment_vlan_raw, + } + + instance_values = parse_instance_values(instance_values_raw) + import_evpn_rt = instance_values.get("switchRouteTargetImportEvpn") + export_evpn_rt = instance_values.get("switchRouteTargetExportEvpn") + + vrf_lite_list = parse_vrf_lite_extension_values(extension_values) + managed_fields_present = bool(vrf_lite_list) or import_evpn_rt not in (None, "") or export_evpn_rt not in (None, "") + if not managed_fields_present: + continue + + deployed = attach_state not in ("OUT-OF-SYNC", "PENDING", "FAILED") + if not deployed: + not_in_sync_vrfs.add(vrf_name) + else: + entry["deploy"] = True + + if entry.get("vlan_id") is None: + try: + if vrf_vlan_raw not in (None, ""): + entry["vlan_id"] = int(vrf_vlan_raw) + except Exception: + pass + + attach_item = { + "ip_address": ip_address, + "deploy": deployed, + "import_evpn_rt": import_evpn_rt if import_evpn_rt is not None else "", + "export_evpn_rt": export_evpn_rt if export_evpn_rt is not None else "", + } + + if vrf_lite_list: + attach_item["vrf_lite"] = vrf_lite_list + + entry["attach"].append(attach_item) + + result = sorted(result_map.values(), key=lambda item: item.get("vrf_name", "")) + for entry in result: + entry["attach"] = sorted(entry.get("attach", []), key=lambda item: item.get("ip_address", "")) + + module.params["_ip_to_sn_mapping"] = ip_to_sn + module.params["_sn_to_ip_mapping"] = sn_to_ip + module.params["_not_in_sync_vrfs"] = sorted(not_in_sync_vrfs) + module.params["_known_vrfs"] = sorted(known_vrfs) + module.params["_raw_vrf_attachment_map"] = raw_vrf_attachment_map + module.params["_vrf_lite_vrf_vlan_map"] = { + item.get("vrf_name"): item.get("vlan_id") + for item in result + if item.get("vrf_name") and item.get("vlan_id") is not None + } + + if flat: + return _flatten_to_entries(result, module=module) + return result diff --git a/plugins/module_utils/manage_vrf_lite/runtime_endpoints.py b/plugins/module_utils/manage_vrf_lite/runtime_endpoints.py new file mode 100644 index 000000000..219acae08 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/runtime_endpoints.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_config_save import ( + EpFabricConfigSavePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_deploy import ( + EpFabricDeployPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpFabricSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs import ( + EpFabricVrfsGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_attachments import ( + EpFabricVrfsAttachmentsGet, + EpFabricVrfsAttachmentsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_deployments import ( + EpFabricVrfsDeploymentsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_switches import ( + EpFabricVrfsSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_resource_manager_reserve_id import ( + EpResourceManagerReserveIdPost, +) + + +class VrfLiteEndpoints: + """Runtime endpoint resolver backed by endpoint model classes.""" + + @staticmethod + def vrfs(fabric_name: str) -> str: + return EpFabricVrfsGet(fabric_name=fabric_name).path + + @staticmethod + def vrf_attachments_query(fabric_name: str, vrf_names_csv: str) -> str: + return EpFabricVrfsAttachmentsGet(fabric_name=fabric_name, vrf_names=vrf_names_csv).path + + @staticmethod + def vrf_attachments_post(fabric_name: str) -> str: + return EpFabricVrfsAttachmentsPost(fabric_name=fabric_name).path + + @staticmethod + def vrf_deployments(fabric_name: str) -> str: + return EpFabricVrfsDeploymentsPost(fabric_name=fabric_name).path + + @staticmethod + def vrf_switch(fabric_name: str, vrf_name: str, serial_number: str) -> str: + return EpFabricVrfsSwitchesGet( + fabric_name=fabric_name, + vrf_names=vrf_name, + serial_numbers=serial_number, + ).path + + @staticmethod + def reserve_id() -> str: + return EpResourceManagerReserveIdPost().path + + @staticmethod + def fabric_switches(fabric_name: str) -> str: + return EpFabricSwitchesGet(fabric_name=fabric_name).path + + @staticmethod + def config_save(fabric_name: str) -> str: + return EpFabricConfigSavePost(fabric_name=fabric_name).path + + @staticmethod + def config_deploy(fabric_name: str) -> str: + return EpFabricDeployPost(fabric_name=fabric_name, force_show_run=True).path diff --git a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py new file mode 100644 index 000000000..5d3a45280 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +import json +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( + VrfLiteConnectionModel, +) + + +def vrf_lite_items_to_config(vrf_lite_items: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for item in vrf_lite_items or []: + try: + model = VrfLiteConnectionModel.model_validate(item, by_alias=True, by_name=True) + except ValidationError: + continue + data = model.model_dump(by_alias=False, exclude_none=True) + items.append({key: value for key, value in data.items() if value != ""}) + return sorted(items, key=lambda i: i.get("interface", "")) + + +def _json_value(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if value in (None, ""): + return None + try: + return json.loads(str(value)) + except Exception: + return None + + +def build_vrf_lite_extension_values( + vrf_lite_items: Optional[list[dict[str, Any]]], + existing_extension_values: Any = None, +) -> str: + """ + Build extensionValues string expected by top-down VRF attachment APIs. + """ + existing_outer = _json_value(existing_extension_values) + if isinstance(existing_outer, dict): + existing_outer = dict(existing_outer) + else: + existing_outer = {} + + configured_items = vrf_lite_items_to_config(vrf_lite_items) + if not configured_items: + if "VRF_LITE_CONN" in existing_outer: + existing_outer["VRF_LITE_CONN"] = json.dumps( + {"VRF_LITE_CONN": []}, + separators=(",", ":"), + ) + + if not existing_outer: + return "" + return json.dumps(existing_outer, separators=(",", ":")) + + connection_rows: list[dict[str, Any]] = [] + for item in configured_items: + row = { + "DOT1Q_ID": "", + "IF_NAME": item.get("interface", ""), + "IP_MASK": item.get("ipv4_addr", ""), + "IPV6_MASK": item.get("ipv6_addr", ""), + "IPV6_NEIGHBOR": item.get("neighbor_ipv6", ""), + "NEIGHBOR_IP": item.get("neighbor_ipv4", ""), + "PEER_VRF_NAME": item.get("peer_vrf", ""), + "VRF_LITE_JYTHON_TEMPLATE": "Ext_VRF_Lite_Jython", + } + if item.get("dot1q") is not None and item.get("dot1q") != "": + row["DOT1Q_ID"] = str(item.get("dot1q")) + connection_rows.append(row) + + vrf_lite_conn = {"VRF_LITE_CONN": connection_rows} + extension_values = dict(existing_outer) + extension_values["VRF_LITE_CONN"] = json.dumps(vrf_lite_conn, separators=(",", ":")) + if "MULTISITE_CONN" not in extension_values: + extension_values["MULTISITE_CONN"] = json.dumps({"MULTISITE_CONN": []}, separators=(",", ":")) + + return json.dumps(extension_values, separators=(",", ":")) + + +def parse_vrf_lite_extension_values(extension_values: Any) -> list[dict[str, Any]]: + """ + Parse controller extensionValues into playbook-style vrf_lite list. + """ + outer = _json_value(extension_values) + if not isinstance(outer, dict): + return [] + + inner = outer.get("VRF_LITE_CONN") + inner = _json_value(inner) + if not isinstance(inner, dict): + return [] + + rows = inner.get("VRF_LITE_CONN") + if not isinstance(rows, list): + return [] + + parsed: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + + item = { + "interface": row.get("IF_NAME"), + "dot1q": None, + "ipv4_addr": row.get("IP_MASK") or None, + "neighbor_ipv4": row.get("NEIGHBOR_IP") or None, + "ipv6_addr": row.get("IPV6_MASK") or None, + "neighbor_ipv6": row.get("IPV6_NEIGHBOR") or None, + "peer_vrf": row.get("PEER_VRF_NAME") or None, + } + + dot1q = row.get("DOT1Q_ID") + if dot1q not in (None, ""): + try: + item["dot1q"] = int(dot1q) + except Exception: + item["dot1q"] = dot1q + + if item.get("interface"): + parsed.append({k: v for k, v in item.items() if v is not None and v != ""}) + + return vrf_lite_items_to_config(parsed) + + +def build_instance_values(import_evpn_rt: Optional[str], export_evpn_rt: Optional[str]) -> str: + values = { + "loopbackId": "", + "loopbackIpAddress": "", + "loopbackIpV6Address": "", + "switchRouteTargetImportEvpn": import_evpn_rt or "", + "switchRouteTargetExportEvpn": export_evpn_rt or "", + } + return json.dumps(values, separators=(",", ":")) + + +def parse_instance_values(instance_values: Any) -> dict[str, Any]: + parsed = _json_value(instance_values) + if isinstance(parsed, dict): + return parsed + return {} diff --git a/plugins/module_utils/manage_vrf_lite/validation.py b/plugins/module_utils/manage_vrf_lite/validation.py new file mode 100644 index 000000000..fe2cfa9ae --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/validation.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + append_runtime_warning, + request_with_verify_settings, + _raise_vrf_lite_error, + _resolve_serial, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( + VrfLiteEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule as NDModuleV2 + + +def _coerce_switch_list(response: Any) -> list[dict[str, Any]]: + if isinstance(response, list): + return [item for item in response if isinstance(item, dict)] + + if isinstance(response, dict): + switches = response.get("switches") + if isinstance(switches, list): + return [item for item in switches if isinstance(item, dict)] + + return [] + + +def _normalize_role(switch_data: dict[str, Any]) -> str: + role_value = switch_data.get("switchRole") or switch_data.get("role") or switch_data.get("switch_role") or "" + return str(role_value).strip().lower() + + +def _is_border_role(role: str) -> bool: + if not role: + return False + keywords = ( + "border", + "bgw", + ) + return any(keyword in role for keyword in keywords) + + +def _is_external_connectivity_switch(switch_data: dict[str, Any]) -> bool: + raw = switch_data.get("raw") + fabric_type = switch_data.get("fabric_type") + if not fabric_type and isinstance(raw, dict): + fabric_type = raw.get("fabricType") or raw.get("fabric_type") + + return str(fabric_type or "").strip().lower() == "externalconnectivity" + + +def _load_switch_inventory(module: Any, fabric_name: str) -> dict[str, dict[str, Any]]: + # TODO: Use a shared FabricContext/fabric inventory helper when available. + cached = module.params.get("_fabric_switch_inventory") + if isinstance(cached, dict) and cached: + return cached + + nd_v2 = NDModuleV2(module) + response = request_with_verify_settings(module, nd_v2, VrfLiteEndpoints.fabric_switches(fabric_name), HttpVerbEnum.GET) + + inventory: dict[str, dict[str, Any]] = {} + for switch in _coerce_switch_list(response): + serial = switch.get("serialNumber") + if not serial: + continue + + serial_text = str(serial).strip() + if not serial_text: + continue + + inventory[serial_text] = { + "role": _normalize_role(switch), + "fabric_type": switch.get("fabricType"), + "ip_address": switch.get("fabricManagementIp"), + "raw": switch, + } + + module.params["_fabric_switch_inventory"] = inventory + return inventory + + +def _extract_support_flag(value: Any) -> Optional[bool]: + if isinstance(value, bool): + return value + + if isinstance(value, dict): + for key in ( + "isVrfLiteSupported", + "vrfLiteSupported", + "isVrfLiteCapable", + "vrfLiteCapable", + "supported", + ): + if key in value and isinstance(value.get(key), bool): + return value.get(key) + + if isinstance(value, list): + for item in value: + if not isinstance(item, dict): + continue + support = _extract_support_flag(item) + if support is not None: + return support + + return None + + +def _query_vrf_lite_support(module: Any, fabric_name: str, vrf_name: str, serial_number: str) -> Optional[bool]: + nd_v2 = NDModuleV2(module) + response = request_with_verify_settings( + module, + nd_v2, + VrfLiteEndpoints.vrf_switch(fabric_name, vrf_name, serial_number), + HttpVerbEnum.GET, + ) + + return _extract_support_flag(response) + + +def _warn_on_uncertain_role(module: Any, serial_number: str, role: str, switch_data: dict[str, Any]) -> None: + if role and not _is_border_role(role) and not _is_external_connectivity_switch(switch_data): + append_runtime_warning( + module.params, + ("Switch '{0}' has role '{1}', but NDFC did not return an explicit VRF Lite " "support flag. Proceeding with controller-side validation.").format( + serial_number, role + ), + ) + if not role: + append_runtime_warning( + module.params, + "Unable to determine switch role for '{0}'. VRF Lite border-role validation was skipped.".format(serial_number), + ) + + +def validate_vrf_lite_write_guardrails(module: Any, model_instance: Any) -> None: + """ + Validate switch existence, role suitability, and platform support hints. + """ + if hasattr(model_instance, "switch_ip"): + attachments = [model_instance] + else: + attachments = model_instance.attach or [] + if not attachments: + return + + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + inventory = _load_switch_inventory(module, fabric_name) + + for attach in attachments: + switch_identifier = getattr(attach, "switch_ip", None) or getattr(attach, "ip_address", None) + serial_number = _resolve_serial(module, switch_identifier) + if not serial_number: + continue + + if serial_number not in inventory: + _raise_vrf_lite_error( + msg=("Switch '{0}' is not present in fabric '{1}'.").format(serial_number, fabric_name), + fabric=fabric_name, + serial_number=serial_number, + vrf_name=vrf_name, + ) + + vrf_lite_entries = getattr(attach, "extensions", None) or getattr(attach, "vrf_lite", None) + if not vrf_lite_entries: + continue + + switch_data = inventory[serial_number] + role = switch_data.get("role", "") + support = None + try: + support = _query_vrf_lite_support(module, fabric_name, vrf_name, serial_number) + except NDModuleError as error: + append_runtime_warning( + module.params, + "Unable to query VRF Lite support for switch '{0}': {1}".format( + serial_number, + error.msg, + ), + ) + _warn_on_uncertain_role(module, serial_number, role, switch_data) + continue + except Exception as error: + append_runtime_warning( + module.params, + "Unable to query VRF Lite support for switch '{0}': {1}".format( + serial_number, + str(error), + ), + ) + _warn_on_uncertain_role(module, serial_number, role, switch_data) + continue + + if support is False: + _raise_vrf_lite_error( + msg=("Switch '{0}' does not report VRF Lite support in fabric '{1}'.").format(serial_number, fabric_name), + fabric=fabric_name, + serial_number=serial_number, + vrf_name=vrf_name, + ) + + if support is True: + continue + + _warn_on_uncertain_role(module, serial_number, role, switch_data) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 57689c3a6..314b787df 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -204,11 +204,14 @@ def get_diff(self, other: "NDBaseModel", exclude_unset: bool = False) -> bool: exclude_unset: When True, only compare fields explicitly set in ``other`` (via Pydantic's ``exclude_unset``). This prevents default values from triggering false diffs during merge - operations. + operations. List elements are matched one-directionally so + that an existing item with extra fields (e.g. ``deploy``) does + not trigger a spurious diff when the proposed item omits those + fields. """ self_data = self.to_diff_dict() other_data = other.to_diff_dict(exclude_unset=exclude_unset) - return issubset(other_data, self_data) + return issubset(other_data, self_data, allow_superset=exclude_unset) def merge(self, other: "NDBaseModel") -> "NDBaseModel": """ diff --git a/plugins/module_utils/models/manage_vrf_lite/__init__.py b/plugins/module_utils/models/manage_vrf_lite/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_attachment_entry.py b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_attachment_entry.py new file mode 100644 index 000000000..197ea08e4 --- /dev/null +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_attachment_entry.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from copy import deepcopy +from typing import ClassVar, Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( + VrfLiteConnectionModel, +) + + +class VrfLiteAttachmentEntry(NDBaseModel): + """Flat per-switch VRF Lite attachment row used by the state machine. + + Represents one (vrf_name, switch_ip) attachment row. The state machine + diffs proposed entries against existing entries at this granularity, so + classification into create / update / delete happens generically and the + orchestrator only needs to build a single attachment row per call. + """ + + identifiers: ClassVar[list[str]] = ["vrf_name", "switch_ip"] + identifier_strategy: ClassVar[Literal["composite"]] = "composite" + exclude_from_diff: ClassVar[set[str]] = {"deploy"} + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) + switch_ip: str = Field(alias="switch_ip", min_length=1, max_length=128) + vlan_id: Optional[int] = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: Optional[bool] = Field(default=None, alias="deploy") + import_evpn_rt: Optional[str] = Field(default=None, alias="import_evpn_rt") + export_evpn_rt: Optional[str] = Field(default=None, alias="export_evpn_rt") + extensions: Optional[list[VrfLiteConnectionModel]] = Field(default=None, alias="extensions") + + @field_validator("import_evpn_rt", "export_evpn_rt") + @classmethod + def _coerce_empty_string_to_none(cls, value: Optional[str]) -> Optional[str]: + """Normalize empty-string EVPN RT values to None. + + The query layer may return '' for absent EVPN RT fields on a switch. + Coercing to None ensures they are excluded from diff comparisons via + exclude_none=True in to_diff_dict, preventing false 'changed' + classifications on replace/override states. + """ + if value is not None and not str(value).strip(): + return None + return value + + def merge(self, other: "VrfLiteAttachmentEntry") -> "VrfLiteAttachmentEntry": + """Merge another entry into this one. + + Custom behavior: + - scalar fields follow NDBaseModel merge semantics (only explicitly set + fields on ``other`` are applied) + - ``extensions`` list is merged by ``interface`` so merged-state runs + preserve existing extensions that the user did not mention + """ + if not isinstance(other, type(self)): + raise TypeError("VrfLiteAttachmentEntry.merge requires both models to be the same type") + + merged_data = self.model_dump(by_alias=False, exclude_none=False) + incoming_data = other.model_dump(by_alias=False, exclude_none=False, exclude_unset=True) + + for field, value in incoming_data.items(): + if field == "extensions": + continue + if value is None: + continue + merged_data[field] = value + + if "extensions" in incoming_data and incoming_data.get("extensions") is not None: + current_extensions = self.extensions or [] + incoming_extensions = other.extensions or [] + + merged_extensions: dict[str, VrfLiteConnectionModel] = {} + for item in current_extensions: + key = str(item.interface).strip().lower() + merged_extensions[key] = deepcopy(item) + + for item in incoming_extensions: + key = str(item.interface).strip().lower() + existing_item = merged_extensions.get(key) + if existing_item is None: + merged_extensions[key] = deepcopy(item) + else: + merged_extensions[key] = existing_item.merge(item) + + merged_data["extensions"] = [ + item.model_dump(by_alias=False, exclude_none=False) + for _key, item in sorted(merged_extensions.items(), key=lambda kv: kv[0]) + ] + + return type(self).model_validate(merged_data, by_name=True, by_alias=True) diff --git a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py new file mode 100644 index 000000000..ee220bf6c --- /dev/null +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from copy import deepcopy +from typing import Any, ClassVar, Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +class VrfLiteConnectionModel(NDNestedModel): + """VRF Lite extension connection details for a single interface.""" + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + interface: str = Field(alias="interface", min_length=1, max_length=128) + dot1q: Optional[int] = Field(default=None, alias="dot1q", ge=1, le=4094) + ipv4_addr: Optional[str] = Field(default=None, alias="ipv4_addr") + neighbor_ipv4: Optional[str] = Field(default=None, alias="neighbor_ipv4") + ipv6_addr: Optional[str] = Field(default=None, alias="ipv6_addr") + neighbor_ipv6: Optional[str] = Field(default=None, alias="neighbor_ipv6") + peer_vrf: Optional[str] = Field(default=None, alias="peer_vrf") + + +class VrfLiteAttachmentModel(NDNestedModel): + """Attachment data for one switch under a VRF.""" + + exclude_from_diff: ClassVar[set[str]] = {"deploy"} + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + ip_address: str = Field(alias="ip_address", min_length=1, max_length=128) + deploy: Optional[bool] = Field(default=None, alias="deploy") + import_evpn_rt: Optional[str] = Field(default=None, alias="import_evpn_rt") + export_evpn_rt: Optional[str] = Field(default=None, alias="export_evpn_rt") + vrf_lite: Optional[list[VrfLiteConnectionModel]] = Field(default=None, alias="vrf_lite") + + @field_validator("ip_address") + @classmethod + def validate_ip_address_not_empty(cls, value: str) -> str: + if value is None or not str(value).strip(): + raise ValueError("ip_address must be a non-empty string") + return str(value).strip() + + def merge(self, other: "VrfLiteAttachmentModel") -> "VrfLiteAttachmentModel": + """ + Merge another attachment model into this one. + + Custom behavior: + - scalar fields follow NDBaseModel merge semantics (explicit fields only) + - `vrf_lite` list is merged by interface so merged mode preserves + existing interfaces not mentioned in the playbook input + """ + if not isinstance(other, type(self)): + raise TypeError("VrfLiteAttachmentModel.merge requires both models to be the same type") + + merged_data = self.model_dump(by_alias=False, exclude_none=False) + incoming_data = other.model_dump( + by_alias=False, + exclude_none=False, + exclude_unset=True, + ) + + for field, value in incoming_data.items(): + if field == "vrf_lite": + continue + if value is None: + continue + merged_data[field] = value + + if "vrf_lite" in incoming_data and incoming_data.get("vrf_lite") is not None: + current_vrf_lite = self.vrf_lite or [] + incoming_vrf_lite = other.vrf_lite or [] + + merged_vrf_lite = {} + for item in current_vrf_lite: + key = str(item.interface).strip().lower() + merged_vrf_lite[key] = deepcopy(item) + + for item in incoming_vrf_lite: + key = str(item.interface).strip().lower() + existing_item = merged_vrf_lite.get(key) + if existing_item is None: + merged_vrf_lite[key] = deepcopy(item) + else: + merged_vrf_lite[key] = existing_item.merge(item) + + merged_data["vrf_lite"] = [ + item.model_dump(by_alias=False, exclude_none=False) for _key, item in sorted(merged_vrf_lite.items(), key=lambda kv: kv[0]) + ] + + return type(self).model_validate(merged_data, by_name=True, by_alias=True) + + +class VrfLiteModel(NDBaseModel): + """Runtime model for nd_manage_vrf_lite state reconciliation.""" + + identifiers: ClassVar[list[str]] = ["vrf_name"] + identifier_strategy: ClassVar[Literal["single"]] = "single" + exclude_from_diff: ClassVar[set[str]] = {"deploy"} + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) + vlan_id: Optional[int] = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: Optional[bool] = Field(default=None, alias="deploy") + attach: Optional[list[VrfLiteAttachmentModel]] = Field(default=None, alias="attach") + + @field_validator("vrf_name") + @classmethod + def validate_vrf_name(cls, value: str) -> str: + if value is None or not str(value).strip(): + raise ValueError("vrf_name must be a non-empty string") + return str(value).strip() + + def to_diff_dict(self, **kwargs) -> dict[str, Any]: + """Exclude nested attachment deploy field from diff comparison.""" + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude={ + "deploy": True, + "attach": {"__all__": {"deploy": True}}, + }, + mode="json", + **kwargs, + ) + + @classmethod + def from_response(cls, response: dict[str, Any], **kwargs) -> "VrfLiteModel": + return cls.model_validate(response, by_alias=True, by_name=True, **kwargs) + + @classmethod + def from_config(cls, ansible_config: dict[str, Any], **kwargs) -> "VrfLiteModel": + return cls.model_validate(ansible_config, by_alias=True, by_name=True, **kwargs) + + @classmethod + def get_argument_spec(cls) -> dict[str, Any]: + """Return the Ansible argument spec for nd_manage_vrf_lite.""" + return VrfLitePlaybookConfigModel.get_argument_spec() + + def merge(self, other: "VrfLiteModel") -> "VrfLiteModel": + """ + Merge another VrfLiteModel into this one. + + Custom behavior: + - scalar fields follow NDBaseModel merge semantics (explicit fields only) + - `attach` list is merged by `ip_address` instead of replacing the full list + """ + if not isinstance(other, type(self)): + raise TypeError("VrfLiteModel.merge requires both models to be the same type") + + merged_data = self.model_dump(by_alias=False, exclude_none=False) + incoming_data = other.model_dump(by_alias=False, exclude_none=False, exclude_unset=True) + + for field, value in incoming_data.items(): + if field == "attach": + continue + if value is None: + continue + merged_data[field] = value + + if "attach" in incoming_data and incoming_data.get("attach") is not None: + current_attach = self.attach or [] + incoming_attach = other.attach or [] + + merged_attach = {} + for item in current_attach: + key = str(item.ip_address).strip().lower() + merged_attach[key] = deepcopy(item) + + for item in incoming_attach: + key = str(item.ip_address).strip().lower() + existing_item = merged_attach.get(key) + if existing_item is None: + merged_attach[key] = deepcopy(item) + else: + merged_attach[key] = existing_item.merge(item) + + merged_data["attach"] = [item.model_dump(by_alias=False, exclude_none=False) for _key, item in sorted(merged_attach.items(), key=lambda kv: kv[0])] + + return type(self).model_validate(merged_data, by_name=True, by_alias=True) + + +class VrfLitePlaybookItemModel(BaseModel): + """One config item under playbook `config`.""" + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) + vlan_id: Optional[int] = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: Optional[bool] = Field(default=None, alias="deploy") + attach: Optional[list[VrfLiteAttachmentModel]] = Field(default=None, alias="attach") + + @field_validator("vrf_name") + @classmethod + def validate_vrf_name(cls, value: str) -> str: + if value is None or not str(value).strip(): + raise ValueError("vrf_name must be a non-empty string") + return str(value).strip() + + def to_runtime_config(self) -> dict[str, Any]: + return self.model_dump(by_alias=False, exclude_none=True) + + +class VerifyConfigModel(BaseModel): + """Verification controls for post-write refresh behavior.""" + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + extra="ignore", + ) + + enabled: bool = Field(default=True) + retries: int = Field(default=5, ge=1) + timeout: int = Field(default=10, ge=1) + + +class ConfigActionsModel(BaseModel): + """Config save/deploy controls for write workflows.""" + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + extra="ignore", + ) + + save: bool = Field(default=True) + deploy: bool = Field(default=True) + type: Literal["switch", "global"] = Field(default="switch") + + @model_validator(mode="after") + def validate_save_deploy_dependency(self) -> "ConfigActionsModel": + if not self.save and self.deploy: + raise ValueError("config_actions.deploy=true requires config_actions.save=true") + return self + + +class VrfLitePlaybookConfigModel(BaseModel): + """Top-level playbook model for nd_manage_vrf_lite.""" + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + state: Literal["merged", "replaced", "deleted", "overridden", "gathered"] = Field(default="merged") + # TODO: Replace with the shared fabric_name Field once the collection adds it. + fabric_name: str = Field(min_length=1) + force: bool = Field(default=False) + verify: Optional[VerifyConfigModel] = Field(default=None) + config_actions: Optional[ConfigActionsModel] = Field(default=None) + config: Optional[list[VrfLitePlaybookItemModel]] = Field(default=None) + + @model_validator(mode="after") + def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": + if self.config_actions and not self.config_actions.save and self.config_actions.deploy: + raise ValueError("config_actions.deploy=true requires config_actions.save=true") + return self + + def to_runtime_config(self) -> list[dict[str, Any]]: + """Normalize playbook config into NDStateMachine runtime items.""" + return [item.to_runtime_config() for item in (self.config or [])] + + @classmethod + def get_argument_spec(cls) -> dict[str, Any]: + return dict( + state=dict(type="str", default="merged", choices=["merged", "replaced", "deleted", "overridden", "gathered"]), + fabric_name=dict(type="str", required=True), + force=dict(type="bool", default=False), + verify=dict( + type="dict", + required=False, + options=dict( + enabled=dict(type="bool", default=True), + retries=dict(type="int", default=5), + timeout=dict(type="int", default=10), + ), + ), + config_actions=dict( + type="dict", + required=False, + options=dict( + save=dict(type="bool", default=True), + deploy=dict(type="bool", default=True), + type=dict(type="str", default="switch", choices=["switch", "global"]), + ), + ), + config=dict( + type="list", + elements="dict", + required=False, + options=dict( + vrf_name=dict(type="str", required=True), + vlan_id=dict(type="int", required=False), + deploy=dict(type="bool", required=False), + attach=dict( + type="list", + elements="dict", + required=False, + options=dict( + ip_address=dict(type="str", required=True), + deploy=dict(type="bool", required=False), + import_evpn_rt=dict(type="str", required=False), + export_evpn_rt=dict(type="str", required=False), + vrf_lite=dict( + type="list", + elements="dict", + required=False, + options=dict( + interface=dict(type="str", required=True), + dot1q=dict(type="int", required=False), + ipv4_addr=dict(type="str", required=False), + neighbor_ipv4=dict(type="str", required=False), + ipv6_addr=dict(type="str", required=False), + neighbor_ipv6=dict(type="str", required=False), + peer_vrf=dict(type="str", required=False), + ), + ), + ), + ), + ), + ), + ) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index de6df2fec..043339cc6 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -34,12 +34,9 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Union[Type[NDBaseO sender = Sender() sender.ansible_module = self.module - self.rest_send = RestSend( - { - "check_mode": self.module.check_mode, - "state": self.module.params.get("state"), - } - ) + rest_send_params = dict(self.module.params) + rest_send_params["check_mode"] = self.module.check_mode + self.rest_send = RestSend(rest_send_params) self.rest_send.sender = sender self.rest_send.response_handler = ResponseHandler() @@ -67,6 +64,10 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Union[Type[NDBaseO # Initialize collections try: response_data = self.model_orchestrator.query_all() + config_data = self.module.params.get("config") or [] + normalize_config = getattr(self.model_orchestrator, "normalize_proposed_config", None) + if callable(normalize_config): + config_data = normalize_config(config=config_data, current=response_data, state=self.state) # State of configuration objects in ND before change execution self.before = NDConfigCollection.from_api_response(response_data=response_data, model_class=self.model_class) # State of current configuration objects in ND during change execution @@ -74,7 +75,7 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Union[Type[NDBaseO # Ongoing collection of configuration objects that were changed self.sent = NDConfigCollection(model_class=self.model_class) # Collection of configuration objects given by user - self.proposed = NDConfigCollection.from_ansible_config(data=self.module.params.get("config", []), model_class=self.model_class) + self.proposed = NDConfigCollection.from_ansible_config(data=config_data, model_class=self.model_class) self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) @@ -89,6 +90,9 @@ def manage_state(self) -> None: if self.state in ["merged", "replaced", "overridden"]: self._manage_create_update_state() + if self.state == "replaced": + self._manage_replace_deletions() + if self.state == "overridden": self._manage_override_deletions() @@ -193,6 +197,15 @@ def _manage_override_deletions(self) -> None: items_to_delete = [existing_item for identifier in diff_identifiers if (existing_item := self.existing.get(identifier)) is not None] self._delete_items(items_to_delete) + def _manage_replace_deletions(self) -> None: + """Allow orchestrators to scope replaced-state child deletions.""" + get_replaced_deletion_items = getattr(self.model_orchestrator, "get_replaced_deletion_items", None) + if not callable(get_replaced_deletion_items): + return + + items_to_delete = get_replaced_deletion_items(before=self.before, proposed=self.proposed, existing=self.existing) + self._delete_items(items_to_delete or []) + def _manage_delete_state(self) -> None: """Handle deleted state.""" items_to_delete = [ @@ -215,6 +228,7 @@ def _delete_items(self, items: List[NDBaseModel]) -> None: # Batch remove from collection (single index rebuild) keys_to_delete = [item.get_identifier_value() for item in items] self.existing.delete_many(keys_to_delete) + self.sent.add_many(items) # Log deletion self.output.assign(after=self.existing) diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py new file mode 100644 index 000000000..3252c71ed --- /dev/null +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +import json +from collections import defaultdict +from typing import Any, ClassVar, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs import ( + EpFabricVrfsGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_attachments import ( + EpFabricVrfsAttachmentsGet, + EpFabricVrfsAttachmentsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( + _ensure_vrf_exists, + _post_attachment_payload, + build_attach_payload_for_entry, + build_detach_payload_for_entry, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + append_runtime_warning, + get_config_actions, + get_runtime_warnings, + get_verify_settings, + _raise_vrf_lite_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.deploy import ( + _is_non_fatal_config_save_error, + _needs_deployment, + _target_vrfs_for_deploy, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( + VrfLiteResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.config_transform import ( + config_vrf_names, + explode_playbook_to_entries, + group_attachment_entries_to_vrfs, + replacement_scope_vrfs, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( + query_vrf_lite_state, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( + VrfLiteEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation import ( + validate_vrf_lite_write_guardrails, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_attachment_entry import ( + VrfLiteAttachmentEntry, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule as NDModuleV2 +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import ( + NDBaseOrchestrator, +) + + +class ManageVrfLiteOrchestrator(NDBaseOrchestrator): + """Attachment-level VRF Lite adapter for the generic ND state machine.""" + + model_class: ClassVar[type[NDBaseModel]] = VrfLiteAttachmentEntry + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True + + create_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost + update_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost + delete_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost + query_one_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsGet + query_all_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsGet + create_bulk_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost + delete_bulk_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost + + @staticmethod + def prepare_module_params(module: Any, module_config: Any, normalized_config: Optional[list[dict[str, Any]]] = None) -> None: + """Normalize module params while preserving nested playbook config.""" + state = module_config.state + if normalized_config is None: + normalized_config = module_config.to_runtime_config() + config_actions = get_config_actions(module.params) + verify_settings = get_verify_settings(module.params) + + if state == "gathered": + raw_module_args = ManageVrfLiteOrchestrator._get_raw_module_args() + raw_config_actions = raw_module_args.get("config_actions") + if isinstance(raw_config_actions, dict): + normalized_actions = module.params.get("config_actions") or {} + save_requested = "save" in raw_config_actions and normalized_actions.get("save") is True + deploy_requested = "deploy" in raw_config_actions and normalized_actions.get("deploy") is True + if save_requested or deploy_requested: + module.fail_json( + msg="config_actions.save/config_actions.deploy are not allowed with 'gathered' state. " + "Gathered workflows are strictly read-only." + ) + + config_actions = { + "save": False, + "deploy": False, + "type": config_actions.get("type", "switch"), + } + + if config_actions.get("deploy") and not config_actions.get("save"): + module.fail_json(msg="Invalid config_actions: config_actions.deploy=true requires config_actions.save=true") + + if module_config.force and state != "deleted": + append_runtime_warning( + module.params, + "Parameter 'force' only applies to state 'deleted'. Ignoring force for state '{0}'.".format(state), + ) + + module.params["config"] = normalized_config + module.params["_vrf_lite_nested_config"] = list(normalized_config) + module.params["config_actions"] = config_actions + module.params["verify"] = verify_settings + + if state == "gathered": + module.params["_gather_filter_config"] = list(normalized_config) + module.params["config"] = [] + else: + module.params["_gather_filter_config"] = [] + + if state == "deleted" and not normalized_config: + module.fail_json(msg="Config parameter is required for state 'deleted'. Specify one or more vrf_name entries in config.") + + module.params["_changed_vrfs"] = [] + module.params["_replace_scope_vrfs"] = [] + module.params["_not_in_sync_vrfs"] = [] + module.params["_ip_to_sn_mapping"] = {} + module.params["_sn_to_ip_mapping"] = {} + module.params["_vrf_lite_vrf_vlan_map"] = {} + module.params["_have"] = [] + module.params["_have_loaded"] = False + module.params["_raw_vrf_attachment_map"] = {} + module.params["_fabric_switch_inventory"] = {} + module.params["_warnings"] = list(module.params.get("_warnings")) if isinstance(module.params.get("_warnings"), list) else [] + + @staticmethod + def _get_raw_module_args() -> dict[str, Any]: + """Best-effort extraction of raw user-provided args before defaults.""" + try: + from ansible.module_utils import basic as ansible_basic + + raw_payload = getattr(ansible_basic, "_ANSIBLE_ARGS", None) + if raw_payload is None: + return {} + + if isinstance(raw_payload, (bytes, bytearray)): + decoded = raw_payload.decode("utf-8") + elif isinstance(raw_payload, str): + decoded = raw_payload + else: + return {} + + parsed = json.loads(decoded) + module_args = parsed.get("ANSIBLE_MODULE_ARGS") + return module_args if isinstance(module_args, dict) else {} + except Exception: + return {} + + def _module(self) -> Any: + return self.rest_send.sender.ansible_module + + def _public_scope_vrfs(self) -> list[str]: + module = self._module() + state = module.params.get("state", "merged") + if state == "gathered": + config = module.params.get("_gather_filter_config") or [] + else: + config = module.params.get("_vrf_lite_nested_config") or module.params.get("config") or [] + return replacement_scope_vrfs(config) + + def normalize_proposed_config(self, config: list[dict[str, Any]], current: list[dict[str, Any]], state: str) -> list[dict[str, Any]]: + """Explode nested user config into flat attachment entries for the state machine.""" + module = self._module() + if state == "replaced": + module.params["_replace_scope_vrfs"] = replacement_scope_vrfs(config) + return explode_playbook_to_entries(config=config, module=module, state=state, current_entries=current) + + def get_replaced_deletion_items( + self, + before: NDConfigCollection, + proposed: NDConfigCollection, + existing: NDConfigCollection, + ) -> list[NDBaseModel]: + """Delete omitted attachments only for VRFs mentioned by replaced state.""" + scope_vrfs = set(self._module().params.get("_replace_scope_vrfs") or []) + if not scope_vrfs: + return [] + + proposed_keys = set(proposed.keys()) + items = [] + for item in before: + if getattr(item, "vrf_name", None) not in scope_vrfs: + continue + if item.get_identifier_value() in proposed_keys: + continue + current = existing.get(item.get_identifier_value()) + if current is not None: + items.append(current) + return items + + def query_all(self, **kwargs: Any) -> list[dict[str, Any]]: + return self._query_current_state(flat=True) + + def create(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + return self.create_bulk([model_instance], **kwargs) + + def update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + return self._post_attach_entries([model_instance]) + + def delete(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + return self.delete_bulk([model_instance], **kwargs) + + def create_bulk(self, model_instances: list[Any], **kwargs: Any) -> dict[str, Any]: + return self._post_attach_entries(model_instances) + + def delete_bulk(self, model_instances: list[Any], **kwargs: Any) -> dict[str, Any]: + return self._post_detach_entries(model_instances) + + def _validate_attach_entries(self, entries: list[Any]) -> None: + module = self._module() + seen_vrfs = set() + for entry in entries: + if entry.vrf_name not in seen_vrfs: + _ensure_vrf_exists(module, entry.vrf_name) + seen_vrfs.add(entry.vrf_name) + validate_vrf_lite_write_guardrails(module=module, model_instance=entry) + + def _post_grouped_rows(self, rows_by_vrf: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: + module = self._module() + nd_v2 = NDModuleV2(module) + responses = [] + for vrf_name in sorted(rows_by_vrf): + rows = rows_by_vrf[vrf_name] + if not rows: + continue + responses.append( + { + "vrf_name": vrf_name, + "response": _post_attachment_payload(nd_v2, module.params.get("fabric_name"), vrf_name, rows), + } + ) + return {"response": responses} + + def _post_attach_entries(self, entries: list[Any]) -> dict[str, Any]: + if not entries: + return {} + module = self._module() + if module.check_mode: + return {"planned": [entry.to_config() for entry in entries]} + + self._validate_attach_entries(entries) + nd_v2 = NDModuleV2(module) + rows_by_vrf: dict[str, list[dict[str, Any]]] = defaultdict(list) + for entry in entries: + rows_by_vrf[entry.vrf_name].append(build_attach_payload_for_entry(module, nd_v2, entry)) + return self._post_grouped_rows(rows_by_vrf) + + def _post_detach_entries(self, entries: list[Any]) -> dict[str, Any]: + # _validate_attach_entries is intentionally skipped here: detach entries only + # reference attachments already confirmed present in current state by the state + # machine before calling delete_bulk. VRF-existence and role-guardrail checks + # are not needed for a clear/detach operation. + if not entries: + return {} + module = self._module() + if module.check_mode: + return {"planned": [entry.to_config() for entry in entries]} + + rows_by_vrf: dict[str, list[dict[str, Any]]] = defaultdict(list) + for entry in entries: + rows_by_vrf[entry.vrf_name].append(build_detach_payload_for_entry(module, entry)) + return self._post_grouped_rows(rows_by_vrf) + + def gather(self) -> dict[str, Any]: + flat_gathered = self._query_current_state(flat=True) + gathered = group_attachment_entries_to_vrfs(flat_gathered, module=self._module(), include_vrfs=self._public_scope_vrfs()) + output = { + "output_level": self._module().params.get("output_level", "normal"), + "changed": False, + "before": gathered, + "after": gathered, + "current": gathered, + "diff": [], + "response": [], + "result": [], + "gathered": gathered, + } + return self.inject_runtime_metadata(output) + + def deploy_pending(self, result: dict[str, Any]) -> Optional[dict[str, Any]]: + module = self._module() + config_actions = get_config_actions(module.params) + + if not config_actions.get("save", False) and not config_actions.get("deploy", False): + return None + + return self._execute_config_actions(result) + + def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: + module = self._module() + verify_settings = get_verify_settings(module.params) + if not verify_settings.get("enabled", True): + return result + if module.check_mode or not result.get("changed"): + return result + + refreshed = self._query_current_state(flat=True) + result["after"] = group_attachment_entries_to_vrfs(refreshed, module=module, include_vrfs=self._public_scope_vrfs()) + result["current"] = result["after"] + return result + + def format_public_output(self, result: dict[str, Any]) -> dict[str, Any]: + module = self._module() + state = module.params.get("state", "merged") + scoped_empty_keys = {"before", "after", "current", "gathered"} + include_vrfs = self._public_scope_vrfs() if state in ("deleted", "replaced", "overridden", "gathered") else [] + for key in ("before", "after", "current", "proposed", "diff", "gathered"): + value = result.get(key) + if not isinstance(value, list): + continue + has_flat_entries = any(isinstance(item, VrfLiteAttachmentEntry) for item in value) + should_preserve_empty_scope = not value and include_vrfs and key in scoped_empty_keys + if has_flat_entries or should_preserve_empty_scope: + result[key] = group_attachment_entries_to_vrfs(value, module=module, include_vrfs=include_vrfs) + result.setdefault("current", result.get("after", [])) + return result + + def _query_current_state(self, flat: bool = True) -> list[dict[str, Any]]: + module = self._module() + fabric_name = module.params.get("fabric_name") + state = module.params.get("state", "merged") + + if not fabric_name: + raise ValueError("fabric_name must be set") + + if state == "gathered": + if module.params.get("_have_loaded") and isinstance(module.params.get("_have"), list): + have = module.params["_have"] + return have if flat else group_attachment_entries_to_vrfs(have, module=module, include_vrfs=self._public_scope_vrfs()) + config = module.params.get("_gather_filter_config") or [] + else: + config = module.params.get("_vrf_lite_nested_config") or module.params.get("config") or [] + + filter_vrfs = set(config_vrf_names(config)) + try: + have = query_vrf_lite_state( + module=module, + fabric_name=fabric_name, + filter_vrfs=(filter_vrfs if filter_vrfs else None), + flat=True, + ) + except NDModuleError as error: + error_dict = error.to_dict() + if "msg" in error_dict: + error_dict["api_error_msg"] = error_dict.pop("msg") + _raise_vrf_lite_error(msg="Failed to query VRF Lite state: {0}".format(error.msg), fabric=fabric_name, **error_dict) + except VrfLiteResourceError: + raise + except Exception as error: + _raise_vrf_lite_error( + msg="Failed to query VRF Lite state: {0}".format(str(error)), + fabric=fabric_name, + exception_type=type(error).__name__, + ) + + module.params["_have"] = have + module.params["_have_loaded"] = True + return have if flat else group_attachment_entries_to_vrfs(have, module=module, include_vrfs=self._public_scope_vrfs()) + + def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: + module = self._module() + fabric_name = module.params.get("fabric_name") + config_actions = get_config_actions(module.params) + save_enabled = config_actions.get("save", True) + deploy_enabled = config_actions.get("deploy", True) + + if deploy_enabled and not save_enabled: + _raise_vrf_lite_error(msg="Invalid config_actions: deploy=true requires save=true") + + if not save_enabled and not deploy_enabled: + return { + "msg": "Config actions disabled (save=false, deploy=false), skipping config save/deploy", + "deployment_needed": False, + "changed": False, + "config_actions": config_actions, + "response": [], + } + + deployment_needed = _needs_deployment(result, module) + if not deployment_needed: + return { + "msg": "No changes or out-of-sync VRF Lite attachments detected, skipping config actions", + "deployment_needed": False, + "changed": False, + "config_actions": config_actions, + "response": [], + } + + requested_deploy_vrfs = set(_target_vrfs_for_deploy(module)) + # Deploy only VRFs that both changed and have deploy enabled. The intersection + # ensures we never push a VRF that the user asked to skip (deploy: false) and + # also prevents deploying stale VRFs that the run did not actually touch. + target_vrfs = sorted(set(module.params.get("_changed_vrfs") or []) & requested_deploy_vrfs) + + planned_actions = [] + if save_enabled: + planned_actions.append("POST {0}".format(VrfLiteEndpoints.config_save(fabric_name))) + if deploy_enabled and target_vrfs: + planned_actions.append("POST {0} vrfNames={1}".format(VrfLiteEndpoints.vrf_deployments(fabric_name), ",".join(target_vrfs))) + + if module.check_mode: + return { + "msg": "CHECK MODE: Would run VRF Lite save/deploy actions", + "deployment_needed": True, + "changed": True, + "config_actions": config_actions, + "target_vrfs": target_vrfs, + "planned_actions": planned_actions, + "response": [], + } + + nd_v2 = NDModuleV2(module) + responses = [] + changed = bool(module.params.get("_changed_vrfs")) + + if save_enabled: + save_payload = {"type": config_actions.get("type", "switch")} + try: + save_resp = nd_v2.request(VrfLiteEndpoints.config_save(fabric_name), HttpVerbEnum.POST, save_payload) + responses.append( + { + "operation": "config_save", + "path": VrfLiteEndpoints.config_save(fabric_name), + "payload": save_payload, + "response": save_resp, + "success": True, + } + ) + except NDModuleError as error: + if deploy_enabled and _is_non_fatal_config_save_error(error): + append_runtime_warning( + module.params, + "Config save returned a known non-fatal platform error: {0}. Continuing with deploy.".format(error.msg), + ) + responses.append( + { + "operation": "config_save", + "path": VrfLiteEndpoints.config_save(fabric_name), + "payload": save_payload, + "response": error.to_dict(), + "success": False, + "non_fatal": True, + } + ) + else: + error_dict = error.to_dict() + if "msg" in error_dict: + error_dict["api_error_msg"] = error_dict.pop("msg") + _raise_vrf_lite_error(msg="Config save failed: {0}".format(error.msg), **error_dict) + + if deploy_enabled and target_vrfs: + deploy_payload = {"vrfNames": ",".join(target_vrfs)} + try: + deploy_resp = nd_v2.request(VrfLiteEndpoints.vrf_deployments(fabric_name), HttpVerbEnum.POST, deploy_payload) + responses.append( + { + "operation": "vrf_deploy", + "path": VrfLiteEndpoints.vrf_deployments(fabric_name), + "payload": deploy_payload, + "response": deploy_resp, + "success": True, + } + ) + except NDModuleError as error: + error_dict = error.to_dict() + if "msg" in error_dict: + error_dict["api_error_msg"] = error_dict.pop("msg") + _raise_vrf_lite_error(msg="VRF deploy failed: {0}".format(error.msg), **error_dict) + + return { + "msg": "VRF Lite config actions completed", + "deployment_needed": True, + "changed": changed, + "config_actions": config_actions, + "target_vrfs": target_vrfs, + "planned_actions": planned_actions, + "response": responses, + } + + def inject_runtime_metadata(self, payload: dict[str, Any]) -> dict[str, Any]: + module = self._module() + warnings = get_runtime_warnings(module.params) + if warnings: + payload["warnings"] = warnings + + if module.params.get("_ip_to_sn_mapping"): + payload["ip_to_sn_mapping"] = module.params.get("_ip_to_sn_mapping") + + return payload diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index d4c3e59b8..141122205 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -29,8 +29,18 @@ def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remo return result -def issubset(subset: Any, superset: Any) -> bool: - """Check if subset is contained in superset.""" +def issubset(subset: Any, superset: Any, allow_superset: bool = False) -> bool: + """Check if subset is contained in superset. + + Args: + subset: The value to check. + superset: The value to check against. + allow_superset: When True, list element matching is one-directional: + an element in ``subset`` is considered matched when it is a subset + of a candidate in ``superset``, even if the candidate has + additional keys. When False (default) both directions are + required, which is equivalent to equality for lists of dicts. + """ if type(subset) is not type(superset): return False @@ -42,7 +52,11 @@ def issubset(subset: Any, superset: Any) -> bool: remaining = list(superset) for item in subset: for index, candidate in enumerate(remaining): - if issubset(item, candidate) and issubset(candidate, item): + if allow_superset: + match = issubset(item, candidate, allow_superset=True) + else: + match = issubset(item, candidate) and issubset(candidate, item) + if match: del remaining[index] break else: @@ -57,7 +71,7 @@ def issubset(subset: Any, superset: Any) -> bool: if key not in superset: return False - if not issubset(value, superset[key]): + if not issubset(value, superset[key], allow_superset=allow_superset): return False return True diff --git a/plugins/modules/nd_manage_vrf_lite.py b/plugins/modules/nd_manage_vrf_lite.py new file mode 100644 index 000000000..265cc8dc0 --- /dev/null +++ b/plugins/modules/nd_manage_vrf_lite.py @@ -0,0 +1,300 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_manage_vrf_lite +version_added: "1.4.0" +short_description: Manage VRF Lite attachments on Cisco Nexus Dashboard + +description: +- Manage VRF Lite attachment configuration in ND/NDFC fabrics. +- Supports C(gathered), C(merged), C(replaced), C(overridden), and C(deleted) states. +- Supports optional save/deploy controls through C(config_actions). + +author: +- Cisco Nexus Dashboard Team + +options: + state: + description: + - Desired state of the VRF Lite configuration. + type: str + default: merged + choices: [ merged, replaced, deleted, overridden, gathered ] + + fabric_name: + description: + - Target fabric name. + type: str + required: true + + config_actions: + description: + - Optional save/deploy actions after state reconciliation. + type: dict + suboptions: + save: + type: bool + default: true + deploy: + type: bool + default: true + type: + type: str + default: switch + choices: [ switch, global ] + + verify: + description: + - Verification controls used by runtime query/deploy helpers. + type: dict + suboptions: + enabled: + type: bool + default: true + retries: + type: int + default: 5 + timeout: + type: int + default: 10 + + force: + description: + - Reserved for delete workflows. + - Currently accepted for interface parity with other ND manage modules. + type: bool + default: false + + config: + description: + - List of VRF Lite entries. + type: list + elements: dict + suboptions: + vrf_name: + description: + - VRF name. + type: str + required: true + vlan_id: + description: + - VRF VLAN id. + type: int + deploy: + description: + - Per-VRF deploy intent used by deploy planning. + type: bool + attach: + description: + - Per-switch attachment list. + type: list + elements: dict + suboptions: + ip_address: + description: + - Switch management IP or serial number. + type: str + required: true + deploy: + description: + - Per-attachment deploy intent used by deploy planning. + type: bool + import_evpn_rt: + type: str + export_evpn_rt: + type: str + vrf_lite: + description: + - VRF Lite extension entries for the attachment. + type: list + elements: dict + suboptions: + interface: + type: str + required: true + dot1q: + type: int + ipv4_addr: + type: str + neighbor_ipv4: + type: str + ipv6_addr: + type: str + neighbor_ipv6: + type: str + peer_vrf: + type: str + +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +""" + +EXAMPLES = r""" +- name: Gather VRF Lite state + cisco.nd.nd_manage_vrf_lite: + fabric_name: my_fabric + state: gathered + +- name: Merge VRF Lite attachment + cisco.nd.nd_manage_vrf_lite: + fabric_name: my_fabric + state: merged + config: + - vrf_name: TENANT_A + vlan_id: 500 + attach: + - ip_address: 10.10.10.11 + import_evpn_rt: "" + export_evpn_rt: "" + vrf_lite: + - interface: Ethernet1/20 + dot1q: 500 + ipv4_addr: 10.33.0.2/24 + neighbor_ipv4: 10.33.0.1 + peer_vrf: TENANT_A + +- name: Delete all attachments for a VRF + cisco.nd.nd_manage_vrf_lite: + fabric_name: my_fabric + state: deleted + config: + - vrf_name: TENANT_A +""" + +RETURN = r""" +changed: + description: Whether any change was made + type: bool + returned: always +before: + description: State before operation + type: list + returned: always +after: + description: State after operation + type: list + returned: always +current: + description: Alias for after + type: list + returned: always +gathered: + description: Gathered data when C(state=gathered) + type: list + returned: when state is gathered +warnings: + description: Collected runtime warnings from validation/deploy helpers + type: list + elements: str + returned: when warnings are present +deployment: + description: Save/deploy action output when config_actions are enabled + type: dict + returned: when config_actions is used +""" + +import json + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ValidationError, + require_pydantic, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( + VrfLiteModel, + VrfLitePlaybookConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( + VrfLiteResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite import ( + ManageVrfLiteOrchestrator, +) + + +def main() -> None: + """Entry point for nd_manage_vrf_lite. + + Follows the 'declare, don't implement' pattern: + - Validate input + - Delegate state reconciliation to NDStateMachine + orchestrator + - Delegate deploy/gather to orchestrator methods + """ + argument_spec = nd_argument_spec() + argument_spec.update(VrfLiteModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + try: + module_config = VrfLitePlaybookConfigModel.model_validate(module.params, by_alias=True, by_name=True) + except ValidationError as error: + validation_errors = [] + detail_msg = str(error) + try: + validation_errors = json.loads(error.json()) + if validation_errors and isinstance(validation_errors[0], dict): + detail_msg = validation_errors[0].get("msg", detail_msg) + except Exception: + validation_errors = [{"msg": str(error)}] + + module.fail_json( + msg="Invalid nd_manage_vrf_lite playbook configuration: {0}".format(detail_msg), + validation_errors=validation_errors, + ) + + state = module_config.state + + ManageVrfLiteOrchestrator.prepare_module_params(module, module_config) + + try: + if state == "gathered": + nd_state_machine = NDStateMachine(module=module, model_orchestrator=ManageVrfLiteOrchestrator) + result = nd_state_machine.model_orchestrator.gather() + module.exit_json(**result) + + nd_state_machine = NDStateMachine(module=module, model_orchestrator=ManageVrfLiteOrchestrator) + nd_state_machine.manage_state() + + module.params["_changed_vrfs"] = sorted({item.vrf_name for item in nd_state_machine.sent}) + + result = nd_state_machine.output.format() + result.setdefault("current", result.get("after", [])) + result = nd_state_machine.model_orchestrator.format_public_output(result) + + deploy_result = nd_state_machine.model_orchestrator.deploy_pending(result) + if deploy_result: + result["deployment"] = deploy_result + result["deployment_needed"] = deploy_result.get("deployment_needed", False) + if deploy_result.get("changed"): + result["changed"] = True + + result = nd_state_machine.model_orchestrator.refresh_verified_state(result) + result = nd_state_machine.model_orchestrator.inject_runtime_metadata(result) + module.exit_json(**result) + + except VrfLiteResourceError as error: + module.fail_json(msg=error.msg, **error.details) + except Exception as error: + module.fail_json(msg=str(error)) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_delete_setup_conf.yaml b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_delete_setup_conf.yaml new file mode 100644 index 000000000..63d32736b --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_delete_setup_conf.yaml @@ -0,0 +1,17 @@ +--- +# This nd_manage_vrf_lite test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_manage_vrf_lite_conf.j2 +# Variables: vrf_lite_conf (list of dicts) + +- attach: + - ip_address: 10.122.84.55 + vrf_lite: + - dot1q: '2001' + interface: Ethernet1/20 + ipv4_addr: 10.33.0.2/24 + neighbor_ipv4: 10.33.0.1 + peer_vrf: VRF_LITE_IT_50001 + vlan_id: '2001' + vrf_name: VRF_LITE_IT_50001 diff --git a/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_full_conf.yaml b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_full_conf.yaml new file mode 100644 index 000000000..234ae6e5c --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_full_conf.yaml @@ -0,0 +1,19 @@ +--- +# This nd_manage_vrf_lite test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_manage_vrf_lite_conf.j2 +# Variables: vrf_lite_conf (list of dicts) + +- attach: + - export_evpn_rt: '' + import_evpn_rt: '' + ip_address: 10.122.84.55 + vrf_lite: + - dot1q: '2001' + interface: Ethernet1/20 + ipv4_addr: 10.33.0.2/24 + neighbor_ipv4: 10.33.0.1 + peer_vrf: VRF_LITE_IT_50001 + vlan_id: '2001' + vrf_name: VRF_LITE_IT_50001 diff --git a/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_minimal_conf.yaml b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_minimal_conf.yaml new file mode 100644 index 000000000..9c94050da --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_minimal_conf.yaml @@ -0,0 +1,10 @@ +--- +# This nd_manage_vrf_lite test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_manage_vrf_lite_conf.j2 +# Variables: vrf_lite_conf (list of dicts) + +- attach: + - ip_address: 10.122.84.55 + vrf_name: VRF_LITE_IT_50001 diff --git a/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_modified_conf.yaml b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_modified_conf.yaml new file mode 100644 index 000000000..de7ca9ab2 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_modified_conf.yaml @@ -0,0 +1,19 @@ +--- +# This nd_manage_vrf_lite test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_manage_vrf_lite_conf.j2 +# Variables: vrf_lite_conf (list of dicts) + +- attach: + - export_evpn_rt: '' + import_evpn_rt: '' + ip_address: 10.122.84.55 + vrf_lite: + - dot1q: '2001' + interface: Ethernet1/20 + ipv4_addr: 10.33.0.10/24 + neighbor_ipv4: 10.33.0.9 + peer_vrf: VRF_LITE_IT_50001 + vlan_id: '2001' + vrf_name: VRF_LITE_IT_50001 diff --git a/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_no_deploy_conf.yaml b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_no_deploy_conf.yaml new file mode 100644 index 000000000..63d32736b --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_no_deploy_conf.yaml @@ -0,0 +1,17 @@ +--- +# This nd_manage_vrf_lite test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_manage_vrf_lite_conf.j2 +# Variables: vrf_lite_conf (list of dicts) + +- attach: + - ip_address: 10.122.84.55 + vrf_lite: + - dot1q: '2001' + interface: Ethernet1/20 + ipv4_addr: 10.33.0.2/24 + neighbor_ipv4: 10.33.0.1 + peer_vrf: VRF_LITE_IT_50001 + vlan_id: '2001' + vrf_name: VRF_LITE_IT_50001 diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/assert_vrf_lite_attachment.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/assert_vrf_lite_attachment.yaml new file mode 100644 index 000000000..675f640e9 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/assert_vrf_lite_attachment.yaml @@ -0,0 +1,53 @@ +--- +- name: ASSERT - Reset VRF Lite assertion selections + ansible.builtin.set_fact: + vrf_lite_assert_entries: [] + vrf_lite_assert_attach_entries: [] + vrf_lite_assert_lite_entries: [] + +- name: ASSERT - Select gathered VRF Lite entry + ansible.builtin.set_fact: + vrf_lite_assert_entries: >- + {{ + (vrf_lite_assert_result.gathered | default([])) + | selectattr('vrf_name', 'equalto', vrf_lite_assert_vrf_name) + | list + }} + +- name: ASSERT - Select gathered VRF Lite attachment + ansible.builtin.set_fact: + vrf_lite_assert_attach_entries: >- + {{ + (vrf_lite_assert_entries[0].attach | default([])) + | selectattr('ip_address', 'equalto', vrf_lite_assert_switch) + | list + }} + when: vrf_lite_assert_entries | length == 1 + +- name: ASSERT - Select gathered VRF Lite interface + ansible.builtin.set_fact: + vrf_lite_assert_lite_entries: >- + {{ + (vrf_lite_assert_attach_entries[0].vrf_lite | default([])) + | selectattr('interface', 'equalto', vrf_lite_assert_interface) + | list + }} + when: + - vrf_lite_assert_entries | length == 1 + - vrf_lite_assert_attach_entries | length == 1 + +- name: ASSERT - Verify gathered VRF Lite attachment content + ansible.builtin.assert: + that: + - vrf_lite_assert_result.failed == false + - vrf_lite_assert_result.gathered is defined + - vrf_lite_assert_entries | length == 1 + - (vrf_lite_assert_entries[0].vlan_id | int) == (vrf_lite_assert_vlan_id | int) + - (vrf_lite_assert_entries[0].attach | default([]) | length) == (vrf_lite_assert_attach_count | default(1) | int) + - vrf_lite_assert_attach_entries | length == 1 + - (vrf_lite_assert_attach_entries[0].vrf_lite | default([]) | length) == (vrf_lite_assert_connection_count | default(1) | int) + - vrf_lite_assert_lite_entries | length == 1 + - (vrf_lite_assert_lite_entries[0].dot1q | int) == (vrf_lite_assert_dot1q | int) + - vrf_lite_assert_lite_entries[0].ipv4_addr == vrf_lite_assert_ipv4_addr + - vrf_lite_assert_lite_entries[0].neighbor_ipv4 == vrf_lite_assert_neighbor_ipv4 + - vrf_lite_assert_lite_entries[0].peer_vrf == vrf_lite_assert_peer_vrf diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml new file mode 100644 index 000000000..7d8f70e50 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml @@ -0,0 +1,118 @@ +--- +# Shared base tasks for nd_manage_vrf_lite integration tests. +# Import this at the top of each test file: +# - import_tasks: base_tasks.yaml +# tags: + +- name: BASE - Test Entry Point - [nd_manage_vrf_lite] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ nd_manage_vrf_lite Integration Test Base Setup +" + - "----------------------------------------------------------------" + +- name: BASE - Set nd_info defaults + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + +# -------------------------------- +# Create Dictionary of Test Data +# -------------------------------- +- name: BASE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_ip | default(switch_id | default(switch1_serial)) }}" + test_switch1_serial: "{{ switch1_serial | default(switch1_ip | default(switch_id)) }}" + test_switch2: "{{ switch2_ip | default(switch2_serial | default('')) }}" + test_switch2_serial: "{{ switch2_serial | default(switch2_ip | default('')) }}" + test_vrf: "{{ vrf_name | default('TENANT_A') }}" + test_vlan_id: "{{ vrf_vlan_id | default(500) }}" + test_interface: "{{ vrf_lite_interface | default('Ethernet1/20') }}" + test_dot1q: "{{ vrf_lite_dot1q | default(500) }}" + test_ipv4_addr: "{{ vrf_lite_ipv4_addr | default('10.33.0.2/24') }}" + test_neighbor_ipv4: "{{ vrf_lite_neighbor_ipv4 | default('10.33.0.1') }}" + test_interface2: "{{ vrf_lite_interface2 | default('Ethernet1/21') }}" + test_dot1q2: "{{ vrf_lite_dot1q2 | default((vrf_lite_dot1q | default(500) | int) + 1) }}" + test_ipv4_addr2: "{{ vrf_lite_ipv4_addr2 | default('10.33.1.2/24') }}" + test_neighbor_ipv4_2: "{{ vrf_lite_neighbor_ipv4_2 | default('10.33.1.1') }}" + test_peer_vrf: "{{ vrf_lite_peer_vrf | default(vrf_name | default('TENANT_A')) }}" + deploy_local: true + delegate_to: localhost + +- name: BASE - Setup extended VRF Lite attachment payload for coverage + ansible.builtin.set_fact: + vrf_lite_attach_extended: + - ip_address: "{{ test_switch1 }}" + import_evpn_rt: "" + export_evpn_rt: "" + vrf_lite: + - interface: "{{ vrf_lite_interface | default('Ethernet1/20') }}" + dot1q: "{{ vrf_lite_dot1q | default(500) }}" + ipv4_addr: "{{ vrf_lite_ipv4_addr | default('10.33.0.2/24') }}" + neighbor_ipv4: "{{ vrf_lite_neighbor_ipv4 | default('10.33.0.1') }}" + ipv6_addr: "" + neighbor_ipv6: "" + peer_vrf: "{{ vrf_lite_peer_vrf | default('TENANT_A') }}" + delegate_to: localhost + +# ------------------------------------------ +# Query Fabric Reachability +# ------------------------------------------ +- name: BASE - Verify fabric is reachable via VRF Lite gather API + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + verify: + timeout: 60 + retries: 3 + register: fabric_query + failed_when: false + +- name: BASE - Assert fabric exists + ansible.builtin.assert: + that: + - fabric_query.failed == false + fail_msg: "Fabric '{{ test_fabric }}' not found or VRF Lite API unreachable." + +- name: BASE - Reset HTTPAPI socket before cleanup + ansible.builtin.meta: reset_connection + +# ------------------------------------------ +# Clean up existing VRF Lite attachments +# ------------------------------------------ +- name: BASE - Clean up existing VRF Lite attachments for test VRF + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + +- name: BASE - Clean up existing secondary VRF Lite attachment for test VRF + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch2 }}" + failed_when: false + when: test_switch2 | length > 0 diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/conf_prep_tasks.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/conf_prep_tasks.yaml new file mode 100644 index 000000000..c059a9685 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/conf_prep_tasks.yaml @@ -0,0 +1,21 @@ +--- +# Shared configuration preparation tasks for nd_manage_vrf_lite integration tests. +# +# Usage: +# - name: Import Configuration Prepare Tasks +# vars: +# file: merge # output variable identifier +# import_tasks: conf_prep_tasks.yaml +# +# Requires: vrf_lite_conf variable to be set before importing. + +- name: Render Configuration Data into Variable + ansible.builtin.set_fact: + "{{ 'nd_manage_vrf_lite_' + file + '_conf' }}": >- + {{ + lookup( + 'ansible.builtin.template', + (nd_vrf_lite_root | default(playbook_dir | dirname)) + '/templates/nd_manage_vrf_lite_conf.j2' + ) | from_yaml + }} + delegate_to: localhost diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml new file mode 100644 index 000000000..f03b593cb --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml @@ -0,0 +1,60 @@ +--- +# Test discovery and execution for nd_manage_vrf_lite integration tests. +# +# Usage: +# ansible-playbook -i hosts.yaml tasks/main.yaml # run all tests +# ansible-playbook -i hosts.yaml tasks/main.yaml -e testcase=nd_manage_vrf_lite_merge # run one +# ansible-playbook -i hosts.yaml tasks/main.yaml --tags merge # run by tag + +- name: nd_manage_vrf_lite integration tests + hosts: nd + gather_facts: false + vars: + nd_vrf_lite_root: "{{ playbook_dir | dirname }}" + nd_vrf_lite_tasks_dir: "{{ playbook_dir }}" + tasks: + - name: Test that we have a Nexus Dashboard host, username and password + tags: always + ansible.builtin.assert: + that: + - ansible_host is defined + - ansible_user is defined + - ansible_password is defined + fail_msg: "Please define the following variables: ansible_host, ansible_user and ansible_password." + + - name: Discover nd_manage_vrf_lite test cases + tags: always + ansible.builtin.find: + paths: "{{ nd_vrf_lite_tasks_dir }}" + patterns: "{{ testcase | default('nd_manage_vrf_lite_*') }}.yaml" + file_type: file + delegate_to: localhost + run_once: true + connection: local + register: nd_vrf_lite_testcases + + - name: Build list of test items + tags: always + ansible.builtin.set_fact: + test_items: "{{ nd_vrf_lite_testcases.files | map(attribute='path') | sort | list }}" + + - name: Assert nd_manage_vrf_lite test discovery has matches + tags: always + ansible.builtin.assert: + that: + - test_items | length > 0 + fail_msg: >- + No nd_manage_vrf_lite test cases matched pattern + '{{ testcase | default("nd_manage_vrf_lite_*") }}.yaml' under '{{ nd_vrf_lite_tasks_dir }}'. + + - name: Display discovered tests + tags: always + ansible.builtin.debug: + msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" + + - name: Run nd_manage_vrf_lite test cases + tags: always + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + loop: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_delete.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_delete.yaml new file mode 100644 index 000000000..e7958b5d4 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_delete.yaml @@ -0,0 +1,735 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_manage_vrf_lite Base Tasks + ansible.builtin.import_tasks: base_tasks.yaml + tags: delete + +- name: DELETE - Set nd_info defaults + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + timeout: "{{ nd_vrf_lite_module_timeout | default(600) }}" + tags: delete + +############################################## +## Setup Delete TestCase Variables ## +############################################## + +- name: DELETE - Setup config + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: delete + +- name: Import Configuration Prepare Tasks - delete_setup + vars: + file: delete_setup + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: delete + +############################################## +## DELETE ## +############################################## + +# TC1 - Setup: Create VRF Lite attachment for deletion tests +- name: DELETE - TC1 - MERGE - Create VRF Lite attachment for deletion testing + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + +- name: DELETE - TC1 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: delete + +- name: DELETE - TC1 - ASSERT - Verify VRF Lite state in ND + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: delete + +# TC2 - Delete VRF Lite with specific config (without deploy) +- name: DELETE - TC2 - DELETE - Delete VRF Lite with specific config (no deploy) + cisco.nd.nd_manage_vrf_lite: &delete_specific + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: delete + +- name: DELETE - TC2 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + - result.deployment is not defined + tags: delete + +- name: DELETE - TC2 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: delete + +- name: DELETE - TC2 - ASSERT - Verify VRF Lite deletion + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: delete + +# TC2b - Delete VRF Lite with deploy=true path +- name: DELETE - TC2b - MERGE - Recreate VRF Lite for deploy-delete path + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC2b - ASSERT - Verify setup creation for deploy-delete + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: delete + +- name: DELETE - TC2b - DELETE - Delete VRF Lite with deploy enabled + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: true + deploy: true + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: delete + +- name: DELETE - TC2b - ASSERT - Verify deploy delete execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is defined + - (result.deployment_needed | default(result.deployment.deployment_needed | default(false))) | bool == true + tags: delete + +- name: DELETE - TC2b - ASSERT - Verify config-save and deploy API traces + ansible.builtin.assert: + that: + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: delete + +- name: DELETE - TC2b - GATHER - Verify deploy delete result in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: delete + +- name: DELETE - TC2b - ASSERT - Verify VRF Lite deleted after deploy + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: delete + +# TC3 - Idempotence test for deletion (no deploy) +- name: DELETE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vrf_lite: *delete_specific + register: result + tags: delete + +- name: DELETE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: delete + +# TC4 - force=true accepted on normal non-deploy delete path +- name: DELETE - TC4 - MERGE - Create VRF Lite for force-accepted delete test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC4 - ASSERT - Verify setup creation for force-accepted test + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: delete + +- name: DELETE - TC4 - DELETE - Delete VRF Lite with force accepted + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + force: true + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: delete + +- name: DELETE - TC4 - ASSERT - Verify non-deploy delete execution with force accepted + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is not defined + tags: delete + +- name: DELETE - TC4 - GATHER - Verify force-accepted deletion result in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: delete + +- name: DELETE - TC4 - ASSERT - Verify VRF Lite deleted with force accepted + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: delete + +# TC5 - Delete with config_actions save+deploy +- name: DELETE - TC5 - MERGE - Recreate VRF Lite for config_actions delete path + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC5 - DELETE - Delete VRF Lite with config_actions save+deploy + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: true + deploy: true + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: tc5_delete_result + tags: delete + +- name: DELETE - TC5 - ASSERT - Verify config_actions save+deploy execution in delete flow + ansible.builtin.assert: + that: + - tc5_delete_result.failed == false + - tc5_delete_result.changed == true + - tc5_delete_result.deployment is defined + - tc5_delete_result.deployment.config_actions is defined + - tc5_delete_result.deployment.config_actions.save == true + - tc5_delete_result.deployment.config_actions.deploy == true + - tc5_delete_result.deployment.config_actions.type == "switch" + - tc5_delete_result.deployment.response is defined + - (tc5_delete_result.deployment.response | length) >= 2 + - > + ( + tc5_delete_result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + tc5_delete_result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: delete + +- name: DELETE - TC5 - GATHER - Verify config_actions delete result in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: tc5_verify_result + tags: delete + +- name: DELETE - TC5 - ASSERT - Verify VRF Lite deleted after config_actions delete + ansible.builtin.assert: + that: + - tc5_verify_result.failed == false + - tc5_verify_result.gathered is defined + - tc5_verify_result.gathered | length == 1 + - tc5_verify_result.gathered[0].vrf_name == test_vrf + - tc5_verify_result.gathered[0].attach | default([]) | length == 0 + tags: delete + +# TC6 - check_mode should not apply delete configuration changes +- name: DELETE - TC6 - MERGE - Ensure VRF Lite exists before check_mode delete test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_delete_setup_conf }}" + register: tc6_setup_result + tags: delete + +- name: DELETE - TC6 - ASSERT - Verify setup creation for check_mode delete + ansible.builtin.assert: + that: + - tc6_setup_result.failed == false + - tc6_setup_result.changed in [true, false] + tags: delete + +- name: DELETE - TC6 - DELETE - Run check_mode delete for existing VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + check_mode: true + register: tc6_delete_result + tags: delete + +- name: DELETE - TC6 - ASSERT - Verify check_mode delete preview behavior + ansible.builtin.assert: + that: + - tc6_delete_result.failed == false + - tc6_delete_result.changed == true + - tc6_delete_result.deployment is not defined + tags: delete + +- name: DELETE - TC6 - GATHER - Verify check_mode delete did not remove VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: tc6_verify_result + tags: delete + +- name: DELETE - TC6 - ASSERT - Verify VRF Lite still exists after check_mode delete + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ tc6_verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: delete + +# TC7 - Delete with empty config must be rejected (guard against accidental purge) +- name: DELETE - TC7 - DELETE - Validate empty config is rejected + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: [] + register: result + failed_when: false + tags: delete + +- name: DELETE - TC7 - ASSERT - Verify empty delete config rejected + ansible.builtin.assert: + that: + - result.changed == false + - result.msg is defined + - result.msg is search("Config parameter is required for state 'deleted'") + tags: delete + +# TC8 - Unknown attachment delete must be a no-op and preserve existing attachment +- name: DELETE - TC8 - MERGE - Ensure original VRF Lite exists before no-op delete + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_delete_setup_conf }}" + register: tc8_setup_result + when: test_switch2 | length > 0 + tags: delete + +- name: DELETE - TC8 - ASSERT - Verify setup for no-op delete + ansible.builtin.assert: + that: + - tc8_setup_result.failed == false + - tc8_setup_result.changed in [true, false] + when: test_switch2 | length > 0 + tags: delete + +- name: DELETE - TC8 - DELETE - Delete with unattached switch + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch2 }}" + register: tc8_delete_result + failed_when: false + when: test_switch2 | length > 0 + tags: delete + +- name: DELETE - TC8 - ASSERT - Verify unknown attachment delete is no-op + ansible.builtin.assert: + that: + - tc8_delete_result.failed == false + - tc8_delete_result.changed == false + - tc8_delete_result.deployment is not defined + - > + ( + (tc8_delete_result.warnings | default([]) | join(' ')) is search('No matching VRF Lite attachment') + or + (tc8_delete_result.msg | default('')) is search('No matching VRF Lite attachment') + ) + when: test_switch2 | length > 0 + tags: delete + +- name: DELETE - TC8 - GATHER - Verify original attachment remains after no-op delete + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: tc8_verify_result + when: test_switch2 | length > 0 + tags: delete + +- name: DELETE - TC8 - ASSERT - Verify original attachment content remains + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ tc8_verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + when: test_switch2 | length > 0 + tags: delete + +# TC9 - Delete with verify.enabled=false clears a multi-link VRF Lite attachment +- name: DELETE - TC9 - MERGE - Create two VRF Lite links for verify-disabled delete + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + - interface: "{{ test_interface2 }}" + dot1q: "{{ test_dot1q2 | int }}" + ipv4_addr: "{{ test_ipv4_addr2 }}" + neighbor_ipv4: "{{ test_neighbor_ipv4_2 }}" + peer_vrf: "{{ test_peer_vrf }}" + register: tc9_setup_result + tags: delete + +- name: DELETE - TC9 - ASSERT - Verify two-link setup + ansible.builtin.assert: + that: + - tc9_setup_result.failed == false + - tc9_setup_result.changed == true + tags: delete + +- name: DELETE - TC9 - DELETE - Delete multi-link attachment with verify disabled + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + verify: + enabled: false + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: tc9_delete_result + tags: delete + +- name: DELETE - TC9 - ASSERT - Verify delete output clears attachment + vars: + tc9_after_vrf: >- + {{ + (tc9_delete_result.after | default([])) + | selectattr('vrf_name', 'equalto', test_vrf) + | list + }} + tc9_current_vrf: >- + {{ + (tc9_delete_result.current | default([])) + | selectattr('vrf_name', 'equalto', test_vrf) + | list + }} + ansible.builtin.assert: + that: + - tc9_delete_result.failed == false + - tc9_delete_result.changed == true + - tc9_after_vrf | length == 1 + - tc9_current_vrf | length == 1 + - > + ( + tc9_after_vrf[0].attach | default([]) + | selectattr('ip_address', 'equalto', test_switch1) + | list + | length + ) == 0 + - > + ( + tc9_current_vrf[0].attach | default([]) + | selectattr('ip_address', 'equalto', test_switch1) + | list + | length + ) == 0 + tags: delete + +- name: DELETE - TC9 - GATHER - Verify multi-link attachment removed after verify-disabled delete + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: tc9_verify_result + tags: delete + +- name: DELETE - TC9 - ASSERT - Verify gathered has no attachment + ansible.builtin.assert: + that: + - tc9_verify_result.failed == false + - tc9_verify_result.gathered is defined + - tc9_verify_result.gathered | length == 1 + - tc9_verify_result.gathered[0].vrf_name == test_vrf + - tc9_verify_result.gathered[0].attach | default([]) | length == 0 + tags: delete + +############################################## +## CLEAN-UP ## +############################################## + +- name: DELETE - END - ensure clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + when: cleanup_at_end | default(true) + tags: delete + +- name: DELETE - END - ensure secondary clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch2 }}" + failed_when: false + when: + - cleanup_at_end | default(true) + - test_switch2 | length > 0 + tags: delete diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_gather.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_gather.yaml new file mode 100644 index 000000000..0d937aca0 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_gather.yaml @@ -0,0 +1,352 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_manage_vrf_lite Base Tasks + ansible.builtin.import_tasks: base_tasks.yaml + tags: gather + +- name: GATHER - Set nd_info defaults + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + timeout: "{{ nd_vrf_lite_module_timeout | default(600) }}" + tags: gather + +############################################## +## Setup Gather TestCase Variables ## +############################################## + +- name: GATHER - Setup config + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: gather + +- name: Import Configuration Prepare Tasks - gather_setup + vars: + file: gather_setup + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: gather + +############################################## +## GATHER ## +############################################## + +# TC1 - Setup: Create VRF Lite attachment for gather tests +- name: GATHER - TC1 - MERGE - Create VRF Lite attachment for testing + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_gather_setup_conf }}" + register: result + tags: gather + +- name: GATHER - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: gather + +- name: GATHER - TC1 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + register: verify_result + tags: gather + +- name: GATHER - TC1 - ASSERT - Verify gathered data is returned + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: gather + +- name: GATHER - TC1 - ASSERT - Verify gathered output shape + ansible.builtin.assert: + that: + - verify_result.changed == false + - verify_result.gathered | type_debug == "list" + - verify_result.gathered.vrf_lite is not defined + tags: gather + +# TC2 - Gather with no filters +- name: GATHER - TC2 - GATHER - Gather all VRF Lite attachments with no filters + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + register: result + tags: gather + +- name: GATHER - TC2 - ASSERT - Check gather results + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: gather + +- name: GATHER - TC2 - ASSERT - Verify gather is read-only + ansible.builtin.assert: + that: + - result.changed == false + tags: gather + +# TC3 - Gather with VRF name filter +- name: GATHER - TC3 - GATHER - Gather VRF Lite with VRF name filter + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: result + tags: gather + +- name: GATHER - TC3 - ASSERT - Check gather results with VRF filter + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == false + - result.gathered is defined + - result.gathered | length == 1 + - result.gathered[0].vrf_name == test_vrf + tags: gather + +- name: GATHER - TC3 - ASSERT - Verify VRF-filtered attachment content + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: gather + +# TC4 - Gather with non-existent VRF +- name: GATHER - TC4 - GATHER - Gather VRF Lite with non-existent VRF + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "NONEXISTENT_VRF_12345" + register: result + failed_when: false + tags: gather + +- name: GATHER - TC4 - ASSERT - Check gather results with non-existent VRF + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == false + - result.gathered is defined + - result.gathered == [] + tags: gather + +# TC5 - Gather with custom verify timeout/retries +- name: GATHER - TC5 - GATHER - Gather with verify override + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + verify: + timeout: 20 + retries: 3 + register: result + tags: gather + +- name: GATHER - TC5 - ASSERT - Verify verify path execution + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: gather + +- name: GATHER - TC5 - ASSERT - Verify gather with verify override is read-only + ansible.builtin.assert: + that: + - result.changed == false + tags: gather + +# TC6 - gathered + deploy validation (must fail) +- name: GATHER - TC6 - GATHER - Gather with deploy enabled (invalid) + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: true + deploy: true + type: switch + register: result + failed_when: false + tags: gather + +- name: GATHER - TC6 - ASSERT - Verify gathered+deploy validation + ansible.builtin.assert: + that: + - result.changed == false + - result.gathered is not defined + - result.msg is defined + tags: gather + +# TC7 - gathered + native check_mode validation +- name: GATHER - TC7 - GATHER - Gather with native check_mode enabled + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + check_mode: true + register: result + tags: gather + +- name: GATHER - TC7 - ASSERT - Verify gathered+check_mode behavior + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: gather + +- name: GATHER - TC7 - ASSERT - Verify check_mode gather is read-only + ansible.builtin.assert: + that: + - result.changed == false + tags: gather + +# TC8 - Gather with VRF name + switch filter +- name: GATHER - TC8 - GATHER - Gather VRF Lite with VRF and switch filter + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: gather + +- name: GATHER - TC8 - ASSERT - Check gather results with VRF and switch filter + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == false + - result.gathered is defined + - result.gathered | length == 1 + - result.gathered[0].vrf_name == test_vrf + - result.gathered[0].attach | default([]) | length == 1 + - result.gathered[0].attach[0].ip_address == test_switch1 + tags: gather + +- name: GATHER - TC8 - ASSERT - Verify VRF and switch filtered attachment content + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: gather + +############################################## +## CLEAN-UP ## +############################################## + +- name: GATHER - END - ensure clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + when: cleanup_at_end | default(true) + tags: gather diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_merge.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_merge.yaml new file mode 100644 index 000000000..e73a1dd92 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_merge.yaml @@ -0,0 +1,899 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_manage_vrf_lite Base Tasks + ansible.builtin.import_tasks: base_tasks.yaml + tags: merge + +- name: MERGE - Set nd_info defaults + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + timeout: "{{ nd_vrf_lite_module_timeout | default(600) }}" + tags: merge + +############################################## +## Setup Merge TestCase Variables ## +############################################## + +- name: MERGE - Setup full config + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + import_evpn_rt: "" + export_evpn_rt: "" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_full + vars: + file: merge_full + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup modified config (changed ipv4 addr) + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + import_evpn_rt: "" + export_evpn_rt: "" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "10.33.0.10/24" + neighbor_ipv4: "10.33.0.9" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_modified + vars: + file: merge_modified + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup minimal config (VRF name + switch only) + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_minimal + vars: + file: merge_minimal + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup no-deploy config + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_no_deploy + vars: + file: merge_no_deploy + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: merge + +############################################## +## MERGE ## +############################################## + +# TC1 - Create VRF Lite attachment with full configuration +- name: MERGE - TC1 - MERGE - Create VRF Lite with full configuration + cisco.nd.nd_manage_vrf_lite: &conf_full + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_full_conf }}" + register: result + tags: merge + +- name: MERGE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +- name: MERGE - TC1 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC1 - ASSERT - Verify VRF Lite state in ND + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: merge + +- name: MERGE - TC1 - conf - Idempotence + cisco.nd.nd_manage_vrf_lite: *conf_full + register: result + tags: merge + +- name: MERGE - TC1 - ASSERT - Check if changed flag is false (idempotence) + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == false + - result.class_diff.created | default([]) | length == 0 + - result.class_diff.deleted | default([]) | length == 0 + tags: merge + +# TC2 - Modify existing VRF Lite configuration +- name: MERGE - TC2 - MERGE - Modify VRF Lite configuration (change IP) + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_modified_conf }}" + register: result + tags: merge + +- name: MERGE - TC2 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +- name: MERGE - TC2 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC2 - ASSERT - Verify modified VRF Lite state + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "10.33.0.10/24" + vrf_lite_assert_neighbor_ipv4: "10.33.0.9" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: merge + +# TC3 - Delete VRF Lite attachment +- name: MERGE - TC3 - DELETE - Delete VRF Lite attachment + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: merge + +- name: MERGE - TC3 - ASSERT - Check if delete successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +- name: MERGE - TC3 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC3 - ASSERT - Verify VRF Lite deletion + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: merge + +# TC4 - Create VRF Lite with minimal configuration +- name: MERGE - TC4 - MERGE - Create VRF Lite with minimal configuration + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_minimal_conf }}" + register: result + tags: merge + +- name: MERGE - TC4 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +- name: MERGE - TC4 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC4 - ASSERT - Verify minimal VRF Lite state + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 1 + - verify_result.gathered[0].attach[0].ip_address == test_switch1 + tags: merge + +# TC4b - Delete VRF Lite after minimal test +- name: MERGE - TC4b - DELETE - Delete VRF Lite after minimal test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: merge + +- name: MERGE - TC4b - ASSERT - Check if delete successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +# TC5 - Create VRF Lite with defaults (state omitted) +- name: MERGE - TC5 - MERGE - Create VRF Lite with defaults + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_minimal_conf }}" + register: result + tags: merge + +- name: MERGE - TC5 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.diff is defined + tags: merge + +# TC5b - Delete VRF Lite after defaults test +- name: MERGE - TC5b - DELETE - Delete VRF Lite after defaults test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + register: result + tags: merge + +- name: MERGE - TC5b - ASSERT - Check if delete successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +# TC6 - Create VRF Lite with deploy flag false +- name: MERGE - TC6 - MERGE - Create VRF Lite with deploy false + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_no_deploy_conf }}" + register: result + tags: merge + +- name: MERGE - TC6 - ASSERT - Check if changed flag is true and no deploy + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is not defined + tags: merge + +- name: MERGE - TC6 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC6 - ASSERT - Verify VRF Lite state after no-deploy merge + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: merge + +# TC6b - Re-run same config with deploy=true after non-deploy create +- name: MERGE - TC6b - MERGE - Re-run same config with deploy true + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: true + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_merge_no_deploy_conf }}" + register: result + tags: merge + +- name: MERGE - TC6b - ASSERT - Verify deployment decision after non-deploy config + vars: + tc6b_deployment_needed: >- + {{ + (result.deployment_needed | default(result.deployment.deployment_needed | default(false))) + | bool + }} + ansible.builtin.assert: + that: + - result.failed == false + - result.deployment is defined + - > + ( + (tc6b_deployment_needed and (result.deployment.response is defined) + and ((result.deployment.response | selectattr('operation', 'equalto', 'config_save') | list | length) > 0) + and ((result.deployment.response | selectattr('operation', 'equalto', 'vrf_deploy') | list | length) > 0)) + or + ((not tc6b_deployment_needed) and (result.deployment.msg | default('') is search('skipping'))) + ) + tags: merge + +# TC7 - Test invalid configurations (non-existent switch) +- name: MERGE - TC7 - MERGE - Create VRF Lite with invalid switch IP + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "192.168.255.255" + vrf_lite: + - interface: "Ethernet1/20" + dot1q: 500 + ipv4_addr: "10.33.0.2/24" + neighbor_ipv4: "10.33.0.1" + peer_vrf: "{{ test_peer_vrf }}" + register: result + failed_when: false + tags: merge + +- name: MERGE - TC7 - ASSERT - Check invalid switch error + ansible.builtin.assert: + that: + - result.changed == false + - result.msg is defined + - > + ( + (result.msg is search("192.168.255.255")) + or + (result.msg is search("not found")) + or + (result.msg is search("do not exist")) + or + (result.msg is search("Switch validation failed")) + ) + tags: merge + +# TC8 - Create VRF Lite with deploy enabled (actual deployment path) +- name: MERGE - TC8 - DELETE - Ensure VRF Lite is absent before deploy test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: merge + +- name: MERGE - TC8 - MERGE - Create VRF Lite with deploy true + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: true + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_merge_full_conf }}" + register: result + tags: merge + +- name: MERGE - TC8 - ASSERT - Verify deploy path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is defined + - (result.deployment_needed | default(result.deployment.deployment_needed | default(false))) | bool == true + tags: merge + +- name: MERGE - TC8 - ASSERT - Verify config-save and deploy API traces + ansible.builtin.assert: + that: + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: merge + +# TC9 - Idempotent deploy skip +- name: MERGE - TC9 - MERGE - Re-run same deploy-enabled config + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: true + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_merge_full_conf }}" + register: result + tags: merge + +- name: MERGE - TC9 - ASSERT - Verify idempotent deploy skip + vars: + tc9_deployment_needed: >- + {{ + (result.deployment_needed | default(result.deployment.deployment_needed | default(false))) + | bool + }} + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == false + - tc9_deployment_needed == false + tags: merge + +# TC10 - check_mode should not apply configuration changes +- name: MERGE - TC10 - DELETE - Ensure VRF Lite absent before check_mode test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: merge + +- name: MERGE - TC10 - MERGE - Run check_mode create for VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_full_conf }}" + check_mode: true + register: result + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is not defined + tags: merge + +- name: MERGE - TC10 - GATHER - Verify check_mode flow did not create VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify gathered after check_mode + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: merge + +# TC11 - Validate invalid config_actions (save: false, deploy: true) +- name: MERGE - TC11 - MERGE - Invalid config_actions (save false, deploy true) + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_merge_full_conf }}" + register: result + failed_when: false + tags: merge + +- name: MERGE - TC11 - ASSERT - Verify invalid config_actions rejected + ansible.builtin.assert: + that: + - result.msg is defined + - result.msg is search("deploy.*requires.*save") + tags: merge + +# TC12 - config_actions with save only (no deploy) +- name: MERGE - TC12 - DELETE - Ensure VRF Lite absent before config_actions test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: merge + +- name: MERGE - TC12 - MERGE - Create VRF Lite with save only + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: true + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_merge_full_conf }}" + register: result + tags: merge + +- name: MERGE - TC12 - ASSERT - Verify save-only behavior + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +# TC13 - Extended VRF Lite attachment payload coverage +- name: MERGE - TC13 - MERGE - Apply extended VRF Lite attachment payload + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: "{{ vrf_lite_attach_extended }}" + register: result + tags: merge + +- name: MERGE - TC13 - ASSERT - Verify extended payload applied + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +- name: MERGE - TC13 - GATHER - Get VRF Lite state after extended payload + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: merge + +- name: MERGE - TC13 - ASSERT - Verify extended state + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: merge + +# TC14 - Multi-link coverage for one VRF attachment +- name: MERGE - TC14 - MERGE - Create two VRF Lite links for one VRF attachment + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + - interface: "{{ test_interface2 }}" + dot1q: "{{ test_dot1q2 | int }}" + ipv4_addr: "{{ test_ipv4_addr2 }}" + neighbor_ipv4: "{{ test_neighbor_ipv4_2 }}" + peer_vrf: "{{ test_peer_vrf }}" + register: tc14_result + tags: merge + +- name: MERGE - TC14 - ASSERT - Verify two-link merge changed + ansible.builtin.assert: + that: + - tc14_result.failed == false + - tc14_result.changed == true + tags: merge + +- name: MERGE - TC14 - GATHER - Get two-link VRF Lite state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: tc14_verify_result + tags: merge + +- name: MERGE - TC14 - ASSERT - Verify first VRF Lite link remains + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ tc14_verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_attach_count: 1 + vrf_lite_assert_connection_count: 2 + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: merge + +- name: MERGE - TC14 - ASSERT - Verify second VRF Lite link exists + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ tc14_verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_attach_count: 1 + vrf_lite_assert_connection_count: 2 + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface2 }}" + vrf_lite_assert_dot1q: "{{ test_dot1q2 }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr2 }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4_2 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: merge + +############################################## +## CLEAN-UP ## +############################################## + +- name: MERGE - END - ensure clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + when: cleanup_at_end | default(true) + tags: merge + +- name: MERGE - END - ensure secondary clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch2 }}" + failed_when: false + when: + - cleanup_at_end | default(true) + - test_switch2 | length > 0 + tags: merge diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_override.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_override.yaml new file mode 100644 index 000000000..72791b601 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_override.yaml @@ -0,0 +1,471 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_manage_vrf_lite Base Tasks + ansible.builtin.import_tasks: base_tasks.yaml + tags: override + +- name: OVERRIDE - Set nd_info defaults + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + timeout: "{{ nd_vrf_lite_module_timeout | default(600) }}" + tags: override + +############################################## +## Setup Override TestCase Variables ## +############################################## + +- name: OVERRIDE - Setup initial config + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: override + +- name: Import Configuration Prepare Tasks - override_initial + vars: + file: override_initial + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: override + +- name: OVERRIDE - Setup overridden config (changed IP address) + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "10.33.0.30/24" + neighbor_ipv4: "10.33.0.29" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: override + +- name: Import Configuration Prepare Tasks - override_overridden + vars: + file: override_overridden + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: override + +############################################## +## OVERRIDE ## +############################################## + +# TC1 - Override with a new VRF Lite attachment +- name: OVERRIDE - TC1 - OVERRIDE - Create VRF Lite using override state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: overridden + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_override_initial_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: override + +- name: OVERRIDE - TC1 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC1 - ASSERT - Verify VRF Lite state in ND + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: override + +# TC2 - Override with same VRF Lite with changes +- name: OVERRIDE - TC2 - OVERRIDE - Override VRF Lite with changes + cisco.nd.nd_manage_vrf_lite: &conf_overridden + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: overridden + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_override_overridden_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: override + +- name: OVERRIDE - TC2 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Verify overridden VRF Lite state + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "10.33.0.30/24" + vrf_lite_assert_neighbor_ipv4: "10.33.0.29" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: override + +# TC3 - Idempotence test +- name: OVERRIDE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vrf_lite: *conf_overridden + register: result + tags: override + +- name: OVERRIDE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: override + +# TC4 - Override with empty config should delete all VRF Lite for the fabric (purge-all) +- name: OVERRIDE - TC4 - SETUP - Ensure at least one VRF Lite exists + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: merged + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: override + +- name: OVERRIDE - TC4 - OVERRIDE - Purge all VRF Lite with empty config + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: overridden + config_actions: + save: false + deploy: false + type: switch + config: [] + register: result + tags: override + +- name: OVERRIDE - TC4 - ASSERT - Verify all attachments deleted + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: override + +- name: OVERRIDE - TC4 - GATHER - Verify no VRF Lite remain + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: gathered + config_actions: + save: false + deploy: false + type: switch + register: verify_result + tags: override + +- name: OVERRIDE - TC4 - ASSERT - Confirm gathered is empty or minimal + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - > + ( + verify_result.gathered + | selectattr('attach', 'defined') + | selectattr('attach') + | list + | length + ) == 0 + tags: override + +# TC5 - Override with deploy enabled +- name: OVERRIDE - TC5 - DELETE - Ensure VRF Lite absent before deploy test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: override + +- name: OVERRIDE - TC5 - OVERRIDE - Create VRF Lite with deploy true + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: overridden + config_actions: + save: true + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_override_initial_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC5 - ASSERT - Verify deploy path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is defined + - (result.deployment_needed | default(result.deployment.deployment_needed | default(false))) | bool == true + tags: override + +- name: OVERRIDE - TC5 - ASSERT - Verify config-save and deploy API traces + ansible.builtin.assert: + that: + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: override + +- name: OVERRIDE - TC5 - GATHER - Verify VRF Lite exists after deploy flow + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC5 - ASSERT - Verify deployed override config + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: override + +# TC6 - Override with config_actions save+deploy (global scope) +- name: OVERRIDE - TC6 - DELETE - Ensure VRF Lite absent before config_actions test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: override + +- name: OVERRIDE - TC6 - OVERRIDE - Create VRF Lite with config_actions save+deploy global + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: overridden + config_actions: + save: true + deploy: true + type: global + config: "{{ nd_manage_vrf_lite_override_initial_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC6 - ASSERT - Verify config_actions save+deploy execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is defined + - result.deployment.config_actions is defined + - result.deployment.config_actions.save == true + - result.deployment.config_actions.deploy == true + - result.deployment.config_actions.type == "global" + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: override + +# TC7 - check_mode should not apply override configuration changes +- name: OVERRIDE - TC7 - DELETE - Ensure VRF Lite is absent before check_mode test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: override + +- name: OVERRIDE - TC7 - OVERRIDE - Run check_mode create for VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: overridden + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_override_initial_conf }}" + check_mode: true + register: result + tags: override + +- name: OVERRIDE - TC7 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is not defined + tags: override + +- name: OVERRIDE - TC7 - GATHER - Verify check_mode flow did not create VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC7 - ASSERT - Verify gathered after check_mode + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: override + +############################################## +## CLEAN-UP ## +############################################## + +- name: OVERRIDE - END - ensure clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + when: cleanup_at_end | default(true) + tags: override diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_replace.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_replace.yaml new file mode 100644 index 000000000..e01202c8e --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_replace.yaml @@ -0,0 +1,407 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_manage_vrf_lite Base Tasks + ansible.builtin.import_tasks: base_tasks.yaml + tags: replace + +- name: REPLACE - Set nd_info defaults + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + timeout: "{{ nd_vrf_lite_module_timeout | default(600) }}" + tags: replace + +############################################## +## Setup Replace TestCase Variables ## +############################################## + +- name: REPLACE - Setup initial config + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "{{ test_ipv4_addr }}" + neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: replace + +- name: Import Configuration Prepare Tasks - replace_initial + vars: + file: replace_initial + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: replace + +- name: REPLACE - Setup replaced config (changed IP address) + ansible.builtin.set_fact: + vrf_lite_conf: + - vrf_name: "{{ test_vrf }}" + vlan_id: "{{ test_vlan_id | int }}" + attach: + - ip_address: "{{ test_switch1 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int }}" + ipv4_addr: "10.33.0.20/24" + neighbor_ipv4: "10.33.0.19" + peer_vrf: "{{ test_peer_vrf }}" + delegate_to: localhost + tags: replace + +- name: Import Configuration Prepare Tasks - replace_replaced + vars: + file: replace_replaced + ansible.builtin.import_tasks: conf_prep_tasks.yaml + tags: replace + +############################################## +## REPLACE ## +############################################## + +# TC1 - Create initial VRF Lite using replace state +- name: REPLACE - TC1 - REPLACE - Create VRF Lite using replace state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: replaced + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_replace_initial_conf }}" + register: result + tags: replace + +- name: REPLACE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: replace + +- name: REPLACE - TC1 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: replace + +- name: REPLACE - TC1 - ASSERT - Verify VRF Lite state in ND + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: replace + +# TC2 - Replace VRF Lite configuration +- name: REPLACE - TC2 - REPLACE - Replace VRF Lite configuration + cisco.nd.nd_manage_vrf_lite: &conf_replaced + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: replaced + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_replace_replaced_conf }}" + register: result + tags: replace + +- name: REPLACE - TC2 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: replace + +- name: REPLACE - TC2 - GATHER - Get VRF Lite state in ND + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: replace + +- name: REPLACE - TC2 - ASSERT - Verify replaced VRF Lite state + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "10.33.0.20/24" + vrf_lite_assert_neighbor_ipv4: "10.33.0.19" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: replace + +# TC3 - Idempotence test +- name: REPLACE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vrf_lite: *conf_replaced + register: result + tags: replace + +- name: REPLACE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: replace + +# TC4 - Replace with deploy enabled +- name: REPLACE - TC4 - DELETE - Ensure VRF Lite absent before deploy test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: replace + +- name: REPLACE - TC4 - REPLACE - Create VRF Lite with deploy true + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: replaced + config_actions: + save: true + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_replace_initial_conf }}" + register: result + tags: replace + +- name: REPLACE - TC4 - ASSERT - Verify deploy path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is defined + - (result.deployment_needed | default(result.deployment.deployment_needed | default(false))) | bool == true + tags: replace + +- name: REPLACE - TC4 - ASSERT - Verify config-save and deploy API traces + ansible.builtin.assert: + that: + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: replace + +- name: REPLACE - TC4 - GATHER - Verify VRF Lite exists after deploy flow + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + fabric_name: "{{ test_fabric }}" + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: replace + +- name: REPLACE - TC4 - ASSERT - Verify deployed replace config + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch1 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q }}" + vrf_lite_assert_ipv4_addr: "{{ test_ipv4_addr }}" + vrf_lite_assert_neighbor_ipv4: "{{ test_neighbor_ipv4 }}" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + tags: replace + +# TC5 - Replace with config_actions save+deploy +- name: REPLACE - TC5 - DELETE - Ensure VRF Lite absent before config_actions test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: replace + +- name: REPLACE - TC5 - REPLACE - Create VRF Lite with config_actions save+deploy + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: replaced + config_actions: + save: true + deploy: true + type: switch + config: "{{ nd_manage_vrf_lite_replace_initial_conf }}" + register: result + tags: replace + +- name: REPLACE - TC5 - ASSERT - Verify config_actions save+deploy execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is defined + - result.deployment.config_actions is defined + - result.deployment.config_actions.save == true + - result.deployment.config_actions.deploy == true + - result.deployment.config_actions.type == "switch" + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'config_save') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('operation', 'equalto', 'vrf_deploy') + | list + | length + ) > 0 + tags: replace + +# TC6 - check_mode should not apply replace configuration changes +- name: REPLACE - TC6 - DELETE - Ensure VRF Lite is absent before check_mode test + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + tags: replace + +- name: REPLACE - TC6 - REPLACE - Run check_mode create for VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: replaced + config_actions: + save: false + deploy: false + type: switch + config: "{{ nd_manage_vrf_lite_replace_initial_conf }}" + check_mode: true + register: result + tags: replace + +- name: REPLACE - TC6 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is not defined + tags: replace + +- name: REPLACE - TC6 - GATHER - Verify check_mode flow did not create VRF Lite + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + state: gathered + config_actions: + save: false + deploy: false + type: switch + fabric_name: "{{ test_fabric }}" + config: + - vrf_name: "{{ test_vrf }}" + register: verify_result + tags: replace + +- name: REPLACE - TC6 - ASSERT - Verify gathered after check_mode + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].vrf_name == test_vrf + - verify_result.gathered[0].attach | default([]) | length == 0 + tags: replace + +############################################## +## CLEAN-UP ## +############################################## + +- name: REPLACE - END - ensure clean state + cisco.nd.nd_manage_vrf_lite: + <<: *nd_info + fabric_name: "{{ test_fabric }}" + state: deleted + config_actions: + save: false + deploy: false + type: switch + config: + - vrf_name: "{{ test_vrf }}" + attach: + - ip_address: "{{ test_switch1 }}" + failed_when: false + when: cleanup_at_end | default(true) + tags: replace diff --git a/tests/integration/targets/nd_manage_vrf_lite/templates/nd_manage_vrf_lite_conf.j2 b/tests/integration/targets/nd_manage_vrf_lite/templates/nd_manage_vrf_lite_conf.j2 new file mode 100644 index 000000000..dc6f60406 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/templates/nd_manage_vrf_lite_conf.j2 @@ -0,0 +1,73 @@ +--- +# This nd_manage_vrf_lite test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_manage_vrf_lite_conf.j2 +# Variables: vrf_lite_conf (list of dicts) + +{% if vrf_lite_conf is iterable %} +{% set vrf_list = [] %} +{% for vrf in vrf_lite_conf %} +{% set vrf_item = {} %} +{% if vrf.vrf_name is defined %} +{% set _ = vrf_item.update({'vrf_name': vrf.vrf_name}) %} +{% endif %} +{% if vrf.vlan_id is defined %} +{% set _ = vrf_item.update({'vlan_id': vrf.vlan_id}) %} +{% endif %} +{% if vrf.deploy is defined %} +{% set _ = vrf_item.update({'deploy': vrf.deploy}) %} +{% endif %} +{% if vrf.attach is defined %} +{% set attach_list = [] %} +{% for att in vrf.attach %} +{% set att_item = {} %} +{% if att.ip_address is defined %} +{% set _ = att_item.update({'ip_address': att.ip_address}) %} +{% endif %} +{% if att.deploy is defined %} +{% set _ = att_item.update({'deploy': att.deploy}) %} +{% endif %} +{% if att.import_evpn_rt is defined %} +{% set _ = att_item.update({'import_evpn_rt': att.import_evpn_rt}) %} +{% endif %} +{% if att.export_evpn_rt is defined %} +{% set _ = att_item.update({'export_evpn_rt': att.export_evpn_rt}) %} +{% endif %} +{% if att.vrf_lite is defined %} +{% set lite_list = [] %} +{% for lite in att.vrf_lite %} +{% set lite_item = {} %} +{% if lite.interface is defined %} +{% set _ = lite_item.update({'interface': lite.interface}) %} +{% endif %} +{% if lite.dot1q is defined %} +{% set _ = lite_item.update({'dot1q': lite.dot1q}) %} +{% endif %} +{% if lite.ipv4_addr is defined %} +{% set _ = lite_item.update({'ipv4_addr': lite.ipv4_addr}) %} +{% endif %} +{% if lite.neighbor_ipv4 is defined %} +{% set _ = lite_item.update({'neighbor_ipv4': lite.neighbor_ipv4}) %} +{% endif %} +{% if lite.ipv6_addr is defined %} +{% set _ = lite_item.update({'ipv6_addr': lite.ipv6_addr}) %} +{% endif %} +{% if lite.neighbor_ipv6 is defined %} +{% set _ = lite_item.update({'neighbor_ipv6': lite.neighbor_ipv6}) %} +{% endif %} +{% if lite.peer_vrf is defined %} +{% set _ = lite_item.update({'peer_vrf': lite.peer_vrf}) %} +{% endif %} +{% set _ = lite_list.append(lite_item) %} +{% endfor %} +{% set _ = att_item.update({'vrf_lite': lite_list}) %} +{% endif %} +{% set _ = attach_list.append(att_item) %} +{% endfor %} +{% set _ = vrf_item.update({'attach': attach_list}) %} +{% endif %} +{% set _ = vrf_list.append(vrf_item) %} +{% endfor %} +{{ vrf_list | to_nice_yaml(indent=2) | trim }} +{% endif %} diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt new file mode 100644 index 000000000..f51baeafa --- /dev/null +++ b/tests/sanity/ignore-2.15.txt @@ -0,0 +1,32 @@ +ansible-nd/plugins/modules/nd_api_key.py shebang +ansible-nd/plugins/modules/nd_backup.py shebang +ansible-nd/plugins/modules/nd_backup_restore.py shebang +ansible-nd/plugins/modules/nd_cluster_config_route.py shebang +ansible-nd/plugins/modules/nd_compliance_analysis.py shebang +ansible-nd/plugins/modules/nd_compliance_requirement_communication.py shebang +ansible-nd/plugins/modules/nd_compliance_requirement_config_import.py shebang +ansible-nd/plugins/modules/nd_compliance_requirement_config_manual.py shebang +ansible-nd/plugins/modules/nd_compliance_requirement_config_snapshot.py shebang +ansible-nd/plugins/modules/nd_compliance_requirement_config_template.py shebang +ansible-nd/plugins/modules/nd_delta_analysis.py shebang +ansible-nd/plugins/modules/nd_federation_member.py shebang +ansible-nd/plugins/modules/nd_flow_rules.py shebang +ansible-nd/plugins/modules/nd_instant_assurance_analysis.py shebang +ansible-nd/plugins/modules/nd_interface_flow_rules.py shebang +ansible-nd/plugins/modules/nd_manage_fabric_ebgp.py shebang +ansible-nd/plugins/modules/nd_manage_fabric_external.py shebang +ansible-nd/plugins/modules/nd_manage_fabric_ibgp.py shebang +ansible-nd/plugins/modules/nd_manage_vrf_lite.py shebang +ansible-nd/plugins/modules/nd_pcv.py shebang +ansible-nd/plugins/modules/nd_pcv_compliance.py shebang +ansible-nd/plugins/modules/nd_pcv_delta_analysis.py shebang +ansible-nd/plugins/modules/nd_policy_cam_statistics_hit_counts.py shebang +ansible-nd/plugins/modules/nd_rest.py shebang +ansible-nd/plugins/modules/nd_service.py shebang +ansible-nd/plugins/modules/nd_service_instance.py shebang +ansible-nd/plugins/modules/nd_setup.py shebang +ansible-nd/plugins/modules/nd_site.py shebang +ansible-nd/plugins/modules/nd_snapshot.py shebang +ansible-nd/plugins/modules/nd_version.py shebang +ansible-nd/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py pylint:invalid-name +ansible-nd/tests/unit/test_log.py pylint:invalid-name diff --git a/tests/sanity/ignore-2.18.txt b/tests/sanity/ignore-2.18.txt new file mode 100644 index 000000000..c506430fa --- /dev/null +++ b/tests/sanity/ignore-2.18.txt @@ -0,0 +1 @@ +ansible-nd/.git/hooks/fsmonitor-watchman.sample shebang!skip diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_vrf_lite.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_vrf_lite.py new file mode 100644 index 000000000..844d79675 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_vrf_lite.py @@ -0,0 +1,107 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_config_save import ( + EpFabricConfigSavePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_deploy import ( + EpFabricDeployPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpFabricSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs import ( + EpFabricVrfsGet, + EpFabricVrfsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_attachments import ( + EpFabricVrfsAttachmentsGet, + EpFabricVrfsAttachmentsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_deployments import ( + EpFabricVrfsDeploymentsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vrfs_switches import ( + EpFabricVrfsSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_resource_manager_reserve_id import ( + EpResourceManagerReserveIdPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( + VrfLiteEndpoints, +) + + +def test_endpoints_api_v1_vrf_lite_00100_top_down_vrfs_paths(): + endpoint_get = EpFabricVrfsGet(fabric_name="Fab A") + endpoint_post = EpFabricVrfsPost(fabric_name="Fab A") + + assert endpoint_get.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs" + assert endpoint_get.verb == HttpVerbEnum.GET + assert endpoint_post.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs" + assert endpoint_post.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_vrf_lite_00200_top_down_attachments_paths(): + endpoint_get = EpFabricVrfsAttachmentsGet( + fabric_name="Fab A", + vrf_names="BLUE,RED", + ) + endpoint_post = EpFabricVrfsAttachmentsPost(fabric_name="Fab A") + + assert endpoint_get.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/attachments?vrf-names=BLUE%2CRED" + assert endpoint_get.verb == HttpVerbEnum.GET + assert endpoint_post.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/attachments" + assert endpoint_post.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_vrf_lite_00300_top_down_switches_deploy_and_reserve_paths(): + endpoint_switches = EpFabricVrfsSwitchesGet( + fabric_name="Fab A", + vrf_names="BLUE", + serial_numbers="SN1", + ) + endpoint_deploy = EpFabricVrfsDeploymentsPost(fabric_name="Fab A") + endpoint_reserve = EpResourceManagerReserveIdPost() + + assert endpoint_switches.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/switches?vrf-names=BLUE&serial-numbers=SN1" + assert endpoint_switches.verb == HttpVerbEnum.GET + assert endpoint_deploy.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/deployments" + assert endpoint_deploy.verb == HttpVerbEnum.POST + assert endpoint_reserve.path == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/resource-manager/reserve-id" + assert endpoint_reserve.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_vrf_lite_00400_manage_paths(): + endpoint_switches = EpFabricSwitchesGet(fabric_name="Fab A") + endpoint_save = EpFabricConfigSavePost(fabric_name="Fab A") + endpoint_deploy = EpFabricDeployPost(fabric_name="Fab A") + + assert endpoint_switches.path == "/api/v1/manage/fabrics/Fab%20A/switches" + assert endpoint_switches.verb == HttpVerbEnum.GET + assert endpoint_save.path == "/api/v1/manage/fabrics/Fab%20A/actions/configSave" + assert endpoint_save.verb == HttpVerbEnum.POST + assert endpoint_deploy.path == "/api/v1/manage/fabrics/Fab%20A/actions/deploy?forceShowRun=true" + assert endpoint_deploy.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_vrf_lite_00500_runtime_endpoints_use_endpoint_models(): + assert VrfLiteEndpoints.vrfs("Fab A") == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs" + assert ( + VrfLiteEndpoints.vrf_attachments_query("Fab A", "BLUE,RED") + == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/attachments?vrf-names=BLUE%2CRED" + ) + assert VrfLiteEndpoints.vrf_attachments_post("Fab A") == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/attachments" + assert VrfLiteEndpoints.vrf_deployments("Fab A") == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/deployments" + assert ( + VrfLiteEndpoints.vrf_switch("Fab A", "BLUE", "SN1") + == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/Fab%20A/vrfs/switches?vrf-names=BLUE&serial-numbers=SN1" + ) + assert VrfLiteEndpoints.reserve_id() == "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/resource-manager/reserve-id" + assert VrfLiteEndpoints.fabric_switches("Fab A") == "/api/v1/manage/fabrics/Fab%20A/switches" + assert VrfLiteEndpoints.config_save("Fab A") == "/api/v1/manage/fabrics/Fab%20A/actions/configSave" + assert VrfLiteEndpoints.config_deploy("Fab A") == "/api/v1/manage/fabrics/Fab%20A/actions/deploy?forceShowRun=true" diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py new file mode 100644 index 000000000..05e3bc67d --- /dev/null +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -0,0 +1,1013 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit tests for nd_manage_vrf_lite merge/payload/config-actions behavior.""" + +from __future__ import absolute_import, annotations, division, print_function + +import json + +import pytest + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( + build_attach_payload_for_entry, + build_detach_payload_for_entry, + _post_attachment_payload, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + get_config_actions, + get_runtime_warnings, + request_with_verify_settings, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.deploy import ( + _needs_deployment, + _target_vrfs_for_deploy, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( + query_vrf_lite_state, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_payloads import ( + build_vrf_lite_extension_values, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( + VrfLiteEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation import ( + validate_vrf_lite_write_guardrails, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( + VrfLiteResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.config_transform import ( + explode_playbook_to_entries, + group_attachment_entries_to_vrfs, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_attachment_entry import ( + VrfLiteAttachmentEntry, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( + VrfLiteModel, + VrfLitePlaybookConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite import ( + ManageVrfLiteOrchestrator, +) +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender + + +class _DummyModule: + def __init__(self, params): + self.params = params + self._debug = False + self.check_mode = bool(params.get("check_mode", False)) + + +class _DummyWarnModule(_DummyModule): + def __init__(self, params): + super().__init__(params) + self.warnings = [] + + def warn(self, msg): + self.warnings.append(msg) + + +def _vrf_lite_orchestrator(module): + sender = Sender() + sender.ansible_module = module + rest_send = RestSend( + { + "check_mode": module.check_mode, + "state": module.params.get("state"), + } + ) + rest_send.sender = sender + rest_send.response_handler = ResponseHandler() + return ManageVrfLiteOrchestrator(rest_send=rest_send) + + +def test_manage_vrf_lite_00050_model_exposes_module_argspec(): + assert VrfLiteModel.get_argument_spec() == VrfLitePlaybookConfigModel.get_argument_spec() + + +def test_manage_vrf_lite_00075_orchestrator_prepares_runtime_params(): + module = _DummyModule( + { + "fabric_name": "F1", + "state": "merged", + "config_actions": {"save": True, "deploy": False, "type": "global"}, + "verify": {"enabled": True, "retries": 2, "timeout": 9}, + } + ) + module_config = VrfLitePlaybookConfigModel.model_validate( + { + "fabric_name": "F1", + "state": "merged", + "config": [{"vrf_name": "BLUE", "vlan_id": 500}], + }, + by_alias=True, + by_name=True, + ) + + ManageVrfLiteOrchestrator.prepare_module_params(module, module_config) + + assert module.params["config"] == [{"vrf_name": "BLUE", "vlan_id": 500}] + assert module.params["config_actions"] == {"save": True, "deploy": False, "type": "global"} + assert module.params["verify"] == {"enabled": True, "retries": 2, "timeout": 9} + assert module.params["_changed_vrfs"] == [] + assert module.params["_gather_filter_config"] == [] + + +def test_manage_vrf_lite_00080_query_reuses_cached_gathered_have(monkeypatch): + cached_have = [{"vrf_name": "BLUE", "vlan_id": 500, "attach": []}] + module = _DummyModule({"state": "gathered", "fabric_name": "FABRIC1", "_have": cached_have, "_have_loaded": True}) + + def _fail_query(**kwargs): + del kwargs + pytest.fail("gathered query should reuse the state machine query result") + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.query_vrf_lite_state", + _fail_query, + ) + + assert _vrf_lite_orchestrator(module)._query_current_state() == cached_have + + +def test_manage_vrf_lite_00100_merge_preserves_unmentioned_switch_and_interface_data(): + have = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "vlan_id": 500, + "attach": [ + { + "ip_address": "10.0.0.1", + "import_evpn_rt": "100:1", + "vrf_lite": [ + { + "interface": "Ethernet1/10", + "dot1q": 100, + "ipv4_addr": "192.0.2.2/30", + "neighbor_ipv4": "192.0.2.1", + }, + { + "interface": "Ethernet1/11", + "dot1q": 101, + "ipv4_addr": "192.0.2.6/30", + "neighbor_ipv4": "192.0.2.5", + }, + ], + }, + { + "ip_address": "10.0.0.2", + "vrf_lite": [ + { + "interface": "Ethernet1/12", + "dot1q": 102, + "ipv4_addr": "192.0.2.10/30", + "neighbor_ipv4": "192.0.2.9", + } + ], + }, + ], + } + ) + + want = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "attach": [ + { + "ip_address": "10.0.0.1", + "vrf_lite": [ + { + "interface": "Ethernet1/10", + "neighbor_ipv4": "192.0.2.9", + } + ], + } + ], + } + ) + + merged = have.merge(want) + + assert merged.attach is not None + assert len(merged.attach) == 2 + + first_attach = {item.ip_address: item for item in merged.attach}["10.0.0.1"] + assert first_attach.vrf_lite is not None + + merged_lite_map = {item.interface.lower(): item for item in first_attach.vrf_lite} + assert set(merged_lite_map.keys()) == {"ethernet1/10", "ethernet1/11"} + + # Updated field from incoming payload + assert merged_lite_map["ethernet1/10"].neighbor_ipv4 == "192.0.2.9" + # Preserved field from existing payload + assert merged_lite_map["ethernet1/10"].dot1q == 100 + # Preserved untouched interface + assert merged_lite_map["ethernet1/11"].dot1q == 101 + + # Preserved second switch attachment (not in incoming payload) + assert {item.ip_address for item in merged.attach} == {"10.0.0.1", "10.0.0.2"} + + +def test_manage_vrf_lite_00200_extension_values_preserve_non_vrf_lite_keys(): + existing_outer = { + "VRF_LITE_CONN": json.dumps({"VRF_LITE_CONN": [{"IF_NAME": "Ethernet1/1"}]}, separators=(",", ":")), + "MULTISITE_CONN": json.dumps({"MULTISITE_CONN": [{"site": "A"}]}, separators=(",", ":")), + "CUSTOM_EXTENSION": "keep-me", + } + + rendered = build_vrf_lite_extension_values( + vrf_lite_items=[ + { + "interface": "Ethernet1/20", + "dot1q": 500, + "ipv4_addr": "10.10.10.2/30", + "neighbor_ipv4": "10.10.10.1", + } + ], + existing_extension_values=json.dumps(existing_outer, separators=(",", ":")), + ) + + outer = json.loads(rendered) + assert outer["MULTISITE_CONN"] == existing_outer["MULTISITE_CONN"] + assert outer["CUSTOM_EXTENSION"] == "keep-me" + + vrf_lite_inner = json.loads(outer["VRF_LITE_CONN"]) + rows = vrf_lite_inner.get("VRF_LITE_CONN") or [] + assert len(rows) == 1 + assert rows[0]["IF_NAME"] == "Ethernet1/20" + assert rows[0]["DOT1Q_ID"] == "500" + + +def test_manage_vrf_lite_00300_extension_values_clear_only_vrf_lite_section(): + existing_outer = { + "VRF_LITE_CONN": json.dumps({"VRF_LITE_CONN": [{"IF_NAME": "Ethernet1/1"}]}, separators=(",", ":")), + "MULTISITE_CONN": json.dumps({"MULTISITE_CONN": [{"site": "A"}]}, separators=(",", ":")), + "OTHER": "preserve", + } + + rendered = build_vrf_lite_extension_values( + vrf_lite_items=[], + existing_extension_values=json.dumps(existing_outer, separators=(",", ":")), + ) + + outer = json.loads(rendered) + assert outer["MULTISITE_CONN"] == existing_outer["MULTISITE_CONN"] + assert outer["OTHER"] == "preserve" + + vrf_lite_inner = json.loads(outer["VRF_LITE_CONN"]) + assert vrf_lite_inner == {"VRF_LITE_CONN": []} + + # No pre-existing extension block + empty input should stay empty for detach payloads. + assert build_vrf_lite_extension_values(vrf_lite_items=[], existing_extension_values=None) == "" + + +def test_manage_vrf_lite_00325_extension_values_does_not_mutate_existing_dict(): + existing_outer = { + "VRF_LITE_CONN": json.dumps({"VRF_LITE_CONN": [{"IF_NAME": "Ethernet1/1"}]}, separators=(",", ":")), + "MULTISITE_CONN": json.dumps({"MULTISITE_CONN": [{"site": "A"}]}, separators=(",", ":")), + } + original = dict(existing_outer) + + rendered = build_vrf_lite_extension_values( + vrf_lite_items=[], + existing_extension_values=existing_outer, + ) + + assert existing_outer == original + assert json.loads(json.loads(rendered)["VRF_LITE_CONN"]) == {"VRF_LITE_CONN": []} + + +def test_manage_vrf_lite_00400_config_actions_ignore_legacy_top_level_deploy(): + module_with_legacy_field_only = _DummyModule({"deploy": False}) + actions = get_config_actions(module_with_legacy_field_only) + assert actions == {"save": True, "deploy": True, "type": "switch"} + assert get_config_actions({"deploy": False}) == {"save": True, "deploy": True, "type": "switch"} + + module_with_config_actions = _DummyModule( + { + "deploy": False, + "config_actions": { + "save": True, + "deploy": False, + "type": "global", + }, + } + ) + + configured_actions = get_config_actions(module_with_config_actions) + assert configured_actions == {"save": True, "deploy": False, "type": "global"} + + +def test_manage_vrf_lite_00475_query_ignores_detached_attachment_rows(monkeypatch): + module = _DummyModule({}) + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_fabric_switches", + lambda _nd_v2, _fabric_name, _timeout: {"SN1": "10.0.0.1"}, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrfs", + lambda _nd_v2, _fabric_name, _timeout: [ + { + "vrfName": "BLUE", + "vrfTemplateConfig": '{"vrfVlanId":500}', + } + ], + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrf_attachments", + lambda **_kwargs: [ + { + "vrfName": "BLUE", + "lanAttachList": [ + { + "serialNumber": "SN1", + "isLanAttached": False, + "vlanId": 500, + } + ], + } + ], + ) + + result = query_vrf_lite_state(module=module, fabric_name="FABRIC1", filter_vrfs={"BLUE"}) + + assert result == [{"vrf_name": "BLUE", "vlan_id": 500, "deploy": False, "attach": []}] + assert module.params["_raw_vrf_attachment_map"] == {} + + +def test_manage_vrf_lite_00480_query_ignores_base_vrf_attachments_without_vrf_lite(monkeypatch): + module = _DummyModule({}) + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_fabric_switches", + lambda _nd_v2, _fabric_name, _timeout: {"SN1": "10.0.0.1"}, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrfs", + lambda _nd_v2, _fabric_name, _timeout: [ + { + "vrfName": "BLUE", + "vrfTemplateConfig": '{"vrfVlanId":500}', + } + ], + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrf_attachments", + lambda **_kwargs: [ + { + "vrfName": "BLUE", + "lanAttachList": [ + { + "serialNumber": "SN1", + "isLanAttached": True, + "lanAttachState": "DEPLOYED", + "vlanId": 500, + "extensionValues": "", + "instanceValues": "", + } + ], + } + ], + ) + + result = query_vrf_lite_state(module=module, fabric_name="FABRIC1", filter_vrfs={"BLUE"}) + + assert result == [{"vrf_name": "BLUE", "vlan_id": 500, "deploy": False, "attach": []}] + assert module.params["_raw_vrf_attachment_map"] == { + "BLUE": { + "SN1": { + "extension_values": "", + "instance_values": "", + "vlan": 500, + } + } + } + + +def test_manage_vrf_lite_00481_query_enriches_pending_attachment_from_switch_details(monkeypatch): + module = _DummyModule({}) + extension_values = json.dumps( + { + "VRF_LITE_CONN": json.dumps( + { + "VRF_LITE_CONN": [ + { + "IF_NAME": "Ethernet1/2", + "DOT1Q_ID": "2", + "IP_MASK": "10.33.0.2/24", + "NEIGHBOR_IP": "10.33.0.1", + "PEER_VRF_NAME": "GREEN", + "VRF_LITE_JYTHON_TEMPLATE": "Ext_VRF_Lite_Jython", + } + ] + }, + separators=(",", ":"), + ), + "MULTISITE_CONN": json.dumps({"MULTISITE_CONN": []}, separators=(",", ":")), + }, + separators=(",", ":"), + ) + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_fabric_switches", + lambda _nd_v2, _fabric_name, _timeout: {"SN1": "10.0.0.1"}, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrfs", + lambda _nd_v2, _fabric_name, _timeout: [ + { + "vrfName": "BLUE", + "vrfTemplateConfig": '{"vrfVlanId":500}', + } + ], + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrf_attachments", + lambda **_kwargs: [ + { + "vrfName": "BLUE", + "lanAttachList": [ + { + "serialNumber": "SN1", + "isLanAttached": False, + "lanAttachState": "PENDING", + "vlanId": 500, + } + ], + } + ], + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query._query_vrf_switch_details", + lambda **_kwargs: { + "SN1": { + "serialNumber": "SN1", + "extensionValues": extension_values, + "instanceValues": "", + "islanAttached": False, + "lanAttachedState": "PENDING", + "vlan": 2, + } + }, + ) + + result = query_vrf_lite_state(module=module, fabric_name="FABRIC1", filter_vrfs={"BLUE"}) + + assert result == [ + { + "vrf_name": "BLUE", + "vlan_id": 500, + "deploy": False, + "attach": [ + { + "ip_address": "10.0.0.1", + "deploy": False, + "import_evpn_rt": "", + "export_evpn_rt": "", + "vrf_lite": [ + { + "interface": "Ethernet1/2", + "dot1q": 2, + "ipv4_addr": "10.33.0.2/24", + "neighbor_ipv4": "10.33.0.1", + "peer_vrf": "GREEN", + } + ], + } + ], + } + ] + assert module.params["_raw_vrf_attachment_map"]["BLUE"]["SN1"]["extension_values"] == extension_values + assert module.params["_raw_vrf_attachment_map"]["BLUE"]["SN1"]["vlan"] == 2 + + +def test_manage_vrf_lite_00490_deploy_needed_when_state_machine_changed_without_changed_vrf_marker(): + module = _DummyModule({}) + + assert _needs_deployment({"changed": True}, module) is True + + +def test_manage_vrf_lite_00491_deploy_targets_honor_vrf_and_attachment_intent(): + module = _DummyModule( + { + "config": [ + { + "vrf_name": "BLUE", + "attach": [{"ip_address": "10.0.0.1", "deploy": False}], + }, + { + "vrf_name": "GREEN", + "attach": [ + {"ip_address": "10.0.0.2", "deploy": False}, + {"ip_address": "10.0.0.3"}, + ], + }, + { + "vrf_name": "RED", + "deploy": False, + "attach": [{"ip_address": "10.0.0.4", "deploy": True}], + }, + { + "vrf_name": "YELLOW", + "deploy": True, + "attach": [{"ip_address": "10.0.0.5", "deploy": False}], + }, + ] + } + ) + + assert _target_vrfs_for_deploy(module) == ["GREEN", "YELLOW"] + + +def test_manage_vrf_lite_00492_deploy_filters_changed_vrfs_by_deploy_intent(): + module = _DummyModule( + { + "check_mode": True, + "fabric_name": "FABRIC1", + "_changed_vrfs": ["BLUE", "GREEN", "RED"], + "config_actions": {"save": True, "deploy": True, "type": "switch"}, + "config": [ + {"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1", "deploy": False}]}, + {"vrf_name": "GREEN", "attach": [{"ip_address": "10.0.0.2"}]}, + {"vrf_name": "RED", "deploy": False, "attach": [{"ip_address": "10.0.0.3"}]}, + ], + } + ) + + result = _vrf_lite_orchestrator(module)._execute_config_actions(result={"changed": True}) + + assert result["target_vrfs"] == ["GREEN"] + assert result["planned_actions"] == [ + "POST {0}".format(VrfLiteEndpoints.config_save("FABRIC1")), + "POST {0} vrfNames=GREEN".format(VrfLiteEndpoints.vrf_deployments("FABRIC1")), + ] + + +def test_manage_vrf_lite_00493_attachment_deploy_false_does_not_suppress_attachment_payload(): + class _FakeNDModule: + def request(self, path, verb, payload): + del path, verb, payload + pytest.fail("dot1q reservation should not be called when dot1q is provided") + + existing_instance_values = ( + '{"loopbackIpV6Address":"","loopbackId":"","switchRouteTargetImportEvpn":"",' + '"loopbackIpAddress":"","deviceSupportL3VniNoVlan":"false","switchRouteTargetExportEvpn":""}' + ) + module = _DummyModule( + { + "fabric_name": "FABRIC1", + "_ip_to_sn_mapping": {"10.0.0.1": "SN1"}, + "_raw_vrf_attachment_map": { + "BLUE": { + "SN1": { + "instance_values": existing_instance_values, + } + } + }, + } + ) + entry = VrfLiteAttachmentEntry.from_config( + { + "vrf_name": "BLUE", + "switch_ip": "10.0.0.1", + "vlan_id": 500, + "deploy": False, + "extensions": [{"interface": "Ethernet1/10", "dot1q": 123}], + } + ) + + payload = build_attach_payload_for_entry( + module=module, + nd_v2=_FakeNDModule(), + entry=entry, + ) + + assert payload["serialNumber"] == "SN1" + assert payload["vlan"] == 500 + assert payload["deployment"] is True + assert json.loads(payload["instanceValues"])["deviceSupportL3VniNoVlan"] == "false" + + +def test_manage_vrf_lite_00494_delete_builds_single_attachment_clear_payload(): + extension_values = build_vrf_lite_extension_values( + [{"interface": "Ethernet1/11", "dot1q": 222, "ipv4_addr": "10.33.0.2/24"}], + ) + instance_values = ( + '{"loopbackIpV6Address":"","loopbackId":"","switchRouteTargetImportEvpn":"",' + '"loopbackIpAddress":"","deviceSupportL3VniNoVlan":"false","switchRouteTargetExportEvpn":""}' + ) + module = _DummyModule( + { + "fabric_name": "FABRIC1", + "state": "deleted", + "config": [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.2"}]}], + "_ip_to_sn_mapping": {"10.0.0.1": "SN1", "10.0.0.2": "SN2"}, + "_raw_vrf_attachment_map": { + "BLUE": { + "SN1": {"vlan": 111}, + "SN2": { + "vlan": 222, + "extension_values": extension_values, + "instance_values": instance_values, + }, + } + }, + } + ) + entry = VrfLiteAttachmentEntry.from_config( + { + "vrf_name": "BLUE", + "switch_ip": "10.0.0.2", + "vlan_id": 500, + } + ) + + payload = build_detach_payload_for_entry(module, entry) + + assert payload["serialNumber"] == "SN2" + assert payload["vlan"] == 500 + assert payload["deployment"] is True + assert payload["isAttached"] is True + assert payload["instanceValues"] == instance_values + clear_outer = json.loads(payload["extensionValues"]) + assert json.loads(clear_outer["VRF_LITE_CONN"]) == {"VRF_LITE_CONN": []} + + +def test_manage_vrf_lite_00495_delete_query_filters_vrfs_without_managed_attachments(monkeypatch): + module = _DummyModule({"state": "deleted", "fabric_name": "FABRIC1", "config": []}) + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.query_vrf_lite_state", + lambda module, fabric_name, filter_vrfs=None, flat=True: [ + {"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}, + ], + ) + + have = _vrf_lite_orchestrator(module)._query_current_state() + + assert have == [{"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}] + assert module.params["_have"] == have + + +def test_manage_vrf_lite_00495a_deleted_vrf_without_attach_expands_to_current_entries(): + module = _DummyModule( + { + "state": "deleted", + "fabric_name": "FABRIC1", + "_warnings": [], + } + ) + current = [ + {"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}, + {"vrf_name": "GREEN", "switch_ip": "SN2", "vlan_id": 501}, + ] + + result = explode_playbook_to_entries([{"vrf_name": "BLUE"}], module=module, state="deleted", current_entries=current) + + assert result == [{"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}] + + +def test_manage_vrf_lite_00495aa_public_grouping_preserves_scoped_empty_vrf(): + module = _DummyModule({"_vrf_lite_vrf_vlan_map": {"BLUE": 500}}) + + result = group_attachment_entries_to_vrfs([], module=module, include_vrfs=["BLUE"]) + + assert result == [{"vrf_name": "BLUE", "attach": [], "vlan_id": 500}] + + +def test_manage_vrf_lite_00495aaa_public_grouping_drops_unknown_scoped_vrf(): + module = _DummyModule({"_known_vrfs": ["BLUE"], "_vrf_lite_vrf_vlan_map": {"BLUE": 500}}) + + result = group_attachment_entries_to_vrfs([], module=module, include_vrfs=["MISSING"]) + + assert result == [] + + +def test_manage_vrf_lite_00495ab_deleted_public_output_preserves_empty_after_scope(): + module = _DummyModule( + { + "state": "deleted", + "_vrf_lite_nested_config": [{"vrf_name": "BLUE"}], + "_vrf_lite_vrf_vlan_map": {"BLUE": 500}, + } + ) + orchestrator = _vrf_lite_orchestrator(module) + + result = orchestrator.format_public_output({"before": [], "after": [], "current": [], "diff": []}) + + assert result["after"] == [{"vrf_name": "BLUE", "attach": [], "vlan_id": 500}] + assert result["current"] == [{"vrf_name": "BLUE", "attach": [], "vlan_id": 500}] + + +def test_manage_vrf_lite_00495b_refresh_is_skipped_when_verify_disabled(monkeypatch): + refreshed = [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}] + module = _DummyModule( + { + "state": "deleted", + "fabric_name": "FABRIC1", + "verify": {"enabled": False}, + } + ) + orchestrator = _vrf_lite_orchestrator(module) + + monkeypatch.setattr(orchestrator, "_query_current_state", lambda: refreshed) + + result = orchestrator.refresh_verified_state({"changed": True, "after": [], "current": []}) + + assert result["after"] == [] + assert result["current"] == [] + + +def test_manage_vrf_lite_00495c_delete_unknown_attachment_warns_during_explode(): + module = _DummyModule( + { + "fabric_name": "FABRIC1", + "state": "deleted", + "config": [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.99"}]}], + "_ip_to_sn_mapping": {"10.0.0.1": "SN1", "10.0.0.99": "SN99"}, + "_warnings": [], + } + ) + current = [{"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}] + + result = explode_playbook_to_entries(module.params["config"], module=module, state="deleted", current_entries=current) + + assert result == [{"vrf_name": "BLUE", "switch_ip": "SN99"}] + assert any("No matching VRF Lite attachment" in warning for warning in module.params["_warnings"]) + + +def test_manage_vrf_lite_00496_verify_retry_policy_is_applied_to_reads(): + class _FakeRestSend: + def __init__(self): + self.timeout = None + self.saved = 0 + self.restored = 0 + self.timeouts = [] + + def save_settings(self): + self.saved += 1 + + def restore_settings(self): + self.restored += 1 + self.timeouts.append(self.timeout) + + class _FakeNDModule: + def __init__(self): + self.calls = 0 + self.rest_send = _FakeRestSend() + + def _get_rest_send(self): + return self.rest_send + + def request(self, path, verb): + assert path == "/read" + assert verb == HttpVerbEnum.GET + self.calls += 1 + if self.calls < 3: + raise RuntimeError("controller not ready") + return {"ok": True} + + module = _DummyModule({"verify": {"retries": 3, "timeout": 7}}) + nd_v2 = _FakeNDModule() + + result = request_with_verify_settings(module, nd_v2, "/read", HttpVerbEnum.GET) + + assert result == {"ok": True} + assert nd_v2.calls == 3 + assert nd_v2.rest_send.saved == 3 + assert nd_v2.rest_send.restored == 3 + assert nd_v2.rest_send.timeouts == [7, 7, 7] + + +def test_manage_vrf_lite_00500_guardrails_warn_non_border_role_without_support_flag(monkeypatch): + module = _DummyWarnModule( + { + "fabric_name": "F1", + "_ip_to_sn_mapping": {"10.0.0.1": "SN1"}, + "_fabric_switch_inventory": {}, + } + ) + model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "attach": [ + { + "ip_address": "10.0.0.1", + "vrf_lite": [ + { + "interface": "Ethernet1/10", + "neighbor_ipv4": "192.0.2.9", + } + ], + } + ], + } + ) + + def _inventory(_module, _fabric_name): + return {"SN1": {"role": "leaf", "ip_address": "10.0.0.1", "raw": {}}} + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._load_switch_inventory", + _inventory, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._query_vrf_lite_support", + lambda _module, _fabric_name, _vrf_name, _serial_number: None, + ) + + validate_vrf_lite_write_guardrails(module=module, model_instance=model) + + warnings = get_runtime_warnings(module.params) + assert any("Proceeding with controller-side validation" in warning for warning in warnings) + + +def test_manage_vrf_lite_00550_guardrails_allow_external_connectivity_leaf(monkeypatch): + module = _DummyWarnModule( + { + "fabric_name": "F1", + "_ip_to_sn_mapping": {"10.0.0.1": "SN1"}, + "_fabric_switch_inventory": {}, + } + ) + model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "attach": [ + { + "ip_address": "10.0.0.1", + "vrf_lite": [ + { + "interface": "Ethernet1/10", + "neighbor_ipv4": "192.0.2.9", + } + ], + } + ], + } + ) + + def _inventory(_module, _fabric_name): + return { + "SN1": { + "role": "leaf", + "fabric_type": "externalConnectivity", + "ip_address": "10.0.0.1", + "raw": {}, + } + } + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._load_switch_inventory", + _inventory, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._query_vrf_lite_support", + lambda _module, _fabric_name, _vrf_name, _serial_number: True, + ) + + validate_vrf_lite_write_guardrails(module=module, model_instance=model) + + +def test_manage_vrf_lite_00600_guardrails_reject_unsupported_switch(monkeypatch): + module = _DummyWarnModule( + { + "fabric_name": "F1", + "_ip_to_sn_mapping": {"10.0.0.1": "SN1"}, + "_fabric_switch_inventory": {}, + } + ) + model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "attach": [ + { + "ip_address": "10.0.0.1", + "vrf_lite": [ + { + "interface": "Ethernet1/10", + "neighbor_ipv4": "192.0.2.9", + } + ], + } + ], + } + ) + + def _inventory(_module, _fabric_name): + return {"SN1": {"role": "border", "ip_address": "10.0.0.1", "raw": {}}} + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._load_switch_inventory", + _inventory, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._query_vrf_lite_support", + lambda _module, _fabric_name, _vrf_name, _serial_number: False, + ) + + with pytest.raises(VrfLiteResourceError, match="does not report VRF Lite support"): + validate_vrf_lite_write_guardrails(module=module, model_instance=model) + + +def test_manage_vrf_lite_00700_guardrails_collect_warnings_without_module_warn(monkeypatch): + module = _DummyWarnModule( + { + "fabric_name": "F1", + "_ip_to_sn_mapping": {"10.0.0.1": "SN1"}, + "_fabric_switch_inventory": {}, + "_warnings": [], + } + ) + model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "attach": [ + { + "ip_address": "10.0.0.1", + "vrf_lite": [ + { + "interface": "Ethernet1/10", + "neighbor_ipv4": "192.0.2.9", + } + ], + } + ], + } + ) + + def _inventory(_module, _fabric_name): + return {"SN1": {"role": "", "ip_address": "10.0.0.1", "raw": {}}} + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._load_switch_inventory", + _inventory, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.validation._query_vrf_lite_support", + lambda _module, _fabric_name, _vrf_name, _serial_number: (_x for _x in ()).throw(Exception("boom")), + ) + + validate_vrf_lite_write_guardrails(module=module, model_instance=model) + + warnings = get_runtime_warnings(module.params) + assert any("Unable to determine switch role" in warning for warning in warnings) + assert any("Unable to query VRF Lite support" in warning for warning in warnings) + # Ensure validator no longer depends on direct Ansible warning side-effects. + assert module.warnings == [] + + +def test_manage_vrf_lite_00800_attachment_post_uses_ndfc_top_level_list_payload(): + class _FakeNDModule: + def __init__(self): + self.calls = [] + + def request(self, path, verb, payload): + self.calls.append((path, verb, payload)) + return {"ok": True} + + nd_v2 = _FakeNDModule() + lan_attach_list = [{"serialNumber": "SN1", "isAttached": True}] + + result = _post_attachment_payload( + nd_v2=nd_v2, + fabric_name="FABRIC1", + vrf_name="BLUE", + lan_attach_list=lan_attach_list, + ) + + assert result == {"ok": True} + assert nd_v2.calls == [ + ( + "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/top-down/fabrics/FABRIC1/vrfs/attachments", + HttpVerbEnum.POST, + [{"vrfName": "BLUE", "lanAttachList": lan_attach_list}], + ) + ] + + +def test_manage_vrf_lite_00850_attachment_post_rejects_controller_failed_body(): + class _FakeNDModule: + def request(self, path, verb, payload): + del path, verb, payload + return { + "BLUE-[SN1/leaf1]": "Attach Response : Failed : VPC details not found for Peer Serial no: SN2", + } + + with pytest.raises(VrfLiteResourceError, match="attachment API reported failure"): + _post_attachment_payload( + nd_v2=_FakeNDModule(), + fabric_name="FABRIC1", + vrf_name="BLUE", + lan_attach_list=[{"serialNumber": "SN1"}], + ) diff --git a/tests/unit/module_utils/test_manage_vrf_lite_attachment_entry.py b/tests/unit/module_utils/test_manage_vrf_lite_attachment_entry.py new file mode 100644 index 000000000..a174a9ef9 --- /dev/null +++ b/tests/unit/module_utils/test_manage_vrf_lite_attachment_entry.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit tests for VrfLiteAttachmentEntry and the flat-mode query output. + +These cover PR 1 (Foundations) of the attachment-granularity refactor: +the new flat per-(vrf_name, switch_ip) attachment model and the +``flat=True`` output mode of ``query_vrf_lite_state``. +""" + +from __future__ import absolute_import, annotations, division, print_function + +import pytest + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_attachment_entry import ( + VrfLiteAttachmentEntry, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import ( + NDConfigCollection, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ValidationError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( + _flatten_to_entries, +) + + +# --- Identifier & basic serialization ------------------------------------ + + +def test_identifier_is_composite_tuple(): + entry = VrfLiteAttachmentEntry(vrf_name="TENANT_A", switch_ip="10.10.10.11") + assert entry.identifier_strategy == "composite" + assert entry.get_identifier_value() == ("TENANT_A", "10.10.10.11") + + +def test_missing_identifier_field_raises(): + with pytest.raises(ValidationError): + VrfLiteAttachmentEntry(vrf_name="TENANT_A") # type: ignore[call-arg] + + +def test_round_trip_from_config_preserves_fields(): + data = { + "vrf_name": "TENANT_B", + "switch_ip": "10.10.10.12", + "vlan_id": 500, + "import_evpn_rt": "65000:100", + "export_evpn_rt": "65000:100", + "extensions": [{"interface": "Ethernet1/20", "dot1q": 500, "ipv4_addr": "10.33.0.2/24"}], + } + entry = VrfLiteAttachmentEntry.from_config(data) + cfg = entry.to_config() + assert cfg["vrf_name"] == "TENANT_B" + assert cfg["switch_ip"] == "10.10.10.12" + assert cfg["vlan_id"] == 500 + assert cfg["extensions"][0]["interface"] == "Ethernet1/20" + + +def test_deploy_excluded_from_diff_dict(): + base = VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1") + with_deploy = VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", deploy=True) + assert base.to_diff_dict() == with_deploy.to_diff_dict() + + +def test_deploy_only_diff_is_no_diff(): + """Existing entry with deploy=True vs proposed without deploy must be no_diff. + + Regression test for TC9: query returns deploy=True on an already-deployed + entry; if the user did not set deploy in their playbook the state machine + must not classify it as 'changed' for either merge or replace/override. + """ + existing = VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", vlan_id=10, deploy=True) + proposed = VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", vlan_id=10) + coll = NDConfigCollection(model_class=VrfLiteAttachmentEntry, items=[existing]) + # merge state (exclude_unset=True) + assert coll.get_diff_config(proposed, exclude_unset=True) == "no_diff" + # replace/override state (exclude_unset=False) + assert coll.get_diff_config(proposed, exclude_unset=False) == "no_diff" + + +def test_empty_string_evpn_rt_coerced_to_none(): + """Empty-string EVPN RT is normalised to None by the model validator. + + The query layer may produce '' for absent EVPN RT fields. The model + must coerce that to None so it is excluded by exclude_none=True in + to_diff_dict, preventing false 'changed' classifications. + """ + entry = VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", import_evpn_rt="", export_evpn_rt="") + assert entry.import_evpn_rt is None + assert entry.export_evpn_rt is None + assert "import_evpn_rt" not in entry.to_diff_dict() + assert "export_evpn_rt" not in entry.to_diff_dict() + + +# --- Merge behaviour ------------------------------------------------------ + + +def test_merge_preserves_unmentioned_interfaces(): + base = VrfLiteAttachmentEntry.from_config({ + "vrf_name": "X", "switch_ip": "1.1.1.1", + "extensions": [ + {"interface": "Eth1", "dot1q": 100}, + {"interface": "Eth2", "dot1q": 200}, + ], + }) + incoming = VrfLiteAttachmentEntry.from_config({ + "vrf_name": "X", "switch_ip": "1.1.1.1", + "extensions": [{"interface": "Eth1", "dot1q": 150}], + }) + merged = base.merge(incoming) + by_iface = {e.interface: e.dot1q for e in merged.extensions} + assert by_iface == {"Eth1": 150, "Eth2": 200} + + +def test_merge_scalar_fields_only_when_set(): + base = VrfLiteAttachmentEntry(vrf_name="X", switch_ip="1.1.1.1", vlan_id=100) + incoming = VrfLiteAttachmentEntry(vrf_name="X", switch_ip="1.1.1.1") # vlan_id unset + merged = base.merge(incoming) + assert merged.vlan_id == 100 + + +def test_merge_empty_extensions_list_preserves_existing(): + """An incoming entry with extensions=[] must not wipe existing extensions. + + An empty list is treated the same as an absent list: the existing + extensions are preserved so that a playbook mentioning a VRF attachment + without specifying extensions does not accidentally remove them. + """ + base = VrfLiteAttachmentEntry.from_config({ + "vrf_name": "X", "switch_ip": "1.1.1.1", + "extensions": [{"interface": "Eth1", "dot1q": 100}], + }) + incoming = VrfLiteAttachmentEntry.from_config({ + "vrf_name": "X", "switch_ip": "1.1.1.1", + "extensions": [], + }) + merged = base.merge(incoming) + assert merged.extensions is not None and len(merged.extensions) == 1 + assert merged.extensions[0].interface == "Eth1" + + +# --- NDConfigCollection integration -------------------------------------- + + +def test_collection_classifies_new_no_diff_changed(): + e1 = VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", vlan_id=10) + e2 = VrfLiteAttachmentEntry(vrf_name="B", switch_ip="2.2.2.2", vlan_id=20) + coll = NDConfigCollection(model_class=VrfLiteAttachmentEntry, items=[e1, e2]) + + assert coll.get_diff_config(VrfLiteAttachmentEntry(vrf_name="C", switch_ip="3.3.3.3")) == "new" + assert coll.get_diff_config(VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", vlan_id=10)) == "no_diff" + assert coll.get_diff_config(VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1", vlan_id=99)) == "changed" + + +def test_collection_keys_use_tuples(): + coll = NDConfigCollection( + model_class=VrfLiteAttachmentEntry, + items=[VrfLiteAttachmentEntry(vrf_name="A", switch_ip="1.1.1.1")], + ) + assert ("A", "1.1.1.1") in coll.keys() + + +# --- Flat query flattener ------------------------------------------------- + + +def test_flatten_emits_one_entry_per_switch(): + nested = [{ + "vrf_name": "TENANT_A", + "vlan_id": 500, + "attach": [ + {"ip_address": "10.10.10.11", "deploy": True, "vrf_lite": [{"interface": "Eth1"}]}, + {"ip_address": "10.10.10.12", "deploy": True}, + ], + }] + flat = _flatten_to_entries(nested) + assert [e["switch_ip"] for e in flat] == ["10.10.10.11", "10.10.10.12"] + assert all(e["vrf_name"] == "TENANT_A" for e in flat) + assert flat[0]["vlan_id"] == 500 + assert flat[0]["extensions"] == [{"interface": "Eth1"}] + + +def test_flatten_skips_vrfs_with_no_attach(): + nested = [{"vrf_name": "EMPTY", "vlan_id": 10, "attach": []}] + assert _flatten_to_entries(nested) == [] + + +def test_flatten_sorts_by_vrf_then_switch(): + nested = [ + {"vrf_name": "B", "attach": [{"ip_address": "2.2.2.2"}, {"ip_address": "1.1.1.1"}]}, + {"vrf_name": "A", "attach": [{"ip_address": "3.3.3.3"}]}, + ] + flat = _flatten_to_entries(nested) + assert [(e["vrf_name"], e["switch_ip"]) for e in flat] == [ + ("A", "3.3.3.3"), ("B", "1.1.1.1"), ("B", "2.2.2.2"), + ] + + +def test_flatten_drops_none_fields(): + nested = [{"vrf_name": "A", "attach": [{"ip_address": "1.1.1.1", "deploy": None, "import_evpn_rt": None}]}] + entry = _flatten_to_entries(nested)[0] + assert "deploy" not in entry + assert "import_evpn_rt" not in entry