From 918e44143777303c8b416dc79bda6b2157622d51 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 14 May 2026 17:42:55 +0530 Subject: [PATCH 01/13] Vrf_lite module initial draft --- .../v1/manage/lan_fabric_base_path.py | 37 + .../manage_fabrics_actions_config_save.py | 50 ++ .../manage/manage_fabrics_actions_deploy.py | 53 ++ .../v1/manage/manage_fabrics_switches.py | 50 ++ .../v1/manage/manage_fabrics_vrfs.py | 66 ++ .../manage/manage_fabrics_vrfs_attachments.py | 78 ++ .../manage/manage_fabrics_vrfs_deployments.py | 51 ++ .../v1/manage/manage_fabrics_vrfs_switches.py | 65 ++ .../manage_resource_manager_reserve_id.py | 40 + .../module_utils/manage_vrf_lite/__init__.py | 0 .../module_utils/manage_vrf_lite/actions.py | 511 ++++++++++++ .../module_utils/manage_vrf_lite/common.py | 155 ++++ .../module_utils/manage_vrf_lite/deploy.py | 206 +++++ .../manage_vrf_lite/exceptions.py | 18 + plugins/module_utils/manage_vrf_lite/query.py | 297 +++++++ .../manage_vrf_lite/runtime_endpoints.py | 77 ++ .../manage_vrf_lite/runtime_payloads.py | 163 ++++ .../manage_vrf_lite/validation.py | 243 ++++++ .../models/manage_vrf_lite/__init__.py | 0 .../models/manage_vrf_lite/vrf_lite_model.py | 352 +++++++++ .../orchestrators/manage_vrf_lite.py | 79 ++ plugins/modules/nd_manage_vrf_lite.py | 428 ++++++++++ .../nd_manage_vrf_lite_delete_setup_conf.yaml | 17 + .../nd_manage_vrf_lite_merge_full_conf.yaml | 19 + ...nd_manage_vrf_lite_merge_minimal_conf.yaml | 10 + ...d_manage_vrf_lite_merge_modified_conf.yaml | 19 + ..._manage_vrf_lite_merge_no_deploy_conf.yaml | 17 + .../nd_manage_vrf_lite/tasks/base_tasks.yaml | 96 +++ .../tasks/conf_prep_tasks.yaml | 35 + .../nd_manage_vrf_lite/tasks/main.yaml | 57 ++ .../tasks/nd_manage_vrf_lite_delete.yaml | 469 +++++++++++ .../tasks/nd_manage_vrf_lite_gather.yaml | 269 +++++++ .../tasks/nd_manage_vrf_lite_merge.yaml | 730 ++++++++++++++++++ .../tasks/nd_manage_vrf_lite_override.yaml | 439 +++++++++++ .../tasks/nd_manage_vrf_lite_replace.yaml | 383 +++++++++ .../templates/nd_manage_vrf_lite_conf.j2 | 73 ++ .../test_endpoints_api_v1_vrf_lite.py | 107 +++ .../unit/module_utils/test_manage_vrf_lite.py | 690 +++++++++++++++++ 38 files changed, 6449 insertions(+) create mode 100644 plugins/module_utils/endpoints/v1/manage/lan_fabric_base_path.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_attachments.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_deployments.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_vrfs_switches.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_resource_manager_reserve_id.py create mode 100644 plugins/module_utils/manage_vrf_lite/__init__.py create mode 100644 plugins/module_utils/manage_vrf_lite/actions.py create mode 100644 plugins/module_utils/manage_vrf_lite/common.py create mode 100644 plugins/module_utils/manage_vrf_lite/deploy.py create mode 100644 plugins/module_utils/manage_vrf_lite/exceptions.py create mode 100644 plugins/module_utils/manage_vrf_lite/query.py create mode 100644 plugins/module_utils/manage_vrf_lite/runtime_endpoints.py create mode 100644 plugins/module_utils/manage_vrf_lite/runtime_payloads.py create mode 100644 plugins/module_utils/manage_vrf_lite/validation.py create mode 100644 plugins/module_utils/models/manage_vrf_lite/__init__.py create mode 100644 plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py create mode 100644 plugins/module_utils/orchestrators/manage_vrf_lite.py create mode 100644 plugins/modules/nd_manage_vrf_lite.py create mode 100644 tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_delete_setup_conf.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_full_conf.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_minimal_conf.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_modified_conf.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/files/nd_manage_vrf_lite_merge_no_deploy_conf.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/conf_prep_tasks.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_delete.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_gather.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_merge.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_override.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_replace.yaml create mode 100644 tests/integration/targets/nd_manage_vrf_lite/templates/nd_manage_vrf_lite_conf.j2 create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_vrf_lite.py create mode 100644 tests/unit/module_utils/test_manage_vrf_lite.py 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..685bcf9ce --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/actions.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 ipaddress +from collections.abc import Callable +from typing import Any + +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 ( + _is_update_needed, + _raise_vrf_lite_error, +) +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.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, + normalize_vrf_lite_list, +) +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.nd_v2 import NDModule as NDModuleV2 +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 _is_ip_literal(value: Any) -> bool: + if not isinstance(value, str): + return False + candidate = value.strip() + if not candidate: + return False + try: + ipaddress.ip_address(candidate) + return True + except ValueError: + return False + + +def _resolve_serial(module: Any, switch_identifier: str) -> str: + """Resolve management IP to serial when mapping is available.""" + 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 serial number.".format(text), + switch_id=text, + ) + + return text + + +def _mark_changed_vrf(module: Any, vrf_name: str) -> None: + changed_vrfs = module.params.get("_changed_vrfs") + if not isinstance(changed_vrfs, list): + changed_vrfs = [] + + if vrf_name not in changed_vrfs: + changed_vrfs.append(vrf_name) + + module.params["_changed_vrfs"] = changed_vrfs + + +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 _get_current_vrf_entry(module: Any, fabric_name: str, vrf_name: str) -> dict[str, Any] | None: + current = query_vrf_lite_state(module=module, fabric_name=fabric_name, filter_vrfs=set([vrf_name])) + if not current: + return None + return current[0] + + +def _build_have_attachment_map(module: Any, current_vrf: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + have_map: dict[str, dict[str, Any]] = {} + + if not current_vrf: + return have_map + + vlan_id = current_vrf.get("vlan_id") + for attach in current_vrf.get("attach", []) or []: + switch_identifier = attach.get("ip_address") + serial_number = _resolve_serial(module, switch_identifier) + if not serial_number: + continue + + have_map[serial_number] = { + "vlan_id": vlan_id, + "import_evpn_rt": attach.get("import_evpn_rt") or "", + "export_evpn_rt": attach.get("export_evpn_rt") or "", + "vrf_lite": normalize_vrf_lite_list(attach.get("vrf_lite") or []), + } + + return have_map + + +def _resolve_vrf_vlan_id(model_instance: Any, current_vrf: dict[str, Any] | None) -> int: + if model_instance.vlan_id is not None: + return int(model_instance.vlan_id) + + if isinstance(current_vrf, dict): + current_vlan = current_vrf.get("vlan_id") + if current_vlan not in (None, ""): + try: + return int(current_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(model_instance.vrf_name), + vrf_name=model_instance.vrf_name, + ) + + +def _build_want_attachment_maps( + module: Any, + nd_v2: Any, + model_instance: Any, + current_vrf: dict[str, Any] | None, +) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: + """Return (want_normalized_map, want_payload_map) keyed by serial number.""" + + want_normalized: dict[str, dict[str, Any]] = {} + want_payloads: dict[str, dict[str, Any]] = {} + + vrf_name = model_instance.vrf_name + 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 {} + + attachments = model_instance.attach or [] + vlan_id = None + if attachments: + vlan_id = _resolve_vrf_vlan_id(model_instance=model_instance, current_vrf=current_vrf) + + for attach in attachments: + serial_number = _resolve_serial(module, attach.ip_address) + if not serial_number: + continue + + lite_items = [item.model_dump(by_alias=False, exclude_none=True) for item in (attach.vrf_lite or [])] + lite_items = normalize_vrf_lite_list(lite_items) + resolved_lite_items: list[dict[str, Any]] = [] + for lite_item in lite_items: + resolved_lite_items.append(_reserve_dot1q_if_needed(nd_v2, vrf_name, serial_number, lite_item)) + + raw_attach = raw_attachment_map.get(serial_number) if isinstance(raw_attachment_map, dict) else None + extension_values = build_vrf_lite_extension_values( + resolved_lite_items, + existing_extension_values=raw_attach.get("extension_values") if isinstance(raw_attach, dict) else None, + ) + instance_values = build_instance_values(attach.import_evpn_rt, attach.export_evpn_rt) + + want_normalized[serial_number] = { + "vlan_id": vlan_id, + "import_evpn_rt": attach.import_evpn_rt or "", + "export_evpn_rt": attach.export_evpn_rt or "", + "vrf_lite": normalize_vrf_lite_list(resolved_lite_items), + } + + want_payloads[serial_number] = { + "fabric": module.params.get("fabric_name"), + "vrfName": vrf_name, + "serialNumber": serial_number, + "vlan": vlan_id if vlan_id is not None else 0, + "deployment": model_instance.deploy is not False and attach.deploy is not False, + "isAttached": True, + "extensionValues": extension_values, + "instanceValues": instance_values, + "freeformConfig": "", + } + + return want_normalized, want_payloads + + +def _build_detach_payload(module: Any, vrf_name: str, serial_number: str, vlan_id: int | None) -> dict[str, Any]: + 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": False, + "isAttached": False, + "extensionValues": "", + "instanceValues": build_instance_values("", ""), + "freeformConfig": "", + } + + +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 + + +def _sync_vrf_attachments(module: Any, model_instance: Any, replace_mode: bool) -> dict[str, Any]: + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + + _ensure_vrf_exists(module, vrf_name) + validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) + + nd_v2 = NDModuleV2(module) + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + + have_map = _build_have_attachment_map(module, current_vrf) + want_map, want_payloads = _build_want_attachment_maps(module, nd_v2, model_instance, current_vrf) + + changes: list[dict[str, Any]] = [] + + for serial_number, want_cfg in want_map.items(): + have_cfg = have_map.get(serial_number) + if have_cfg is None or _is_update_needed(want_cfg, have_cfg): + changes.append(want_payloads[serial_number]) + + if replace_mode: + vlan_for_detach = current_vrf.get("vlan_id") if current_vrf else None + for serial_number in sorted(have_map.keys()): + if serial_number in want_map: + continue + changes.append(_build_detach_payload(module, vrf_name, serial_number, vlan_for_detach)) + + if not changes: + return current_vrf or {} + + response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) + _mark_changed_vrf(module, vrf_name) + return response + + +def custom_vrf_lite_create(model_instance: Any, module: Any) -> dict[str, Any]: + if module.check_mode: + return model_instance.to_config() + + return _sync_vrf_attachments(module=module, model_instance=model_instance, replace_mode=False) + + +def custom_vrf_lite_update(model_instance: Any, module: Any) -> dict[str, Any]: + if module.check_mode: + return model_instance.to_config() + + state = module.params.get("state") + replace_mode = state in ("replaced", "overridden") + return _sync_vrf_attachments(module=module, model_instance=model_instance, replace_mode=replace_mode) + + +def custom_vrf_lite_delete(model_instance: Any, module: Any) -> bool: + if module.check_mode: + return True + + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + if not current_vrf: + return False + + have_map = _build_have_attachment_map(module, current_vrf) + if not have_map: + return False + + serials_to_detach = [] + if model_instance.attach: + for attach in model_instance.attach: + serial_number = _resolve_serial(module, attach.ip_address) + if serial_number in have_map: + serials_to_detach.append(serial_number) + else: + serials_to_detach = sorted(have_map.keys()) + + if not serials_to_detach: + return False + + vlan_for_detach = current_vrf.get("vlan_id") + detach_payloads = [_build_detach_payload(module, vrf_name, serial, vlan_for_detach) for serial in serials_to_detach] + + nd_v2 = NDModuleV2(module) + _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) + + _mark_changed_vrf(module, vrf_name) + return True + + +def _wrap_action_errors(func: Callable[..., Any]) -> Callable[..., Any]: + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + 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=error.msg, **error_dict) + except VrfLiteResourceError: + raise + except Exception as error: + _raise_vrf_lite_error(msg=str(error), exception_type=type(error).__name__) + + return wrapper + + +custom_vrf_lite_create = _wrap_action_errors(custom_vrf_lite_create) +custom_vrf_lite_update = _wrap_action_errors(custom_vrf_lite_update) +custom_vrf_lite_delete = _wrap_action_errors(custom_vrf_lite_delete) 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..fd16033f1 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/common.py @@ -0,0 +1,155 @@ +# -*- 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 + +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 _raise_vrf_lite_error(msg: str, **details: Any) -> None: + raise VrfLiteResourceError(msg=msg, **details) + + +def _get_params(source: Any) -> dict[str, Any]: + """Return a mutable params mapping from either module.params or a raw params dict.""" + if isinstance(source, dict): + return source + + params = getattr(source, "params", None) + if isinstance(params, dict): + return params + + return {} + + +def append_runtime_warning(source: Any, message: str) -> None: + """Collect runtime warnings without requiring direct Ansible dependencies.""" + params = _get_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 = _get_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 _canonicalize_for_compare(value: Any) -> Any: + """Normalize nested structures for deterministic comparison.""" + if isinstance(value, dict): + return {key: _canonicalize_for_compare(item) for key, item in sorted(value.items())} + if isinstance(value, list): + normalized_items = [_canonicalize_for_compare(item) for item in value] + return sorted(normalized_items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":"), ensure_ascii=True)) + return value + + +def _is_update_needed(want: dict[str, Any], have: dict[str, Any]) -> bool: + return _canonicalize_for_compare(want) != _canonicalize_for_compare(have) + + +def get_verify_settings(source: Any) -> dict[str, Any]: + params = _get_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 get_verify_timeout(source: Any) -> int: + return get_verify_settings(source).get("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() + + if last_error is not None: + raise last_error + return None + + +def get_config_actions(source: Any) -> dict[str, Any]: + params = _get_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, + } + + +def normalize_config_list(source: Any, state: str) -> list: + params = _get_params(source) + if state == "gathered": + return list(params.get("_gather_filter_config") or []) + return list(params.get("config") or []) 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..40ea181c2 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/deploy.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) + +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.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + append_runtime_warning, + get_config_actions, + _raise_vrf_lite_error, +) +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 _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 = item.get("vrf_name") or item.get("vrfName") + if not vrf_name: + continue + + deploy = item.get("deploy") + if deploy is False: + continue + if deploy is True: + target.add(str(vrf_name).strip()) + continue + + attachments = item.get("attach") or [] + if not isinstance(attachments, list) or not attachments: + target.add(str(vrf_name).strip()) + continue + + if any(isinstance(attachment, dict) and attachment.get("deploy") is not False for attachment in attachments): + target.add(str(vrf_name).strip()) + continue + + if not any(isinstance(attachment, dict) for attachment in attachments): + target.add(str(vrf_name).strip()) + 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: + if result.get("changed"): + return True + + changed_vrfs = module.params.get("_changed_vrfs") or [] + if changed_vrfs: + return True + + not_in_sync = set(module.params.get("_not_in_sync_vrfs") or []) + if not not_in_sync: + return False + + target_vrfs = set(_target_vrfs_for_deploy(module)) + if not target_vrfs: + return False + + return len(target_vrfs & not_in_sync) > 0 + + +def custom_vrf_lite_deploy(module: Any, fabric_name: str, result: dict[str, Any]) -> dict[str, Any]: + 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)) + changed_deploy_vrfs = set(module.params.get("_changed_vrfs") or []) & requested_deploy_vrfs + target_vrfs = sorted(changed_deploy_vrfs | 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 = False + + 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, + } + ) + changed = 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, + } + ) + changed = 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 deployment 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, + } 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..45da0685d --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -0,0 +1,297 @@ +# -*- 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 + +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, + _raise_vrf_lite_error, +) +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.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 +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError + + +def _coerce_list(data: Any) -> list[dict[str, Any]]: + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + + if isinstance(data, dict): + for key in ("DATA", "data", "vrfs", "items"): + value = data.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + + return [] + + +def _parse_vrf_template_vlan(vrf_object: dict[str, Any]) -> int | None: + 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 _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 = _coerce_list(response if not isinstance(response, dict) else response.get("switches", response)) + + sn_to_ip = {} + for switch in switches: + 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 _build_filter_set(config: list[dict[str, Any]]) -> set[str]: + filters: set[str] = set() + for item in config or []: + if not isinstance(item, dict): + continue + vrf_name = item.get("vrf_name") or item.get("vrfName") + if vrf_name: + filters.add(str(vrf_name).strip()) + return filters + + +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 _coerce_list(response) + + +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 _coerce_list(response) + + +def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | None = None) -> list[dict[str, Any]]: + """ + Query controller state and return normalized vrf-lite config shape. + """ + 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]]] = {} + + 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 "" + + 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(attach.get("lanAttachState") 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: + continue + + if serial_number: + raw_vrf_attachment_map.setdefault(vrf_name, {})[serial_number] = { + "extension_values": attach.get("extensionValues"), + "instance_values": attach.get("instanceValues"), + "vlan": attach.get("vlanId") if attach.get("vlanId") not in (None, "") else attach.get("vlan"), + } + + instance_values = parse_instance_values(attach.get("instanceValues")) + import_evpn_rt = instance_values.get("switchRouteTargetImportEvpn") + export_evpn_rt = instance_values.get("switchRouteTargetExportEvpn") + + vrf_lite_list = parse_vrf_lite_extension_values(attach.get("extensionValues")) + 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: + vlan = attach.get("vlanId") + if vlan in (None, ""): + vlan = attach.get("vlan") + try: + if vlan not in (None, ""): + entry["vlan_id"] = int(vlan) + except Exception: + pass + + attach_item = { + "ip_address": ip_address, + "deploy": deployed, + } + if import_evpn_rt not in (None, ""): + attach_item["import_evpn_rt"] = import_evpn_rt + if export_evpn_rt not in (None, ""): + attach_item["export_evpn_rt"] = export_evpn_rt + + 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 + + return result + + +def custom_vrf_lite_query_all(nrm: Any) -> list[dict[str, Any]]: + """Query all normalized VRF Lite state for reconciliation workflows.""" + module = nrm.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": + config = module.params.get("_gather_filter_config") or [] + else: + config = module.params.get("config") or [] + + filter_vrfs = _build_filter_set(config) + try: + have = query_vrf_lite_state( + module=module, + fabric_name=fabric_name, + filter_vrfs=(filter_vrfs if filter_vrfs else None), + ) + 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 + if state in ("deleted", "overridden"): + have = [item for item in have if item.get("attach")] + module.params["_have"] = have + + return have 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..e386de3a3 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py @@ -0,0 +1,163 @@ +# -*- 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 ast +import json +from typing import Any + + +def _loads_maybe_json(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if value is None: + return None + + text = str(value).strip() + if not text: + return None + + try: + return json.loads(text) + except Exception: + try: + return ast.literal_eval(text) + except Exception: + return None + + +def normalize_vrf_lite_list(vrf_lite_items: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for item in vrf_lite_items or []: + interface = item.get("interface") + if not interface: + continue + + normalized_item = { + "interface": str(interface).strip(), + "dot1q": item.get("dot1q"), + "ipv4_addr": item.get("ipv4_addr"), + "neighbor_ipv4": item.get("neighbor_ipv4"), + "ipv6_addr": item.get("ipv6_addr"), + "neighbor_ipv6": item.get("neighbor_ipv6"), + "peer_vrf": item.get("peer_vrf"), + } + normalized.append({k: v for k, v in normalized_item.items() if v is not None and v != ""}) + + normalized.sort(key=lambda i: i.get("interface", "")) + return normalized + + +def build_vrf_lite_extension_values( + vrf_lite_items: list[dict[str, Any]] | None, + existing_extension_values: Any = None, +) -> str: + """ + Build extensionValues string expected by top-down VRF attachment APIs. + """ + existing_outer = _loads_maybe_json(existing_extension_values) + if not isinstance(existing_outer, dict): + existing_outer = {} + + normalized = normalize_vrf_lite_list(vrf_lite_items) + if not normalized: + 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 normalized: + 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 = _loads_maybe_json(extension_values) + if not isinstance(outer, dict): + return [] + + inner = outer.get("VRF_LITE_CONN") + inner = _loads_maybe_json(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 normalize_vrf_lite_list(parsed) + + +def build_instance_values(import_evpn_rt: str | None, export_evpn_rt: str | None) -> 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 = _loads_maybe_json(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..6ea62a58f --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/validation.py @@ -0,0 +1,243 @@ +# -*- 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 + +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, +) +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 _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 _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 _resolve_serial(module: Any, switch_identifier: str) -> str: + if switch_identifier is None: + return "" + + text = str(switch_identifier).strip() + if not text: + return "" + + ip_to_sn = module.params.get("_ip_to_sn_mapping") or {} + if text in ip_to_sn: + return ip_to_sn[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 _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) -> bool | None: + 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) -> bool | None: + 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. + """ + 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: + serial_number = _resolve_serial(module, attach.ip_address) + 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, + ) + + if not attach.vrf_lite: + 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/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_model.py b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py new file mode 100644 index 000000000..9c07c25d6 --- /dev/null +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -0,0 +1,352 @@ +# -*- 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, Dict, List, 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] = {"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] = {"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() + + @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) + + 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 + + @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/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py new file mode 100644 index 000000000..ba299141d --- /dev/null +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -0,0 +1,79 @@ +# -*- 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, ClassVar + +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.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( + VrfLiteModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import ( + NDBaseOrchestrator, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( + custom_vrf_lite_create, + custom_vrf_lite_delete, + custom_vrf_lite_update, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( + custom_vrf_lite_query_all, +) + + +class _VrfLiteQueryContext: + """Minimal context object exposing .module for custom query flows.""" + + def __init__(self, module: Any) -> None: + self.module = module + + +class ManageVrfLiteOrchestrator(NDBaseOrchestrator): + """Orchestrator wiring NDStateMachine to VRF Lite action handlers.""" + + model_class: ClassVar[type[NDBaseModel]] = VrfLiteModel + + # Endpoint classes document the controller surface used by the custom + # handlers below. VRF Lite attach/detach is a POST-based sub-resource API, + # so generic CRUD methods are intentionally overridden. + 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 + + def _module(self) -> Any: + # TODO: Remove direct AnsibleModule access after custom orchestrator + # hooks can pass runtime context without reaching through rest_send. + return self.rest_send.sender.ansible_module + + def query_all(self) -> list[dict[str, Any]]: + module = self._module() + return custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + + def create(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + del kwargs + module = self._module() + return custom_vrf_lite_create(model_instance=model_instance, module=module) + + def update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + del kwargs + module = self._module() + return custom_vrf_lite_update(model_instance=model_instance, module=module) + + def delete(self, model_instance: Any, **kwargs: Any) -> bool: + del kwargs + module = self._module() + return custom_vrf_lite_delete(model_instance=model_instance, module=module) diff --git a/plugins/modules/nd_manage_vrf_lite.py b/plugins/modules/nd_manage_vrf_lite.py new file mode 100644 index 000000000..6b7acacd1 --- /dev/null +++ b/plugins/modules/nd_manage_vrf_lite.py @@ -0,0 +1,428 @@ +#!/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 typing import Any + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( + VrfLitePlaybookConfigModel, +) +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.deploy import ( + custom_vrf_lite_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.query import ( + custom_vrf_lite_query_all, +) +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 _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 {} + + +class _VrfLiteQueryContext: + """Minimal query context exposing .module.""" + + def __init__(self, module: Any) -> None: + self.module = module + + +def _inject_runtime_metadata(module: Any, payload: dict[str, Any]) -> dict[str, Any]: + 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 + + +def _build_gathered_output(module: Any, gathered: list) -> dict[str, Any]: + output = { + "output_level": module.params.get("output_level", "normal"), + "changed": False, + "before": gathered, + "after": gathered, + "current": gathered, + "diff": [], + "response": [], + "result": [], + "gathered": gathered, + } + return _inject_runtime_metadata(module, output) + + +def _refresh_verified_state(module: Any, result: dict[str, Any]) -> dict[str, Any]: + 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 = custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + result["after"] = refreshed + result["current"] = refreshed + return result + + +def main() -> None: + argument_spec = nd_argument_spec() + argument_spec.update(VrfLitePlaybookConfigModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + 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 + config_actions = get_config_actions(module.params) + verify_settings = get_verify_settings(module.params) + + raw_module_args = _get_raw_module_args() + raw_config_actions = raw_module_args.get("config_actions") + explicit_config_actions = isinstance(raw_config_actions, dict) + + if state == "gathered": + explicit_write_requested = False + if explicit_config_actions: + 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: + explicit_write_requested = True + + if explicit_write_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), + ) + + normalized_config = [item.to_runtime_config() for item in (module_config.config or [])] + module.params["config"] = 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 module.params.get("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["_not_in_sync_vrfs"] = [] + module.params["_ip_to_sn_mapping"] = {} + module.params["_sn_to_ip_mapping"] = {} + module.params["_have"] = [] + 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 [] + + fabric_name = module.params.get("fabric_name") + + try: + if state == "gathered": + result = _build_gathered_output(module, custom_vrf_lite_query_all(_VrfLiteQueryContext(module))) + module.exit_json(**result) + + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=ManageVrfLiteOrchestrator, + ) + nd_state_machine.manage_state() + result = nd_state_machine.output.format() + result.setdefault("current", result.get("after", [])) + + if config_actions.get("save", False) or config_actions.get("deploy", False): + deploy_result = custom_vrf_lite_deploy(module, fabric_name=fabric_name, result=result) + result["deployment"] = deploy_result + result["deployment_needed"] = deploy_result.get("deployment_needed", False) + if deploy_result.get("changed"): + result["changed"] = True + + result = _refresh_verified_state(module, result) + result = _inject_runtime_metadata(module, 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..9a3d3b173 --- /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/base_tasks.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml new file mode 100644 index 000000000..b173689bc --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml @@ -0,0 +1,96 @@ +--- +# 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_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_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 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..3820cfba1 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/conf_prep_tasks.yaml @@ -0,0 +1,35 @@ +--- +# Shared configuration preparation tasks for nd_manage_vrf_lite integration tests. +# +# Usage: +# - name: Import Configuration Prepare Tasks +# vars: +# file: merge # output file identifier +# import_tasks: conf_prep_tasks.yaml +# +# Requires: vrf_lite_conf variable to be set before importing. + +- name: Build VRF Lite Config Data from Template + ansible.builtin.file: + path: "{{ nd_vrf_lite_root | default(playbook_dir | dirname) }}/files" + state: directory + mode: "0755" + delegate_to: localhost + +- name: Build VRF Lite Config Data from Template + ansible.builtin.template: + src: "{{ nd_vrf_lite_root | default(playbook_dir | dirname) }}/templates/nd_manage_vrf_lite_conf.j2" + dest: "{{ nd_vrf_lite_root | default(playbook_dir | dirname) }}/files/nd_manage_vrf_lite_{{ file }}_conf.yaml" + mode: "0644" + delegate_to: localhost + +- name: Load Configuration Data into Variable + ansible.builtin.set_fact: + "{{ 'nd_manage_vrf_lite_' + file + '_conf' }}": >- + {{ + lookup( + 'file', + (nd_vrf_lite_root | default(playbook_dir | dirname)) + '/files/nd_manage_vrf_lite_' + file + '_conf.yaml' + ) | 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..7c57d9b1b --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml @@ -0,0 +1,57 @@ +--- +# 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.fail: + msg: "Please define the following variables: ansible_host, ansible_user and ansible_password." + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + + - 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..6463b5cb7 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_delete.yaml @@ -0,0 +1,469 @@ +############################################## +## 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + 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 + 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 deletion bypass path +- name: DELETE - TC4 - MERGE - Create VRF Lite for force 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 test + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: delete + +- name: DELETE - TC4 - DELETE - Delete VRF Lite with force true + 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 force delete execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + - result.deployment is not defined + tags: delete + +- name: DELETE - TC4 - GATHER - Verify force 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 + ansible.builtin.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + +# 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.assert: + that: + - tc6_verify_result.failed == false + - tc6_verify_result.gathered is defined + 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 + +############################################## +## 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 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..db5b82943 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_gather.yaml @@ -0,0 +1,269 @@ +############################################## +## 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.assert: + that: + - verify_result.failed == false + - verify_result.changed == false + - verify_result.gathered is defined + - 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.assert: + that: + - result.failed == false + - result.changed == false + - result.gathered is defined + 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 + 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 + 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.assert: + that: + - result.failed == false + - result.changed == false + - result.gathered is defined + 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.assert: + that: + - result.failed == false + - result.changed == false + - result.gathered is defined + 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 + 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..0f035788d --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_merge.yaml @@ -0,0 +1,730 @@ +############################################## +## 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 }}" + attach: + - ip_address: "{{ test_switch1 }}" + 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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.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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + 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 + 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + 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 + +# 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.failed == true + - 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 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..d63541d7f --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_override.yaml @@ -0,0 +1,439 @@ +############################################## +## 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + 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..994fa4ce6 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_replace.yaml @@ -0,0 +1,383 @@ +############################################## +## 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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.assert: + that: + - verify_result.failed == false + - verify_result.gathered is defined + 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 + 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/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..48ab379a8 --- /dev/null +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -0,0 +1,690 @@ +# -*- 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_want_attachment_maps, + _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, + custom_vrf_lite_deploy, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( + custom_vrf_lite_query_all, + 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.models.manage_vrf_lite.vrf_lite_model import ( + VrfLiteModel, +) + + +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) + + +class _DummyQueryContext: + def __init__(self, module): + self.module = module + + +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_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_00490_deploy_needed_when_reconciler_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_custom_deploy_filters_changed_vrfs_by_deploy_intent(): + module = _DummyModule( + { + "check_mode": True, + "_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 = custom_vrf_lite_deploy(module=module, fabric_name="FABRIC1", 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_flows_into_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") + + module = _DummyModule( + { + "fabric_name": "FABRIC1", + "_ip_to_sn_mapping": {"10.0.0.1": "SN1"}, + "_raw_vrf_attachment_map": {}, + } + ) + model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "vlan_id": 500, + "attach": [ + { + "ip_address": "10.0.0.1", + "deploy": False, + "vrf_lite": [{"interface": "Ethernet1/10", "dot1q": 500}], + } + ], + } + ) + + _want_map, payloads = _build_want_attachment_maps( + module=module, + nd_v2=_FakeNDModule(), + model_instance=model, + current_vrf={"vrf_name": "BLUE", "vlan_id": 500, "attach": []}, + ) + + assert payloads["SN1"]["deployment"] is False + + +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.manage_vrf_lite.query.query_vrf_lite_state", + lambda module, fabric_name, filter_vrfs=None: [ + {"vrf_name": "EMPTY", "attach": []}, + {"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}, + ], + ) + + have = custom_vrf_lite_query_all(_DummyQueryContext(module)) + + assert have == [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}] + assert module.params["_have"] == have + + +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 module.warn 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"}], + ) From 405b6da567127255ea64c39e6604df1b41451303 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 19 May 2026 15:50:19 +0530 Subject: [PATCH 02/13] vrf lite thin architecture for state machine adn orchestrator --- plugins/module_utils/manage_vrf_lite/query.py | 8 +- .../models/manage_vrf_lite/vrf_lite_model.py | 9 + .../orchestrators/manage_vrf_lite.py | 198 +++++++++++++++++- plugins/modules/nd_manage_vrf_lite.py | 171 ++------------- .../unit/module_utils/test_manage_vrf_lite.py | 36 ++++ 5 files changed, 259 insertions(+), 163 deletions(-) diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index 45da0685d..ad0241181 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -192,7 +192,13 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | attach_state = str(attach.get("lanAttachState") 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: + # For VRF Lite, include entries that have extension values even if + # not yet lan-attached (pending save/deploy state). + has_extension_values = bool( + attach.get("extensionValues") and str(attach.get("extensionValues")).strip() + and str(attach.get("extensionValues")).strip() != "[]" + ) + if not is_attached and not has_extension_values: continue if serial_number: 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 index 9c07c25d6..d94b6f617 100644 --- a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -157,6 +157,11 @@ def from_response(cls, response: Dict[str, Any], **kwargs) -> "VrfLiteModel": 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. @@ -290,6 +295,10 @@ def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": 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( diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py index ba299141d..610c7a0ad 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -6,7 +6,8 @@ from __future__ import absolute_import, annotations, division, print_function -from typing import Any, ClassVar +import json +from typing import Any, ClassVar, Optional 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 ( @@ -28,6 +29,15 @@ custom_vrf_lite_delete, custom_vrf_lite_update, ) +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.deploy import ( + custom_vrf_lite_deploy, +) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( custom_vrf_lite_query_all, ) @@ -41,39 +51,205 @@ def __init__(self, module: Any) -> None: class ManageVrfLiteOrchestrator(NDBaseOrchestrator): - """Orchestrator wiring NDStateMachine to VRF Lite action handlers.""" + """Self-contained orchestrator for VRF Lite management. + + Follows the 'declare, don't implement' pattern: + - Declares model_class and endpoint types + - Provides CRUD + deploy_pending() + gather() methods + - Module only wires NDStateMachine and calls orchestrator methods + """ model_class: ClassVar[type[NDBaseModel]] = VrfLiteModel - # Endpoint classes document the controller surface used by the custom - # handlers below. VRF Lite attach/detach is a POST-based sub-resource API, - # so generic CRUD methods are intentionally overridden. + # Endpoint declarations document the controller surface. + # VRF Lite uses a POST-based sub-resource API, so generic CRUD methods + # are intentionally overridden. 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 + # Module params preparation (called before orchestrator creation) + + @staticmethod + def prepare_module_params(module: Any, module_config: Any, normalized_config: Optional[list[dict[str, Any]]] = None) -> None: + """Set up module.params for the orchestrator and state machine. + + Handles input validation, config_actions normalization, gathered state + preparation, and runtime state initialization. Called once from the + module's main() before NDStateMachine creation. + """ + 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) + + # Validate gathered + explicit save/deploy conflict + 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"), + } + + # Validate deploy requires save + 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") + + # Warn about force on non-deleted states + 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), + ) + + # Normalize module params + module.params["config"] = 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"] = [] + + # Validate deleted requires config + if state == "deleted" and not module.params.get("config"): + module.fail_json(msg="Config parameter is required for state 'deleted'. Specify one or more vrf_name entries in config.") + + # Initialize runtime state + module.params["_changed_vrfs"] = [] + module.params["_not_in_sync_vrfs"] = [] + module.params["_ip_to_sn_mapping"] = {} + module.params["_sn_to_ip_mapping"] = {} + module.params["_have"] = [] + 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 {} + + # Private helpers + def _module(self) -> Any: - # TODO: Remove direct AnsibleModule access after custom orchestrator - # hooks can pass runtime context without reaching through rest_send. + """Access the AnsibleModule instance through REST infrastructure.""" return self.rest_send.sender.ansible_module - def query_all(self) -> list[dict[str, Any]]: + # CRUD operations override the base methods for VRF Lite's POST-based API. + + def query_all(self, **kwargs: Any) -> list[dict[str, Any]]: module = self._module() return custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) def create(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: - del kwargs module = self._module() return custom_vrf_lite_create(model_instance=model_instance, module=module) def update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: - del kwargs module = self._module() return custom_vrf_lite_update(model_instance=model_instance, module=module) def delete(self, model_instance: Any, **kwargs: Any) -> bool: - del kwargs module = self._module() return custom_vrf_lite_delete(model_instance=model_instance, module=module) + + # Gathered state (read-only query) + + def gather(self) -> dict[str, Any]: + """Execute gathered-state workflow and return formatted output.""" + module = self._module() + gathered = custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + output = { + "output_level": 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) + + # Deploy (config save + VRF deployments after state reconciliation) + + def deploy_pending(self, result: dict[str, Any]) -> dict[str, Any] | None: + """Execute save/deploy actions if config_actions are enabled. + + Returns deploy result dict, or None if no deploy actions are needed. + """ + 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 + + fabric_name = module.params.get("fabric_name") + return custom_vrf_lite_deploy(module, fabric_name=fabric_name, result=result) + + # Post-operation utilities + + def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: + """Re-query state after write to confirm changes were applied.""" + 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 = custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + result["after"] = refreshed + result["current"] = refreshed + return result + + def inject_runtime_metadata(self, payload: dict[str, Any]) -> dict[str, Any]: + """Attach runtime warnings and IP mapping to the output.""" + 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/modules/nd_manage_vrf_lite.py b/plugins/modules/nd_manage_vrf_lite.py index 6b7acacd1..32fe9b7f2 100644 --- a/plugins/modules/nd_manage_vrf_lite.py +++ b/plugins/modules/nd_manage_vrf_lite.py @@ -205,115 +205,43 @@ """ import json -from typing import Any 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.common import ( - append_runtime_warning, - get_config_actions, - get_runtime_warnings, - get_verify_settings, -) -from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.deploy import ( - custom_vrf_lite_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.query import ( - custom_vrf_lite_query_all, -) 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 _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 {} - - -class _VrfLiteQueryContext: - """Minimal query context exposing .module.""" - - def __init__(self, module: Any) -> None: - self.module = module - - -def _inject_runtime_metadata(module: Any, payload: dict[str, Any]) -> dict[str, Any]: - 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 - - -def _build_gathered_output(module: Any, gathered: list) -> dict[str, Any]: - output = { - "output_level": module.params.get("output_level", "normal"), - "changed": False, - "before": gathered, - "after": gathered, - "current": gathered, - "diff": [], - "response": [], - "result": [], - "gathered": gathered, - } - return _inject_runtime_metadata(module, output) - - -def _refresh_verified_state(module: Any, result: dict[str, Any]) -> dict[str, Any]: - 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 = custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) - result["after"] = refreshed - result["current"] = refreshed - return result - - 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(VrfLitePlaybookConfigModel.get_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) @@ -333,89 +261,30 @@ def main() -> None: ) state = module_config.state - config_actions = get_config_actions(module.params) - verify_settings = get_verify_settings(module.params) - - raw_module_args = _get_raw_module_args() - raw_config_actions = raw_module_args.get("config_actions") - explicit_config_actions = isinstance(raw_config_actions, dict) - - if state == "gathered": - explicit_write_requested = False - if explicit_config_actions: - 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: - explicit_write_requested = True - - if explicit_write_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), - ) - - normalized_config = [item.to_runtime_config() for item in (module_config.config or [])] - module.params["config"] = 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 module.params.get("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["_not_in_sync_vrfs"] = [] - module.params["_ip_to_sn_mapping"] = {} - module.params["_sn_to_ip_mapping"] = {} - module.params["_have"] = [] - 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 [] - - fabric_name = module.params.get("fabric_name") + ManageVrfLiteOrchestrator.prepare_module_params(module, module_config) try: if state == "gathered": - result = _build_gathered_output(module, custom_vrf_lite_query_all(_VrfLiteQueryContext(module))) + 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 = NDStateMachine(module=module, model_orchestrator=ManageVrfLiteOrchestrator) nd_state_machine.manage_state() + result = nd_state_machine.output.format() result.setdefault("current", result.get("after", [])) - if config_actions.get("save", False) or config_actions.get("deploy", False): - deploy_result = custom_vrf_lite_deploy(module, fabric_name=fabric_name, result=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 = _refresh_verified_state(module, result) - result = _inject_runtime_metadata(module, result) + 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: diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 48ab379a8..5e6dde087 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -45,6 +45,10 @@ ) 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, ) @@ -69,6 +73,38 @@ def __init__(self, module): self.module = module +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_00100_merge_preserves_unmentioned_switch_and_interface_data(): have = VrfLiteModel.from_config( { From ccf6ef9dab6a2f5d635e0a11e9359d2e8ba52e37 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 21 May 2026 12:08:11 +0530 Subject: [PATCH 03/13] Fix VRF Lite targeted delete flow and tests --- .../module_utils/manage_vrf_lite/actions.py | 119 +++++++++++++----- plugins/module_utils/manage_vrf_lite/query.py | 3 + .../tasks/nd_manage_vrf_lite_delete.yaml | 33 +++++ tests/sanity/ignore-2.15.txt | 32 +++++ tests/sanity/ignore-2.18.txt | 1 + .../unit/module_utils/test_manage_vrf_lite.py | 63 ++++++++++ 6 files changed, 219 insertions(+), 32 deletions(-) create mode 100644 tests/sanity/ignore-2.15.txt create mode 100644 tests/sanity/ignore-2.18.txt diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py index 685bcf9ce..31877d4f7 100644 --- a/plugins/module_utils/manage_vrf_lite/actions.py +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -270,6 +270,48 @@ def _build_have_attachment_map(module: Any, current_vrf: dict[str, Any] | None) return have_map +def _get_delete_config_for_vrf(module: Any, vrf_name: str) -> dict[str, Any] | None: + """Return the user's delete intent for this VRF, when state is deleted.""" + if module.params.get("state") != "deleted": + return None + + for item in module.params.get("config") or []: + if not isinstance(item, dict): + continue + if str(item.get("vrf_name", "")).strip() == vrf_name: + return item + + return None + + +def _attachment_serials(module: Any, attachments: Any, have_map: dict[str, dict[str, Any]]) -> list[str]: + serials: list[str] = [] + seen: set[str] = set() + + for attach in attachments or []: + switch_identifier = attach.get("ip_address") if isinstance(attach, dict) else getattr(attach, "ip_address", None) + serial_number = _resolve_serial(module, switch_identifier) + if serial_number in have_map and serial_number not in seen: + serials.append(serial_number) + seen.add(serial_number) + + return serials + + +def _serials_to_detach_for_delete(module: Any, vrf_name: str, model_instance: Any, have_map: dict[str, dict[str, Any]]) -> list[str]: + delete_config = _get_delete_config_for_vrf(module, vrf_name) + if delete_config is not None: + attachments = delete_config.get("attach") or [] + if attachments: + return _attachment_serials(module, attachments, have_map) + return sorted(have_map.keys()) + + if model_instance.attach: + return _attachment_serials(module, model_instance.attach, have_map) + + return sorted(have_map.keys()) + + def _resolve_vrf_vlan_id(model_instance: Any, current_vrf: dict[str, Any] | None) -> int: if model_instance.vlan_id is not None: return int(model_instance.vlan_id) @@ -401,6 +443,44 @@ def _post_attachment_payload(nd_v2: Any, fabric_name: str, vrf_name: str, lan_at return response +class AttachmentReconciler: + """Build the attachment POST payloads needed to reconcile one VRF.""" + + def __init__(self, module: Any, nd_v2: Any, model_instance: Any, current_vrf: dict[str, Any] | None) -> None: + self.module = module + self.nd_v2 = nd_v2 + self.model_instance = model_instance + self.current_vrf = current_vrf + self.vrf_name = model_instance.vrf_name + self.have_map = _build_have_attachment_map(module, current_vrf) + + def sync_payloads(self, replace_mode: bool) -> list[dict[str, Any]]: + changes: list[dict[str, Any]] = [] + want_map, want_payloads = _build_want_attachment_maps(self.module, self.nd_v2, self.model_instance, self.current_vrf) + + for serial_number, want_cfg in want_map.items(): + have_cfg = self.have_map.get(serial_number) + if have_cfg is None or _is_update_needed(want_cfg, have_cfg): + changes.append(want_payloads[serial_number]) + + if replace_mode: + vlan_for_detach = self.current_vrf.get("vlan_id") if self.current_vrf else None + for serial_number in sorted(self.have_map.keys()): + if serial_number in want_map: + continue + changes.append(_build_detach_payload(self.module, self.vrf_name, serial_number, vlan_for_detach)) + + return changes + + def detach_payloads(self) -> list[dict[str, Any]]: + if not self.have_map: + return [] + + serials_to_detach = _serials_to_detach_for_delete(self.module, self.vrf_name, self.model_instance, self.have_map) + vlan_for_detach = self.current_vrf.get("vlan_id") if self.current_vrf else None + return [_build_detach_payload(self.module, self.vrf_name, serial, vlan_for_detach) for serial in serials_to_detach] + + def _sync_vrf_attachments(module: Any, model_instance: Any, replace_mode: bool) -> dict[str, Any]: fabric_name = module.params.get("fabric_name") vrf_name = model_instance.vrf_name @@ -411,22 +491,8 @@ def _sync_vrf_attachments(module: Any, model_instance: Any, replace_mode: bool) nd_v2 = NDModuleV2(module) current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - have_map = _build_have_attachment_map(module, current_vrf) - want_map, want_payloads = _build_want_attachment_maps(module, nd_v2, model_instance, current_vrf) - - changes: list[dict[str, Any]] = [] - - for serial_number, want_cfg in want_map.items(): - have_cfg = have_map.get(serial_number) - if have_cfg is None or _is_update_needed(want_cfg, have_cfg): - changes.append(want_payloads[serial_number]) - - if replace_mode: - vlan_for_detach = current_vrf.get("vlan_id") if current_vrf else None - for serial_number in sorted(have_map.keys()): - if serial_number in want_map: - continue - changes.append(_build_detach_payload(module, vrf_name, serial_number, vlan_for_detach)) + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + changes = reconciler.sync_payloads(replace_mode=replace_mode) if not changes: return current_vrf or {} @@ -463,26 +529,15 @@ def custom_vrf_lite_delete(model_instance: Any, module: Any) -> bool: if not current_vrf: return False - have_map = _build_have_attachment_map(module, current_vrf) - if not have_map: + nd_v2 = NDModuleV2(module) + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + if not reconciler.have_map: return False - serials_to_detach = [] - if model_instance.attach: - for attach in model_instance.attach: - serial_number = _resolve_serial(module, attach.ip_address) - if serial_number in have_map: - serials_to_detach.append(serial_number) - else: - serials_to_detach = sorted(have_map.keys()) - - if not serials_to_detach: + detach_payloads = reconciler.detach_payloads() + if not detach_payloads: return False - vlan_for_detach = current_vrf.get("vlan_id") - detach_payloads = [_build_detach_payload(module, vrf_name, serial, vlan_for_detach) for serial in serials_to_detach] - - nd_v2 = NDModuleV2(module) _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) _mark_changed_vrf(module, vrf_name) diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index ad0241181..ada852a30 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -270,6 +270,8 @@ def custom_vrf_lite_query_all(nrm: Any) -> list[dict[str, Any]]: raise ValueError("fabric_name must be set") if state == "gathered": + if module.params.get("_have_loaded") and isinstance(module.params.get("_have"), list): + return module.params["_have"] config = module.params.get("_gather_filter_config") or [] else: config = module.params.get("config") or [] @@ -300,4 +302,5 @@ def custom_vrf_lite_query_all(nrm: Any) -> list[dict[str, Any]]: have = [item for item in have if item.get("attach")] module.params["_have"] = have + module.params["_have_loaded"] = True return have 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 index 6463b5cb7..29879b464 100644 --- 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 @@ -83,6 +83,8 @@ that: - verify_result.failed == false - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].attach | default([]) | length == 1 tags: delete # TC2 - Delete VRF Lite with specific config (without deploy) @@ -129,6 +131,8 @@ that: - verify_result.failed == false - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].attach | default([]) | length == 0 tags: delete # TC2b - Delete VRF Lite with deploy=true path @@ -217,6 +221,8 @@ that: - verify_result.failed == false - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].attach | default([]) | length == 0 tags: delete # TC3 - Idempotence test for deletion (no deploy) @@ -297,6 +303,8 @@ that: - verify_result.failed == false - verify_result.gathered is defined + - verify_result.gathered | length == 1 + - verify_result.gathered[0].attach | default([]) | length == 0 tags: delete # TC5 - Delete with config_actions save+deploy @@ -357,6 +365,29 @@ ) > 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].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: @@ -422,6 +453,8 @@ that: - tc6_verify_result.failed == false - tc6_verify_result.gathered is defined + - tc6_verify_result.gathered | length == 1 + - tc6_verify_result.gathered[0].attach | default([]) | length == 1 tags: delete # TC7 - Delete with empty config must be rejected (guard against accidental purge) 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/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 5e6dde087..4fffbd5c3 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -16,6 +16,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( _build_want_attachment_maps, _post_attachment_payload, + custom_vrf_lite_delete, ) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( get_config_actions, @@ -105,6 +106,22 @@ def test_manage_vrf_lite_00075_orchestrator_prepares_runtime_params(): 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.manage_vrf_lite.query.query_vrf_lite_state", + _fail_query, + ) + + assert custom_vrf_lite_query_all(_DummyQueryContext(module)) == cached_have + + def test_manage_vrf_lite_00100_merge_preserves_unmentioned_switch_and_interface_data(): have = VrfLiteModel.from_config( { @@ -442,6 +459,52 @@ def request(self, path, verb, payload): assert payloads["SN1"]["deployment"] is False +def test_manage_vrf_lite_00494_delete_uses_requested_attachment_intent(monkeypatch): + posted_payloads = [] + 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"}, + } + ) + existing_model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "vlan_id": 500, + "attach": [ + {"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}, + {"ip_address": "10.0.0.2", "vrf_lite": [{"interface": "Ethernet1/11"}]}, + ], + } + ) + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions._get_current_vrf_entry", + lambda _module, _fabric_name, _vrf_name: { + "vrf_name": "BLUE", + "vlan_id": 500, + "attach": [ + {"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}, + {"ip_address": "10.0.0.2", "vrf_lite": [{"interface": "Ethernet1/11"}]}, + ], + }, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions.NDModuleV2", + lambda _module: object(), + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions._post_attachment_payload", + lambda _nd_v2, _fabric_name, _vrf_name, lan_attach_list: posted_payloads.extend(lan_attach_list) or {"ok": True}, + ) + + assert custom_vrf_lite_delete(module=module, model_instance=existing_model) is True + assert [payload["serialNumber"] for payload in posted_payloads] == ["SN2"] + assert module.params["_changed_vrfs"] == ["BLUE"] + + def test_manage_vrf_lite_00495_delete_query_filters_vrfs_without_managed_attachments(monkeypatch): module = _DummyModule({"state": "deleted", "fabric_name": "FABRIC1", "config": []}) From a452a6de7593a4ff00c3e9a1e48519719b7f6a24 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 21 May 2026 19:13:07 +0530 Subject: [PATCH 04/13] Moving towards genric orchestrator --- .../module_utils/manage_vrf_lite/__init__.py | 7 + .../module_utils/manage_vrf_lite/actions.py | 94 ------ .../module_utils/manage_vrf_lite/deploy.py | 129 ------- plugins/module_utils/manage_vrf_lite/query.py | 51 --- .../models/manage_vrf_lite/__init__.py | 7 + .../models/manage_vrf_lite/vrf_lite_model.py | 54 +-- .../orchestrators/manage_vrf_lite.py | 315 ++++++++++++++++-- .../unit/module_utils/test_manage_vrf_lite.py | 42 ++- 8 files changed, 349 insertions(+), 350 deletions(-) diff --git a/plugins/module_utils/manage_vrf_lite/__init__.py b/plugins/module_utils/manage_vrf_lite/__init__.py index e69de29bb..5e4d12ab6 100644 --- a/plugins/module_utils/manage_vrf_lite/__init__.py +++ b/plugins/module_utils/manage_vrf_lite/__init__.py @@ -0,0 +1,7 @@ +# -*- 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 diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py index 31877d4f7..d238a2712 100644 --- a/plugins/module_utils/manage_vrf_lite/actions.py +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -7,18 +7,13 @@ from __future__ import absolute_import, annotations, division, print_function import ipaddress -from collections.abc import Callable from typing import Any -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 ( _is_update_needed, _raise_vrf_lite_error, ) -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.query import ( query_vrf_lite_state, ) @@ -30,10 +25,6 @@ build_vrf_lite_extension_values, normalize_vrf_lite_list, ) -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.nd_v2 import NDModule as NDModuleV2 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 @@ -479,88 +470,3 @@ def detach_payloads(self) -> list[dict[str, Any]]: serials_to_detach = _serials_to_detach_for_delete(self.module, self.vrf_name, self.model_instance, self.have_map) vlan_for_detach = self.current_vrf.get("vlan_id") if self.current_vrf else None return [_build_detach_payload(self.module, self.vrf_name, serial, vlan_for_detach) for serial in serials_to_detach] - - -def _sync_vrf_attachments(module: Any, model_instance: Any, replace_mode: bool) -> dict[str, Any]: - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name - - _ensure_vrf_exists(module, vrf_name) - validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) - - nd_v2 = NDModuleV2(module) - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - changes = reconciler.sync_payloads(replace_mode=replace_mode) - - if not changes: - return current_vrf or {} - - response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) - _mark_changed_vrf(module, vrf_name) - return response - - -def custom_vrf_lite_create(model_instance: Any, module: Any) -> dict[str, Any]: - if module.check_mode: - return model_instance.to_config() - - return _sync_vrf_attachments(module=module, model_instance=model_instance, replace_mode=False) - - -def custom_vrf_lite_update(model_instance: Any, module: Any) -> dict[str, Any]: - if module.check_mode: - return model_instance.to_config() - - state = module.params.get("state") - replace_mode = state in ("replaced", "overridden") - return _sync_vrf_attachments(module=module, model_instance=model_instance, replace_mode=replace_mode) - - -def custom_vrf_lite_delete(model_instance: Any, module: Any) -> bool: - if module.check_mode: - return True - - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name - - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - if not current_vrf: - return False - - nd_v2 = NDModuleV2(module) - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - if not reconciler.have_map: - return False - - detach_payloads = reconciler.detach_payloads() - if not detach_payloads: - return False - - _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) - - _mark_changed_vrf(module, vrf_name) - return True - - -def _wrap_action_errors(func: Callable[..., Any]) -> Callable[..., Any]: - def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - return func(*args, **kwargs) - 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=error.msg, **error_dict) - except VrfLiteResourceError: - raise - except Exception as error: - _raise_vrf_lite_error(msg=str(error), exception_type=type(error).__name__) - - return wrapper - - -custom_vrf_lite_create = _wrap_action_errors(custom_vrf_lite_create) -custom_vrf_lite_update = _wrap_action_errors(custom_vrf_lite_update) -custom_vrf_lite_delete = _wrap_action_errors(custom_vrf_lite_delete) diff --git a/plugins/module_utils/manage_vrf_lite/deploy.py b/plugins/module_utils/manage_vrf_lite/deploy.py index 40ea181c2..b76eb21fb 100644 --- a/plugins/module_utils/manage_vrf_lite/deploy.py +++ b/plugins/module_utils/manage_vrf_lite/deploy.py @@ -9,16 +9,6 @@ from typing import Any 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, - get_config_actions, - _raise_vrf_lite_error, -) -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 _target_vrfs_for_deploy(module: Any) -> list[str]: @@ -85,122 +75,3 @@ def _needs_deployment(result: dict[str, Any], module: Any) -> bool: return False return len(target_vrfs & not_in_sync) > 0 - - -def custom_vrf_lite_deploy(module: Any, fabric_name: str, result: dict[str, Any]) -> dict[str, Any]: - 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)) - changed_deploy_vrfs = set(module.params.get("_changed_vrfs") or []) & requested_deploy_vrfs - target_vrfs = sorted(changed_deploy_vrfs | 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 = False - - 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, - } - ) - changed = 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, - } - ) - changed = 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 deployment 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, - } diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index ada852a30..b1e66ff77 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -12,10 +12,6 @@ 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, - _raise_vrf_lite_error, -) -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.runtime_endpoints import ( VrfLiteEndpoints, @@ -25,7 +21,6 @@ parse_vrf_lite_extension_values, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule as NDModuleV2 -from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError def _coerce_list(data: Any) -> list[dict[str, Any]]: @@ -258,49 +253,3 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | module.params["_raw_vrf_attachment_map"] = raw_vrf_attachment_map return result - - -def custom_vrf_lite_query_all(nrm: Any) -> list[dict[str, Any]]: - """Query all normalized VRF Lite state for reconciliation workflows.""" - module = nrm.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): - return module.params["_have"] - config = module.params.get("_gather_filter_config") or [] - else: - config = module.params.get("config") or [] - - filter_vrfs = _build_filter_set(config) - try: - have = query_vrf_lite_state( - module=module, - fabric_name=fabric_name, - filter_vrfs=(filter_vrfs if filter_vrfs else None), - ) - 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 - if state in ("deleted", "overridden"): - have = [item for item in have if item.get("attach")] - module.params["_have"] = have - - module.params["_have_loaded"] = True - return have diff --git a/plugins/module_utils/models/manage_vrf_lite/__init__.py b/plugins/module_utils/models/manage_vrf_lite/__init__.py index e69de29bb..5e4d12ab6 100644 --- a/plugins/module_utils/models/manage_vrf_lite/__init__.py +++ b/plugins/module_utils/models/manage_vrf_lite/__init__.py @@ -0,0 +1,7 @@ +# -*- 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 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 index d94b6f617..67121907e 100644 --- a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function from copy import deepcopy -from typing import Any, ClassVar, Dict, List, Literal, Optional +from typing import Any, ClassVar, Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, @@ -34,12 +34,12 @@ class VrfLiteConnectionModel(NDNestedModel): ) 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") + dot1q: int | None = Field(default=None, alias="dot1q", ge=1, le=4094) + ipv4_addr: str | None = Field(default=None, alias="ipv4_addr") + neighbor_ipv4: str | None = Field(default=None, alias="neighbor_ipv4") + ipv6_addr: str | None = Field(default=None, alias="ipv6_addr") + neighbor_ipv6: str | None = Field(default=None, alias="neighbor_ipv6") + peer_vrf: str | None = Field(default=None, alias="peer_vrf") class VrfLiteAttachmentModel(NDNestedModel): @@ -58,10 +58,10 @@ class VrfLiteAttachmentModel(NDNestedModel): ) 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") + deploy: bool | None = Field(default=None, alias="deploy") + import_evpn_rt: str | None = Field(default=None, alias="import_evpn_rt") + export_evpn_rt: str | None = Field(default=None, alias="export_evpn_rt") + vrf_lite: list[VrfLiteConnectionModel] | None = Field(default=None, alias="vrf_lite") @field_validator("ip_address") @classmethod @@ -123,7 +123,7 @@ def merge(self, other: "VrfLiteAttachmentModel") -> "VrfLiteAttachmentModel": class VrfLiteModel(NDBaseModel): """Runtime model for nd_manage_vrf_lite state reconciliation.""" - identifiers: ClassVar[List[str]] = ["vrf_name"] + identifiers: ClassVar[list[str]] = ["vrf_name"] identifier_strategy: ClassVar[Literal["single"]] = "single" exclude_from_diff: ClassVar[set] = {"deploy"} @@ -138,9 +138,9 @@ class VrfLiteModel(NDBaseModel): ) 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") + vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: bool | None = Field(default=None, alias="deploy") + attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") @field_validator("vrf_name") @classmethod @@ -150,15 +150,15 @@ def validate_vrf_name(cls, value: str) -> str: return str(value).strip() @classmethod - def from_response(cls, response: Dict[str, Any], **kwargs) -> "VrfLiteModel": + 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": + 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]: + def get_argument_spec(cls) -> dict[str, Any]: """Return the Ansible argument spec for nd_manage_vrf_lite.""" return VrfLitePlaybookConfigModel.get_argument_spec() @@ -219,9 +219,9 @@ class VrfLitePlaybookItemModel(BaseModel): ) 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") + vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: bool | None = Field(default=None, alias="deploy") + attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") @field_validator("vrf_name") @classmethod @@ -230,7 +230,7 @@ def validate_vrf_name(cls, value: str) -> str: raise ValueError("vrf_name must be a non-empty string") return str(value).strip() - def to_runtime_config(self) -> Dict[str, Any]: + def to_runtime_config(self) -> dict[str, Any]: return self.model_dump(by_alias=False, exclude_none=True) @@ -285,9 +285,9 @@ class VrfLitePlaybookConfigModel(BaseModel): # 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) + verify: VerifyConfigModel | None = Field(default=None) + config_actions: ConfigActionsModel | None = Field(default=None) + config: list[VrfLitePlaybookItemModel] | None = Field(default=None) @model_validator(mode="after") def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": @@ -295,12 +295,12 @@ def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": raise ValueError("config_actions.deploy=true requires config_actions.save=true") return self - def to_runtime_config(self) -> List[Dict[str, Any]]: + 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]: + 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), diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py index 610c7a0ad..8254e3506 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -7,8 +7,9 @@ from __future__ import absolute_import, annotations, division, print_function import json -from typing import Any, ClassVar, Optional +from typing import Any, ClassVar +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, @@ -21,42 +22,53 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vrf_lite.vrf_lite_model import ( VrfLiteModel, ) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( + AttachmentReconciler, + _ensure_vrf_exists, + _get_current_vrf_entry, + _mark_changed_vrf, + _post_attachment_payload, +) from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import ( NDBaseOrchestrator, ) -from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( - custom_vrf_lite_create, - custom_vrf_lite_delete, - custom_vrf_lite_update, -) 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 ( - custom_vrf_lite_deploy, + _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.query import ( - custom_vrf_lite_query_all, + _build_filter_set, + query_vrf_lite_state, ) - - -class _VrfLiteQueryContext: - """Minimal context object exposing .module for custom query flows.""" - - def __init__(self, module: Any) -> None: - self.module = module +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.nd_v2 import NDModule as NDModuleV2 class ManageVrfLiteOrchestrator(NDBaseOrchestrator): - """Self-contained orchestrator for VRF Lite management. + """VRF Lite resource adapter for the generic ND state machine. - Follows the 'declare, don't implement' pattern: - - Declares model_class and endpoint types - - Provides CRUD + deploy_pending() + gather() methods - - Module only wires NDStateMachine and calls orchestrator methods + The base state machine still decides merged/replaced/overridden/deleted + lifecycle. This orchestrator adapts those generic method calls to VRF + Lite's attachment API: create/update are attachment sync POSTs, delete is a + detach POST, query is normalized from several controller reads, and deploy + is a separate save/deploy action. """ model_class: ClassVar[type[NDBaseModel]] = VrfLiteModel @@ -73,7 +85,7 @@ class ManageVrfLiteOrchestrator(NDBaseOrchestrator): # Module params preparation (called before orchestrator creation) @staticmethod - def prepare_module_params(module: Any, module_config: Any, normalized_config: Optional[list[dict[str, Any]]] = None) -> None: + def prepare_module_params(module: Any, module_config: Any, normalized_config: list[dict[str, Any]] | None = None) -> None: """Set up module.params for the orchestrator and state machine. Handles input validation, config_actions normalization, gathered state @@ -138,6 +150,7 @@ def prepare_module_params(module: Any, module_config: Any, normalized_config: Op module.params["_ip_to_sn_mapping"] = {} module.params["_sn_to_ip_mapping"] = {} module.params["_have"] = [] + module.params["_have_loaded"] = False module.params["_raw_vrf_attachment_map"] = {} module.params["_fabric_switch_inventory"] = {} module.params["_warnings"] = ( @@ -173,30 +186,45 @@ def _module(self) -> Any: """Access the AnsibleModule instance through REST infrastructure.""" return self.rest_send.sender.ansible_module - # CRUD operations override the base methods for VRF Lite's POST-based API. + # CRUD operations adapt generic state-machine calls to VRF Lite's + # attachment sub-resource API. def query_all(self, **kwargs: Any) -> list[dict[str, Any]]: - module = self._module() - return custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + return self._query_current_state() def create(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: - module = self._module() - return custom_vrf_lite_create(model_instance=model_instance, module=module) + if self._module().check_mode: + return model_instance.to_config() + + return self._run_vrf_lite_action( + self._sync_vrf_attachments, + model_instance, + replace_mode=False, + ) def update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: module = self._module() - return custom_vrf_lite_update(model_instance=model_instance, module=module) + if module.check_mode: + return model_instance.to_config() + + return self._run_vrf_lite_action( + self._sync_vrf_attachments, + model_instance, + replace_mode=module.params.get("state") in ("replaced", "overridden"), + ) def delete(self, model_instance: Any, **kwargs: Any) -> bool: - module = self._module() - return custom_vrf_lite_delete(model_instance=model_instance, module=module) + if self._module().check_mode: + return True + + return self._run_vrf_lite_action(self._detach_vrf_attachments, model_instance) # Gathered state (read-only query) def gather(self) -> dict[str, Any]: """Execute gathered-state workflow and return formatted output.""" module = self._module() - gathered = custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + gathered = self._query_current_state() output = { "output_level": module.params.get("output_level", "normal"), "changed": False, @@ -223,8 +251,7 @@ def deploy_pending(self, result: dict[str, Any]) -> dict[str, Any] | None: if not config_actions.get("save", False) and not config_actions.get("deploy", False): return None - fabric_name = module.params.get("fabric_name") - return custom_vrf_lite_deploy(module, fabric_name=fabric_name, result=result) + return self._execute_config_actions(result) # Post-operation utilities @@ -237,11 +264,233 @@ def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: if module.check_mode or not result.get("changed"): return result - refreshed = custom_vrf_lite_query_all(_VrfLiteQueryContext(module)) + refreshed = self._query_current_state() result["after"] = refreshed result["current"] = refreshed return result + # VRF Lite resource behavior + + def _run_vrf_lite_action(self, action: Any, *args: Any, **kwargs: Any) -> Any: + try: + return action(*args, **kwargs) + 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=error.msg, **error_dict) + except VrfLiteResourceError: + raise + except Exception as error: + _raise_vrf_lite_error(msg=str(error), exception_type=type(error).__name__) + + def _query_current_state(self) -> 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): + return module.params["_have"] + config = module.params.get("_gather_filter_config") or [] + else: + config = module.params.get("config") or [] + + filter_vrfs = _build_filter_set(config) + try: + have = query_vrf_lite_state( + module=module, + fabric_name=fabric_name, + filter_vrfs=(filter_vrfs if filter_vrfs else None), + ) + 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 + if state in ("deleted", "overridden"): + have = [item for item in have if item.get("attach")] + module.params["_have"] = have + + module.params["_have_loaded"] = True + return have + + def _sync_vrf_attachments(self, model_instance: Any, replace_mode: bool) -> dict[str, Any]: + module = self._module() + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + + _ensure_vrf_exists(module, vrf_name) + validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) + + nd_v2 = NDModuleV2(module) + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + changes = reconciler.sync_payloads(replace_mode=replace_mode) + + if not changes: + return current_vrf or {} + + response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) + _mark_changed_vrf(module, vrf_name) + return response + + def _detach_vrf_attachments(self, model_instance: Any) -> bool: + module = self._module() + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + if not current_vrf: + return False + + nd_v2 = NDModuleV2(module) + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + if not reconciler.have_map: + return False + + detach_payloads = reconciler.detach_payloads() + if not detach_payloads: + return False + + _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) + _mark_changed_vrf(module, vrf_name) + return True + + 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)) + changed_deploy_vrfs = set(module.params.get("_changed_vrfs") or []) & requested_deploy_vrfs + target_vrfs = sorted(changed_deploy_vrfs | 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 = False + + 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, + } + ) + changed = 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, + } + ) + changed = 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]: """Attach runtime warnings and IP mapping to the output.""" module = self._module() diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 4fffbd5c3..80e13f5dc 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -16,7 +16,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( _build_want_attachment_maps, _post_attachment_payload, - custom_vrf_lite_delete, ) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( get_config_actions, @@ -26,10 +25,8 @@ from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.deploy import ( _needs_deployment, _target_vrfs_for_deploy, - custom_vrf_lite_deploy, ) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( - custom_vrf_lite_query_all, query_vrf_lite_state, ) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_payloads import ( @@ -51,6 +48,9 @@ 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: @@ -69,9 +69,18 @@ def warn(self, msg): self.warnings.append(msg) -class _DummyQueryContext: - def __init__(self, module): - self.module = module +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(): @@ -115,11 +124,11 @@ def _fail_query(**kwargs): pytest.fail("gathered query should reuse the state machine query result") monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query.query_vrf_lite_state", + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.query_vrf_lite_state", _fail_query, ) - assert custom_vrf_lite_query_all(_DummyQueryContext(module)) == cached_have + assert _vrf_lite_orchestrator(module)._query_current_state() == cached_have def test_manage_vrf_lite_00100_merge_preserves_unmentioned_switch_and_interface_data(): @@ -399,10 +408,11 @@ def test_manage_vrf_lite_00491_deploy_targets_honor_vrf_and_attachment_intent(): assert _target_vrfs_for_deploy(module) == ["GREEN", "YELLOW"] -def test_manage_vrf_lite_00492_custom_deploy_filters_changed_vrfs_by_deploy_intent(): +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": [ @@ -413,7 +423,7 @@ def test_manage_vrf_lite_00492_custom_deploy_filters_changed_vrfs_by_deploy_inte } ) - result = custom_vrf_lite_deploy(module=module, fabric_name="FABRIC1", result={"changed": True}) + result = _vrf_lite_orchestrator(module)._execute_config_actions(result={"changed": True}) assert result["target_vrfs"] == ["GREEN"] assert result["planned_actions"] == [ @@ -481,7 +491,7 @@ def test_manage_vrf_lite_00494_delete_uses_requested_attachment_intent(monkeypat ) monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions._get_current_vrf_entry", + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._get_current_vrf_entry", lambda _module, _fabric_name, _vrf_name: { "vrf_name": "BLUE", "vlan_id": 500, @@ -492,15 +502,15 @@ def test_manage_vrf_lite_00494_delete_uses_requested_attachment_intent(monkeypat }, ) monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions.NDModuleV2", + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.NDModuleV2", lambda _module: object(), ) monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions._post_attachment_payload", + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._post_attachment_payload", lambda _nd_v2, _fabric_name, _vrf_name, lan_attach_list: posted_payloads.extend(lan_attach_list) or {"ok": True}, ) - assert custom_vrf_lite_delete(module=module, model_instance=existing_model) is True + assert _vrf_lite_orchestrator(module)._detach_vrf_attachments(model_instance=existing_model) is True assert [payload["serialNumber"] for payload in posted_payloads] == ["SN2"] assert module.params["_changed_vrfs"] == ["BLUE"] @@ -509,14 +519,14 @@ def test_manage_vrf_lite_00495_delete_query_filters_vrfs_without_managed_attachm module = _DummyModule({"state": "deleted", "fabric_name": "FABRIC1", "config": []}) monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query.query_vrf_lite_state", + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.query_vrf_lite_state", lambda module, fabric_name, filter_vrfs=None: [ {"vrf_name": "EMPTY", "attach": []}, {"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}, ], ) - have = custom_vrf_lite_query_all(_DummyQueryContext(module)) + have = _vrf_lite_orchestrator(module)._query_current_state() assert have == [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}] assert module.params["_have"] == have From dbb68d95b439a0e17e46d9060f37dd0a3e652bf1 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 21 May 2026 19:24:03 +0530 Subject: [PATCH 05/13] Moving towards generic vrf_lite architecture --- .../orchestrators/manage_vrf_lite.py | 169 +++++++++++------- .../unit/module_utils/test_manage_vrf_lite.py | 2 +- 2 files changed, 101 insertions(+), 70 deletions(-) diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py index 8254e3506..68dacde5d 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -193,31 +193,118 @@ def query_all(self, **kwargs: Any) -> list[dict[str, Any]]: return self._query_current_state() def create(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: - if self._module().check_mode: + """Create VRF Lite attachment intent by posting required attach rows.""" + module = self._module() + if module.check_mode: return model_instance.to_config() - return self._run_vrf_lite_action( - self._sync_vrf_attachments, - model_instance, - replace_mode=False, - ) + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + + try: + _ensure_vrf_exists(module, vrf_name) + validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) + + nd_v2 = NDModuleV2(module) + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + changes = reconciler.sync_payloads(replace_mode=False) + + if not changes: + return current_vrf or {} + + response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) + _mark_changed_vrf(module, vrf_name) + return response + 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="Create failed for {0}: {1}".format(model_instance.get_identifier_value(), error.msg), **error_dict) + except VrfLiteResourceError: + raise + except Exception as error: + _raise_vrf_lite_error( + msg="Create failed for {0}: {1}".format(model_instance.get_identifier_value(), error), + exception_type=type(error).__name__, + ) def update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + """Update VRF Lite attachment intent by posting changed attach/detach rows.""" module = self._module() if module.check_mode: return model_instance.to_config() - return self._run_vrf_lite_action( - self._sync_vrf_attachments, - model_instance, - replace_mode=module.params.get("state") in ("replaced", "overridden"), - ) + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + replace_mode = module.params.get("state") in ("replaced", "overridden") + + try: + _ensure_vrf_exists(module, vrf_name) + validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) + + nd_v2 = NDModuleV2(module) + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + changes = reconciler.sync_payloads(replace_mode=replace_mode) + + if not changes: + return current_vrf or {} + + response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) + _mark_changed_vrf(module, vrf_name) + return response + 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="Update failed for {0}: {1}".format(model_instance.get_identifier_value(), error.msg), **error_dict) + except VrfLiteResourceError: + raise + except Exception as error: + _raise_vrf_lite_error( + msg="Update failed for {0}: {1}".format(model_instance.get_identifier_value(), error), + exception_type=type(error).__name__, + ) def delete(self, model_instance: Any, **kwargs: Any) -> bool: - if self._module().check_mode: + """Detach VRF Lite attachment intent by posting detach rows.""" + module = self._module() + if module.check_mode: return True - return self._run_vrf_lite_action(self._detach_vrf_attachments, model_instance) + fabric_name = module.params.get("fabric_name") + vrf_name = model_instance.vrf_name + + try: + current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) + if not current_vrf: + return False + + nd_v2 = NDModuleV2(module) + reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) + if not reconciler.have_map: + return False + + detach_payloads = reconciler.detach_payloads() + if not detach_payloads: + return False + + _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) + _mark_changed_vrf(module, vrf_name) + return 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="Delete failed for {0}: {1}".format(model_instance.get_identifier_value(), error.msg), **error_dict) + except VrfLiteResourceError: + raise + except Exception as error: + _raise_vrf_lite_error( + msg="Delete failed for {0}: {1}".format(model_instance.get_identifier_value(), error), + exception_type=type(error).__name__, + ) # Gathered state (read-only query) @@ -271,19 +358,6 @@ def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: # VRF Lite resource behavior - def _run_vrf_lite_action(self, action: Any, *args: Any, **kwargs: Any) -> Any: - try: - return action(*args, **kwargs) - 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=error.msg, **error_dict) - except VrfLiteResourceError: - raise - except Exception as error: - _raise_vrf_lite_error(msg=str(error), exception_type=type(error).__name__) - def _query_current_state(self) -> list[dict[str, Any]]: module = self._module() fabric_name = module.params.get("fabric_name") @@ -328,49 +402,6 @@ def _query_current_state(self) -> list[dict[str, Any]]: module.params["_have_loaded"] = True return have - def _sync_vrf_attachments(self, model_instance: Any, replace_mode: bool) -> dict[str, Any]: - module = self._module() - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name - - _ensure_vrf_exists(module, vrf_name) - validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) - - nd_v2 = NDModuleV2(module) - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - changes = reconciler.sync_payloads(replace_mode=replace_mode) - - if not changes: - return current_vrf or {} - - response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) - _mark_changed_vrf(module, vrf_name) - return response - - def _detach_vrf_attachments(self, model_instance: Any) -> bool: - module = self._module() - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name - - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - if not current_vrf: - return False - - nd_v2 = NDModuleV2(module) - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - if not reconciler.have_map: - return False - - detach_payloads = reconciler.detach_payloads() - if not detach_payloads: - return False - - _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) - _mark_changed_vrf(module, vrf_name) - return True - def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: module = self._module() fabric_name = module.params.get("fabric_name") diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 80e13f5dc..1fcc51200 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -510,7 +510,7 @@ def test_manage_vrf_lite_00494_delete_uses_requested_attachment_intent(monkeypat lambda _nd_v2, _fabric_name, _vrf_name, lan_attach_list: posted_payloads.extend(lan_attach_list) or {"ok": True}, ) - assert _vrf_lite_orchestrator(module)._detach_vrf_attachments(model_instance=existing_model) is True + assert _vrf_lite_orchestrator(module).delete(model_instance=existing_model) is True assert [payload["serialNumber"] for payload in posted_payloads] == ["SN2"] assert module.params["_changed_vrfs"] == ["BLUE"] From 5f91d627de5d3689f42239468d071cc199781630 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 22 May 2026 09:57:29 +0530 Subject: [PATCH 06/13] Using modern annotations --- .../models/manage_vrf_lite/vrf_lite_model.py | 58 ++++++------- .../orchestrators/manage_vrf_lite.py | 46 +++++++++- plugins/modules/nd_manage_vrf_lite.py | 1 + .../nd_manage_vrf_lite_delete_setup_conf.yaml | 4 +- .../unit/module_utils/test_manage_vrf_lite.py | 87 +++++++++++++++++++ 5 files changed, 164 insertions(+), 32 deletions(-) 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 index 67121907e..4f36f1f5e 100644 --- a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function from copy import deepcopy -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Dict, List, Literal, Optional, Set from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, @@ -34,18 +34,18 @@ class VrfLiteConnectionModel(NDNestedModel): ) interface: str = Field(alias="interface", min_length=1, max_length=128) - dot1q: int | None = Field(default=None, alias="dot1q", ge=1, le=4094) - ipv4_addr: str | None = Field(default=None, alias="ipv4_addr") - neighbor_ipv4: str | None = Field(default=None, alias="neighbor_ipv4") - ipv6_addr: str | None = Field(default=None, alias="ipv6_addr") - neighbor_ipv6: str | None = Field(default=None, alias="neighbor_ipv6") - peer_vrf: str | None = Field(default=None, alias="peer_vrf") + 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] = {"deploy"} + exclude_from_diff: ClassVar[Set[str]] = {"deploy"} model_config = ConfigDict( str_strip_whitespace=True, @@ -58,10 +58,10 @@ class VrfLiteAttachmentModel(NDNestedModel): ) ip_address: str = Field(alias="ip_address", min_length=1, max_length=128) - deploy: bool | None = Field(default=None, alias="deploy") - import_evpn_rt: str | None = Field(default=None, alias="import_evpn_rt") - export_evpn_rt: str | None = Field(default=None, alias="export_evpn_rt") - vrf_lite: list[VrfLiteConnectionModel] | None = Field(default=None, alias="vrf_lite") + 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 @@ -123,9 +123,9 @@ def merge(self, other: "VrfLiteAttachmentModel") -> "VrfLiteAttachmentModel": class VrfLiteModel(NDBaseModel): """Runtime model for nd_manage_vrf_lite state reconciliation.""" - identifiers: ClassVar[list[str]] = ["vrf_name"] + identifiers: ClassVar[List[str]] = ["vrf_name"] identifier_strategy: ClassVar[Literal["single"]] = "single" - exclude_from_diff: ClassVar[set] = {"deploy"} + exclude_from_diff: ClassVar[Set[str]] = {"deploy"} model_config = ConfigDict( str_strip_whitespace=True, @@ -138,9 +138,9 @@ class VrfLiteModel(NDBaseModel): ) vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) - vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) - deploy: bool | None = Field(default=None, alias="deploy") - attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") + 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 @@ -150,15 +150,15 @@ def validate_vrf_name(cls, value: str) -> str: return str(value).strip() @classmethod - def from_response(cls, response: dict[str, Any], **kwargs) -> "VrfLiteModel": + 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": + 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]: + def get_argument_spec(cls) -> Dict[str, Any]: """Return the Ansible argument spec for nd_manage_vrf_lite.""" return VrfLitePlaybookConfigModel.get_argument_spec() @@ -219,9 +219,9 @@ class VrfLitePlaybookItemModel(BaseModel): ) vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) - vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) - deploy: bool | None = Field(default=None, alias="deploy") - attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") + 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 @@ -230,7 +230,7 @@ def validate_vrf_name(cls, value: str) -> str: raise ValueError("vrf_name must be a non-empty string") return str(value).strip() - def to_runtime_config(self) -> dict[str, Any]: + def to_runtime_config(self) -> Dict[str, Any]: return self.model_dump(by_alias=False, exclude_none=True) @@ -285,9 +285,9 @@ class VrfLitePlaybookConfigModel(BaseModel): # 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: VerifyConfigModel | None = Field(default=None) - config_actions: ConfigActionsModel | None = Field(default=None) - config: list[VrfLitePlaybookItemModel] | None = Field(default=None) + 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": @@ -295,12 +295,12 @@ def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": raise ValueError("config_actions.deploy=true requires config_actions.save=true") return self - def to_runtime_config(self) -> list[dict[str, Any]]: + 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]: + 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), diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py index 68dacde5d..35b98c9a0 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -146,6 +146,8 @@ def prepare_module_params(module: Any, module_config: Any, normalized_config: li # Initialize runtime state module.params["_changed_vrfs"] = [] + module.params["_vrf_lite_delete_attempted"] = False + module.params["_vrf_lite_delete_posted"] = False module.params["_not_in_sync_vrfs"] = [] module.params["_ip_to_sn_mapping"] = {} module.params["_sn_to_ip_mapping"] = {} @@ -275,6 +277,8 @@ def delete(self, model_instance: Any, **kwargs: Any) -> bool: fabric_name = module.params.get("fabric_name") vrf_name = model_instance.vrf_name + module.params["_vrf_lite_delete_attempted"] = True + module.params["_vrf_lite_delete_posted"] = False try: current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) @@ -288,9 +292,11 @@ def delete(self, model_instance: Any, **kwargs: Any) -> bool: detach_payloads = reconciler.detach_payloads() if not detach_payloads: + self._warn_no_delete_match(vrf_name, model_instance) return False _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) + module.params["_vrf_lite_delete_posted"] = True _mark_changed_vrf(module, vrf_name) return True except NDModuleError as error: @@ -346,7 +352,8 @@ def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: """Re-query state after write to confirm changes were applied.""" module = self._module() verify_settings = get_verify_settings(module.params) - if not verify_settings.get("enabled", True): + delete_refresh_required = module.params.get("state") == "deleted" and module.params.get("_vrf_lite_delete_posted") + if not verify_settings.get("enabled", True) and not delete_refresh_required: return result if module.check_mode or not result.get("changed"): return result @@ -358,6 +365,43 @@ def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: # VRF Lite resource behavior + def normalize_delete_result(self, result: dict[str, Any]) -> dict[str, Any]: + """Correct generic object-delete output for VRF Lite attachment deletes.""" + module = self._module() + if module.params.get("state") != "deleted": + return result + + if module.params.get("_vrf_lite_delete_posted"): + return result + + if module.params.get("_vrf_lite_delete_attempted"): + before = result.get("before", []) + result["changed"] = False + result["after"] = before + result["current"] = before + result["diff"] = [] + + return result + + def _warn_no_delete_match(self, vrf_name: str, model_instance: Any) -> None: + module = self._module() + requested_attachments = [] + for item in module.params.get("config") or []: + if not isinstance(item, dict): + continue + if str(item.get("vrf_name", "")).strip() == vrf_name: + requested_attachments = item.get("attach") or [] + break + + if not requested_attachments and getattr(model_instance, "attach", None): + requested_attachments = model_instance.attach or [] + + if requested_attachments: + append_runtime_warning( + module.params, + "No matching VRF Lite attachment was found to delete for VRF '{0}'. No detach payload was sent.".format(vrf_name), + ) + def _query_current_state(self) -> list[dict[str, Any]]: module = self._module() fabric_name = module.params.get("fabric_name") diff --git a/plugins/modules/nd_manage_vrf_lite.py b/plugins/modules/nd_manage_vrf_lite.py index 32fe9b7f2..39444adea 100644 --- a/plugins/modules/nd_manage_vrf_lite.py +++ b/plugins/modules/nd_manage_vrf_lite.py @@ -275,6 +275,7 @@ def main() -> None: result = nd_state_machine.output.format() result.setdefault("current", result.get("after", [])) + result = nd_state_machine.model_orchestrator.normalize_delete_result(result) deploy_result = nd_state_machine.model_orchestrator.deploy_pending(result) if deploy_result: 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 index 9a3d3b173..63d32736b 100644 --- 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 @@ -8,10 +8,10 @@ - attach: - ip_address: 10.122.84.55 vrf_lite: - - dot1q: 2001 + - 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 + vlan_id: '2001' vrf_name: VRF_LITE_IT_50001 diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 1fcc51200..566a42f35 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -532,6 +532,93 @@ def test_manage_vrf_lite_00495_delete_query_filters_vrfs_without_managed_attachm assert module.params["_have"] == have +def test_manage_vrf_lite_00495a_noop_delete_result_is_not_changed(): + before = [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}] + module = _DummyModule( + { + "state": "deleted", + "fabric_name": "FABRIC1", + "_vrf_lite_delete_attempted": True, + "_vrf_lite_delete_posted": False, + } + ) + + result = _vrf_lite_orchestrator(module).normalize_delete_result( + { + "changed": True, + "before": before, + "after": [], + "current": [], + "diff": before, + } + ) + + assert result["changed"] is False + assert result["after"] == before + assert result["current"] == before + assert result["diff"] == [] + + +def test_manage_vrf_lite_00495b_delete_refresh_runs_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}, + "_vrf_lite_delete_posted": True, + } + ) + 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"] == refreshed + assert result["current"] == refreshed + + +def test_manage_vrf_lite_00495c_delete_unknown_attachment_warns_and_noops(monkeypatch): + 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": [], + } + ) + existing_model = VrfLiteModel.from_config( + { + "vrf_name": "BLUE", + "vlan_id": 500, + "attach": [{"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}], + } + ) + + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._get_current_vrf_entry", + lambda _module, _fabric_name, _vrf_name: { + "vrf_name": "BLUE", + "vlan_id": 500, + "attach": [{"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}], + }, + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.NDModuleV2", + lambda _module: object(), + ) + monkeypatch.setattr( + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._post_attachment_payload", + lambda *_args, **_kwargs: pytest.fail("unknown attachment delete must not post a detach payload"), + ) + + assert _vrf_lite_orchestrator(module).delete(model_instance=existing_model) is False + assert module.params["_vrf_lite_delete_posted"] is False + 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): From 50772e0a861ec4be60feae0fcdc7ae39d6cee532 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 26 May 2026 00:50:57 +0530 Subject: [PATCH 07/13] Integ test changes --- .../tasks/assert_vrf_lite_attachment.yaml | 52 +++ .../nd_manage_vrf_lite/tasks/base_tasks.yaml | 18 ++ .../tasks/conf_prep_tasks.yaml | 22 +- .../tasks/nd_manage_vrf_lite_delete.yaml | 303 ++++++++++++++++-- .../tasks/nd_manage_vrf_lite_gather.yaml | 99 +++++- .../tasks/nd_manage_vrf_lite_merge.yaml | 103 +++++- .../tasks/nd_manage_vrf_lite_override.yaml | 56 +++- .../tasks/nd_manage_vrf_lite_replace.yaml | 48 ++- .../unit/module_utils/test_manage_vrf_lite.py | 297 +++++++++++------ 9 files changed, 819 insertions(+), 179 deletions(-) create mode 100644 tests/integration/targets/nd_manage_vrf_lite/tasks/assert_vrf_lite_attachment.yaml 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..43710fa00 --- /dev/null +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/assert_vrf_lite_attachment.yaml @@ -0,0 +1,52 @@ +--- +- 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_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 index b173689bc..f98a2562d 100644 --- a/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml @@ -24,6 +24,8 @@ 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') }}" @@ -94,3 +96,19 @@ 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 index 3820cfba1..c059a9685 100644 --- 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 @@ -4,32 +4,18 @@ # Usage: # - name: Import Configuration Prepare Tasks # vars: -# file: merge # output file identifier +# file: merge # output variable identifier # import_tasks: conf_prep_tasks.yaml # # Requires: vrf_lite_conf variable to be set before importing. -- name: Build VRF Lite Config Data from Template - ansible.builtin.file: - path: "{{ nd_vrf_lite_root | default(playbook_dir | dirname) }}/files" - state: directory - mode: "0755" - delegate_to: localhost - -- name: Build VRF Lite Config Data from Template - ansible.builtin.template: - src: "{{ nd_vrf_lite_root | default(playbook_dir | dirname) }}/templates/nd_manage_vrf_lite_conf.j2" - dest: "{{ nd_vrf_lite_root | default(playbook_dir | dirname) }}/files/nd_manage_vrf_lite_{{ file }}_conf.yaml" - mode: "0644" - delegate_to: localhost - -- name: Load Configuration Data into Variable +- name: Render Configuration Data into Variable ansible.builtin.set_fact: "{{ 'nd_manage_vrf_lite_' + file + '_conf' }}": >- {{ lookup( - 'file', - (nd_vrf_lite_root | default(playbook_dir | dirname)) + '/files/nd_manage_vrf_lite_' + file + '_conf.yaml' + '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/nd_manage_vrf_lite_delete.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/nd_manage_vrf_lite_delete.yaml index 29879b464..f2f3c3bf5 100644 --- 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 @@ -79,12 +79,17 @@ tags: delete - name: DELETE - TC1 - ASSERT - Verify VRF Lite state in ND - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined - - verify_result.gathered | length == 1 - - verify_result.gathered[0].attach | default([]) | length == 1 + 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) @@ -132,6 +137,7 @@ - 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 @@ -222,6 +228,7 @@ - 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 @@ -238,8 +245,8 @@ - result.failed == false tags: delete -# TC4 - Force deletion bypass path -- name: DELETE - TC4 - MERGE - Create VRF Lite for force delete test +# 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 }}" @@ -252,14 +259,14 @@ register: result tags: delete -- name: DELETE - TC4 - ASSERT - Verify setup creation for force test +- 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 true +- name: DELETE - TC4 - DELETE - Delete VRF Lite with force accepted cisco.nd.nd_manage_vrf_lite: <<: *nd_info fabric_name: "{{ test_fabric }}" @@ -276,7 +283,7 @@ register: result tags: delete -- name: DELETE - TC4 - ASSERT - Verify force delete execution +- name: DELETE - TC4 - ASSERT - Verify non-deploy delete execution with force accepted ansible.builtin.assert: that: - result.failed == false @@ -284,7 +291,7 @@ - result.deployment is not defined tags: delete -- name: DELETE - TC4 - GATHER - Verify force deletion result in ND +- name: DELETE - TC4 - GATHER - Verify force-accepted deletion result in ND cisco.nd.nd_manage_vrf_lite: <<: *nd_info fabric_name: "{{ test_fabric }}" @@ -298,12 +305,13 @@ register: verify_result tags: delete -- name: DELETE - TC4 - ASSERT - Verify VRF Lite deleted with force +- 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 @@ -385,6 +393,7 @@ - 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 @@ -449,12 +458,17 @@ tags: delete - name: DELETE - TC6 - ASSERT - Verify VRF Lite still exists after check_mode delete - ansible.builtin.assert: - that: - - tc6_verify_result.failed == false - - tc6_verify_result.gathered is defined - - tc6_verify_result.gathered | length == 1 - - tc6_verify_result.gathered[0].attach | default([]) | length == 1 + 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) @@ -480,6 +494,238 @@ - 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 still refreshes after/current correctly +# Requires a second VRF Lite-capable switch in the lab inventory. +- name: DELETE - TC9 - MERGE - Create two VRF Lite attachments 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 }}" + - ip_address: "{{ test_switch2 }}" + vrf_lite: + - interface: "{{ test_interface }}" + dot1q: "{{ test_dot1q | int + 1 }}" + ipv4_addr: "10.33.0.6/24" + neighbor_ipv4: "10.33.0.5" + peer_vrf: "{{ test_peer_vrf }}" + register: tc9_setup_result + when: + - test_switch2 | length > 0 + - switch2_vrf_lite_enabled | default(false) | bool + tags: delete + +- name: DELETE - TC9 - ASSERT - Verify two-attachment setup + ansible.builtin.assert: + that: + - tc9_setup_result.failed == false + - tc9_setup_result.changed == true + when: + - test_switch2 | length > 0 + - switch2_vrf_lite_enabled | default(false) | bool + tags: delete + +- name: DELETE - TC9 - DELETE - Delete one 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 + when: + - test_switch2 | length > 0 + - switch2_vrf_lite_enabled | default(false) | bool + tags: delete + +- name: DELETE - TC9 - ASSERT - Verify delete output keeps remaining 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_switch2) + | list + | 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_switch2) + | list + | length + ) == 1 + when: + - test_switch2 | length > 0 + - switch2_vrf_lite_enabled | default(false) | bool + tags: delete + +- name: DELETE - TC9 - GATHER - Verify remaining attachment 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 + when: + - test_switch2 | length > 0 + - switch2_vrf_lite_enabled | default(false) | bool + tags: delete + +- name: DELETE - TC9 - ASSERT - Verify gathered keeps remaining attachment + ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml + vars: + vrf_lite_assert_result: "{{ tc9_verify_result }}" + vrf_lite_assert_vrf_name: "{{ test_vrf }}" + vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" + vrf_lite_assert_switch: "{{ test_switch2 }}" + vrf_lite_assert_interface: "{{ test_interface }}" + vrf_lite_assert_dot1q: "{{ test_dot1q | int + 1 }}" + vrf_lite_assert_ipv4_addr: "10.33.0.6/24" + vrf_lite_assert_neighbor_ipv4: "10.33.0.5" + vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" + when: + - test_switch2 | length > 0 + - switch2_vrf_lite_enabled | default(false) | bool + tags: delete + ############################################## ## CLEAN-UP ## ############################################## @@ -500,3 +746,22 @@ 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 index db5b82943..0d937aca0 100644 --- 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 @@ -77,11 +77,23 @@ 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.failed == false - verify_result.changed == false - - verify_result.gathered is defined - verify_result.gathered | type_debug == "list" - verify_result.gathered.vrf_lite is not defined tags: gather @@ -100,11 +112,23 @@ 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.failed == false - result.changed == false - - result.gathered is defined tags: gather # TC3 - Gather with VRF name filter @@ -128,6 +152,22 @@ - 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 @@ -152,6 +192,7 @@ - result.failed == false - result.changed == false - result.gathered is defined + - result.gathered == [] tags: gather # TC5 - Gather with custom verify timeout/retries @@ -171,11 +212,23 @@ 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.failed == false - result.changed == false - - result.gathered is defined tags: gather # TC6 - gathered + deploy validation (must fail) @@ -215,11 +268,23 @@ 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.failed == false - result.changed == false - - result.gathered is defined tags: gather # TC8 - Gather with VRF name + switch filter @@ -245,6 +310,24 @@ - 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 ############################################## 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 index 0f035788d..6e81acb76 100644 --- 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 @@ -69,8 +69,15 @@ 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 @@ -142,10 +149,17 @@ tags: merge - name: MERGE - TC1 - ASSERT - Verify VRF Lite state in ND - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -157,6 +171,7 @@ 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 @@ -197,10 +212,17 @@ tags: merge - name: MERGE - TC2 - ASSERT - Verify modified VRF Lite state - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -246,6 +268,9 @@ 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 @@ -288,6 +313,10 @@ 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 @@ -396,10 +425,17 @@ tags: merge - name: MERGE - TC6 - ASSERT - Verify VRF Lite state after no-deploy merge - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -562,6 +598,8 @@ ansible.builtin.assert: that: - result.failed == false + - result.changed == false + - tc9_deployment_needed == false tags: merge # TC10 - check_mode should not apply configuration changes @@ -603,6 +641,30 @@ - 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: @@ -615,7 +677,7 @@ type: switch config: "{{ nd_manage_vrf_lite_merge_full_conf }}" register: result - failed_when: false + ignore_errors: true tags: merge - name: MERGE - TC11 - ASSERT - Verify invalid config_actions rejected @@ -702,10 +764,17 @@ tags: merge - name: MERGE - TC13 - ASSERT - Verify extended state - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 ############################################## 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 index d63541d7f..72791b601 100644 --- 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 @@ -101,10 +101,17 @@ tags: override - name: OVERRIDE - TC1 - ASSERT - Verify VRF Lite state in ND - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -143,10 +150,17 @@ tags: override - name: OVERRIDE - TC2 - ASSERT - Verify overridden VRF Lite state - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -216,6 +230,14 @@ 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 @@ -293,10 +315,17 @@ tags: override - name: OVERRIDE - TC5 - ASSERT - Verify deployed override config - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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) @@ -415,6 +444,9 @@ 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 ############################################## 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 index 994fa4ce6..e01202c8e 100644 --- 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 @@ -101,10 +101,17 @@ tags: replace - name: REPLACE - TC1 - ASSERT - Verify VRF Lite state in ND - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -143,10 +150,17 @@ tags: replace - name: REPLACE - TC2 - ASSERT - Verify replaced VRF Lite state - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -237,10 +251,17 @@ tags: replace - name: REPLACE - TC4 - ASSERT - Verify deployed replace config - ansible.builtin.assert: - that: - - verify_result.failed == false - - verify_result.gathered is defined + 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 @@ -359,6 +380,9 @@ 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 ############################################## diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 566a42f35..052f0114f 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -14,7 +14,8 @@ 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_want_attachment_maps, + 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 ( @@ -41,6 +42,13 @@ 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, @@ -370,7 +378,104 @@ def test_manage_vrf_lite_00480_query_ignores_base_vrf_attachments_without_vrf_li } -def test_manage_vrf_lite_00490_deploy_needed_when_reconciler_changed_without_changed_vrf_marker(): +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 @@ -432,87 +537,94 @@ def test_manage_vrf_lite_00492_deploy_filters_changed_vrfs_by_deploy_intent(): ] -def test_manage_vrf_lite_00493_attachment_deploy_false_flows_into_attachment_payload(): +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": {}, + "_raw_vrf_attachment_map": { + "BLUE": { + "SN1": { + "instance_values": existing_instance_values, + } + } + }, } ) - model = VrfLiteModel.from_config( + entry = VrfLiteAttachmentEntry.from_config( { "vrf_name": "BLUE", + "switch_ip": "10.0.0.1", "vlan_id": 500, - "attach": [ - { - "ip_address": "10.0.0.1", - "deploy": False, - "vrf_lite": [{"interface": "Ethernet1/10", "dot1q": 500}], - } - ], + "deploy": False, + "extensions": [{"interface": "Ethernet1/10", "dot1q": 123}], } ) - _want_map, payloads = _build_want_attachment_maps( + payload = build_attach_payload_for_entry( module=module, nd_v2=_FakeNDModule(), - model_instance=model, - current_vrf={"vrf_name": "BLUE", "vlan_id": 500, "attach": []}, + entry=entry, ) - assert payloads["SN1"]["deployment"] is False + 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_uses_requested_attachment_intent(monkeypatch): - posted_payloads = [] +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, + }, + } + }, } ) - existing_model = VrfLiteModel.from_config( + entry = VrfLiteAttachmentEntry.from_config( { "vrf_name": "BLUE", + "switch_ip": "10.0.0.2", "vlan_id": 500, - "attach": [ - {"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}, - {"ip_address": "10.0.0.2", "vrf_lite": [{"interface": "Ethernet1/11"}]}, - ], } ) - monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._get_current_vrf_entry", - lambda _module, _fabric_name, _vrf_name: { - "vrf_name": "BLUE", - "vlan_id": 500, - "attach": [ - {"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}, - {"ip_address": "10.0.0.2", "vrf_lite": [{"interface": "Ethernet1/11"}]}, - ], - }, - ) - monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.NDModuleV2", - lambda _module: object(), - ) - monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._post_attachment_payload", - lambda _nd_v2, _fabric_name, _vrf_name, lan_attach_list: posted_payloads.extend(lan_attach_list) or {"ok": True}, - ) + payload = build_detach_payload_for_entry(module, entry) - assert _vrf_lite_orchestrator(module).delete(model_instance=existing_model) is True - assert [payload["serialNumber"] for payload in posted_payloads] == ["SN2"] - assert module.params["_changed_vrfs"] == ["BLUE"] + 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): @@ -520,53 +632,74 @@ def test_manage_vrf_lite_00495_delete_query_filters_vrfs_without_managed_attachm monkeypatch.setattr( "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.query_vrf_lite_state", - lambda module, fabric_name, filter_vrfs=None: [ - {"vrf_name": "EMPTY", "attach": []}, - {"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}, + 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", "attach": [{"ip_address": "10.0.0.1"}]}] + assert have == [{"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}] assert module.params["_have"] == have -def test_manage_vrf_lite_00495a_noop_delete_result_is_not_changed(): - before = [{"vrf_name": "BLUE", "attach": [{"ip_address": "10.0.0.1"}]}] +def test_manage_vrf_lite_00495a_deleted_vrf_without_attach_expands_to_current_entries(): module = _DummyModule( { "state": "deleted", "fabric_name": "FABRIC1", - "_vrf_lite_delete_attempted": True, - "_vrf_lite_delete_posted": False, + "_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}] - result = _vrf_lite_orchestrator(module).normalize_delete_result( + +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( { - "changed": True, - "before": before, - "after": [], - "current": [], - "diff": before, + "state": "deleted", + "_vrf_lite_nested_config": [{"vrf_name": "BLUE"}], + "_vrf_lite_vrf_vlan_map": {"BLUE": 500}, } ) + orchestrator = _vrf_lite_orchestrator(module) - assert result["changed"] is False - assert result["after"] == before - assert result["current"] == before - assert result["diff"] == [] + 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_delete_refresh_runs_when_verify_disabled(monkeypatch): + +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}, - "_vrf_lite_delete_posted": True, } ) orchestrator = _vrf_lite_orchestrator(module) @@ -575,11 +708,11 @@ def test_manage_vrf_lite_00495b_delete_refresh_runs_when_verify_disabled(monkeyp result = orchestrator.refresh_verified_state({"changed": True, "after": [], "current": []}) - assert result["after"] == refreshed - assert result["current"] == refreshed + assert result["after"] == [] + assert result["current"] == [] -def test_manage_vrf_lite_00495c_delete_unknown_attachment_warns_and_noops(monkeypatch): +def test_manage_vrf_lite_00495c_delete_unknown_attachment_warns_during_explode(): module = _DummyModule( { "fabric_name": "FABRIC1", @@ -589,33 +722,11 @@ def test_manage_vrf_lite_00495c_delete_unknown_attachment_warns_and_noops(monkey "_warnings": [], } ) - existing_model = VrfLiteModel.from_config( - { - "vrf_name": "BLUE", - "vlan_id": 500, - "attach": [{"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}], - } - ) + current = [{"vrf_name": "BLUE", "switch_ip": "SN1", "vlan_id": 500}] - monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._get_current_vrf_entry", - lambda _module, _fabric_name, _vrf_name: { - "vrf_name": "BLUE", - "vlan_id": 500, - "attach": [{"ip_address": "10.0.0.1", "vrf_lite": [{"interface": "Ethernet1/10"}]}], - }, - ) - monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite.NDModuleV2", - lambda _module: object(), - ) - monkeypatch.setattr( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vrf_lite._post_attachment_payload", - lambda *_args, **_kwargs: pytest.fail("unknown attachment delete must not post a detach payload"), - ) + result = explode_playbook_to_entries(module.params["config"], module=module, state="deleted", current_entries=current) - assert _vrf_lite_orchestrator(module).delete(model_instance=existing_model) is False - assert module.params["_vrf_lite_delete_posted"] is False + assert result == [{"vrf_name": "BLUE", "switch_ip": "SN99"}] assert any("No matching VRF Lite attachment" in warning for warning in module.params["_warnings"]) From 132734b74ca4450590ce9563c0d7752df6381bdb Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 26 May 2026 00:53:11 +0530 Subject: [PATCH 08/13] General architecture fitment changes --- .../module_utils/manage_vrf_lite/actions.py | 342 ++++++----------- .../module_utils/manage_vrf_lite/common.py | 60 ++- .../module_utils/manage_vrf_lite/deploy.py | 20 +- plugins/module_utils/manage_vrf_lite/query.py | 195 +++++++++- .../manage_vrf_lite/validation.py | 47 +-- plugins/module_utils/models/base.py | 7 +- .../models/manage_vrf_lite/vrf_lite_model.py | 13 + plugins/module_utils/nd_state_machine.py | 19 +- .../orchestrators/manage_vrf_lite.py | 362 +++++++----------- plugins/module_utils/utils.py | 22 +- plugins/modules/nd_manage_vrf_lite.py | 4 +- 11 files changed, 561 insertions(+), 530 deletions(-) diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py index d238a2712..f61a9f454 100644 --- a/plugins/module_utils/manage_vrf_lite/actions.py +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -6,13 +6,13 @@ from __future__ import absolute_import, annotations, division, print_function -import ipaddress +import json from typing import Any from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( - _is_update_needed, _raise_vrf_lite_error, + _resolve_serial, ) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( query_vrf_lite_state, @@ -24,6 +24,7 @@ build_instance_values, build_vrf_lite_extension_values, normalize_vrf_lite_list, + parse_instance_values, ) 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 @@ -128,52 +129,6 @@ def _request_vrf_lite_payload(nd_v2: Any, path: str, verb: HttpVerbEnum, payload return response.get("DATA", {}) -def _is_ip_literal(value: Any) -> bool: - if not isinstance(value, str): - return False - candidate = value.strip() - if not candidate: - return False - try: - ipaddress.ip_address(candidate) - return True - except ValueError: - return False - - -def _resolve_serial(module: Any, switch_identifier: str) -> str: - """Resolve management IP to serial when mapping is available.""" - 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 serial number.".format(text), - switch_id=text, - ) - - return text - - -def _mark_changed_vrf(module: Any, vrf_name: str) -> None: - changed_vrfs = module.params.get("_changed_vrfs") - if not isinstance(changed_vrfs, list): - changed_vrfs = [] - - if vrf_name not in changed_vrfs: - changed_vrfs.append(vrf_name) - - module.params["_changed_vrfs"] = changed_vrfs - - def _ensure_vrf_exists(module: Any, vrf_name: str) -> None: known_vrfs = module.params.get("_known_vrfs") or [] if vrf_name in known_vrfs: @@ -231,170 +186,161 @@ def _reserve_dot1q_if_needed(nd_v2: Any, vrf_name: str, serial_number: str, lite return updated -def _get_current_vrf_entry(module: Any, fabric_name: str, vrf_name: str) -> dict[str, Any] | None: - current = query_vrf_lite_state(module=module, fabric_name=fabric_name, filter_vrfs=set([vrf_name])) - if not current: - return None - return current[0] - - -def _build_have_attachment_map(module: Any, current_vrf: dict[str, Any] | None) -> dict[str, dict[str, Any]]: - have_map: dict[str, dict[str, Any]] = {} - - if not current_vrf: - return have_map - - vlan_id = current_vrf.get("vlan_id") - for attach in current_vrf.get("attach", []) or []: - switch_identifier = attach.get("ip_address") - serial_number = _resolve_serial(module, switch_identifier) - if not serial_number: - continue - - have_map[serial_number] = { - "vlan_id": vlan_id, - "import_evpn_rt": attach.get("import_evpn_rt") or "", - "export_evpn_rt": attach.get("export_evpn_rt") or "", - "vrf_lite": normalize_vrf_lite_list(attach.get("vrf_lite") or []), - } - - return have_map - +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 _get_delete_config_for_vrf(module: Any, vrf_name: str) -> dict[str, Any] | None: - """Return the user's delete intent for this VRF, when state is deleted.""" - if module.params.get("state") != "deleted": - return None - for item in module.params.get("config") or []: - if not isinstance(item, dict): - continue - if str(item.get("vrf_name", "")).strip() == vrf_name: - return item +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=(",", ":")), + }, + ) - return None +def _build_instance_values_for_payload(import_evpn_rt: Any, export_evpn_rt: Any, existing_instance_values: Any = None) -> str: + existing = parse_instance_values(existing_instance_values) + if not existing: + return build_instance_values(import_evpn_rt, export_evpn_rt) -def _attachment_serials(module: Any, attachments: Any, have_map: dict[str, dict[str, Any]]) -> list[str]: - serials: list[str] = [] - seen: set[str] = set() + existing["switchRouteTargetImportEvpn"] = import_evpn_rt or "" + existing["switchRouteTargetExportEvpn"] = export_evpn_rt or "" + for key in ("loopbackId", "loopbackIpAddress", "loopbackIpV6Address"): + existing.setdefault(key, "") - for attach in attachments or []: - switch_identifier = attach.get("ip_address") if isinstance(attach, dict) else getattr(attach, "ip_address", None) - serial_number = _resolve_serial(module, switch_identifier) - if serial_number in have_map and serial_number not in seen: - serials.append(serial_number) - seen.add(serial_number) + return json.dumps(existing, separators=(",", ":")) - return serials +def _entry_extensions(entry: Any) -> list[dict[str, Any]]: + extensions = getattr(entry, "extensions", None) or [] + return [ + item.model_dump(by_alias=False, exclude_none=True) if hasattr(item, "model_dump") else dict(item) + for item in extensions + ] -def _serials_to_detach_for_delete(module: Any, vrf_name: str, model_instance: Any, have_map: dict[str, dict[str, Any]]) -> list[str]: - delete_config = _get_delete_config_for_vrf(module, vrf_name) - if delete_config is not None: - attachments = delete_config.get("attach") or [] - if attachments: - return _attachment_serials(module, attachments, have_map) - return sorted(have_map.keys()) - if model_instance.attach: - return _attachment_serials(module, model_instance.attach, have_map) +def _try_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None - return sorted(have_map.keys()) +def _entry_vlan_id(module: Any, entry: Any, raw_attach: dict[str, Any] | None = None) -> int: + if getattr(entry, "vlan_id", None) is not None: + return int(entry.vlan_id) -def _resolve_vrf_vlan_id(model_instance: Any, current_vrf: dict[str, Any] | None) -> int: - if model_instance.vlan_id is not None: - return int(model_instance.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, ""): + vlan = _try_int(mapped_vlan) + if vlan is not None: + return vlan - if isinstance(current_vrf, dict): - current_vlan = current_vrf.get("vlan_id") - if current_vlan not in (None, ""): - try: - return int(current_vlan) - except (TypeError, ValueError): - pass + if isinstance(raw_attach, dict) and raw_attach.get("vlan") not in (None, ""): + vlan = _try_int(raw_attach.get("vlan")) + if vlan is not None: + return vlan _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(model_instance.vrf_name), - vrf_name=model_instance.vrf_name, + 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_want_attachment_maps( - module: Any, - nd_v2: Any, - model_instance: Any, - current_vrf: dict[str, Any] | None, -) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: - """Return (want_normalized_map, want_payload_map) keyed by serial number.""" - - want_normalized: dict[str, dict[str, Any]] = {} - want_payloads: dict[str, dict[str, Any]] = {} - - vrf_name = model_instance.vrf_name - 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 {} +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) - attachments = model_instance.attach or [] - vlan_id = None - if attachments: - vlan_id = _resolve_vrf_vlan_id(model_instance=model_instance, current_vrf=current_vrf) - - for attach in attachments: - serial_number = _resolve_serial(module, attach.ip_address) - if not serial_number: - continue - - lite_items = [item.model_dump(by_alias=False, exclude_none=True) for item in (attach.vrf_lite or [])] - lite_items = normalize_vrf_lite_list(lite_items) - resolved_lite_items: list[dict[str, Any]] = [] - for lite_item in lite_items: - resolved_lite_items.append(_reserve_dot1q_if_needed(nd_v2, vrf_name, serial_number, lite_item)) - - raw_attach = raw_attachment_map.get(serial_number) if isinstance(raw_attachment_map, dict) else None - extension_values = build_vrf_lite_extension_values( - resolved_lite_items, - existing_extension_values=raw_attach.get("extension_values") if isinstance(raw_attach, dict) else None, - ) - instance_values = build_instance_values(attach.import_evpn_rt, attach.export_evpn_rt) + resolved_extensions = [] + for lite_item in normalize_vrf_lite_list(_entry_extensions(entry)): + resolved_extensions.append(_reserve_dot1q_if_needed(nd_v2, entry.vrf_name, serial_number, lite_item)) - want_normalized[serial_number] = { - "vlan_id": vlan_id, - "import_evpn_rt": attach.import_evpn_rt or "", - "export_evpn_rt": attach.export_evpn_rt or "", - "vrf_lite": normalize_vrf_lite_list(resolved_lite_items), - } - - want_payloads[serial_number] = { - "fabric": module.params.get("fabric_name"), - "vrfName": vrf_name, - "serialNumber": serial_number, - "vlan": vlan_id if vlan_id is not None else 0, - "deployment": model_instance.deploy is not False and attach.deploy is not False, - "isAttached": True, - "extensionValues": extension_values, - "instanceValues": instance_values, - "freeformConfig": "", - } + 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 want_normalized, want_payloads + 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: int | None) -> dict[str, Any]: +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": False, - "isAttached": False, - "extensionValues": "", - "instanceValues": build_instance_values("", ""), + "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] = [] @@ -432,41 +378,3 @@ def _post_attachment_payload(nd_v2: Any, fabric_name: str, vrf_name: str, lan_at response=response, ) return response - - -class AttachmentReconciler: - """Build the attachment POST payloads needed to reconcile one VRF.""" - - def __init__(self, module: Any, nd_v2: Any, model_instance: Any, current_vrf: dict[str, Any] | None) -> None: - self.module = module - self.nd_v2 = nd_v2 - self.model_instance = model_instance - self.current_vrf = current_vrf - self.vrf_name = model_instance.vrf_name - self.have_map = _build_have_attachment_map(module, current_vrf) - - def sync_payloads(self, replace_mode: bool) -> list[dict[str, Any]]: - changes: list[dict[str, Any]] = [] - want_map, want_payloads = _build_want_attachment_maps(self.module, self.nd_v2, self.model_instance, self.current_vrf) - - for serial_number, want_cfg in want_map.items(): - have_cfg = self.have_map.get(serial_number) - if have_cfg is None or _is_update_needed(want_cfg, have_cfg): - changes.append(want_payloads[serial_number]) - - if replace_mode: - vlan_for_detach = self.current_vrf.get("vlan_id") if self.current_vrf else None - for serial_number in sorted(self.have_map.keys()): - if serial_number in want_map: - continue - changes.append(_build_detach_payload(self.module, self.vrf_name, serial_number, vlan_for_detach)) - - return changes - - def detach_payloads(self) -> list[dict[str, Any]]: - if not self.have_map: - return [] - - serials_to_detach = _serials_to_detach_for_delete(self.module, self.vrf_name, self.model_instance, self.have_map) - vlan_for_detach = self.current_vrf.get("vlan_id") if self.current_vrf else None - return [_build_detach_payload(self.module, self.vrf_name, serial, vlan_for_detach) for serial in serials_to_detach] diff --git a/plugins/module_utils/manage_vrf_lite/common.py b/plugins/module_utils/manage_vrf_lite/common.py index fd16033f1..744dce2b9 100644 --- a/plugins/module_utils/manage_vrf_lite/common.py +++ b/plugins/module_utils/manage_vrf_lite/common.py @@ -6,8 +6,8 @@ from __future__ import absolute_import, annotations, division, print_function -import json -from typing import Any +import ipaddress +from typing import Any, NoReturn from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( VrfLiteResourceError, @@ -19,7 +19,7 @@ CONFIG_ACTION_TYPE_CHOICES = ("switch", "global") -def _raise_vrf_lite_error(msg: str, **details: Any) -> None: +def _raise_vrf_lite_error(msg: str, **details: Any) -> NoReturn: raise VrfLiteResourceError(msg=msg, **details) @@ -35,6 +35,46 @@ def _get_params(source: Any) -> dict[str, Any]: return {} +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 append_runtime_warning(source: Any, message: str) -> None: """Collect runtime warnings without requiring direct Ansible dependencies.""" params = _get_params(source) @@ -62,20 +102,6 @@ def get_runtime_warnings(source: Any) -> list[str]: return normalized -def _canonicalize_for_compare(value: Any) -> Any: - """Normalize nested structures for deterministic comparison.""" - if isinstance(value, dict): - return {key: _canonicalize_for_compare(item) for key, item in sorted(value.items())} - if isinstance(value, list): - normalized_items = [_canonicalize_for_compare(item) for item in value] - return sorted(normalized_items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":"), ensure_ascii=True)) - return value - - -def _is_update_needed(want: dict[str, Any], have: dict[str, Any]) -> bool: - return _canonicalize_for_compare(want) != _canonicalize_for_compare(have) - - def get_verify_settings(source: Any) -> dict[str, Any]: params = _get_params(source) raw_verify = params.get("verify") diff --git a/plugins/module_utils/manage_vrf_lite/deploy.py b/plugins/module_utils/manage_vrf_lite/deploy.py index b76eb21fb..15c379e8b 100644 --- a/plugins/module_utils/manage_vrf_lite/deploy.py +++ b/plugins/module_utils/manage_vrf_lite/deploy.py @@ -59,19 +59,15 @@ def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: 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 [] - if changed_vrfs: - return True - - not_in_sync = set(module.params.get("_not_in_sync_vrfs") or []) - if not not_in_sync: - return False - - target_vrfs = set(_target_vrfs_for_deploy(module)) - if not target_vrfs: - return False - - return len(target_vrfs & not_in_sync) > 0 + return bool(changed_vrfs) diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index b1e66ff77..55dbba900 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -105,9 +105,114 @@ def _query_vrf_attachments(module: Any, nd_v2: Any, fabric_name: str, vrf_names: return _coerce_list(response) -def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | None = None) -> list[dict[str, Any]]: +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 _coerce_list(response): + 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: set[str] | None = 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) @@ -145,6 +250,43 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | 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") @@ -175,6 +317,7 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | 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: @@ -184,30 +327,40 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | else: continue - attach_state = str(attach.get("lanAttachState") or "").upper() - attached_value = attach.get("isLanAttached", attach.get("isAttached", False)) + 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( - attach.get("extensionValues") and str(attach.get("extensionValues")).strip() - and str(attach.get("extensionValues")).strip() != "[]" + 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": attach.get("extensionValues"), - "instance_values": attach.get("instanceValues"), - "vlan": attach.get("vlanId") if attach.get("vlanId") not in (None, "") else attach.get("vlan"), + "extension_values": extension_values, + "instance_values": instance_values_raw, + "vlan": attachment_vlan_raw, } - instance_values = parse_instance_values(attach.get("instanceValues")) + 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(attach.get("extensionValues")) + 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 @@ -219,23 +372,18 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | entry["deploy"] = True if entry.get("vlan_id") is None: - vlan = attach.get("vlanId") - if vlan in (None, ""): - vlan = attach.get("vlan") try: - if vlan not in (None, ""): - entry["vlan_id"] = int(vlan) + 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 import_evpn_rt not in (None, ""): - attach_item["import_evpn_rt"] = import_evpn_rt - if export_evpn_rt not in (None, ""): - attach_item["export_evpn_rt"] = export_evpn_rt if vrf_lite_list: attach_item["vrf_lite"] = vrf_lite_list @@ -251,5 +399,12 @@ def query_vrf_lite_state(module: Any, fabric_name: str, filter_vrfs: set[str] | 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/validation.py b/plugins/module_utils/manage_vrf_lite/validation.py index 6ea62a58f..02558d6f6 100644 --- a/plugins/module_utils/manage_vrf_lite/validation.py +++ b/plugins/module_utils/manage_vrf_lite/validation.py @@ -6,7 +6,6 @@ from __future__ import absolute_import, annotations, division, print_function -import ipaddress from typing import Any from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError @@ -15,6 +14,7 @@ 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, @@ -34,19 +34,6 @@ def _coerce_switch_list(response: Any) -> list[dict[str, Any]]: return [] -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 _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() @@ -71,27 +58,6 @@ def _is_external_connectivity_switch(switch_data: dict[str, Any]) -> bool: return str(fabric_type or "").strip().lower() == "externalconnectivity" -def _resolve_serial(module: Any, switch_identifier: str) -> str: - if switch_identifier is None: - return "" - - text = str(switch_identifier).strip() - if not text: - return "" - - ip_to_sn = module.params.get("_ip_to_sn_mapping") or {} - if text in ip_to_sn: - return ip_to_sn[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 _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") @@ -179,7 +145,10 @@ def validate_vrf_lite_write_guardrails(module: Any, model_instance: Any) -> None """ Validate switch existence, role suitability, and platform support hints. """ - attachments = model_instance.attach or [] + if hasattr(model_instance, "switch_ip"): + attachments = [model_instance] + else: + attachments = model_instance.attach or [] if not attachments: return @@ -188,7 +157,8 @@ def validate_vrf_lite_write_guardrails(module: Any, model_instance: Any) -> None inventory = _load_switch_inventory(module, fabric_name) for attach in attachments: - serial_number = _resolve_serial(module, attach.ip_address) + 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 @@ -200,7 +170,8 @@ def validate_vrf_lite_write_guardrails(module: Any, model_instance: Any) -> None vrf_name=vrf_name, ) - if not attach.vrf_lite: + vrf_lite_entries = getattr(attach, "extensions", None) or getattr(attach, "vrf_lite", None) + if not vrf_lite_entries: continue switch_data = inventory[serial_number] 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/vrf_lite_model.py b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py index 4f36f1f5e..6b2f8a53d 100644 --- a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -149,6 +149,19 @@ def validate_vrf_name(cls, value: str) -> str: 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) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index de6df2fec..9a425c838 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -67,6 +67,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", []) + 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 +78,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 +93,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 +200,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 +231,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 index 35b98c9a0..3313c0b30 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -7,6 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function import json +from collections import defaultdict from typing import Any, ClassVar from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError @@ -18,20 +19,12 @@ EpFabricVrfsAttachmentsGet, EpFabricVrfsAttachmentsPost, ) -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 ( - VrfLiteModel, -) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.actions import ( - AttachmentReconciler, _ensure_vrf_exists, - _get_current_vrf_entry, - _mark_changed_vrf, _post_attachment_payload, -) -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import ( - NDBaseOrchestrator, + 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, @@ -48,6 +41,11 @@ 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, + replacement_scope_vrfs, +) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.query import ( _build_filter_set, query_vrf_lite_state, @@ -58,47 +56,41 @@ 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): - """VRF Lite resource adapter for the generic ND state machine. - - The base state machine still decides merged/replaced/overridden/deleted - lifecycle. This orchestrator adapts those generic method calls to VRF - Lite's attachment API: create/update are attachment sync POSTs, delete is a - detach POST, query is normalized from several controller reads, and deploy - is a separate save/deploy action. - """ + """Attachment-level VRF Lite adapter for the generic ND state machine.""" - model_class: ClassVar[type[NDBaseModel]] = VrfLiteModel + model_class: ClassVar[type[NDBaseModel]] = VrfLiteAttachmentEntry + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True - # Endpoint declarations document the controller surface. - # VRF Lite uses a POST-based sub-resource API, so generic CRUD methods - # are intentionally overridden. 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 - - # Module params preparation (called before orchestrator creation) + create_bulk_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost + delete_bulk_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost @staticmethod def prepare_module_params(module: Any, module_config: Any, normalized_config: list[dict[str, Any]] | None = None) -> None: - """Set up module.params for the orchestrator and state machine. - - Handles input validation, config_actions normalization, gathered state - preparation, and runtime state initialization. Called once from the - module's main() before NDStateMachine creation. - """ + """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) - # Validate gathered + explicit save/deploy conflict if state == "gathered": raw_module_args = ManageVrfLiteOrchestrator._get_raw_module_args() raw_config_actions = raw_module_args.get("config_actions") @@ -118,19 +110,17 @@ def prepare_module_params(module: Any, module_config: Any, normalized_config: li "type": config_actions.get("type", "switch"), } - # Validate deploy requires save 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") - # Warn about force on non-deleted states 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), ) - # Normalize module params 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 @@ -140,24 +130,20 @@ def prepare_module_params(module: Any, module_config: Any, normalized_config: li else: module.params["_gather_filter_config"] = [] - # Validate deleted requires config - if state == "deleted" and not module.params.get("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.") - # Initialize runtime state module.params["_changed_vrfs"] = [] - module.params["_vrf_lite_delete_attempted"] = False - module.params["_vrf_lite_delete_posted"] = False + 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 [] - ) + 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]: @@ -182,144 +168,122 @@ def _get_raw_module_args() -> dict[str, Any]: except Exception: return {} - # Private helpers - def _module(self) -> Any: - """Access the AnsibleModule instance through REST infrastructure.""" return self.rest_send.sender.ansible_module - # CRUD operations adapt generic state-machine calls to VRF Lite's - # attachment sub-resource API. + 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() + return self._query_current_state(flat=True) def create(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: - """Create VRF Lite attachment intent by posting required attach rows.""" - module = self._module() - if module.check_mode: - return model_instance.to_config() + return self.create_bulk([model_instance], **kwargs) - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name + def update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + return self._post_attach_entries([model_instance]) - try: - _ensure_vrf_exists(module, vrf_name) - validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) + def delete(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: + return self.delete_bulk([model_instance], **kwargs) - nd_v2 = NDModuleV2(module) - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - changes = reconciler.sync_payloads(replace_mode=False) + def create_bulk(self, model_instances: list[Any], **kwargs: Any) -> dict[str, Any]: + return self._post_attach_entries(model_instances) - if not changes: - return current_vrf or {} + def delete_bulk(self, model_instances: list[Any], **kwargs: Any) -> dict[str, Any]: + return self._post_detach_entries(model_instances) - response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) - _mark_changed_vrf(module, vrf_name) - return response - 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="Create failed for {0}: {1}".format(model_instance.get_identifier_value(), error.msg), **error_dict) - except VrfLiteResourceError: - raise - except Exception as error: - _raise_vrf_lite_error( - msg="Create failed for {0}: {1}".format(model_instance.get_identifier_value(), error), - exception_type=type(error).__name__, + 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 update(self, model_instance: Any, **kwargs: Any) -> dict[str, Any]: - """Update VRF Lite attachment intent by posting changed attach/detach rows.""" + def _post_attach_entries(self, entries: list[Any]) -> dict[str, Any]: + if not entries: + return {} module = self._module() if module.check_mode: - return model_instance.to_config() - - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name - replace_mode = module.params.get("state") in ("replaced", "overridden") - - try: - _ensure_vrf_exists(module, vrf_name) - validate_vrf_lite_write_guardrails(module=module, model_instance=model_instance) + return {"planned": [entry.to_config() for entry in entries]} - nd_v2 = NDModuleV2(module) - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - changes = reconciler.sync_payloads(replace_mode=replace_mode) - - if not changes: - return current_vrf or {} - - response = _post_attachment_payload(nd_v2, fabric_name, vrf_name, changes) - _mark_changed_vrf(module, vrf_name) - return response - 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="Update failed for {0}: {1}".format(model_instance.get_identifier_value(), error.msg), **error_dict) - except VrfLiteResourceError: - raise - except Exception as error: - _raise_vrf_lite_error( - msg="Update failed for {0}: {1}".format(model_instance.get_identifier_value(), error), - exception_type=type(error).__name__, - ) + 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 delete(self, model_instance: Any, **kwargs: Any) -> bool: - """Detach VRF Lite attachment intent by posting detach rows.""" + def _post_detach_entries(self, entries: list[Any]) -> dict[str, Any]: + if not entries: + return {} module = self._module() if module.check_mode: - return True + return {"planned": [entry.to_config() for entry in entries]} - fabric_name = module.params.get("fabric_name") - vrf_name = model_instance.vrf_name - module.params["_vrf_lite_delete_attempted"] = True - module.params["_vrf_lite_delete_posted"] = False - - try: - current_vrf = _get_current_vrf_entry(module, fabric_name, vrf_name) - if not current_vrf: - return False - - nd_v2 = NDModuleV2(module) - reconciler = AttachmentReconciler(module=module, nd_v2=nd_v2, model_instance=model_instance, current_vrf=current_vrf) - if not reconciler.have_map: - return False - - detach_payloads = reconciler.detach_payloads() - if not detach_payloads: - self._warn_no_delete_match(vrf_name, model_instance) - return False - - _post_attachment_payload(nd_v2, fabric_name, vrf_name, detach_payloads) - module.params["_vrf_lite_delete_posted"] = True - _mark_changed_vrf(module, vrf_name) - return 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="Delete failed for {0}: {1}".format(model_instance.get_identifier_value(), error.msg), **error_dict) - except VrfLiteResourceError: - raise - except Exception as error: - _raise_vrf_lite_error( - msg="Delete failed for {0}: {1}".format(model_instance.get_identifier_value(), error), - exception_type=type(error).__name__, - ) - - # Gathered state (read-only query) + 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]: - """Execute gathered-state workflow and return formatted output.""" - module = self._module() - gathered = self._query_current_state() + 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": module.params.get("output_level", "normal"), + "output_level": self._module().params.get("output_level", "normal"), "changed": False, "before": gathered, "after": gathered, @@ -331,13 +295,7 @@ def gather(self) -> dict[str, Any]: } return self.inject_runtime_metadata(output) - # Deploy (config save + VRF deployments after state reconciliation) - def deploy_pending(self, result: dict[str, Any]) -> dict[str, Any] | None: - """Execute save/deploy actions if config_actions are enabled. - - Returns deploy result dict, or None if no deploy actions are needed. - """ module = self._module() config_actions = get_config_actions(module.params) @@ -346,63 +304,36 @@ def deploy_pending(self, result: dict[str, Any]) -> dict[str, Any] | None: return self._execute_config_actions(result) - # Post-operation utilities - def refresh_verified_state(self, result: dict[str, Any]) -> dict[str, Any]: - """Re-query state after write to confirm changes were applied.""" module = self._module() verify_settings = get_verify_settings(module.params) - delete_refresh_required = module.params.get("state") == "deleted" and module.params.get("_vrf_lite_delete_posted") - if not verify_settings.get("enabled", True) and not delete_refresh_required: + 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() - result["after"] = refreshed - result["current"] = refreshed - return result - - # VRF Lite resource behavior - - def normalize_delete_result(self, result: dict[str, Any]) -> dict[str, Any]: - """Correct generic object-delete output for VRF Lite attachment deletes.""" - module = self._module() - if module.params.get("state") != "deleted": - return result - - if module.params.get("_vrf_lite_delete_posted"): - return result - - if module.params.get("_vrf_lite_delete_attempted"): - before = result.get("before", []) - result["changed"] = False - result["after"] = before - result["current"] = before - result["diff"] = [] - + 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 _warn_no_delete_match(self, vrf_name: str, model_instance: Any) -> None: + def format_public_output(self, result: dict[str, Any]) -> dict[str, Any]: module = self._module() - requested_attachments = [] - for item in module.params.get("config") or []: - if not isinstance(item, dict): + 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 - if str(item.get("vrf_name", "")).strip() == vrf_name: - requested_attachments = item.get("attach") or [] - break - - if not requested_attachments and getattr(model_instance, "attach", None): - requested_attachments = model_instance.attach or [] - - if requested_attachments: - append_runtime_warning( - module.params, - "No matching VRF Lite attachment was found to delete for VRF '{0}'. No detach payload was sent.".format(vrf_name), - ) + 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) -> list[dict[str, Any]]: + 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") @@ -412,10 +343,11 @@ def _query_current_state(self) -> list[dict[str, Any]]: if state == "gathered": if module.params.get("_have_loaded") and isinstance(module.params.get("_have"), list): - return module.params["_have"] + 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("config") or [] + config = module.params.get("_vrf_lite_nested_config") or module.params.get("config") or [] filter_vrfs = _build_filter_set(config) try: @@ -423,6 +355,7 @@ def _query_current_state(self) -> list[dict[str, Any]]: 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() @@ -439,12 +372,8 @@ def _query_current_state(self) -> list[dict[str, Any]]: ) module.params["_have"] = have - if state in ("deleted", "overridden"): - have = [item for item in have if item.get("attach")] - module.params["_have"] = have - module.params["_have_loaded"] = True - return have + 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() @@ -498,7 +427,7 @@ def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: nd_v2 = NDModuleV2(module) responses = [] - changed = False + changed = bool(module.params.get("_changed_vrfs")) if save_enabled: save_payload = {"type": config_actions.get("type", "switch")} @@ -513,7 +442,6 @@ def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: "success": True, } ) - changed = True except NDModuleError as error: if deploy_enabled and _is_non_fatal_config_save_error(error): append_runtime_warning( @@ -549,7 +477,6 @@ def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: "success": True, } ) - changed = True except NDModuleError as error: error_dict = error.to_dict() if "msg" in error_dict: @@ -567,7 +494,6 @@ def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: } def inject_runtime_metadata(self, payload: dict[str, Any]) -> dict[str, Any]: - """Attach runtime warnings and IP mapping to the output.""" module = self._module() warnings = get_runtime_warnings(module.params) if warnings: 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 index 39444adea..265cc8dc0 100644 --- a/plugins/modules/nd_manage_vrf_lite.py +++ b/plugins/modules/nd_manage_vrf_lite.py @@ -273,9 +273,11 @@ def main() -> None: 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.normalize_delete_result(result) + result = nd_state_machine.model_orchestrator.format_public_output(result) deploy_result = nd_state_machine.model_orchestrator.deploy_pending(result) if deploy_result: From d1b1e1c2668e0cc0eaf24c7467613e3860d7d52b Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 27 May 2026 09:34:37 +0530 Subject: [PATCH 09/13] vrflite refactor with per-attachment --- plugins/module_utils/common/data.py | 76 +++++++ .../module_utils/manage_vrf_lite/__init__.py | 7 - .../module_utils/manage_vrf_lite/actions.py | 25 +-- .../module_utils/manage_vrf_lite/common.py | 28 +-- .../manage_vrf_lite/config_transform.py | 178 +++++++++++++++ .../module_utils/manage_vrf_lite/deploy.py | 13 +- plugins/module_utils/manage_vrf_lite/query.py | 38 +--- .../manage_vrf_lite/runtime_payloads.py | 34 +-- .../models/manage_vrf_lite/__init__.py | 7 - .../vrf_lite_attachment_entry.py | 112 ++++++++++ .../models/manage_vrf_lite/vrf_lite_model.py | 60 ++--- .../orchestrators/manage_vrf_lite.py | 14 +- .../unit/module_utils/test_manage_vrf_lite.py | 18 +- .../test_manage_vrf_lite_attachment_entry.py | 206 ++++++++++++++++++ 14 files changed, 679 insertions(+), 137 deletions(-) create mode 100644 plugins/module_utils/common/data.py create mode 100644 plugins/module_utils/manage_vrf_lite/config_transform.py create mode 100644 plugins/module_utils/models/manage_vrf_lite/vrf_lite_attachment_entry.py create mode 100644 tests/unit/module_utils/test_manage_vrf_lite_attachment_entry.py diff --git a/plugins/module_utils/common/data.py b/plugins/module_utils/common/data.py new file mode 100644 index 000000000..6eba16c2f --- /dev/null +++ b/plugins/module_utils/common/data.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 + +import ast +import json +from typing import Any + + +def get_params(source: Any) -> dict[str, Any]: + """Return a mutable params mapping from either module.params or a raw params dict.""" + if isinstance(source, dict): + return source + + params = getattr(source, "params", None) + if isinstance(params, dict): + return params + + return {} + + +def loads_maybe_json(value: Any) -> Any: + """Parse JSON or Python-literal strings while leaving parsed values unchanged.""" + if isinstance(value, (dict, list)): + return value + if value is None: + return None + + text = str(value).strip() + if not text: + return None + + try: + return json.loads(text) + except Exception: + try: + return ast.literal_eval(text) + except Exception: + return None + + +def coerce_dict_list(data: Any, list_keys: tuple[str, ...] = ("DATA", "data", "items")) -> list[dict[str, Any]]: + """Return a list containing only dict items from common controller response shapes.""" + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + + if isinstance(data, dict): + for key in list_keys: + value = data.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + + return [] + + +def copy_dict_items(items: Any) -> list[dict[str, Any]]: + """Copy a list of dict-like or pydantic-like objects into plain dicts.""" + copied = [] + for item in items or []: + if isinstance(item, dict): + copied.append(dict(item)) + elif hasattr(item, "model_dump"): + copied.append(item.model_dump(by_alias=False, exclude_none=True)) + return copied + + +def try_int(value: Any) -> int | None: + """Best-effort integer conversion.""" + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/plugins/module_utils/manage_vrf_lite/__init__.py b/plugins/module_utils/manage_vrf_lite/__init__.py index 5e4d12ab6..e69de29bb 100644 --- a/plugins/module_utils/manage_vrf_lite/__init__.py +++ b/plugins/module_utils/manage_vrf_lite/__init__.py @@ -1,7 +0,0 @@ -# -*- 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 diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py index f61a9f454..36341599c 100644 --- a/plugins/module_utils/manage_vrf_lite/actions.py +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -9,6 +9,10 @@ import json from typing import Any +from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( + copy_dict_items, + try_int, +) 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, @@ -211,7 +215,9 @@ def _empty_vrf_lite_extension_values(existing_extension_values: Any) -> str: def _build_instance_values_for_payload(import_evpn_rt: Any, export_evpn_rt: Any, existing_instance_values: Any = None) -> str: - existing = parse_instance_values(existing_instance_values) + # 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) @@ -224,18 +230,7 @@ def _build_instance_values_for_payload(import_evpn_rt: Any, export_evpn_rt: Any, def _entry_extensions(entry: Any) -> list[dict[str, Any]]: - extensions = getattr(entry, "extensions", None) or [] - return [ - item.model_dump(by_alias=False, exclude_none=True) if hasattr(item, "model_dump") else dict(item) - for item in extensions - ] - - -def _try_int(value: Any) -> int | None: - try: - return int(value) - except (TypeError, ValueError): - return None + return copy_dict_items(getattr(entry, "extensions", None) or []) def _entry_vlan_id(module: Any, entry: Any, raw_attach: dict[str, Any] | None = None) -> int: @@ -245,12 +240,12 @@ def _entry_vlan_id(module: Any, entry: Any, raw_attach: dict[str, Any] | None = 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, ""): - vlan = _try_int(mapped_vlan) + vlan = try_int(mapped_vlan) if vlan is not None: return vlan if isinstance(raw_attach, dict) and raw_attach.get("vlan") not in (None, ""): - vlan = _try_int(raw_attach.get("vlan")) + vlan = try_int(raw_attach.get("vlan")) if vlan is not None: return vlan diff --git a/plugins/module_utils/manage_vrf_lite/common.py b/plugins/module_utils/manage_vrf_lite/common.py index 744dce2b9..dc9eb91d9 100644 --- a/plugins/module_utils/manage_vrf_lite/common.py +++ b/plugins/module_utils/manage_vrf_lite/common.py @@ -9,6 +9,9 @@ import ipaddress from typing import Any, NoReturn +from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( + get_params as _get_params, +) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( VrfLiteResourceError, ) @@ -23,18 +26,6 @@ def _raise_vrf_lite_error(msg: str, **details: Any) -> NoReturn: raise VrfLiteResourceError(msg=msg, **details) -def _get_params(source: Any) -> dict[str, Any]: - """Return a mutable params mapping from either module.params or a raw params dict.""" - if isinstance(source, dict): - return source - - params = getattr(source, "params", None) - if isinstance(params, dict): - return params - - return {} - - def _is_ip_literal(value: Any) -> bool: if not isinstance(value, str): return False @@ -75,6 +66,15 @@ def _resolve_serial(module: Any, switch_identifier: Any) -> str: 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 = _get_params(source) @@ -147,10 +147,6 @@ def request_with_verify_settings(module: Any, nd_v2: Any, path: str, verb: Any) finally: rest_send.restore_settings() - if last_error is not None: - raise last_error - return None - def get_config_actions(source: Any) -> dict[str, Any]: params = _get_params(source) 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..d97444155 --- /dev/null +++ b/plugins/module_utils/manage_vrf_lite/config_transform.py @@ -0,0 +1,178 @@ +# -*- 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.data import ( + copy_dict_items, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.common import ( + _resolve_serial, + append_runtime_warning, + vrf_name_from_config_item, +) + + +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"] = copy_dict_items(extensions) + + return {key: value for key, value in entry.items() if value is not None} + + +def explode_playbook_to_entries( + config: list[dict[str, Any]], + module: Any, + state: str, + current_entries: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Flatten nested playbook config into attachment-level state-machine entries.""" + entries: list[dict[str, Any]] = [] + current_entries = current_entries or [] + current_keys = {(item.get("vrf_name"), item.get("switch_ip")) for item in current_entries} + 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 + 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: list[str] | set[str] | None = None, +) -> list[dict[str, Any]]: + """Convert flat state-machine entries 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 index 15c379e8b..795311029 100644 --- a/plugins/module_utils/manage_vrf_lite/deploy.py +++ b/plugins/module_utils/manage_vrf_lite/deploy.py @@ -9,6 +9,9 @@ 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]: @@ -16,7 +19,7 @@ def _target_vrfs_for_deploy(module: Any) -> list[str]: for item in module.params.get("config") or []: if not isinstance(item, dict): continue - vrf_name = item.get("vrf_name") or item.get("vrfName") + vrf_name = vrf_name_from_config_item(item) if not vrf_name: continue @@ -24,20 +27,20 @@ def _target_vrfs_for_deploy(module: Any) -> list[str]: if deploy is False: continue if deploy is True: - target.add(str(vrf_name).strip()) + target.add(vrf_name) continue attachments = item.get("attach") or [] if not isinstance(attachments, list) or not attachments: - target.add(str(vrf_name).strip()) + target.add(vrf_name) continue if any(isinstance(attachment, dict) and attachment.get("deploy") is not False for attachment in attachments): - target.add(str(vrf_name).strip()) + target.add(vrf_name) continue if not any(isinstance(attachment, dict) for attachment in attachments): - target.add(str(vrf_name).strip()) + target.add(vrf_name) continue return sorted(target) diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index 55dbba900..1400ccc7d 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -9,6 +9,9 @@ import json from typing import Any +from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( + coerce_dict_list, +) 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, @@ -23,19 +26,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule as NDModuleV2 -def _coerce_list(data: Any) -> list[dict[str, Any]]: - if isinstance(data, list): - return [item for item in data if isinstance(item, dict)] - - if isinstance(data, dict): - for key in ("DATA", "data", "vrfs", "items"): - value = data.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, dict)] - - return [] - - def _parse_vrf_template_vlan(vrf_object: dict[str, Any]) -> int | None: template_cfg = vrf_object.get("vrfTemplateConfig") if not template_cfg: @@ -65,7 +55,10 @@ def _query_fabric_switches(module: Any, nd_v2: Any, fabric_name: str) -> dict[st path = VrfLiteEndpoints.fabric_switches(fabric_name) response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) - switches = _coerce_list(response if not isinstance(response, dict) else response.get("switches", response)) + switches = coerce_dict_list( + response if not isinstance(response, dict) else response.get("switches", response), + list_keys=("switches", "DATA", "data", "items"), + ) sn_to_ip = {} for switch in switches: @@ -77,22 +70,11 @@ def _query_fabric_switches(module: Any, nd_v2: Any, fabric_name: str) -> dict[st return sn_to_ip -def _build_filter_set(config: list[dict[str, Any]]) -> set[str]: - filters: set[str] = set() - for item in config or []: - if not isinstance(item, dict): - continue - vrf_name = item.get("vrf_name") or item.get("vrfName") - if vrf_name: - filters.add(str(vrf_name).strip()) - return filters - - 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 _coerce_list(response) + return coerce_dict_list(response, list_keys=("DATA", "data", "vrfs", "items")) def _query_vrf_attachments(module: Any, nd_v2: Any, fabric_name: str, vrf_names: list[str]) -> list[dict[str, Any]]: @@ -102,7 +84,7 @@ def _query_vrf_attachments(module: Any, nd_v2: Any, fabric_name: str, vrf_names: path = VrfLiteEndpoints.vrf_attachments_query(fabric_name, ",".join(vrf_names)) response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) - return _coerce_list(response) + return coerce_dict_list(response, list_keys=("DATA", "data", "vrfs", "items")) def _query_vrf_switch_details( @@ -126,7 +108,7 @@ def _query_vrf_switch_details( response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) detail_map: dict[str, dict[str, Any]] = {} - for vrf_switch in _coerce_list(response): + for vrf_switch in coerce_dict_list(response, list_keys=("DATA", "data", "vrfs", "items")): for switch_detail in vrf_switch.get("switchDetailsList") or []: if not isinstance(switch_detail, dict): continue diff --git a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py index e386de3a3..b617bede6 100644 --- a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py +++ b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py @@ -6,28 +6,12 @@ from __future__ import absolute_import, annotations, division, print_function -import ast import json from typing import Any - -def _loads_maybe_json(value: Any) -> Any: - if isinstance(value, (dict, list)): - return value - if value is None: - return None - - text = str(value).strip() - if not text: - return None - - try: - return json.loads(text) - except Exception: - try: - return ast.literal_eval(text) - except Exception: - return None +from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( + loads_maybe_json, +) def normalize_vrf_lite_list(vrf_lite_items: list[dict[str, Any]] | None) -> list[dict[str, Any]]: @@ -59,8 +43,10 @@ def build_vrf_lite_extension_values( """ Build extensionValues string expected by top-down VRF attachment APIs. """ - existing_outer = _loads_maybe_json(existing_extension_values) - if not isinstance(existing_outer, dict): + existing_outer = loads_maybe_json(existing_extension_values) + if isinstance(existing_outer, dict): + existing_outer = dict(existing_outer) + else: existing_outer = {} normalized = normalize_vrf_lite_list(vrf_lite_items) @@ -104,12 +90,12 @@ def parse_vrf_lite_extension_values(extension_values: Any) -> list[dict[str, Any """ Parse controller extensionValues into playbook-style vrf_lite list. """ - outer = _loads_maybe_json(extension_values) + outer = loads_maybe_json(extension_values) if not isinstance(outer, dict): return [] inner = outer.get("VRF_LITE_CONN") - inner = _loads_maybe_json(inner) + inner = loads_maybe_json(inner) if not isinstance(inner, dict): return [] @@ -157,7 +143,7 @@ def build_instance_values(import_evpn_rt: str | None, export_evpn_rt: str | None def parse_instance_values(instance_values: Any) -> dict[str, Any]: - parsed = _loads_maybe_json(instance_values) + parsed = loads_maybe_json(instance_values) if isinstance(parsed, dict): return parsed return {} diff --git a/plugins/module_utils/models/manage_vrf_lite/__init__.py b/plugins/module_utils/models/manage_vrf_lite/__init__.py index 5e4d12ab6..e69de29bb 100644 --- a/plugins/module_utils/models/manage_vrf_lite/__init__.py +++ b/plugins/module_utils/models/manage_vrf_lite/__init__.py @@ -1,7 +0,0 @@ -# -*- 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 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..60a01816d --- /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 Any, ClassVar, Literal + +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: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: bool | None = Field(default=None, alias="deploy") + import_evpn_rt: str | None = Field(default=None, alias="import_evpn_rt") + export_evpn_rt: str | None = Field(default=None, alias="export_evpn_rt") + extensions: list[VrfLiteConnectionModel] | None = Field(default=None, alias="extensions") + + @field_validator("import_evpn_rt", "export_evpn_rt") + @classmethod + def _coerce_empty_string_to_none(cls, value: str | None) -> str | None: + """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 index 6b2f8a53d..d57c70f5f 100644 --- a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function from copy import deepcopy -from typing import Any, ClassVar, Dict, List, Literal, Optional, Set +from typing import Any, ClassVar, Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, @@ -34,18 +34,18 @@ class VrfLiteConnectionModel(NDNestedModel): ) 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") + dot1q: int | None = Field(default=None, alias="dot1q", ge=1, le=4094) + ipv4_addr: str | None = Field(default=None, alias="ipv4_addr") + neighbor_ipv4: str | None = Field(default=None, alias="neighbor_ipv4") + ipv6_addr: str | None = Field(default=None, alias="ipv6_addr") + neighbor_ipv6: str | None = Field(default=None, alias="neighbor_ipv6") + peer_vrf: str | None = Field(default=None, alias="peer_vrf") class VrfLiteAttachmentModel(NDNestedModel): """Attachment data for one switch under a VRF.""" - exclude_from_diff: ClassVar[Set[str]] = {"deploy"} + exclude_from_diff: ClassVar[set[str]] = {"deploy"} model_config = ConfigDict( str_strip_whitespace=True, @@ -58,10 +58,10 @@ class VrfLiteAttachmentModel(NDNestedModel): ) 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") + deploy: bool | None = Field(default=None, alias="deploy") + import_evpn_rt: str | None = Field(default=None, alias="import_evpn_rt") + export_evpn_rt: str | None = Field(default=None, alias="export_evpn_rt") + vrf_lite: list[VrfLiteConnectionModel] | None = Field(default=None, alias="vrf_lite") @field_validator("ip_address") @classmethod @@ -123,9 +123,9 @@ def merge(self, other: "VrfLiteAttachmentModel") -> "VrfLiteAttachmentModel": class VrfLiteModel(NDBaseModel): """Runtime model for nd_manage_vrf_lite state reconciliation.""" - identifiers: ClassVar[List[str]] = ["vrf_name"] + identifiers: ClassVar[list[str]] = ["vrf_name"] identifier_strategy: ClassVar[Literal["single"]] = "single" - exclude_from_diff: ClassVar[Set[str]] = {"deploy"} + exclude_from_diff: ClassVar[set[str]] = {"deploy"} model_config = ConfigDict( str_strip_whitespace=True, @@ -138,9 +138,9 @@ class VrfLiteModel(NDBaseModel): ) 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") + vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: bool | None = Field(default=None, alias="deploy") + attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") @field_validator("vrf_name") @classmethod @@ -149,7 +149,7 @@ def validate_vrf_name(cls, value: str) -> str: raise ValueError("vrf_name must be a non-empty string") return str(value).strip() - def to_diff_dict(self, **kwargs) -> Dict[str, Any]: + def to_diff_dict(self, **kwargs) -> dict[str, Any]: """Exclude nested attachment deploy field from diff comparison.""" return self.model_dump( by_alias=True, @@ -163,15 +163,15 @@ def to_diff_dict(self, **kwargs) -> Dict[str, Any]: ) @classmethod - def from_response(cls, response: Dict[str, Any], **kwargs) -> "VrfLiteModel": + 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": + 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]: + def get_argument_spec(cls) -> dict[str, Any]: """Return the Ansible argument spec for nd_manage_vrf_lite.""" return VrfLitePlaybookConfigModel.get_argument_spec() @@ -232,9 +232,9 @@ class VrfLitePlaybookItemModel(BaseModel): ) 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") + vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) + deploy: bool | None = Field(default=None, alias="deploy") + attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") @field_validator("vrf_name") @classmethod @@ -243,7 +243,7 @@ def validate_vrf_name(cls, value: str) -> str: raise ValueError("vrf_name must be a non-empty string") return str(value).strip() - def to_runtime_config(self) -> Dict[str, Any]: + def to_runtime_config(self) -> dict[str, Any]: return self.model_dump(by_alias=False, exclude_none=True) @@ -298,9 +298,9 @@ class VrfLitePlaybookConfigModel(BaseModel): # 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) + verify: VerifyConfigModel | None = Field(default=None) + config_actions: ConfigActionsModel | None = Field(default=None) + config: list[VrfLitePlaybookItemModel] | None = Field(default=None) @model_validator(mode="after") def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": @@ -308,12 +308,12 @@ def validate_config_actions(self) -> "VrfLitePlaybookConfigModel": raise ValueError("config_actions.deploy=true requires config_actions.save=true") return self - def to_runtime_config(self) -> List[Dict[str, Any]]: + 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]: + 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), diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py index 3313c0b30..893842757 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -42,12 +42,12 @@ 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 ( - _build_filter_set, query_vrf_lite_state, ) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_endpoints import ( @@ -268,6 +268,10 @@ def _post_attach_entries(self, entries: list[Any]) -> dict[str, Any]: 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() @@ -349,7 +353,7 @@ def _query_current_state(self, flat: bool = True) -> list[dict[str, Any]]: else: config = module.params.get("_vrf_lite_nested_config") or module.params.get("config") or [] - filter_vrfs = _build_filter_set(config) + filter_vrfs = set(config_vrf_names(config)) try: have = query_vrf_lite_state( module=module, @@ -405,8 +409,10 @@ def _execute_config_actions(self, result: dict[str, Any]) -> dict[str, Any]: } requested_deploy_vrfs = set(_target_vrfs_for_deploy(module)) - changed_deploy_vrfs = set(module.params.get("_changed_vrfs") or []) & requested_deploy_vrfs - target_vrfs = sorted(changed_deploy_vrfs | requested_deploy_vrfs) + # 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: diff --git a/tests/unit/module_utils/test_manage_vrf_lite.py b/tests/unit/module_utils/test_manage_vrf_lite.py index 052f0114f..05e3bc67d 100644 --- a/tests/unit/module_utils/test_manage_vrf_lite.py +++ b/tests/unit/module_utils/test_manage_vrf_lite.py @@ -270,6 +270,22 @@ def test_manage_vrf_lite_00300_extension_values_clear_only_vrf_lite_section(): 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) @@ -947,7 +963,7 @@ def _inventory(_module, _fabric_name): 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 module.warn side-effects. + # Ensure validator no longer depends on direct Ansible warning side-effects. assert module.warnings == [] 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 From c1957cbb5a8263593d24db21eeedd39df7aa9a48 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 27 May 2026 10:59:00 +0530 Subject: [PATCH 10/13] Modern annotations --- plugins/module_utils/common/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/common/data.py b/plugins/module_utils/common/data.py index 6eba16c2f..5818bac09 100644 --- a/plugins/module_utils/common/data.py +++ b/plugins/module_utils/common/data.py @@ -8,7 +8,7 @@ import ast import json -from typing import Any +from typing import Any, Optional def get_params(source: Any) -> dict[str, Any]: @@ -68,7 +68,7 @@ def copy_dict_items(items: Any) -> list[dict[str, Any]]: return copied -def try_int(value: Any) -> int | None: +def try_int(value: Any) -> Optional[int]: """Best-effort integer conversion.""" try: return int(value) From ddd73670005cf16e58dc56d6343ec3c230185961 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 4 Jun 2026 15:50:47 +0530 Subject: [PATCH 11/13] Mulitipath vrflite and other changes --- plugins/module_utils/common/data.py | 47 +++++--- .../module_utils/manage_vrf_lite/actions.py | 4 +- .../manage_vrf_lite/config_transform.py | 6 +- plugins/module_utils/manage_vrf_lite/query.py | 6 +- .../manage_vrf_lite/runtime_payloads.py | 18 +-- .../manage_vrf_lite/validation.py | 6 +- .../vrf_lite_attachment_entry.py | 14 +-- .../models/manage_vrf_lite/vrf_lite_model.py | 40 +++---- plugins/module_utils/nd_state_machine.py | 2 +- .../orchestrators/manage_vrf_lite.py | 6 +- .../tasks/assert_vrf_lite_attachment.yaml | 1 + .../nd_manage_vrf_lite/tasks/base_tasks.yaml | 4 + .../nd_manage_vrf_lite/tasks/main.yaml | 9 +- .../tasks/nd_manage_vrf_lite_delete.yaml | 72 ++++-------- .../tasks/nd_manage_vrf_lite_merge.yaml | 104 +++++++++++++++++- 15 files changed, 216 insertions(+), 123 deletions(-) diff --git a/plugins/module_utils/common/data.py b/plugins/module_utils/common/data.py index 5818bac09..ce4b61cb2 100644 --- a/plugins/module_utils/common/data.py +++ b/plugins/module_utils/common/data.py @@ -8,7 +8,10 @@ import ast import json -from typing import Any, Optional +from typing import Any, Callable, Iterable, Mapping, Optional, Sequence + +Parser = Callable[[str], Any] +DEFAULT_VALUE_PARSERS: tuple[Parser, ...] = (json.loads, ast.literal_eval) def get_params(source: Any) -> dict[str, Any]: @@ -23,28 +26,42 @@ def get_params(source: Any) -> dict[str, Any]: return {} -def loads_maybe_json(value: Any) -> Any: - """Parse JSON or Python-literal strings while leaving parsed values unchanged.""" +def parse_value(value: Any, parsers: Sequence[Parser] = DEFAULT_VALUE_PARSERS, default: Any = None) -> Any: + """Parse a serialized value with the first parser that accepts it. + + Dicts and lists are returned unchanged because callers often pass values + that were already decoded by the controller client. Empty, invalid, or + unprintable values return ``default``. + """ if isinstance(value, (dict, list)): return value if value is None: - return None - - text = str(value).strip() - if not text: - return None + return default try: - return json.loads(text) + text = str(value).strip() except Exception: + return default + + if not text: + return default + + for parser in parsers: try: - return ast.literal_eval(text) + return parser(text) except Exception: - return None + continue + + return default + +def coerce_dict_list(data: Any, list_keys: Sequence[str] = ("DATA", "data", "items")) -> list[dict[str, Any]]: + """Return dict items from common shallow controller response shapes. -def coerce_dict_list(data: Any, list_keys: tuple[str, ...] = ("DATA", "data", "items")) -> list[dict[str, Any]]: - """Return a list containing only dict items from common controller response shapes.""" + This intentionally checks only the top-level value or one configured + top-level wrapper key. Deeper response shapes should be handled by the + caller because those paths are resource-specific. + """ if isinstance(data, list): return [item for item in data if isinstance(item, dict)] @@ -57,11 +74,11 @@ def coerce_dict_list(data: Any, list_keys: tuple[str, ...] = ("DATA", "data", "i return [] -def copy_dict_items(items: Any) -> list[dict[str, Any]]: +def copy_dict_items(items: Optional[Iterable[Any]]) -> list[dict[str, Any]]: """Copy a list of dict-like or pydantic-like objects into plain dicts.""" copied = [] for item in items or []: - if isinstance(item, dict): + if isinstance(item, Mapping): copied.append(dict(item)) elif hasattr(item, "model_dump"): copied.append(item.model_dump(by_alias=False, exclude_none=True)) diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py index 36341599c..deedaeace 100644 --- a/plugins/module_utils/manage_vrf_lite/actions.py +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function import json -from typing import Any +from typing import Any, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( copy_dict_items, @@ -233,7 +233,7 @@ def _entry_extensions(entry: Any) -> list[dict[str, Any]]: return copy_dict_items(getattr(entry, "extensions", None) or []) -def _entry_vlan_id(module: Any, entry: Any, raw_attach: dict[str, Any] | None = None) -> int: +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) diff --git a/plugins/module_utils/manage_vrf_lite/config_transform.py b/plugins/module_utils/manage_vrf_lite/config_transform.py index d97444155..500d01da7 100644 --- a/plugins/module_utils/manage_vrf_lite/config_transform.py +++ b/plugins/module_utils/manage_vrf_lite/config_transform.py @@ -6,7 +6,7 @@ from __future__ import absolute_import, annotations, division, print_function -from typing import Any +from typing import Any, Optional, Union from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( copy_dict_items, @@ -60,7 +60,7 @@ def explode_playbook_to_entries( config: list[dict[str, Any]], module: Any, state: str, - current_entries: list[dict[str, Any]] | None = None, + current_entries: Optional[list[dict[str, Any]]] = None, ) -> list[dict[str, Any]]: """Flatten nested playbook config into attachment-level state-machine entries.""" entries: list[dict[str, Any]] = [] @@ -121,7 +121,7 @@ def replacement_scope_vrfs(config: list[dict[str, Any]]) -> list[str]: def group_attachment_entries_to_vrfs( entries: list[Any], module: Any = None, - include_vrfs: list[str] | set[str] | None = None, + include_vrfs: Optional[Union[list[str], set[str]]] = None, ) -> list[dict[str, Any]]: """Convert flat state-machine entries back to the public nested VRF shape.""" sn_to_ip = {} diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index 1400ccc7d..bd9570897 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function import json -from typing import Any +from typing import Any, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( coerce_dict_list, @@ -26,7 +26,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule as NDModuleV2 -def _parse_vrf_template_vlan(vrf_object: dict[str, Any]) -> int | None: +def _parse_vrf_template_vlan(vrf_object: dict[str, Any]) -> Optional[int]: template_cfg = vrf_object.get("vrfTemplateConfig") if not template_cfg: return None @@ -185,7 +185,7 @@ def _flatten_to_entries(nested: list[dict[str, Any]], module: Any = None) -> lis def query_vrf_lite_state( module: Any, fabric_name: str, - filter_vrfs: set[str] | None = None, + filter_vrfs: Optional[set[str]] = None, flat: bool = False, ) -> list[dict[str, Any]]: """ diff --git a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py index b617bede6..ea6db07b0 100644 --- a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py +++ b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py @@ -7,14 +7,14 @@ from __future__ import absolute_import, annotations, division, print_function import json -from typing import Any +from typing import Any, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( - loads_maybe_json, + parse_value, ) -def normalize_vrf_lite_list(vrf_lite_items: list[dict[str, Any]] | None) -> list[dict[str, Any]]: +def normalize_vrf_lite_list(vrf_lite_items: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for item in vrf_lite_items or []: interface = item.get("interface") @@ -37,13 +37,13 @@ def normalize_vrf_lite_list(vrf_lite_items: list[dict[str, Any]] | None) -> list def build_vrf_lite_extension_values( - vrf_lite_items: list[dict[str, Any]] | None, + 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 = loads_maybe_json(existing_extension_values) + existing_outer = parse_value(existing_extension_values) if isinstance(existing_outer, dict): existing_outer = dict(existing_outer) else: @@ -90,12 +90,12 @@ def parse_vrf_lite_extension_values(extension_values: Any) -> list[dict[str, Any """ Parse controller extensionValues into playbook-style vrf_lite list. """ - outer = loads_maybe_json(extension_values) + outer = parse_value(extension_values) if not isinstance(outer, dict): return [] inner = outer.get("VRF_LITE_CONN") - inner = loads_maybe_json(inner) + inner = parse_value(inner) if not isinstance(inner, dict): return [] @@ -131,7 +131,7 @@ def parse_vrf_lite_extension_values(extension_values: Any) -> list[dict[str, Any return normalize_vrf_lite_list(parsed) -def build_instance_values(import_evpn_rt: str | None, export_evpn_rt: str | None) -> str: +def build_instance_values(import_evpn_rt: Optional[str], export_evpn_rt: Optional[str]) -> str: values = { "loopbackId": "", "loopbackIpAddress": "", @@ -143,7 +143,7 @@ def build_instance_values(import_evpn_rt: str | None, export_evpn_rt: str | None def parse_instance_values(instance_values: Any) -> dict[str, Any]: - parsed = loads_maybe_json(instance_values) + parsed = parse_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 index 02558d6f6..fe2cfa9ae 100644 --- a/plugins/module_utils/manage_vrf_lite/validation.py +++ b/plugins/module_utils/manage_vrf_lite/validation.py @@ -6,7 +6,7 @@ from __future__ import absolute_import, annotations, division, print_function -from typing import Any +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 @@ -88,7 +88,7 @@ def _load_switch_inventory(module: Any, fabric_name: str) -> dict[str, dict[str, return inventory -def _extract_support_flag(value: Any) -> bool | None: +def _extract_support_flag(value: Any) -> Optional[bool]: if isinstance(value, bool): return value @@ -114,7 +114,7 @@ def _extract_support_flag(value: Any) -> bool | None: return None -def _query_vrf_lite_support(module: Any, fabric_name: str, vrf_name: str, serial_number: str) -> bool | 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, 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 index 60a01816d..710e0bced 100644 --- 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 @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function from copy import deepcopy -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( ConfigDict, @@ -45,15 +45,15 @@ class VrfLiteAttachmentEntry(NDBaseModel): 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: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) - deploy: bool | None = Field(default=None, alias="deploy") - import_evpn_rt: str | None = Field(default=None, alias="import_evpn_rt") - export_evpn_rt: str | None = Field(default=None, alias="export_evpn_rt") - extensions: list[VrfLiteConnectionModel] | None = Field(default=None, alias="extensions") + 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: str | None) -> str | None: + 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. 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 index d57c70f5f..ee220bf6c 100644 --- a/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py +++ b/plugins/module_utils/models/manage_vrf_lite/vrf_lite_model.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function from copy import deepcopy -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, @@ -34,12 +34,12 @@ class VrfLiteConnectionModel(NDNestedModel): ) interface: str = Field(alias="interface", min_length=1, max_length=128) - dot1q: int | None = Field(default=None, alias="dot1q", ge=1, le=4094) - ipv4_addr: str | None = Field(default=None, alias="ipv4_addr") - neighbor_ipv4: str | None = Field(default=None, alias="neighbor_ipv4") - ipv6_addr: str | None = Field(default=None, alias="ipv6_addr") - neighbor_ipv6: str | None = Field(default=None, alias="neighbor_ipv6") - peer_vrf: str | None = Field(default=None, alias="peer_vrf") + 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): @@ -58,10 +58,10 @@ class VrfLiteAttachmentModel(NDNestedModel): ) ip_address: str = Field(alias="ip_address", min_length=1, max_length=128) - deploy: bool | None = Field(default=None, alias="deploy") - import_evpn_rt: str | None = Field(default=None, alias="import_evpn_rt") - export_evpn_rt: str | None = Field(default=None, alias="export_evpn_rt") - vrf_lite: list[VrfLiteConnectionModel] | None = Field(default=None, alias="vrf_lite") + 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 @@ -138,9 +138,9 @@ class VrfLiteModel(NDBaseModel): ) vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) - vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) - deploy: bool | None = Field(default=None, alias="deploy") - attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") + 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 @@ -232,9 +232,9 @@ class VrfLitePlaybookItemModel(BaseModel): ) vrf_name: str = Field(alias="vrf_name", min_length=1, max_length=64) - vlan_id: int | None = Field(default=None, alias="vlan_id", ge=1, le=4094) - deploy: bool | None = Field(default=None, alias="deploy") - attach: list[VrfLiteAttachmentModel] | None = Field(default=None, alias="attach") + 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 @@ -298,9 +298,9 @@ class VrfLitePlaybookConfigModel(BaseModel): # 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: VerifyConfigModel | None = Field(default=None) - config_actions: ConfigActionsModel | None = Field(default=None) - config: list[VrfLitePlaybookItemModel] | None = Field(default=None) + 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": diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 9a425c838..11f2b1771 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -67,7 +67,7 @@ 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", []) + 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) diff --git a/plugins/module_utils/orchestrators/manage_vrf_lite.py b/plugins/module_utils/orchestrators/manage_vrf_lite.py index 893842757..3252c71ed 100644 --- a/plugins/module_utils/orchestrators/manage_vrf_lite.py +++ b/plugins/module_utils/orchestrators/manage_vrf_lite.py @@ -8,7 +8,7 @@ import json from collections import defaultdict -from typing import Any, ClassVar +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 @@ -83,7 +83,7 @@ class ManageVrfLiteOrchestrator(NDBaseOrchestrator): delete_bulk_endpoint: type[NDEndpointBaseModel] = EpFabricVrfsAttachmentsPost @staticmethod - def prepare_module_params(module: Any, module_config: Any, normalized_config: list[dict[str, Any]] | None = None) -> None: + 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: @@ -299,7 +299,7 @@ def gather(self) -> dict[str, Any]: } return self.inject_runtime_metadata(output) - def deploy_pending(self, result: dict[str, Any]) -> dict[str, Any] | None: + def deploy_pending(self, result: dict[str, Any]) -> Optional[dict[str, Any]]: module = self._module() config_actions = get_config_actions(module.params) 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 index 43710fa00..675f640e9 100644 --- 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 @@ -45,6 +45,7 @@ - (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 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 index f98a2562d..7d8f70e50 100644 --- a/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/base_tasks.yaml @@ -32,6 +32,10 @@ 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 diff --git a/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml b/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml index 7c57d9b1b..f03b593cb 100644 --- a/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml +++ b/tests/integration/targets/nd_manage_vrf_lite/tasks/main.yaml @@ -15,9 +15,12 @@ tasks: - name: Test that we have a Nexus Dashboard host, username and password tags: always - ansible.builtin.fail: - msg: "Please define the following variables: ansible_host, ansible_user and ansible_password." - when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + 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 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 index f2f3c3bf5..e7958b5d4 100644 --- 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 @@ -580,9 +580,8 @@ when: test_switch2 | length > 0 tags: delete -# TC9 - Delete with verify.enabled=false still refreshes after/current correctly -# Requires a second VRF Lite-capable switch in the lab inventory. -- name: DELETE - TC9 - MERGE - Create two VRF Lite attachments for verify-disabled 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 }}" @@ -602,30 +601,22 @@ ipv4_addr: "{{ test_ipv4_addr }}" neighbor_ipv4: "{{ test_neighbor_ipv4 }}" peer_vrf: "{{ test_peer_vrf }}" - - ip_address: "{{ test_switch2 }}" - vrf_lite: - - interface: "{{ test_interface }}" - dot1q: "{{ test_dot1q | int + 1 }}" - ipv4_addr: "10.33.0.6/24" - neighbor_ipv4: "10.33.0.5" + - 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 - when: - - test_switch2 | length > 0 - - switch2_vrf_lite_enabled | default(false) | bool tags: delete -- name: DELETE - TC9 - ASSERT - Verify two-attachment setup +- name: DELETE - TC9 - ASSERT - Verify two-link setup ansible.builtin.assert: that: - tc9_setup_result.failed == false - tc9_setup_result.changed == true - when: - - test_switch2 | length > 0 - - switch2_vrf_lite_enabled | default(false) | bool tags: delete -- name: DELETE - TC9 - DELETE - Delete one attachment with verify disabled +- name: DELETE - TC9 - DELETE - Delete multi-link attachment with verify disabled cisco.nd.nd_manage_vrf_lite: <<: *nd_info fabric_name: "{{ test_fabric }}" @@ -641,12 +632,9 @@ attach: - ip_address: "{{ test_switch1 }}" register: tc9_delete_result - when: - - test_switch2 | length > 0 - - switch2_vrf_lite_enabled | default(false) | bool tags: delete -- name: DELETE - TC9 - ASSERT - Verify delete output keeps remaining attachment +- name: DELETE - TC9 - ASSERT - Verify delete output clears attachment vars: tc9_after_vrf: >- {{ @@ -666,13 +654,6 @@ - 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_switch2) - | list - | length - ) == 1 - > ( tc9_after_vrf[0].attach | default([]) @@ -683,16 +664,13 @@ - > ( tc9_current_vrf[0].attach | default([]) - | selectattr('ip_address', 'equalto', test_switch2) + | selectattr('ip_address', 'equalto', test_switch1) | list | length - ) == 1 - when: - - test_switch2 | length > 0 - - switch2_vrf_lite_enabled | default(false) | bool + ) == 0 tags: delete -- name: DELETE - TC9 - GATHER - Verify remaining attachment after verify-disabled 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 }}" @@ -704,26 +682,16 @@ config: - vrf_name: "{{ test_vrf }}" register: tc9_verify_result - when: - - test_switch2 | length > 0 - - switch2_vrf_lite_enabled | default(false) | bool tags: delete -- name: DELETE - TC9 - ASSERT - Verify gathered keeps remaining attachment - ansible.builtin.include_tasks: assert_vrf_lite_attachment.yaml - vars: - vrf_lite_assert_result: "{{ tc9_verify_result }}" - vrf_lite_assert_vrf_name: "{{ test_vrf }}" - vrf_lite_assert_vlan_id: "{{ test_vlan_id }}" - vrf_lite_assert_switch: "{{ test_switch2 }}" - vrf_lite_assert_interface: "{{ test_interface }}" - vrf_lite_assert_dot1q: "{{ test_dot1q | int + 1 }}" - vrf_lite_assert_ipv4_addr: "10.33.0.6/24" - vrf_lite_assert_neighbor_ipv4: "10.33.0.5" - vrf_lite_assert_peer_vrf: "{{ test_peer_vrf }}" - when: - - test_switch2 | length > 0 - - switch2_vrf_lite_enabled | default(false) | bool +- 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 ############################################## 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 index 6e81acb76..e73a1dd92 100644 --- 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 @@ -677,13 +677,12 @@ type: switch config: "{{ nd_manage_vrf_lite_merge_full_conf }}" register: result - ignore_errors: true + failed_when: false tags: merge - name: MERGE - TC11 - ASSERT - Verify invalid config_actions rejected ansible.builtin.assert: that: - - result.failed == true - result.msg is defined - result.msg is search("deploy.*requires.*save") tags: merge @@ -777,6 +776,88 @@ 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 ## ############################################## @@ -797,3 +878,22 @@ 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 From 0a928e90cd0ed5a3da2b7ed786edebb7e179fa21 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 4 Jun 2026 17:09:15 +0530 Subject: [PATCH 12/13] Changes in generic architecture added --- plugins/module_utils/nd_state_machine.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 11f2b1771..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() From 5ed41b5658b71c267a373905deb7f0ad478081ec Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 5 Jun 2026 15:01:08 +0530 Subject: [PATCH 13/13] Refactoring generic architecture --- plugins/module_utils/common/data.py | 93 ------------------- .../module_utils/manage_vrf_lite/actions.py | 24 +++-- .../module_utils/manage_vrf_lite/common.py | 29 +++--- .../manage_vrf_lite/config_transform.py | 22 +++-- plugins/module_utils/manage_vrf_lite/query.py | 28 ++++-- .../manage_vrf_lite/runtime_payloads.py | 54 +++++------ .../vrf_lite_attachment_entry.py | 2 +- 7 files changed, 86 insertions(+), 166 deletions(-) delete mode 100644 plugins/module_utils/common/data.py diff --git a/plugins/module_utils/common/data.py b/plugins/module_utils/common/data.py deleted file mode 100644 index ce4b61cb2..000000000 --- a/plugins/module_utils/common/data.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- 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 ast -import json -from typing import Any, Callable, Iterable, Mapping, Optional, Sequence - -Parser = Callable[[str], Any] -DEFAULT_VALUE_PARSERS: tuple[Parser, ...] = (json.loads, ast.literal_eval) - - -def get_params(source: Any) -> dict[str, Any]: - """Return a mutable params mapping from either module.params or a raw params dict.""" - if isinstance(source, dict): - return source - - params = getattr(source, "params", None) - if isinstance(params, dict): - return params - - return {} - - -def parse_value(value: Any, parsers: Sequence[Parser] = DEFAULT_VALUE_PARSERS, default: Any = None) -> Any: - """Parse a serialized value with the first parser that accepts it. - - Dicts and lists are returned unchanged because callers often pass values - that were already decoded by the controller client. Empty, invalid, or - unprintable values return ``default``. - """ - if isinstance(value, (dict, list)): - return value - if value is None: - return default - - try: - text = str(value).strip() - except Exception: - return default - - if not text: - return default - - for parser in parsers: - try: - return parser(text) - except Exception: - continue - - return default - - -def coerce_dict_list(data: Any, list_keys: Sequence[str] = ("DATA", "data", "items")) -> list[dict[str, Any]]: - """Return dict items from common shallow controller response shapes. - - This intentionally checks only the top-level value or one configured - top-level wrapper key. Deeper response shapes should be handled by the - caller because those paths are resource-specific. - """ - if isinstance(data, list): - return [item for item in data if isinstance(item, dict)] - - if isinstance(data, dict): - for key in list_keys: - value = data.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, dict)] - - return [] - - -def copy_dict_items(items: Optional[Iterable[Any]]) -> list[dict[str, Any]]: - """Copy a list of dict-like or pydantic-like objects into plain dicts.""" - copied = [] - for item in items or []: - if isinstance(item, Mapping): - copied.append(dict(item)) - elif hasattr(item, "model_dump"): - copied.append(item.model_dump(by_alias=False, exclude_none=True)) - return copied - - -def try_int(value: Any) -> Optional[int]: - """Best-effort integer conversion.""" - try: - return int(value) - except (TypeError, ValueError): - return None diff --git a/plugins/module_utils/manage_vrf_lite/actions.py b/plugins/module_utils/manage_vrf_lite/actions.py index deedaeace..293f4ed6c 100644 --- a/plugins/module_utils/manage_vrf_lite/actions.py +++ b/plugins/module_utils/manage_vrf_lite/actions.py @@ -9,10 +9,6 @@ import json from typing import Any, Optional -from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( - copy_dict_items, - try_int, -) 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, @@ -27,8 +23,8 @@ from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.runtime_payloads import ( build_instance_values, build_vrf_lite_extension_values, - normalize_vrf_lite_list, 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 @@ -230,7 +226,7 @@ def _build_instance_values_for_payload(import_evpn_rt: Any, export_evpn_rt: Any, def _entry_extensions(entry: Any) -> list[dict[str, Any]]: - return copy_dict_items(getattr(entry, "extensions", None) or []) + 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: @@ -240,14 +236,16 @@ def _entry_vlan_id(module: Any, entry: Any, raw_attach: Optional[dict[str, Any]] 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, ""): - vlan = try_int(mapped_vlan) - if vlan is not None: - return vlan + try: + return int(mapped_vlan) + except (TypeError, ValueError): + pass if isinstance(raw_attach, dict) and raw_attach.get("vlan") not in (None, ""): - vlan = try_int(raw_attach.get("vlan")) - if vlan is not None: - return vlan + 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), @@ -264,7 +262,7 @@ def build_attach_payload_for_entry(module: Any, nd_v2: Any, entry: Any) -> dict[ vlan_id = _entry_vlan_id(module, entry, raw_attach) resolved_extensions = [] - for lite_item in normalize_vrf_lite_list(_entry_extensions(entry)): + 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( diff --git a/plugins/module_utils/manage_vrf_lite/common.py b/plugins/module_utils/manage_vrf_lite/common.py index dc9eb91d9..125eba700 100644 --- a/plugins/module_utils/manage_vrf_lite/common.py +++ b/plugins/module_utils/manage_vrf_lite/common.py @@ -9,9 +9,6 @@ import ipaddress from typing import Any, NoReturn -from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( - get_params as _get_params, -) from ansible_collections.cisco.nd.plugins.module_utils.manage_vrf_lite.exceptions import ( VrfLiteResourceError, ) @@ -22,6 +19,13 @@ 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) @@ -77,7 +81,7 @@ def vrf_name_from_config_item(item: Any) -> str: def append_runtime_warning(source: Any, message: str) -> None: """Collect runtime warnings without requiring direct Ansible dependencies.""" - params = _get_params(source) + params = _params(source) warnings = params.get("_warnings") if not isinstance(warnings, list): warnings = [] @@ -86,7 +90,7 @@ def append_runtime_warning(source: Any, message: str) -> None: def get_runtime_warnings(source: Any) -> list[str]: - params = _get_params(source) + params = _params(source) warnings = params.get("_warnings") if not isinstance(warnings, list): return [] @@ -103,7 +107,7 @@ def get_runtime_warnings(source: Any) -> list[str]: def get_verify_settings(source: Any) -> dict[str, Any]: - params = _get_params(source) + params = _params(source) raw_verify = params.get("verify") if isinstance(raw_verify, dict): return { @@ -119,10 +123,6 @@ def get_verify_settings(source: Any) -> dict[str, Any]: } -def get_verify_timeout(source: Any) -> int: - return get_verify_settings(source).get("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) @@ -149,7 +149,7 @@ def request_with_verify_settings(module: Any, nd_v2: Any, path: str, verb: Any) def get_config_actions(source: Any) -> dict[str, Any]: - params = _get_params(source) + 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) @@ -168,10 +168,3 @@ def get_config_actions(source: Any) -> dict[str, Any]: "deploy": True, "type": DEFAULT_CONFIG_ACTION_TYPE, } - - -def normalize_config_list(source: Any, state: str) -> list: - params = _get_params(source) - if state == "gathered": - return list(params.get("_gather_filter_config") or []) - return list(params.get("config") or []) diff --git a/plugins/module_utils/manage_vrf_lite/config_transform.py b/plugins/module_utils/manage_vrf_lite/config_transform.py index 500d01da7..c7efa7585 100644 --- a/plugins/module_utils/manage_vrf_lite/config_transform.py +++ b/plugins/module_utils/manage_vrf_lite/config_transform.py @@ -8,14 +8,14 @@ from typing import Any, Optional, Union -from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( - copy_dict_items, -) 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: @@ -51,21 +51,27 @@ def _entry_from_attach(module: Any, vrf_item: dict[str, Any], attach: dict[str, extensions = attach.get("extensions", attach.get("vrf_lite")) if extensions is not None: - entry["extensions"] = copy_dict_items(extensions) + 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 state-machine entries.""" + """Flatten nested playbook config into attachment-level payload DTO data.""" entries: list[dict[str, Any]] = [] current_entries = current_entries or [] - current_keys = {(item.get("vrf_name"), item.get("switch_ip")) for item in current_entries} + 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 @@ -89,6 +95,8 @@ def explode_playbook_to_entries( 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: @@ -123,7 +131,7 @@ def group_attachment_entries_to_vrfs( module: Any = None, include_vrfs: Optional[Union[list[str], set[str]]] = None, ) -> list[dict[str, Any]]: - """Convert flat state-machine entries back to the public nested VRF shape.""" + """Convert flat attachment DTOs back to the public nested VRF shape.""" sn_to_ip = {} vlan_map = {} known_vrfs = set() diff --git a/plugins/module_utils/manage_vrf_lite/query.py b/plugins/module_utils/manage_vrf_lite/query.py index bd9570897..e6aa915a8 100644 --- a/plugins/module_utils/manage_vrf_lite/query.py +++ b/plugins/module_utils/manage_vrf_lite/query.py @@ -9,9 +9,6 @@ import json from typing import Any, Optional -from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( - coerce_dict_list, -) 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, @@ -49,19 +46,32 @@ def _parse_vrf_template_vlan(vrf_object: dict[str, Any]) -> Optional[int]: 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 = coerce_dict_list( + switches = _result_list( response if not isinstance(response, dict) else response.get("switches", response), - list_keys=("switches", "DATA", "data", "items"), + ("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: @@ -74,7 +84,7 @@ 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 coerce_dict_list(response, list_keys=("DATA", "data", "vrfs", "items")) + 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]]: @@ -84,7 +94,7 @@ def _query_vrf_attachments(module: Any, nd_v2: Any, fabric_name: str, vrf_names: path = VrfLiteEndpoints.vrf_attachments_query(fabric_name, ",".join(vrf_names)) response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) - return coerce_dict_list(response, list_keys=("DATA", "data", "vrfs", "items")) + return [item for item in _result_list(response, ("DATA", "data", "vrfs", "items")) if isinstance(item, dict)] def _query_vrf_switch_details( @@ -108,7 +118,9 @@ def _query_vrf_switch_details( response = request_with_verify_settings(module, nd_v2, path, HttpVerbEnum.GET) detail_map: dict[str, dict[str, Any]] = {} - for vrf_switch in coerce_dict_list(response, list_keys=("DATA", "data", "vrfs", "items")): + 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 diff --git a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py index ea6db07b0..5d3a45280 100644 --- a/plugins/module_utils/manage_vrf_lite/runtime_payloads.py +++ b/plugins/module_utils/manage_vrf_lite/runtime_payloads.py @@ -9,31 +9,33 @@ import json from typing import Any, Optional -from ansible_collections.cisco.nd.plugins.module_utils.common.data import ( - parse_value, +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 normalize_vrf_lite_list(vrf_lite_items: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]: - normalized: list[dict[str, Any]] = [] +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 []: - interface = item.get("interface") - if not interface: + 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", "")) - normalized_item = { - "interface": str(interface).strip(), - "dot1q": item.get("dot1q"), - "ipv4_addr": item.get("ipv4_addr"), - "neighbor_ipv4": item.get("neighbor_ipv4"), - "ipv6_addr": item.get("ipv6_addr"), - "neighbor_ipv6": item.get("neighbor_ipv6"), - "peer_vrf": item.get("peer_vrf"), - } - normalized.append({k: v for k, v in normalized_item.items() if v is not None and v != ""}) - normalized.sort(key=lambda i: i.get("interface", "")) - return normalized +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( @@ -43,14 +45,14 @@ def build_vrf_lite_extension_values( """ Build extensionValues string expected by top-down VRF attachment APIs. """ - existing_outer = parse_value(existing_extension_values) + existing_outer = _json_value(existing_extension_values) if isinstance(existing_outer, dict): existing_outer = dict(existing_outer) else: existing_outer = {} - normalized = normalize_vrf_lite_list(vrf_lite_items) - if not normalized: + 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": []}, @@ -62,7 +64,7 @@ def build_vrf_lite_extension_values( return json.dumps(existing_outer, separators=(",", ":")) connection_rows: list[dict[str, Any]] = [] - for item in normalized: + for item in configured_items: row = { "DOT1Q_ID": "", "IF_NAME": item.get("interface", ""), @@ -90,12 +92,12 @@ def parse_vrf_lite_extension_values(extension_values: Any) -> list[dict[str, Any """ Parse controller extensionValues into playbook-style vrf_lite list. """ - outer = parse_value(extension_values) + outer = _json_value(extension_values) if not isinstance(outer, dict): return [] inner = outer.get("VRF_LITE_CONN") - inner = parse_value(inner) + inner = _json_value(inner) if not isinstance(inner, dict): return [] @@ -128,7 +130,7 @@ def parse_vrf_lite_extension_values(extension_values: Any) -> list[dict[str, Any if item.get("interface"): parsed.append({k: v for k, v in item.items() if v is not None and v != ""}) - return normalize_vrf_lite_list(parsed) + return vrf_lite_items_to_config(parsed) def build_instance_values(import_evpn_rt: Optional[str], export_evpn_rt: Optional[str]) -> str: @@ -143,7 +145,7 @@ def build_instance_values(import_evpn_rt: Optional[str], export_evpn_rt: Optiona def parse_instance_values(instance_values: Any) -> dict[str, Any]: - parsed = parse_value(instance_values) + parsed = _json_value(instance_values) if isinstance(parsed, dict): return parsed return {} 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 index 710e0bced..197ea08e4 100644 --- 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 @@ -7,7 +7,7 @@ from __future__ import absolute_import, annotations, division, print_function from copy import deepcopy -from typing import Any, ClassVar, Literal, Optional +from typing import ClassVar, Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( ConfigDict,