diff --git a/plugins/action/tests/integration/nd_vpc_pair_validate.py b/plugins/action/tests/integration/nd_vpc_pair_validate.py new file mode 100644 index 00000000..51239e3e --- /dev/null +++ b/plugins/action/tests/integration/nd_vpc_pair_validate.py @@ -0,0 +1,210 @@ +# -*- 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, division, print_function + +__metaclass__ = type + +from ansible.plugins.action import ActionBase +from ansible.utils.display import Display + +display = Display() + + +def _normalize_pair(pair): + """Return a frozenset key of (switch_id, peer_switch_id) so order does not matter.""" + s1 = pair.get("switchId") or pair.get("switch_id") or pair.get("peer1_switch_id", "") + s2 = pair.get("peerSwitchId") or pair.get("peer_switch_id") or pair.get("peer2_switch_id", "") + return frozenset([s1.strip(), s2.strip()]) + + +def _get_virtual_peer_link(pair): + """Extract the use_virtual_peer_link / useVirtualPeerLink value from a pair dict.""" + for key in ("useVirtualPeerLink", "use_virtual_peer_link"): + if key in pair: + return pair[key] + return None + + +class ActionModule(ActionBase): + """Ansible action plugin that validates nd_vpc_pair gathered output against expected test data. + + Usage in a playbook task:: + + - name: Validate vPC pairs + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ gathered_result }}" + expected_data: "{{ expected_conf }}" + changed: "{{ result.changed }}" + mode: "full" # full | count_only | exists + + Parameters + ---------- + gathered_data : dict + The full register output of a ``cisco.nd.nd_manage_vpc_pair`` task with ``state: gathered``. + Must contain ``gathered.vpc_pairs`` (list). + expected_data : list + List of dicts with expected vPC pair config. Each dict should have at least + ``peer1_switch_id`` / ``peer2_switch_id`` (playbook-style keys). + API-style keys (``switchId`` / ``peerSwitchId``) are also accepted. + changed : bool, optional + If provided the plugin asserts that the previous action reported ``changed``. + mode : str, optional + ``full`` – (default) match count **and** per-pair field values. + ``count_only`` – only verify the number of pairs matches. + ``exists`` – verify that every expected pair exists (extra pairs OK). + """ + + VALID_MODES = frozenset(["full", "count_only", "exists"]) + + def run(self, tmp=None, task_vars=None): + results = super(ActionModule, self).run(tmp, task_vars) + results["failed"] = False + + # ------------------------------------------------------------------ + # Extract arguments + # ------------------------------------------------------------------ + gathered_data = self._task.args.get("gathered_data") + expected_data = self._task.args.get("expected_data") + changed = self._task.args.get("changed") + mode = self._task.args.get("mode", "full").lower() + + if mode not in self.VALID_MODES: + results["failed"] = True + results["msg"] = "Invalid mode '{0}'. Choose from: {1}".format(mode, ", ".join(sorted(self.VALID_MODES))) + return results + + # ------------------------------------------------------------------ + # Validate 'changed' flag if provided + # ------------------------------------------------------------------ + if changed is not None: + # Accept bool or string representation + if isinstance(changed, str): + changed = changed.strip().lower() in ("true", "1", "yes") + if not changed: + results["failed"] = True + results["msg"] = "Preceding task reported changed=false but expected a change." + return results + + # ------------------------------------------------------------------ + # Unwrap gathered data + # ------------------------------------------------------------------ + if gathered_data is None: + results["failed"] = True + results["msg"] = "gathered_data is required." + return results + + if isinstance(gathered_data, dict): + # Could be the full register dict or just the gathered sub-dict + vpc_pairs = ( + gathered_data.get("gathered", {}).get("vpc_pairs") + or gathered_data.get("vpc_pairs") + ) + else: + results["failed"] = True + results["msg"] = "gathered_data must be a dict (register output or gathered sub-dict)." + return results + + if vpc_pairs is None: + vpc_pairs = [] + + # ------------------------------------------------------------------ + # Normalise expected data + # ------------------------------------------------------------------ + if expected_data is None: + expected_data = [] + if not isinstance(expected_data, list): + results["failed"] = True + results["msg"] = "expected_data must be a list of vpc pair dicts." + return results + + # ------------------------------------------------------------------ + # Count check + # ------------------------------------------------------------------ + if mode in ("full", "count_only"): + if len(vpc_pairs) != len(expected_data): + results["failed"] = True + results["msg"] = ( + "Pair count mismatch: gathered {0} pair(s) but expected {1}.".format( + len(vpc_pairs), len(expected_data) + ) + ) + results["gathered_count"] = len(vpc_pairs) + results["expected_count"] = len(expected_data) + return results + + if mode == "count_only": + results["msg"] = "Validation successful (count_only): {0} pair(s).".format(len(vpc_pairs)) + return results + + # ------------------------------------------------------------------ + # Build lookup of gathered pairs keyed by normalised pair key + # ------------------------------------------------------------------ + gathered_by_key = {} + for pair in vpc_pairs: + key = _normalize_pair(pair) + gathered_by_key[key] = pair + + # ------------------------------------------------------------------ + # Match each expected pair + # ------------------------------------------------------------------ + missing_pairs = [] + field_mismatches = [] + + for expected in expected_data: + key = _normalize_pair(expected) + gathered_pair = gathered_by_key.get(key) + + if gathered_pair is None: + missing_pairs.append( + { + "peer1": expected.get("peer1_switch_id") or expected.get("switchId", "?"), + "peer2": expected.get("peer2_switch_id") or expected.get("peerSwitchId", "?"), + } + ) + continue + + # Field-level comparison (only in full mode) + if mode == "full": + expected_vpl = _get_virtual_peer_link(expected) + gathered_vpl = _get_virtual_peer_link(gathered_pair) + if expected_vpl is not None and gathered_vpl is not None: + # Normalise to bool + if isinstance(expected_vpl, str): + expected_vpl = expected_vpl.lower() in ("true", "1", "yes") + if isinstance(gathered_vpl, str): + gathered_vpl = gathered_vpl.lower() in ("true", "1", "yes") + if bool(expected_vpl) != bool(gathered_vpl): + field_mismatches.append( + { + "pair": "{0}-{1}".format( + expected.get("peer1_switch_id") or expected.get("switchId", "?"), + expected.get("peer2_switch_id") or expected.get("peerSwitchId", "?"), + ), + "field": "use_virtual_peer_link", + "expected": bool(expected_vpl), + "actual": bool(gathered_vpl), + } + ) + + # ------------------------------------------------------------------ + # Compose result + # ------------------------------------------------------------------ + if missing_pairs or field_mismatches: + results["failed"] = True + parts = [] + if missing_pairs: + parts.append("Missing pairs: {0}".format(missing_pairs)) + if field_mismatches: + parts.append("Field mismatches: {0}".format(field_mismatches)) + results["msg"] = "Validation failed. " + "; ".join(parts) + results["missing_pairs"] = missing_pairs + results["field_mismatches"] = field_mismatches + else: + results["msg"] = "Validation successful: {0} pair(s) verified ({1} mode).".format( + len(expected_data), mode + ) + + return results diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index e7f0620c..2e8fc4d1 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -84,3 +84,67 @@ class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") + + +class SwitchIdMixin(BaseModel): + """Mixin for endpoints that require switch_id parameter.""" + + switch_id: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + + +class PeerSwitchIdMixin(BaseModel): + """Mixin for endpoints that require peer_switch_id parameter.""" + + peer_switch_id: Optional[str] = Field(default=None, min_length=1, description="Peer switch serial number") + + +class UseVirtualPeerLinkMixin(BaseModel): + """Mixin for endpoints that require use_virtual_peer_link parameter.""" + + use_virtual_peer_link: Optional[bool] = Field( + default=False, + description="Indicates whether a virtual peer link is present", + ) + + +class FromClusterMixin(BaseModel): + """Mixin for endpoints that support fromCluster query parameter.""" + + from_cluster: Optional[str] = Field(default=None, description="Optional cluster name") + + +class TicketIdMixin(BaseModel): + """Mixin for endpoints that support ticketId query parameter.""" + + ticket_id: Optional[str] = Field(default=None, description="Change ticket ID") + + +class ComponentTypeMixin(BaseModel): + """Mixin for endpoints that require componentType query parameter.""" + + component_type: Optional[str] = Field(default=None, description="Component type for filtering response") + + +class FilterMixin(BaseModel): + """Mixin for endpoints that support filter query parameter.""" + + filter: Optional[str] = Field(default=None, description="Filter expression for results") + + +class PaginationMixin(BaseModel): + """Mixin for endpoints that support pagination parameters.""" + + max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") + offset: Optional[int] = Field(default=None, ge=0, description="Offset for pagination") + + +class SortMixin(BaseModel): + """Mixin for endpoints that support sort parameter.""" + + sort: Optional[str] = Field(default=None, description="Sort field and direction (e.g., 'name:asc')") + + +class ViewMixin(BaseModel): + """Mixin for endpoints that support view parameter.""" + + view: Optional[str] = Field(default=None, description="Optional view type for filtering results") diff --git a/plugins/module_utils/endpoints/v1/manage/__init__.py b/plugins/module_utils/endpoints/v1/manage/__init__.py index e69de29b..1683dfbd 100644 --- a/plugins/module_utils/endpoints/v1/manage/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage/__init__.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( + EpVpcPairSupportGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( + EpVpcPairOverviewGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( + EpVpcPairsListGet, +) +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_actions_config_save import ( + EpFabricConfigSavePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_deploy import ( + EpFabricDeployPost, +) + +__all__ = [ + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", + "EpFabricSwitchesGet", + "EpFabricConfigSavePost", + "EpFabricDeployPost", +] 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 00000000..d4cc68e6 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FromClusterMixin, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/actions/configSave +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpFabricConfigSavePost( + FabricNameMixin, + FromClusterMixin, + NDEndpointBaseModel, +): + """ + POST /api/v1/manage/fabrics/{fabricName}/actions/configSave + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpFabricConfigSavePost"] = Field(default="EpFabricConfigSavePost") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "actions", "configSave") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +__all__ = ["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 00000000..4cb0bed7 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FromClusterMixin, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/actions/deploy +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpFabricDeployPost( + FabricNameMixin, + FromClusterMixin, + NDEndpointBaseModel, +): + """ + POST /api/v1/manage/fabrics/{fabricName}/actions/deploy + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpFabricDeployPost"] = Field(default="EpFabricDeployPost") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "actions", "deploy") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +__all__ = ["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 00000000..8c309f6a --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FilterMixin, + FromClusterMixin, + PaginationMixin, + SortMixin, + ViewMixin, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpFabricSwitchesGet( + FabricNameMixin, + FromClusterMixin, + FilterMixin, + PaginationMixin, + SortMixin, + ViewMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpFabricSwitchesGet"] = Field(default="EpFabricSwitchesGet") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "switches") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpFabricSwitchesGet"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py new file mode 100644 index 00000000..fa352b07 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FromClusterMixin, + SwitchIdMixin, + TicketIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class _EpVpcPairBase( + FabricNameMixin, + SwitchIdMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + base_path = BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPair", + ) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + +class VpcPairGetEndpointParams(FromClusterMixin, EndpointQueryParams): + """Endpoint-specific query parameters for vPC pair GET endpoint.""" + + +class VpcPairPutEndpointParams(VpcPairGetEndpointParams, TicketIdMixin): + """Endpoint-specific query parameters for vPC pair PUT endpoint.""" + + +class EpVpcPairGet(_EpVpcPairBase): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ + + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairGet"] = Field( + default="EpVpcPairGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairGetEndpointParams = Field( + default_factory=VpcPairGetEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +class EpVpcPairPut(_EpVpcPairBase): + """ + PUT /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ + + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairPut"] = Field( + default="EpVpcPairPut", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairPutEndpointParams = Field( + default_factory=VpcPairPutEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.PUT + + +__all__ = [ + "EpVpcPairGet", + "EpVpcPairPut", + "VpcPairGetEndpointParams", + "VpcPairPutEndpointParams", +] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py new file mode 100644 index 00000000..869c408e --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FromClusterMixin, + SwitchIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class VpcPairConsistencyEndpointParams(FromClusterMixin, EndpointQueryParams): + """Endpoint-specific query parameters for vPC pair consistency endpoint.""" + + +class EpVpcPairConsistencyGet( + FabricNameMixin, + SwitchIdMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairConsistencyGet"] = Field( + default="EpVpcPairConsistencyGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairConsistencyEndpointParams = Field( + default_factory=VpcPairConsistencyEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + base_path = BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairConsistency", + ) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairConsistencyGet", "VpcPairConsistencyEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py new file mode 100644 index 00000000..717e3db7 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ComponentTypeMixin, + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class VpcPairOverviewEndpointParams( + FromClusterMixin, + ComponentTypeMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pair overview endpoint.""" + + +class EpVpcPairOverviewGet( + FabricNameMixin, + SwitchIdMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairOverviewGet"] = Field( + default="EpVpcPairOverviewGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairOverviewEndpointParams = Field( + default_factory=VpcPairOverviewEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + base_path = BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairOverview", + ) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairOverviewGet", "VpcPairOverviewEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py new file mode 100644 index 00000000..0822b67a --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FromClusterMixin, + SwitchIdMixin, + UseVirtualPeerLinkMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class VpcPairRecommendationEndpointParams( + FromClusterMixin, + UseVirtualPeerLinkMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pair recommendation endpoint.""" + + # Keep this optional for this endpoint so query param is omitted unless explicitly set. + use_virtual_peer_link: Optional[bool] = Field(default=None, description="Optional virtual peer link flag") + + +class EpVpcPairRecommendationGet( + FabricNameMixin, + SwitchIdMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairRecommendationGet"] = Field( + default="EpVpcPairRecommendationGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairRecommendationEndpointParams = Field( + default_factory=VpcPairRecommendationEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + base_path = BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairRecommendation", + ) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairRecommendationGet", "VpcPairRecommendationEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py new file mode 100644 index 00000000..8732782f --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ComponentTypeMixin, + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class VpcPairSupportEndpointParams( + FromClusterMixin, + ComponentTypeMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pair support endpoint.""" + + +class EpVpcPairSupportGet( + FabricNameMixin, + SwitchIdMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairSupportGet"] = Field( + default="EpVpcPairSupportGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairSupportEndpointParams = Field( + default_factory=VpcPairSupportEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + base_path = BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairSupport", + ) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairSupportGet", "VpcPairSupportEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py new file mode 100644 index 00000000..9fc2dce2 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + 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, + FilterMixin, + FromClusterMixin, + PaginationMixin, + SortMixin, + ViewMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +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 + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/vpcPairs +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class VpcPairsListEndpointParams( + FromClusterMixin, + FilterMixin, + PaginationMixin, + SortMixin, + ViewMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pairs list endpoint.""" + + +class EpVpcPairsListGet( + FabricNameMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/vpcPairs + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairsListGet"] = Field( + default="EpVpcPairsListGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairsListEndpointParams = Field( + default_factory=VpcPairsListEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + base_path = BasePath.path("fabrics", self.fabric_name, "vpcPairs") + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairsListGet", "VpcPairsListEndpointParams"] diff --git a/plugins/module_utils/manage_vpc_pair/__init__.py b/plugins/module_utils/manage_vpc_pair/__init__.py new file mode 100644 index 00000000..cffdaf68 --- /dev/null +++ b/plugins/module_utils/manage_vpc_pair/__init__.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, +) + +__all__ = [ + "ComponentTypeSupportEnum", + "VpcActionEnum", + "VpcFieldNames", + "VpcPairEndpoints", + "VpcPairResourceService", + "VpcPairStateMachine", + "_build_vpc_pair_payload", + "_get_api_field_value", +] + + +def __getattr__(name): + """ + Lazy-load heavy symbols to avoid import-time cycles. + """ + if name in ("VpcPairResourceService", "VpcPairStateMachine"): + from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( + VpcPairResourceService, + VpcPairStateMachine, + ) + + return { + "VpcPairResourceService": VpcPairResourceService, + "VpcPairStateMachine": VpcPairStateMachine, + }[name] + + if name == "VpcPairEndpoints": + from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, + ) + + return VpcPairEndpoints + + if name in ("_build_vpc_pair_payload", "_get_api_field_value"): + from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, + ) + + return { + "_build_vpc_pair_payload": _build_vpc_pair_payload, + "_get_api_field_value": _get_api_field_value, + }[name] + + raise AttributeError("module '{}' has no attribute '{}'".format(__name__, name)) diff --git a/plugins/module_utils/manage_vpc_pair/enums.py b/plugins/module_utils/manage_vpc_pair/enums.py new file mode 100644 index 00000000..6c4c8345 --- /dev/null +++ b/plugins/module_utils/manage_vpc_pair/enums.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2026 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Enums for VPC pair management. + +This module provides enumeration types used throughout the VPC pair +management implementation. + +Note: +- This file does not define API paths. +- Endpoint path mappings are defined by path-based endpoint files under + `plugins/module_utils/endpoints/v1/manage/`. +""" + +from __future__ import absolute_import, division, print_function + +__author__ = "Sivakami Sivaraman" + +from enum import Enum + +# Import HttpVerbEnum from top-level enums module (RestSend infrastructure) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +# Backward compatibility alias - Use HttpVerbEnum directly in new code +VerbEnum = HttpVerbEnum + + +# ============================================================================ +# VPC ACTION ENUMS +# ============================================================================ + + +class VpcActionEnum(str, Enum): + """ + VPC pair action types for discriminator pattern. + + Used in API payloads to distinguish between pair/unpair operations. + Values must match OpenAPI discriminator mapping exactly: + - "pair" (lowercase) for pairing operations + - "unPair" (camelCase) for unpairing operations + """ + + PAIR = "pair" # Create or update VPC pair (lowercase per OpenAPI spec) + UNPAIR = "unPair" # Delete VPC pair (camelCase per OpenAPI spec) + + +# ============================================================================ +# TEMPLATE AND CONFIGURATION ENUMS +# ============================================================================ + + +class VpcPairTypeEnum(str, Enum): + """ + VPC pair template types. + + Discriminator for vpc_pair_details field. + """ + + DEFAULT = "default" # Use default VPC pair template + CUSTOM = "custom" # Use custom VPC pair template + + +class KeepAliveVrfEnum(str, Enum): + """ + VPC keep-alive VRF options. + + VRF used for vPC keep-alive link traffic. + """ + + DEFAULT = "default" # Use default VRF + MANAGEMENT = "management" # Use management VRF + + +class PoModeEnum(str, Enum): + """ + Port-channel mode options for vPC interfaces. + + Defines LACP behavior. + """ + + ON = "on" # Static channel mode (no LACP) + ACTIVE = "active" # LACP active mode (initiates negotiation) + PASSIVE = "passive" # LACP passive mode (waits for negotiation) + + +class PortChannelDuplexEnum(str, Enum): + """ + Port-channel duplex mode options. + """ + + HALF = "half" # Half duplex mode + FULL = "full" # Full duplex mode + + +# ============================================================================ +# VPC ROLE AND STATUS ENUMS +# ============================================================================ + + +class VpcRoleEnum(str, Enum): + """ + VPC role designation for switches in a vPC pair. + """ + + PRIMARY = "primary" # Configured primary peer + SECONDARY = "secondary" # Configured secondary peer + OPERATIONAL_PRIMARY = "operationalPrimary" # Runtime primary role + OPERATIONAL_SECONDARY = "operationalSecondary" # Runtime secondary role + + +class MaintenanceModeEnum(str, Enum): + """ + Switch maintenance mode status. + """ + + MAINTENANCE = "maintenance" # Switch in maintenance mode + NORMAL = "normal" # Switch in normal operation + + +# ============================================================================ +# QUERY AND VIEW ENUMS +# ============================================================================ + + +class ComponentTypeOverviewEnum(str, Enum): + """ + VPC pair overview component types. + + Used for filtering overview endpoint responses. + """ + + FULL = "full" # Full overview with all components + HEALTH = "health" # Health status only + MODULE = "module" # Module information only + VXLAN = "vxlan" # VXLAN configuration only + OVERLAY = "overlay" # Overlay information only + PAIRS_INFO = "pairsInfo" # Pairs information only + INVENTORY = "inventory" # Inventory information only + ANOMALIES = "anomalies" # Anomalies information only + + +class ComponentTypeSupportEnum(str, Enum): + """ + VPC pair support check types. + + Used for validation endpoints. + """ + + CHECK_PAIRING = "checkPairing" # Check if pairing is allowed + CHECK_FABRIC_PEERING_SUPPORT = "checkFabricPeeringSupport" # Check fabric support + + +class VpcPairViewEnum(str, Enum): + """ + VPC pairs list view options. + + Controls which VPC pairs are returned in queries. + """ + + INTENDED_PAIRS = "intendedPairs" # Show intended VPC pairs + DISCOVERED_PAIRS = "discoveredPairs" # Show discovered VPC pairs (default) + + +# ============================================================================ +# API FIELD NAME CONSTANTS (Not Enums - Used as Dict Keys) +# ============================================================================ + + +class VpcFieldNames: + """ + API field name constants for VPC pair operations. + + These are string constants, not enums, because they're used as + dictionary keys in API payloads and responses. + + Centralized to: + - Eliminate magic strings + - Enable IDE autocomplete + - Prevent typos + - Easy refactoring + """ + + # VPC Action Discriminator Field + VPC_ACTION = "vpcAction" + + # Primary Identifier Fields (API format) + SWITCH_ID = "switchId" + PEER_SWITCH_ID = "peerSwitchId" + USE_VIRTUAL_PEER_LINK = "useVirtualPeerLink" + + # Ansible Playbook Fields (user input aliases) + ANSIBLE_PEER1_SWITCH_ID = "peer1SwitchId" + ANSIBLE_PEER2_SWITCH_ID = "peer2SwitchId" + + # Configuration Fields + VPC_PAIR_DETAILS = "vpcPairDetails" + DOMAIN_ID = "domainId" + SWITCH_NAME = "switchName" + PEER_SWITCH_NAME = "peerSwitchName" + TEMPLATE_NAME = "templateName" + TEMPLATE_TYPE = "type" + + # Status Fields (for query responses) + VPC_CONFIGURED = "vpcConfigured" + CONFIG_SYNC_STATUS = "configSyncStatus" + CURRENT_PEER = "currentPeer" + IS_CURRENT_PEER = "isCurrentPeer" + IS_CONSISTENT = "isConsistent" + IS_DISCOVERED = "isDiscovered" + + # Response Keys + VPC_PAIRS = "vpcPairs" + SWITCHES = "switches" + DATA = "data" + VPC_DATA = "vpcData" + + # Network Fields + FABRIC_MGMT_IP = "fabricManagementIp" + SERIAL_NUMBER = "serialNumber" + IP_ADDRESS = "ipAddress" + + # Validation Fields (for pre-deletion checks) + OVERLAY = "overlay" + INVENTORY = "inventory" + NETWORK_COUNT = "networkCount" + VRF_COUNT = "vrfCount" + VPC_INTERFACE_COUNT = "vpcInterfaceCount" + + # Template Detail Fields + KEEP_ALIVE_VRF = "keepAliveVrf" + PEER_KEEPALIVE_DEST = "peerKeepAliveDest" + PEER_GATEWAY_ENABLE = "peerGatewayEnable" + AUTO_RECOVERY_ENABLE = "autoRecoveryEnable" + DELAY_RESTORE = "delayRestore" + DELAY_RESTORE_TIME = "delayRestoreTime" + + # Port-Channel Fields + PO_MODE = "poMode" + PO_SPEED = "poSpeed" + PO_DESCRIPTION = "poDescription" + PO_DUPLEX = "poDuplex" + PO_MTU = "poMtu" diff --git a/plugins/module_utils/manage_vpc_pair/resources.py b/plugins/module_utils/manage_vpc_pair/resources.py new file mode 100644 index 00000000..6d0e7e8c --- /dev/null +++ b/plugins/module_utils/manage_vpc_pair/resources.py @@ -0,0 +1,578 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +import json +from typing import Any, Callable, Dict, List, Optional + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( + NDStateMachine, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import ( + NDConfigCollection, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vpc_pair import ( + VpcPairOrchestrator, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) + +""" +State-machine resource service for nd_manage_vpc_pair. + +Note: +- This file does not define endpoint paths directly. +- Runtime endpoint path usage is centralized in `vpc_pair_runtime_endpoints.py`. +""" + + +RunStateHandler = Callable[[Any], Dict[str, Any]] +DeployHandler = Callable[[Any, str, Dict[str, Any]], Dict[str, Any]] +NeedsDeployHandler = Callable[[Dict[str, Any], Any], bool] + + +class VpcPairStateMachine(NDStateMachine): + """NDStateMachine adapter with state handling for nd_manage_vpc_pair.""" + + def __init__(self, module: AnsibleModule): + """ + Initialize VpcPairStateMachine. + + Creates the underlying NDStateMachine with VpcPairOrchestrator, binds + the state machine back to the orchestrator, and initializes log/result + containers. + + Args: + module: AnsibleModule instance with validated params + """ + super().__init__(module=module, model_orchestrator=VpcPairOrchestrator) + self.model_orchestrator.bind_state_machine(self) + + self.current_identifier = None + self.existing_config: Dict[str, Any] = {} + self.proposed_config: Dict[str, Any] = {} + self.logs: List[Dict[str, Any]] = [] + self.result: Dict[str, Any] = {} + + def format_log( + self, + identifier: Any, + status: str, + before_data: Optional[Any] = None, + after_data: Optional[Any] = None, + sent_payload_data: Optional[Any] = None, + ) -> None: + """ + Collect operation log entries expected by nd_manage_vpc_pair flows. + + Args: + identifier: Pair identifier tuple (switch_id, peer_switch_id) + status: Operation status (created, updated, deleted, no_change) + before_data: Optional before-state dict for the pair + after_data: Optional after-state dict for the pair + sent_payload_data: Optional API payload that was sent + """ + log_entry: Dict[str, Any] = {"identifier": identifier, "status": status} + if before_data is not None: + log_entry["before"] = before_data + if after_data is not None: + log_entry["after"] = after_data + if sent_payload_data is not None: + log_entry["sent_payload"] = sent_payload_data + self.logs.append(log_entry) + + def add_logs_and_outputs(self) -> None: + """ + Build final result payload compatible with nd_manage_vpc_pair runtime. + + Refreshes after-state from controller, walks all log entries to build + the output dict with before, after, current, diff, created, deleted, + updated lists. Populates self.result with the final Ansible output. + """ + self._refresh_after_state() + self.output.assign( + after=getattr(self, "existing", None), + before=getattr(self, "before", None), + proposed=getattr(self, "proposed", None), + logs=self.logs, + ) + + formatted = self.output.format() + formatted.setdefault("current", formatted.get("after", [])) + formatted.setdefault("response", []) + formatted.setdefault("result", []) + class_diff = self._build_class_diff() + changed_by_class_diff = bool( + class_diff["created"] or class_diff["deleted"] or class_diff["updated"] + ) + formatted["changed"] = bool(formatted.get("changed")) or changed_by_class_diff + formatted["created"] = class_diff["created"] + formatted["deleted"] = class_diff["deleted"] + formatted["updated"] = class_diff["updated"] + formatted["class_diff"] = class_diff + + if self.logs and "logs" not in formatted: + formatted["logs"] = self.logs + self.result = formatted + + def _refresh_after_state(self) -> None: + """ + Optionally refresh the final "after" state from controller query. + + Enabled by default for write states to better reflect live controller + state. Can be disabled for performance-sensitive runs via + suppress_verification or refresh_after_apply params. + + Skipped when: + - State is gathered (read-only) + - Running in check mode + - suppress_verification is True + - refresh_after_apply is False + """ + state = self.module.params.get("state") + if state not in ("merged", "replaced", "overridden", "deleted"): + return + if self.module.check_mode: + return + if self.module.params.get("suppress_verification", False): + return + if not self.module.params.get("refresh_after_apply", True): + return + if self.logs and not any( + log.get("status") in ("created", "updated", "deleted") + for log in self.logs + ): + # Skip refresh for pure no-op runs to avoid false changed flips from + # stale/synthetic before-state fallbacks. + return + + refresh_timeout = self.module.params.get("refresh_after_timeout") + had_original_timeout = "query_timeout" in self.module.params + original_timeout = self.module.params.get("query_timeout") + + try: + if refresh_timeout is not None: + self.module.params["query_timeout"] = refresh_timeout + response_data = self.model_orchestrator.query_all() + self.existing = NDConfigCollection.from_api_response( + response_data=response_data, + model_class=self.model_class, + ) + except Exception as exc: + self.module.warn( + f"Failed to refresh final after-state from controller query: {exc}" + ) + finally: + if refresh_timeout is not None: + if had_original_timeout: + self.module.params["query_timeout"] = original_timeout + else: + self.module.params.pop("query_timeout", None) + + @staticmethod + def _identifier_to_key(identifier: Any) -> str: + """ + Build a stable key for de-duplicating identifiers in class diff output. + + Args: + identifier: Pair identifier (tuple, string, or any serializable value) + + Returns: + JSON string representation of the identifier for use as dict key. + """ + try: + return json.dumps(identifier, sort_keys=True, default=str) + except Exception: + return str(identifier) + + @staticmethod + def _extract_changed_properties(log_entry: Dict[str, Any]) -> List[str]: + """ + Best-effort changed-property extraction for update operations. + + Args: + log_entry: Single log entry dict with before/after/sent_payload keys + + Returns: + Sorted list of property names that changed between before and after. + Falls back to sent_payload keys if before/after comparison yields nothing. + """ + before = log_entry.get("before") + after = log_entry.get("after") + sent_payload = log_entry.get("sent_payload") + + changed = [] + if isinstance(before, dict) and isinstance(after, dict): + all_keys = set(before.keys()) | set(after.keys()) + changed = [key for key in all_keys if before.get(key) != after.get(key)] + + if not changed and isinstance(sent_payload, dict): + changed = list(sent_payload.keys()) + + return sorted(set(changed)) + + def _build_class_diff(self) -> Dict[str, List[Any]]: + """ + Build class-level diff with created/deleted/updated entries. + + Walks all log entries, deduplicates by identifier key, and sorts each + into created/deleted/updated buckets based on operation status. + + Returns: + Dict with 'created', 'deleted', 'updated' lists of identifiers. + """ + created: List[Any] = [] + deleted: List[Any] = [] + updated: List[Dict[str, Any]] = [] + + created_seen = set() + deleted_seen = set() + updated_map: Dict[str, Dict[str, Any]] = {} + + for log_entry in self.logs: + status = log_entry.get("status") + identifier = log_entry.get("identifier") + key = self._identifier_to_key(identifier) + + if status == "created": + if key not in created_seen: + created_seen.add(key) + created.append(identifier) + elif status == "deleted": + if key not in deleted_seen: + deleted_seen.add(key) + deleted.append(identifier) + elif status == "updated": + changed_props = self._extract_changed_properties(log_entry) + entry = updated_map.get(key) + if entry is None: + entry = {"identifier": identifier} + if changed_props: + entry["changed_properties"] = changed_props + updated_map[key] = entry + elif changed_props: + merged = set(entry.get("changed_properties", [])) | set(changed_props) + entry["changed_properties"] = sorted(merged) + + updated.extend(updated_map.values()) + return {"created": created, "deleted": deleted, "updated": updated} + + def manage_state( + self, + state: str, + new_configs: List[Dict[str, Any]], + unwanted_keys: Optional[List] = None, + override_exceptions: Optional[List] = None, + ) -> None: + """ + Execute state reconciliation for the given state and config items. + + Builds proposed and previous NDConfigCollection objects, then dispatches + to create/update or delete handlers based on state. + + Args: + state: Desired state (merged, replaced, overridden, deleted) + new_configs: List of config dicts from playbook + unwanted_keys: Optional keys to exclude from diff comparison + override_exceptions: Optional identifiers to skip during override deletions + + Raises: + VpcPairResourceError: On validation or processing failures + """ + unwanted_keys = unwanted_keys or [] + override_exceptions = override_exceptions or [] + + self.state = state + if hasattr(self, "params") and isinstance(getattr(self, "params"), dict): + self.params["state"] = state + else: + self.module.params["state"] = state + self.ansible_config = new_configs or [] + + try: + self.proposed = NDConfigCollection.from_ansible_config( + data=self.ansible_config, + model_class=self.model_class, + ) + self.previous = self.existing.copy() + except Exception as e: + if isinstance(e, VpcPairResourceError): + raise + error_details = {"error": str(e)} + if hasattr(e, "errors"): + error_details["validation_errors"] = e.errors() + raise VpcPairResourceError( + msg=f"Failed to prepare configurations: {e}", + **error_details, + ) + + if state in ["merged", "replaced", "overridden"]: + self._manage_create_update_state(state, unwanted_keys) + if state == "overridden": + self._manage_override_deletions(override_exceptions) + elif state == "deleted": + self._manage_delete_state() + else: + raise VpcPairResourceError(msg=f"Invalid state: {state}") + + def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: + """ + Process proposed config items for create or update operations. + + Loops over each proposed config item, diffs against existing state. + Creates new pairs, updates changed pairs, and skips unchanged pairs. + + Args: + state: Current state (merged, replaced, overridden) + unwanted_keys: Keys to exclude from diff comparison + + Raises: + VpcPairResourceError: If create/update fails for an item + """ + for proposed_item in self.proposed: + identifier = proposed_item.get_identifier_value() + try: + self.current_identifier = identifier + + existing_item = self.existing.get(identifier) + self.existing_config = ( + existing_item.model_dump(by_alias=True, exclude_none=True) + if existing_item + else {} + ) + + try: + diff_status = self.existing.get_diff_config( + proposed_item, unwanted_keys=unwanted_keys + ) + except TypeError: + diff_status = self.existing.get_diff_config(proposed_item) + + if diff_status == "no_diff": + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config, + ) + continue + + if state == "merged" and existing_item: + final_item = self.existing.merge(proposed_item) + else: + if existing_item: + self.existing.replace(proposed_item) + else: + self.existing.add(proposed_item) + final_item = proposed_item + + self.proposed_config = final_item.to_payload() + + if diff_status == "changed": + response = self.model_orchestrator.update(final_item) + operation_status = "updated" + else: + response = self.model_orchestrator.create(final_item) + operation_status = "created" + + self.sent.add(final_item) + if not self.module.check_mode: + sent_payload = self.proposed_config + else: + sent_payload = None + + self.format_log( + identifier=identifier, + status=operation_status, + after_data=( + response + if not self.module.check_mode + else final_item.model_dump(by_alias=True, exclude_none=True) + ), + sent_payload_data=sent_payload, + ) + except VpcPairResourceError as e: + # Preserve detailed context from vPC handlers instead of losing + # it in generic state-machine wrapping layers. + error_msg = f"Failed to process {identifier}: {e.msg}" + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config, + ) + if not self.module.params.get("ignore_errors", False): + error_details = dict(getattr(e, "details", {}) or {}) + error_details.setdefault("identifier", str(identifier)) + error_details.setdefault("error", str(e)) + raise VpcPairResourceError(msg=error_msg, **error_details) + except Exception as e: + error_msg = f"Failed to process {identifier}: {e}" + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config, + ) + if not self.module.params.get("ignore_errors", False): + raise VpcPairResourceError( + msg=error_msg, + identifier=str(identifier), + error=str(e), + ) + + def _manage_override_deletions(self, override_exceptions: List) -> None: + """ + Delete pairs that exist on controller but are not in proposed config. + + Used by overridden state to remove unspecified pairs. + + Args: + override_exceptions: List of identifiers to skip (not delete) + + Raises: + VpcPairResourceError: If deletion fails for a pair + """ + diff_identifiers = self.previous.get_diff_identifiers(self.proposed) + for identifier in diff_identifiers: + if identifier in override_exceptions: + continue + + try: + self.current_identifier = identifier + existing_item = self.existing.get(identifier) + if not existing_item: + continue + self.existing_config = existing_item.model_dump( + by_alias=True, exclude_none=True + ) + delete_changed = self.model_orchestrator.delete(existing_item) + if delete_changed is not False: + self.existing.delete(identifier) + self.format_log( + identifier=identifier, + status="deleted" if delete_changed is not False else "no_change", + after_data={} if delete_changed is not False else self.existing_config, + ) + except VpcPairResourceError as e: + error_msg = f"Failed to delete {identifier}: {e.msg}" + if not self.module.params.get("ignore_errors", False): + error_details = dict(getattr(e, "details", {}) or {}) + error_details.setdefault("identifier", str(identifier)) + error_details.setdefault("error", str(e)) + raise VpcPairResourceError(msg=error_msg, **error_details) + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + raise VpcPairResourceError( + msg=error_msg, + identifier=str(identifier), + error=str(e), + ) + + def _manage_delete_state(self) -> None: + """ + Process proposed config items for delete operations. + + Loops over each proposed delete item, finds matching existing pair, + calls orchestrator.delete(), and removes from collection. + + Raises: + VpcPairResourceError: If deletion fails for an item + """ + for proposed_item in self.proposed: + identifier = proposed_item.get_identifier_value() + try: + self.current_identifier = identifier + existing_item = self.existing.get(identifier) + if not existing_item: + self.format_log(identifier=identifier, status="no_change", after_data={}) + continue + + self.existing_config = existing_item.model_dump( + by_alias=True, exclude_none=True + ) + delete_changed = self.model_orchestrator.delete(existing_item) + if delete_changed is not False: + self.existing.delete(identifier) + self.format_log( + identifier=identifier, + status="deleted" if delete_changed is not False else "no_change", + after_data={} if delete_changed is not False else self.existing_config, + ) + except VpcPairResourceError as e: + error_msg = f"Failed to delete {identifier}: {e.msg}" + if not self.module.params.get("ignore_errors", False): + error_details = dict(getattr(e, "details", {}) or {}) + error_details.setdefault("identifier", str(identifier)) + error_details.setdefault("error", str(e)) + raise VpcPairResourceError(msg=error_msg, **error_details) + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + raise VpcPairResourceError( + msg=error_msg, + identifier=str(identifier), + error=str(e), + ) + + +class VpcPairResourceService: + """ + Runtime service for nd_manage_vpc_pair execution flow. + + Orchestrates state management and optional deployment while keeping module + entrypoint thin. + """ + + def __init__( + self, + module: AnsibleModule, + run_state_handler: RunStateHandler, + deploy_handler: DeployHandler, + needs_deployment_handler: NeedsDeployHandler, + ): + """ + Initialize VpcPairResourceService. + + Args: + module: AnsibleModule instance with validated params + run_state_handler: Callback for state execution (run_vpc_module) + deploy_handler: Callback for deployment (custom_vpc_deploy) + needs_deployment_handler: Callback to check if deploy is needed (_needs_deployment) + """ + self.module = module + self.run_state_handler = run_state_handler + self.deploy_handler = deploy_handler + self.needs_deployment_handler = needs_deployment_handler + + def execute(self, fabric_name: str) -> Dict[str, Any]: + """ + Execute the full vpc_pair module lifecycle. + + Creates VpcPairStateMachine, runs state handler, optionally deploys. + + Args: + fabric_name: Fabric name to operate on + + Returns: + Dict with complete module result including before, after, current, + changed, deployment info, and ip_to_sn_mapping. + """ + nd_manage_vpc_pair = VpcPairStateMachine(module=self.module) + result = self.run_state_handler(nd_manage_vpc_pair) + + if "_ip_to_sn_mapping" in self.module.params: + result["ip_to_sn_mapping"] = self.module.params["_ip_to_sn_mapping"] + + deploy = self.module.params.get("deploy", False) + if deploy: + deploy_result = self.deploy_handler(nd_manage_vpc_pair, fabric_name, result) + result["deployment"] = deploy_result + result["deployment_needed"] = deploy_result.get( + "deployment_needed", + self.needs_deployment_handler(result, nd_manage_vpc_pair), + ) + + return result diff --git a/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py new file mode 100644 index 00000000..708ba268 --- /dev/null +++ b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + CompositeQueryParams, + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, +) +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_switches_vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( + EpVpcPairOverviewGet, + VpcPairOverviewEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( + EpVpcPairSupportGet, + VpcPairSupportEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( + EpVpcPairsListGet, +) +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, +) + + +class _ForceShowRunQueryParams(EndpointQueryParams): + """Query params for deploy endpoint.""" + + force_show_run: Optional[bool] = None + + +class VpcPairEndpoints: + """ + Centralized endpoint builders for vPC pair runtime operations. + + Runtime helper -> API path: + - vpc_pairs_list/vpc_pair_base -> /api/v1/manage/fabrics/{fabricName}/vpcPairs + - switch_vpc_pair/vpc_pair_put -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + - switch_vpc_support -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport + - switch_vpc_overview -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview + - switch_vpc_recommendations -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation + - switch_vpc_consistency -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency + - fabric_config_save -> /api/v1/manage/fabrics/{fabricName}/actions/configSave + - fabric_config_deploy -> /api/v1/manage/fabrics/{fabricName}/actions/deploy + """ + + @staticmethod + def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: + """ + Append query parameters to an endpoint path. + + Args: + path: Base URL path + *query_groups: One or more EndpointQueryParams to serialize + + Returns: + Path with query string appended, or original path if no params. + """ + composite_params = CompositeQueryParams() + for query_group in query_groups: + composite_params.add(query_group) + query_string = composite_params.to_query_string(url_encode=False) + return f"{path}?{query_string}" if query_string else path + + @staticmethod + def vpc_pair_base(fabric_name: str) -> str: + """ + Build base path for vPC pairs list endpoint. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/vpcPairs + """ + endpoint = EpVpcPairsListGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def vpc_pairs_list(fabric_name: str) -> str: + """ + Build path for listing all vPC pairs in a fabric. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/vpcPairs + """ + endpoint = EpVpcPairsListGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def vpc_pair_put(fabric_name: str, switch_id: str) -> str: + """ + Build path for PUT (create/update/delete) on a switch vPC pair. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ + endpoint = EpVpcPairPut(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def fabric_switches(fabric_name: str) -> str: + """ + Build path for querying fabric switch inventory. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/switches + """ + endpoint = EpFabricSwitchesGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: + """ + Build path for GET/PUT on a specific switch vPC pair. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ + endpoint = EpVpcPairGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def switch_vpc_recommendations(fabric_name: str, switch_id: str) -> str: + """ + Build path for querying vPC pair recommendations for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: .../switches/{switchId}/vpcPairRecommendation + """ + endpoint = EpVpcPairRecommendationGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = "full") -> str: + """ + Build path for querying vPC pair overview for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Overview filter (default: "full") + + Returns: + Path: .../switches/{switchId}/vpcPairOverview?componentType={type} + """ + endpoint = EpVpcPairOverviewGet( + fabric_name=fabric_name, + switch_id=switch_id, + endpoint_params=VpcPairOverviewEndpointParams(component_type=component_type), + ) + return endpoint.path + + @staticmethod + def switch_vpc_support( + fabric_name: str, + switch_id: str, + component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, + ) -> str: + """ + Build path for querying vPC pair support status for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Support check type (default: checkPairing) + + Returns: + Path: .../switches/{switchId}/vpcPairSupport?componentType={type} + """ + endpoint = EpVpcPairSupportGet( + fabric_name=fabric_name, + switch_id=switch_id, + endpoint_params=VpcPairSupportEndpointParams(component_type=component_type), + ) + return endpoint.path + + @staticmethod + def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: + """ + Build path for querying vPC pair consistency diagnostics. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: .../switches/{switchId}/vpcPairConsistency + """ + endpoint = EpVpcPairConsistencyGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def fabric_config_save(fabric_name: str) -> str: + """ + Build path for fabric config-save action. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/actions/configSave + """ + endpoint = EpFabricConfigSavePost(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: + """ + Build path for fabric deploy action. + + Args: + fabric_name: Fabric name + force_show_run: Whether to include forceShowRun query param (default: True) + + Returns: + Path: .../fabrics/{fabricName}/actions/deploy?forceShowRun=true + """ + endpoint = EpFabricDeployPost(fabric_name=fabric_name) + base_path = endpoint.path + query_params = _ForceShowRunQueryParams( + force_show_run=True if force_show_run else None + ) + return VpcPairEndpoints._append_query(base_path, query_params) diff --git a/plugins/module_utils/manage_vpc_pair/runtime_payloads.py b/plugins/module_utils/manage_vpc_pair/runtime_payloads.py new file mode 100644 index 00000000..16ad5a47 --- /dev/null +++ b/plugins/module_utils/manage_vpc_pair/runtime_payloads.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Any, Dict, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + VpcActionEnum, + VpcFieldNames, +) + +""" +Payload helpers for vPC runtime operations. + +Note: +- This file builds request/response payload structures only. +- Endpoint paths are resolved in `vpc_pair_runtime_endpoints.py`. +""" + + +def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: + """ + Extract template configuration from a vPC pair model if present. + + Args: + vpc_pair_model: VpcPairModel instance with optional vpc_pair_details field + + Returns: + Dict with serialized template config, or None if not present. + """ + if not hasattr(vpc_pair_model, "vpc_pair_details"): + return None + + vpc_pair_details = vpc_pair_model.vpc_pair_details + if not vpc_pair_details: + return None + + return vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + + +def _build_vpc_pair_payload(vpc_pair_model) -> Dict[str, Any]: + """ + Build pair payload with vpcAction discriminator for ND 4.2 APIs. + + Args: + vpc_pair_model: VpcPairModel instance or dict with switchId, + peerSwitchId, useVirtualPeerLink fields + + Returns: + Dict with vpcAction, switchId, peerSwitchId, useVirtualPeerLink, + and optional vpcPairDetails keys. + """ + if isinstance(vpc_pair_model, dict): + switch_id = vpc_pair_model.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = vpc_pair_model.get(VpcFieldNames.PEER_SWITCH_ID) + use_virtual_peer_link = vpc_pair_model.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + else: + switch_id = vpc_pair_model.switch_id + peer_switch_id = vpc_pair_model.peer_switch_id + use_virtual_peer_link = vpc_pair_model.use_virtual_peer_link + + payload = { + VpcFieldNames.VPC_ACTION: VpcActionEnum.PAIR.value, + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, + } + + if not isinstance(vpc_pair_model, dict): + template_config = _get_template_config(vpc_pair_model) + if template_config: + payload[VpcFieldNames.VPC_PAIR_DETAILS] = template_config + + return payload + + +# ND API versions use inconsistent field names. This mapping keeps one lookup API. +API_FIELD_ALIASES = { + "useVirtualPeerLink": ["useVirtualPeerlink"], + "serialNumber": ["serial_number", "serialNo"], +} + + +def _get_api_field_value(api_response: Dict[str, Any], field_name: str, default=None): + """ + Get a field value across known ND API naming aliases. + + Args: + api_response: API response dict to search + field_name: Primary field name to look up + default: Default value if field not found in any alias + + Returns: + Field value from the response, or default if not found. + """ + if not isinstance(api_response, dict): + return default + + if field_name in api_response: + return api_response[field_name] + + aliases = API_FIELD_ALIASES.get(field_name, []) + for alias in aliases: + if alias in api_response: + return api_response[alias] + + return default diff --git a/plugins/module_utils/models/manage_vpc_pair/__init__.py b/plugins/module_utils/models/manage_vpc_pair/__init__.py new file mode 100644 index 00000000..98b04d6c --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( # noqa: F401 + VpcPairPlaybookConfigModel, + VpcPairPlaybookItemModel, + VpcPairModel, +) diff --git a/plugins/module_utils/models/manage_vpc_pair/base.py b/plugins/module_utils/models/manage_vpc_pair/base.py new file mode 100644 index 00000000..419a9251 --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/base.py @@ -0,0 +1,258 @@ +# -*- 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, division, print_function + +__metaclass__ = type + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Dict, List, Literal, Tuple, Union, Annotated +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + BeforeValidator, + ConfigDict, +) +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset +from typing_extensions import Self + + +def coerce_str_to_int(data): + """ + Convert string to int, handle None. + + Args: + data: Value to coerce (str, int, or None) + + Returns: + Integer value, or None if input is None. + + Raises: + ValueError: If string cannot be converted to int + """ + if data is None: + return None + if isinstance(data, str): + if data.strip() and data.lstrip("-").isdigit(): + return int(data) + raise ValueError(f"Cannot convert '{data}' to int") + return int(data) + + +def coerce_to_bool(data): + """ + Convert various formats to bool. + + Args: + data: Value to coerce (str, bool, int, or None) + + Returns: + Boolean value, or None if input is None. + Strings 'true', '1', 'yes', 'on' map to True. + """ + if data is None: + return None + if isinstance(data, str): + return data.lower() in ("true", "1", "yes", "on") + return bool(data) + + +def coerce_list_of_str(data): + """ + Ensure data is a list of strings. + + Args: + data: Value to coerce (str, list, or None) + + Returns: + List of strings, or None if input is None. + Comma-separated strings are split into list items. + """ + if data is None: + return None + if isinstance(data, str): + return [item.strip() for item in data.split(",") if item.strip()] + if isinstance(data, list): + return [str(item) for item in data] + return data + + +FlexibleInt = Annotated[int, BeforeValidator(coerce_str_to_int)] +FlexibleBool = Annotated[bool, BeforeValidator(coerce_to_bool)] +FlexibleListStr = Annotated[List[str], BeforeValidator(coerce_list_of_str)] + + +class NDVpcPairBaseModel(BaseModel, ABC): + """ + Base model for VPC pair objects with identifiers. + + Similar to NDBaseModel from base.py but specific to VPC pair resources. + """ + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + extra="ignore", + ) + + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + exclude_from_diff: ClassVar[List[str]] = [] + + @abstractmethod + def to_payload(self) -> Dict[str, Any]: + """ + Convert model to API payload format. + + Returns: + Dict with camelCase API field names. + """ + pass + + @classmethod + @abstractmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """ + Create model instance from API response. + + Args: + response: Dict from ND API response + + Returns: + Validated model instance. + """ + pass + + def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: + """ + Extract identifier value(s) from this instance. + + Uses the configured identifier_strategy (single, composite, or hierarchical) + to determine how to extract and return the identifier. + + Returns: + Single value for 'single' strategy, tuple for 'composite', + or (field_name, value) tuple for 'hierarchical'. + + Raises: + ValueError: If identifiers are not defined, required fields are None, + or strategy is unknown. + """ + if not self.identifiers: + raise ValueError(f"{self.__class__.__name__} has no identifiers defined") + + if self.identifier_strategy == "single": + value = getattr(self, self.identifiers[0], None) + if value is None: + raise ValueError(f"Single identifier field '{self.identifiers[0]}' is None") + return value + + if self.identifier_strategy == "composite": + values = [] + missing = [] + + for field in self.identifiers: + value = getattr(self, field, None) + if value is None: + missing.append(field) + values.append(value) + + if missing: + raise ValueError( + f"Composite identifier fields {missing} are None. All required: {self.identifiers}" + ) + + return tuple(values) + + if self.identifier_strategy == "hierarchical": + for field in self.identifiers: + value = getattr(self, field, None) + if value is not None: + return (field, value) + + raise ValueError(f"No non-None value in hierarchical fields {self.identifiers}") + + raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") + + def get_switch_pair_key(self) -> str: + """ + Generate a unique key for VPC pair (sorted switch IDs). + + Returns: + Deterministic "ID1-ID2" string with sorted switch serial numbers. + + Raises: + ValueError: If identifier_strategy is not composite with 2 identifiers + """ + if self.identifier_strategy != "composite" or len(self.identifiers) != 2: + raise ValueError( + "get_switch_pair_key only works with composite strategy and 2 identifiers" + ) + + values = self.get_identifier_value() + sorted_ids = sorted([str(v) for v in values]) + return f"{sorted_ids[0]}-{sorted_ids[1]}" + + def to_diff_dict(self) -> Dict[str, Any]: + """ + Export for diff comparison (excludes sensitive fields). + + Returns: + Dict with alias keys, excluding None and exclude_from_diff fields. + """ + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=set(self.exclude_from_diff), + ) + + def get_diff(self, other: "NDVpcPairBaseModel") -> bool: + """ + Return True when ``other`` is a subset of this model for diff checks. + + Args: + other: Model instance to compare against + + Returns: + True if other's diff dict is a subset of self's diff dict. + """ + self_data = self.to_diff_dict() + other_data = other.to_diff_dict() + return issubset(other_data, self_data) + + def merge(self, other: "NDVpcPairBaseModel") -> "NDVpcPairBaseModel": + """ + Merge another model's non-None values into this model instance. + + Nested NDVpcPairBaseModel values are merged recursively. + + Args: + other: Model instance whose non-None fields overwrite this model + + Returns: + Self with merged values. + + Raises: + TypeError: If other is not the same type as self + """ + if not isinstance(other, type(self)): + raise TypeError( + f"Cannot merge {type(other).__name__} into {type(self).__name__}. " + "Both must be the same type." + ) + + for field_name, value in other: + if value is None: + continue + + current = getattr(self, field_name, None) + if isinstance(current, NDVpcPairBaseModel) and isinstance(value, NDVpcPairBaseModel): + current.merge(value) + else: + setattr(self, field_name, value) + + return self diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py new file mode 100644 index 00000000..d41074fd --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -0,0 +1,481 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Any, ClassVar, Dict, List, Literal, Optional, Union + +try: + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( + NDVpcPairBaseModel as _VpcPairBaseModel, + ) +except ImportError: + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel as _VpcPairBaseModel, + ) + +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.manage_vpc_pair.enums import ( + VpcFieldNames, +) + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.vpc_pair_models import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, +) + + +class VpcPairModel(_VpcPairBaseModel): + """ + Pydantic model for nd_manage_vpc_pair input. + + Uses a composite identifier `(switch_id, peer_switch_id)` and module-oriented + defaults/validation behavior. + """ + + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["composite"]] = "composite" + exclude_from_diff: ClassVar[List[str]] = [] + + 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", + ) + + switch_id: str = Field( + alias=VpcFieldNames.SWITCH_ID, + description="Peer-1 switch serial number or management IP address", + min_length=3, + max_length=64, + ) + peer_switch_id: str = Field( + alias=VpcFieldNames.PEER_SWITCH_ID, + description="Peer-2 switch serial number or management IP address", + min_length=3, + max_length=64, + ) + use_virtual_peer_link: bool = Field( + default=True, + alias=VpcFieldNames.USE_VIRTUAL_PEER_LINK, + description="Virtual peer link enabled", + ) + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, + discriminator="type", + alias=VpcFieldNames.VPC_PAIR_DETAILS, + description="VPC pair configuration details (default or custom template)", + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Raw switch ID string + + Returns: + Stripped switch ID string. + + Raises: + ValueError: If switch ID is empty or whitespace-only + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> "VpcPairModel": + """ + Validate that switch_id and peer_switch_id are not the same. + + Returns: + Self if validation passes. + + Raises: + ValueError: If both switch IDs are identical + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """ + Serialize model to camelCase API payload dict. + + Returns: + Dict with alias (camelCase) keys, excluding None values. + """ + return self.model_dump(by_alias=True, exclude_none=True) + + def to_diff_dict(self) -> Dict[str, Any]: + """ + Serialize model for diff comparison, excluding configured fields. + + Returns: + Dict with alias keys, excluding None and exclude_from_diff fields. + """ + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=set(self.exclude_from_diff), + ) + + def get_identifier_value(self): + """ + Return the unique identifier for this vPC pair. + + Returns: + Tuple of sorted (switch_id, peer_switch_id) for order-independent matching. + """ + return tuple(sorted([self.switch_id, self.peer_switch_id])) + + def to_config(self, **kwargs) -> Dict[str, Any]: + """ + Serialize model to snake_case Ansible config dict. + + Args: + **kwargs: Additional kwargs passed to model_dump + + Returns: + Dict with Python-name keys, excluding None values. + """ + return self.model_dump(by_alias=False, exclude_none=True, **kwargs) + + @classmethod + def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": + """ + Construct VpcPairModel from playbook config dict. + + Accepts both snake_case module input and API camelCase aliases. + + Args: + ansible_config: Dict from playbook config item + + Returns: + Validated VpcPairModel instance. + """ + data = dict(ansible_config or {}) + + # Accept both snake_case module input and API camelCase aliases. + if VpcFieldNames.SWITCH_ID not in data and "switch_id" in data: + data[VpcFieldNames.SWITCH_ID] = data.get("switch_id") + if VpcFieldNames.PEER_SWITCH_ID not in data and "peer_switch_id" in data: + data[VpcFieldNames.PEER_SWITCH_ID] = data.get("peer_switch_id") + if ( + VpcFieldNames.USE_VIRTUAL_PEER_LINK not in data + and "use_virtual_peer_link" in data + ): + data[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = data.get("use_virtual_peer_link") + if VpcFieldNames.VPC_PAIR_DETAILS not in data and "vpc_pair_details" in data: + data[VpcFieldNames.VPC_PAIR_DETAILS] = data.get("vpc_pair_details") + + return cls.model_validate(data, by_alias=True, by_name=True) + + def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": + """ + Merge non-None values from another model into this instance. + + Args: + other_model: VpcPairModel whose non-None fields overwrite this model + + Returns: + Self with merged values. + + Raises: + TypeError: If other_model is not the same type + """ + if not isinstance(other_model, type(self)): + raise TypeError( + "VpcPairModel.merge requires both models to be the same type" + ) + + merged_data = self.model_dump(by_alias=False, exclude_none=False) + incoming_data = other_model.model_dump(by_alias=False, exclude_none=False) + for field, value in incoming_data.items(): + if value is None: + continue + merged_data[field] = value + + # Validate once after the full merge so reversed pair updates do not + # fail on transient assignment states with validate_assignment=True. + return type(self).model_validate(merged_data, by_name=True, by_alias=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": + """ + Construct VpcPairModel from an API response dict. + + Args: + response: Dict from ND API response + + Returns: + Validated VpcPairModel instance. + """ + data = { + VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), + VpcFieldNames.USE_VIRTUAL_PEER_LINK: response.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + return cls.model_validate(data) + + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + """ + Return Ansible argument_spec for nd_manage_vpc_pair. + + Backward-compatible wrapper around the dedicated playbook config model. + """ + return VpcPairPlaybookConfigModel.get_argument_spec() + + +class VpcPairPlaybookItemModel(BaseModel): + """ + One item under playbook `config` for nd_manage_vpc_pair. + """ + + 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", + ) + + peer1_switch_id: str = Field( + alias="switch_id", + description="Peer-1 switch serial number or management IP address", + min_length=3, + max_length=64, + ) + peer2_switch_id: str = Field( + alias="peer_switch_id", + description="Peer-2 switch serial number or management IP address", + min_length=3, + max_length=64, + ) + use_virtual_peer_link: bool = Field( + default=True, + description="Virtual peer link enabled", + ) + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, + discriminator="type", + alias=VpcFieldNames.VPC_PAIR_DETAILS, + description="VPC pair configuration details (default or custom template)", + ) + + @field_validator("peer1_switch_id", "peer2_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Raw switch ID string + + Returns: + Stripped switch ID string. + + Raises: + ValueError: If switch ID is empty or whitespace-only + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> "VpcPairPlaybookItemModel": + """ + Validate that peer1_switch_id and peer2_switch_id are not the same. + + Returns: + Self if validation passes. + + Raises: + ValueError: If both switch IDs are identical + """ + if self.peer1_switch_id == self.peer2_switch_id: + raise ValueError( + "peer1_switch_id and peer2_switch_id must be different: " + f"{self.peer1_switch_id}" + ) + return self + + def to_runtime_config(self) -> Dict[str, Any]: + """ + Normalize playbook keys into runtime keys consumed by state machine code. + + Returns: + Dict with both snake_case and camelCase keys for switch IDs, + use_virtual_peer_link, and vpc_pair_details. + """ + switch_id = self.peer1_switch_id + peer_switch_id = self.peer2_switch_id + use_virtual_peer_link = self.use_virtual_peer_link + vpc_pair_details = self.vpc_pair_details + return { + "switch_id": switch_id, + "peer_switch_id": peer_switch_id, + "use_virtual_peer_link": use_virtual_peer_link, + "vpc_pair_details": ( + vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + if vpc_pair_details is not None + else None + ), + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, + VpcFieldNames.VPC_PAIR_DETAILS: ( + vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + if vpc_pair_details is not None + else None + ), + } + + +class VpcPairPlaybookConfigModel(BaseModel): + """ + Top-level playbook configuration model for nd_manage_vpc_pair. + """ + + 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", + description="Desired state for vPC pair configuration", + ) + fabric_name: str = Field(description="Fabric name") + deploy: bool = Field(default=False, description="Deploy after configuration changes") + force: bool = Field( + default=False, + description="Force deletion without pre-deletion safety checks", + ) + api_timeout: int = Field( + default=30, + description="API request timeout in seconds for write operations", + ) + query_timeout: int = Field( + default=10, + description="API request timeout in seconds for query/recommendation operations", + ) + refresh_after_apply: bool = Field( + default=True, + description="Refresh final after-state with a post-apply query", + ) + refresh_after_timeout: Optional[int] = Field( + default=None, + description="Optional timeout for post-apply refresh query", + ) + suppress_verification: bool = Field( + default=False, + description="Skip final after-state refresh query", + ) + config: Optional[List[VpcPairPlaybookItemModel]] = Field( + default=None, + description="List of vPC pair configurations", + ) + + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + """ + Return Ansible argument_spec for nd_manage_vpc_pair. + """ + return dict( + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "overridden", "gathered"], + ), + fabric_name=dict(type="str", required=True), + deploy=dict(type="bool", default=False), + force=dict( + type="bool", + default=False, + description=( + "Force deletion without pre-deletion validation " + "(bypasses safety checks)" + ), + ), + api_timeout=dict( + type="int", + default=30, + description=( + "API request timeout in seconds for primary operations" + ), + ), + query_timeout=dict( + type="int", + default=10, + description=( + "API request timeout in seconds for query/recommendation " + "operations" + ), + ), + refresh_after_apply=dict( + type="bool", + default=True, + description=( + "Refresh final after-state by querying controller " + "after write operations" + ), + ), + refresh_after_timeout=dict( + type="int", + required=False, + description=( + "Optional timeout in seconds for post-apply after-state " + "refresh query" + ), + ), + suppress_verification=dict( + type="bool", + default=False, + description=( + "Skip post-apply controller query for after-state " + "verification (alias for refresh_after_apply=false)." + ), + ), + config=dict( + type="list", + elements="dict", + options=dict( + peer1_switch_id=dict( + type="str", required=True, aliases=["switch_id"] + ), + peer2_switch_id=dict( + type="str", required=True, aliases=["peer_switch_id"] + ), + use_virtual_peer_link=dict(type="bool", default=True), + vpc_pair_details=dict(type="dict"), + ), + ), + ) diff --git a/plugins/module_utils/models/manage_vpc_pair/nested.py b/plugins/module_utils/models/manage_vpc_pair/nested.py new file mode 100644 index 00000000..999f60b4 --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/nested.py @@ -0,0 +1,34 @@ +# -*- 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, division, print_function + +__metaclass__ = type + +from typing import Any, Dict, List, ClassVar +from typing_extensions import Self +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( + NDVpcPairBaseModel, +) + + +class NDVpcPairNestedModel(NDVpcPairBaseModel): + """ + Base for nested VPC pair models without identifiers. + + Similar to NDNestedModel from PR172 split pattern. + """ + + identifiers: ClassVar[List[str]] = [] + + def to_payload(self) -> Dict[str, Any]: + """Convert model to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + return cls.model_validate(response) diff --git a/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py new file mode 100644 index 00000000..b7301466 --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py @@ -0,0 +1,790 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, 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, division, print_function + +__metaclass__ = type + +""" +Pydantic models for VPC pair management in Nexus Dashboard 4.x API. + +This module provides comprehensive models covering all 34 OpenAPI schemas +organized into functional domains: +- Configuration Domain: VPC pairing and lifecycle management +- Inventory Domain: VPC pair listing and discovery +- Monitoring Domain: Health, status, and operational metrics +- Consistency Domain: Configuration consistency validation +- Validation Domain: Support checks and peer recommendations +""" + +from typing import List, Dict, Any, Optional, Union, ClassVar, Literal +from typing_extensions import Self +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, + model_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( + FlexibleBool, + FlexibleInt, + FlexibleListStr, + NDVpcPairBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.nested import ( + NDVpcPairNestedModel, +) + +# Import enums from centralized location +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + VpcActionEnum, + VpcPairTypeEnum, + KeepAliveVrfEnum, + PoModeEnum, + PortChannelDuplexEnum, + VpcRoleEnum, + MaintenanceModeEnum, + ComponentTypeOverviewEnum, + ComponentTypeSupportEnum, + VpcPairViewEnum, + VpcFieldNames, +) + +# ============================================================================ +# NESTED MODELS (No Identifiers) +# ============================================================================ + + +class SwitchInfo(NDVpcPairNestedModel): + """Generic switch information for both peers.""" + + switch: str = Field(alias="switch", description="Switch value") + peer_switch: str = Field(alias="peerSwitch", description="Peer switch value") + + +class SwitchIntInfo(NDVpcPairNestedModel): + """Generic switch integer information for both peers.""" + + switch: FlexibleInt = Field(alias="switch", description="Switch value") + peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch value") + + +class SwitchBoolInfo(NDVpcPairNestedModel): + """Generic switch boolean information for both peers.""" + + switch: FlexibleBool = Field(alias="switch", description="Switch value") + peer_switch: FlexibleBool = Field(alias="peerSwitch", description="Peer switch value") + + +class SyncCounts(NDVpcPairNestedModel): + """Sync status counts.""" + + in_sync: FlexibleInt = Field(default=0, alias="inSync", description="In-sync items") + pending: FlexibleInt = Field(default=0, alias="pending", description="Pending items") + out_of_sync: FlexibleInt = Field(default=0, alias="outOfSync", description="Out-of-sync items") + in_progress: FlexibleInt = Field(default=0, alias="inProgress", description="In-progress items") + + +class AnomaliesCount(NDVpcPairNestedModel): + """Anomaly counts by severity.""" + + critical: FlexibleInt = Field(default=0, alias="critical", description="Critical anomalies") + major: FlexibleInt = Field(default=0, alias="major", description="Major anomalies") + minor: FlexibleInt = Field(default=0, alias="minor", description="Minor anomalies") + warning: FlexibleInt = Field(default=0, alias="warning", description="Warning anomalies") + + +class HealthMetrics(NDVpcPairNestedModel): + """Health metrics for both switches.""" + + switch: str = Field(alias="switch", description="Switch health status") + peer_switch: str = Field(alias="peerSwitch", description="Peer switch health status") + + +class ResourceMetrics(NDVpcPairNestedModel): + """Resource utilization metrics.""" + + switch: FlexibleInt = Field(alias="switch", description="Switch metric value") + peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch metric value") + + +class InterfaceStatusCounts(NDVpcPairNestedModel): + """Interface status counts.""" + + up: FlexibleInt = Field(alias="up", description="Interfaces in up state") + down: FlexibleInt = Field(alias="down", description="Interfaces in down state") + + +class LogicalInterfaceCounts(NDVpcPairNestedModel): + """Logical interface type counts.""" + + port_channel: FlexibleInt = Field(alias="portChannel", description="Port channel interfaces") + loopback: FlexibleInt = Field(alias="loopback", description="Loopback interfaces") + vpc: FlexibleInt = Field(alias="vPC", description="VPC interfaces") + vlan: FlexibleInt = Field(alias="vlan", description="VLAN interfaces") + nve: FlexibleInt = Field(alias="nve", description="NVE interfaces") + + +class ResponseCounts(NDVpcPairNestedModel): + """Response metadata counts.""" + + total: FlexibleInt = Field(alias="total", description="Total count") + remaining: FlexibleInt = Field(alias="remaining", description="Remaining count") + + +# ============================================================================ +# VPC PAIR DETAILS MODELS (Nested Template Configuration) +# ============================================================================ + + +class VpcPairDetailsDefault(NDVpcPairNestedModel): + """ + Default template VPC pair configuration. + + OpenAPI: vpcPairDetailsDefault + """ + + type: Literal["default"] = Field(default="default", alias="type", description="Template type") + domain_id: Optional[FlexibleInt] = Field(default=None, alias="domainId", description="VPC domain ID") + switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="switchKeepAliveLocalIp", description="Peer-1 keep-alive IP") + peer_switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="peerSwitchKeepAliveLocalIp", description="Peer-2 keep-alive IP") + keep_alive_vrf: Optional[KeepAliveVrfEnum] = Field(default=None, alias="keepAliveVrf", description="Keep-alive VRF") + keep_alive_hold_timeout: Optional[FlexibleInt] = Field(default=3, alias="keepAliveHoldTimeout", description="Keep-alive hold timeout") + enable_mirror_config: Optional[FlexibleBool] = Field(default=False, alias="enableMirrorConfig", description="Enable config mirroring") + is_vpc_plus: Optional[FlexibleBool] = Field(default=False, alias="isVpcPlus", description="VPC+ topology") + fabric_path_switch_id: Optional[FlexibleInt] = Field(default=None, alias="fabricPathSwitchId", description="FabricPath switch ID") + is_vteps: Optional[FlexibleBool] = Field(default=False, alias="isVteps", description="Configure NVE source loopback") + nve_interface: Optional[FlexibleInt] = Field(default=1, alias="nveInterface", description="NVE interface") + switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="switchSourceLoopback", description="Peer-1 source loopback") + peer_switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchSourceLoopback", description="Peer-2 source loopback") + switch_primary_ip: Optional[str] = Field(default=None, alias="switchPrimaryIp", description="Peer-1 primary IP") + peer_switch_primary_ip: Optional[str] = Field(default=None, alias="peerSwitchPrimaryIp", description="Peer-2 primary IP") + loopback_secondary_ip: Optional[str] = Field(default=None, alias="loopbackSecondaryIp", description="Secondary loopback IP") + switch_domain_config: Optional[str] = Field(default=None, alias="switchDomainConfig", description="Peer-1 domain config CLI") + peer_switch_domain_config: Optional[str] = Field(default=None, alias="peerSwitchDomainConfig", description="Peer-2 domain config CLI") + switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="switchPoId", description="Peer-1 port-channel ID") + peer_switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchPoId", description="Peer-2 port-channel ID") + switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="switchMemberInterfaces", description="Peer-1 member interfaces") + peer_switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="peerSwitchMemberInterfaces", description="Peer-2 member interfaces") + po_mode: Optional[str] = Field(default="active", alias="poMode", description="Port-channel mode") + switch_po_description: Optional[str] = Field(default=None, alias="switchPoDescription", description="Peer-1 port-channel description") + peer_switch_po_description: Optional[str] = Field(default=None, alias="peerSwitchPoDescription", description="Peer-2 port-channel description") + admin_state: Optional[FlexibleBool] = Field(default=True, alias="adminState", description="Admin state") + allowed_vlans: Optional[str] = Field(default="all", alias="allowedVlans", description="Allowed VLANs") + switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="switchNativeVlan", description="Peer-1 native VLAN") + peer_switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchNativeVlan", description="Peer-2 native VLAN") + switch_po_config: Optional[str] = Field(default=None, alias="switchPoConfig", description="Peer-1 port-channel freeform config") + peer_switch_po_config: Optional[str] = Field(default=None, alias="peerSwitchPoConfig", description="Peer-2 port-channel freeform config") + fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name") + + +class VpcPairDetailsCustom(NDVpcPairNestedModel): + """ + Custom template VPC pair configuration. + + OpenAPI: vpcPairDetailsCustom + """ + + type: Literal["custom"] = Field(default="custom", alias="type", description="Template type") + template_name: str = Field(alias="templateName", description="Name of the custom template") + template_config: Dict[str, Any] = Field(alias="templateConfig", description="Free-form configuration") + + +# ============================================================================ +# CONFIGURATION DOMAIN MODELS +# ============================================================================ + + +class VpcPairBase(NDVpcPairBaseModel): + """ + Base schema for VPC pairing with common properties. + + Identifier: (switch_id, peer_switch_id) - composite + OpenAPI: vpcPairBase + + Note: The nd_vpc_pair module uses a separate VpcPairModel class (not this one) because: + - Module needs use_virtual_peer_link=True as default (this uses False per API spec) + - Module uses NDBaseModel base class for framework integration + - Module needs strict bool types, this uses FlexibleBool for API flexibility + See plugins/modules/nd_vpc_pair.py VpcPairModel for the module-specific implementation. + """ + + # Identifier configuration + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Fields with validation constraints + switch_id: str = Field( + alias="switchId", + description="Switch serial number (Peer-1)", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias="peerSwitchId", + description="Peer switch serial number (Peer-2)", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> Self: + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +class VpcPairingRequest(NDVpcPairBaseModel): + """ + Request schema for pairing VPC switches. + + Identifier: (switch_id, peer_switch_id) - composite + OpenAPI: vpcPairingRequest + """ + + # Identifier configuration + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Fields with validation constraints + vpc_action: VpcActionEnum = Field(default=VpcActionEnum.PAIR, alias="vpcAction", description="Action to pair") + switch_id: str = Field( + alias="switchId", + description="Switch serial number (Peer-1)", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias="peerSwitchId", + description="Peer switch serial number (Peer-2)", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> Self: + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +class VpcUnpairingRequest(NDVpcPairBaseModel): + """ + Request schema for unpairing VPC switches. + + Identifier: N/A (no specific switch IDs in unpair request) + OpenAPI: vpcUnpairingRequest + """ + + # No identifiers for unpair request + identifiers: ClassVar[List[str]] = [] + + # Fields + vpc_action: VpcActionEnum = Field(default=VpcActionEnum.UNPAIR, alias="vpcAction", description="Action to unpair") + + def get_identifier_value(self) -> str: + """Override - unpair doesn't have identifiers.""" + return "unpair" + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +# ============================================================================ +# MONITORING DOMAIN MODELS +# ============================================================================ + + +class VpcPairsInfoBase(NDVpcPairNestedModel): + """ + VPC pair information base. + + OpenAPI: vpcPairsInfoBase + """ + + switch_name: SwitchInfo = Field(alias="switchName", description="Switch name") + ip_address: SwitchInfo = Field(alias="ipAddress", description="IP address") + fabric_name: str = Field(alias="fabricName", description="Fabric name") + connectivity_status: SwitchInfo = Field(alias="connectivityStatus", description="Connectivity status") + maintenance_mode: SwitchInfo = Field(alias="maintenanceMode", description="Maintenance mode") + uptime: SwitchInfo = Field(alias="uptime", description="Switch uptime") + switch_id: SwitchInfo = Field(alias="switchId", description="Switch serial number") + model: SwitchInfo = Field(alias="model", description="Switch model") + switch_role: SwitchInfo = Field(alias="switchRole", description="Switch role") + is_consistent: SwitchBoolInfo = Field(alias="isConsistent", description="Consistency status") + domain_id: SwitchIntInfo = Field(alias="domainId", description="Domain ID") + platform_type: SwitchInfo = Field(alias="platformType", description="Platform type") + + +class VpcPairHealthBase(NDVpcPairNestedModel): + """ + VPC pair health information. + + OpenAPI: vpcPairHealthBase + """ + + switch_id: str = Field(alias="switchId", description="Switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer switch serial number") + health: HealthMetrics = Field(alias="health", description="Health status") + cpu: ResourceMetrics = Field(alias="cpu", description="CPU utilization") + memory: ResourceMetrics = Field(alias="memory", description="Memory utilization") + temperature: ResourceMetrics = Field(alias="temperature", description="Temperature in Celsius") + + +class VpcPairsVxlanBase(NDVpcPairNestedModel): + """ + VPC pairs VXLAN details. + + OpenAPI: vpcPairsVxlanBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + routing_loopback: SwitchInfo = Field(alias="routingLoopback", description="Routing loopback") + routing_loopback_status: SwitchInfo = Field(alias="routingLoopbackStatus", description="Routing loopback status") + routing_loopback_primary_ip: SwitchInfo = Field(alias="routingLoopbackPrimaryIp", description="Routing loopback primary IP") + routing_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="routingLoopbackSecondaryIp", description="Routing loopback secondary IP") + vtep_loopback: SwitchInfo = Field(alias="vtepLoopback", description="VTEP loopback") + vtep_loopback_status: SwitchInfo = Field(alias="vtepLoopbackStatus", description="VTEP loopback status") + vtep_loopback_primary_ip: SwitchInfo = Field(alias="vtepLoopbackPrimaryIp", description="VTEP loopback primary IP") + vtep_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="vtepLoopbackSecondaryIp", description="VTEP loopback secondary IP") + nve_interface: SwitchInfo = Field(alias="nveInterface", description="NVE interface") + nve_status: SwitchInfo = Field(alias="nveStatus", description="NVE status") + multisite_loopback: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopback", description="Multisite loopback") + multisite_loopback_status: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackStatus", description="Multisite loopback status") + multisite_loopback_primary_ip: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackPrimaryIp", description="Multisite loopback primary IP") + + +class VpcPairsOverlayBase(NDVpcPairNestedModel): + """ + VPC pairs overlay base. + + OpenAPI: vpcPairsOverlayBase + """ + + network_count: SyncCounts = Field(alias="networkCount", description="Network count") + vrf_count: SyncCounts = Field(alias="vrfCount", description="VRF count") + + +class VpcPairsInventoryBase(NDVpcPairNestedModel): + """ + VPC pair inventory base. + + OpenAPI: vpcPairsInventoryBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + admin_status: InterfaceStatusCounts = Field(alias="adminStatus", description="Admin status") + operational_status: InterfaceStatusCounts = Field(alias="operationalStatus", description="Operational status") + sync_status: Dict[str, FlexibleInt] = Field(alias="syncStatus", description="Sync status") + logical_interfaces: LogicalInterfaceCounts = Field(alias="logicalInterfaces", description="Logical interfaces") + + +class VpcPairsModuleBase(NDVpcPairNestedModel): + """ + VPC pair module base. + + OpenAPI: vpcPairsModuleBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + module_information: Dict[str, str] = Field(default_factory=dict, alias="moduleInformation", description="VPC pair module information") + fex_details: Dict[str, str] = Field(default_factory=dict, alias="fexDetails", description="Fex details name-value pair(s)") + + +class VpcPairAnomaliesBase(NDVpcPairNestedModel): + """ + VPC pair anomalies information. + + OpenAPI: vpcPairAnomaliesBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + anomalies_count: AnomaliesCount = Field(alias="anomaliesCount", description="Anomaly counts by severity") + + +# ============================================================================ +# CONSISTENCY DOMAIN MODELS +# ============================================================================ + + +class CommonVpcConsistencyParams(NDVpcPairNestedModel): + """ + Common consistency parameters for VPC domain. + + OpenAPI: commonVpcConsistencyParams + """ + + # Basic identifiers + switch_name: str = Field(alias="switchName", description="Switch name") + ip_address: str = Field(alias="ipAddress", description="IP address") + domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID") + + # Port channel info + peer_link_port_channel: FlexibleInt = Field(alias="peerLinkPortChannel", description="Port channel peer link") + port_channel_name: Optional[str] = Field(default=None, alias="portChannelName", description="Port channel name") + description: Optional[str] = Field(default=None, alias="description", description="Port channel description") + + # VPC system parameters + system_mac_address: str = Field(alias="systemMacAddress", description="System MAC address") + system_priority: FlexibleInt = Field(alias="systemPriority", description="System priority") + udp_port: FlexibleInt = Field(alias="udpPort", description="UDP port") + interval: FlexibleInt = Field(alias="interval", description="Interval") + timeout: FlexibleInt = Field(alias="timeout", description="Timeout") + + # Additional fields (simplified - add as needed) + # NOTE: OpenAPI has many more fields - add them as required + + +class VpcPairConsistency(NDVpcPairNestedModel): + """ + VPC pair consistency check results. + + OpenAPI: vpcPairConsistency + """ + + switch_id: str = Field(alias="switchId", description="Primary switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Secondary switch serial number") + type2_consistency: FlexibleBool = Field(alias="type2Consistency", description="Type-2 consistency status") + type2_consistency_reason: str = Field(alias="type2ConsistencyReason", description="Consistency reason") + timestamp: Optional[FlexibleInt] = Field(default=None, alias="timestamp", description="Timestamp of check") + primary_parameters: CommonVpcConsistencyParams = Field(alias="primaryParameters", description="Primary switch consistency parameters") + secondary_parameters: CommonVpcConsistencyParams = Field(alias="secondaryParameters", description="Secondary switch consistency parameters") + is_consistent: Optional[FlexibleBool] = Field(default=None, alias="isConsistent", description="Overall consistency") + is_discovered: Optional[FlexibleBool] = Field(default=None, alias="isDiscovered", description="Whether pair is discovered") + + +# ============================================================================ +# VALIDATION DOMAIN MODELS +# ============================================================================ + + +class VpcPairRecommendation(NDVpcPairNestedModel): + """ + Recommendation information for a switch. + + OpenAPI: vpcPairRecommendation + """ + + hostname: str = Field(alias="hostname", description="Logical name of switch") + ip_address: str = Field(alias="ipAddress", description="IP address of switch") + switch_id: str = Field(alias="switchId", description="Serial number of the switch") + software_version: str = Field(alias="softwareVersion", description="NXOS version of switch") + fabric_name: str = Field(alias="fabricName", description="Fabric name") + recommendation_reason: str = Field(alias="recommendationReason", description="Recommendation message") + block_selection: FlexibleBool = Field(alias="blockSelection", description="Block selection") + platform_type: str = Field(alias="platformType", description="Platform type of switch") + use_virtual_peer_link: FlexibleBool = Field(alias="useVirtualPeerLink", description="Virtual peer link available") + is_current_peer: FlexibleBool = Field(alias="isCurrentPeer", description="Device is current peer") + is_recommended: FlexibleBool = Field(alias="isRecommended", description="Recommended device") + + +# ============================================================================ +# INVENTORY DOMAIN MODELS +# ============================================================================ + + +class VpcPairBaseSwitchDetails(NDVpcPairNestedModel): + """ + Base fields for VPC pair records. + + OpenAPI: vpcPairBaseSwitchDetails + """ + + domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID of the VPC") + switch_id: str = Field(alias="switchId", description="Serial number of the switch") + switch_name: str = Field(alias="switchName", description="Hostname of the switch") + peer_switch_id: str = Field(alias="peerSwitchId", description="Serial number of the peer switch") + peer_switch_name: str = Field(alias="peerSwitchName", description="Hostname of the peer switch") + + +class VpcPairIntended(VpcPairBaseSwitchDetails): + """ + Intended VPC pair record. + + OpenAPI: vpcPairIntended + """ + + type: Literal["intendedPairs"] = Field(default="intendedPairs", alias="type", description="Type identifier") + + +class VpcPairDiscovered(VpcPairBaseSwitchDetails): + """ + Discovered VPC pair record. + + OpenAPI: vpcPairDiscovered + """ + + type: Literal["discoveredPairs"] = Field(default="discoveredPairs", alias="type", description="Type identifier") + switch_vpc_role: VpcRoleEnum = Field(alias="switchVpcRole", description="VPC role of the switch") + peer_switch_vpc_role: VpcRoleEnum = Field(alias="peerSwitchVpcRole", description="VPC role of the peer switch") + intended_peer_name: str = Field(alias="intendedPeerName", description="Name of the intended peer switch") + description: str = Field(alias="description", description="Description of any discrepancies or issues") + + +class Metadata(NDVpcPairNestedModel): + """ + Metadata for pagination and links. + + OpenAPI: Metadata + """ + + counts: ResponseCounts = Field(alias="counts", description="Count information") + links: Optional[Dict[str, str]] = Field(default=None, alias="links", description="Pagination links (next, previous)") + + +class VpcPairsResponse(NDVpcPairNestedModel): + """ + Response schema for listing VPC pairs. + + OpenAPI: vpcPairsResponse + """ + + vpc_pairs: List[Union[VpcPairIntended, VpcPairDiscovered]] = Field(alias="vpcPairs", description="List of VPC pairs") + meta: Metadata = Field(alias="meta", description="Response metadata") + + +# ============================================================================ +# WRAPPER MODELS WITH COMPONENT TYPE +# ============================================================================ + + +class VpcPairsInfo(NDVpcPairNestedModel): + """VPC pairs information wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.PAIRS_INFO, alias="componentType", description="Type of the component") + info: VpcPairsInfoBase = Field(alias="info", description="VPC pair info") + + +class VpcPairHealth(NDVpcPairNestedModel): + """VPC pair health wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.HEALTH, alias="componentType", description="Type of the component") + health: VpcPairHealthBase = Field(alias="health", description="Health details") + + +class VpcPairsModule(NDVpcPairNestedModel): + """VPC pairs module wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.MODULE, alias="componentType", description="Type of the component") + module: VpcPairsModuleBase = Field(alias="module", description="Module details") + + +class VpcPairAnomalies(NDVpcPairNestedModel): + """VPC pair anomalies wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.ANOMALIES, alias="componentType", description="Type of the component") + anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="Anomalies details") + + +class VpcPairsVxlan(NDVpcPairNestedModel): + """VPC pairs VXLAN wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.VXLAN, alias="componentType", description="Type of the component") + vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VXLAN details") + + +class VpcPairsOverlay(NDVpcPairNestedModel): + """VPC overlay details wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.OVERLAY, alias="componentType", description="Type of the component") + overlay: VpcPairsOverlayBase = Field(alias="overlay", description="Overlay details") + + +class VpcPairsInventory(NDVpcPairNestedModel): + """VPC pairs inventory details wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.INVENTORY, alias="componentType", description="Type of the component") + inventory: VpcPairsInventoryBase = Field(alias="inventory", description="Inventory details") + + +class FullOverview(NDVpcPairNestedModel): + """Full VPC overview response.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.FULL, alias="componentType", description="Type of the component") + anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="VPC pair anomalies") + health: VpcPairHealthBase = Field(alias="health", description="VPC pair health") + module: VpcPairsModuleBase = Field(alias="module", description="VPC pair module") + vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VPC pair VXLAN") + overlay: VpcPairsOverlayBase = Field(alias="overlay", description="VPC pair overlay") + pairs_info: VpcPairsInfoBase = Field(alias="pairsInfo", description="VPC pair info") + inventory: VpcPairsInventoryBase = Field(alias="inventory", description="VPC pair inventory") + + +# ============================================================================ +# BACKWARD COMPATIBILITY CONTAINER (NdVpcPairSchema) +# ============================================================================ + + +class NdVpcPairSchema: + """ + Backward compatibility container for all VPC pair schemas. + + This provides a namespace similar to the old structure where models + were nested inside a container class. Allows imports like: + + from model_playbook_vpc_pair_nested import NdVpcPairSchema + vpc_pair = NdVpcPairSchema.VpcPairBase(**data) + """ + + # Base classes + VpcPairBaseModel = NDVpcPairBaseModel + VpcPairNestedModel = NDVpcPairNestedModel + + # Enumerations (these are class variable type hints, not assignments) + # VpcRole = VpcRoleEnum # Commented out - not needed + # TemplateType = VpcPairTypeEnum # Commented out - not needed + # KeepAliveVrf = KeepAliveVrfEnum # Commented out - not needed + # VpcAction = VpcActionEnum # Commented out - not needed + # ComponentType = ComponentTypeOverviewEnum # Commented out - not needed + + # Nested helper models + SwitchInfo = SwitchInfo + SwitchIntInfo = SwitchIntInfo + SwitchBoolInfo = SwitchBoolInfo + SyncCounts = SyncCounts + AnomaliesCount = AnomaliesCount + HealthMetrics = HealthMetrics + ResourceMetrics = ResourceMetrics + InterfaceStatusCounts = InterfaceStatusCounts + LogicalInterfaceCounts = LogicalInterfaceCounts + ResponseCounts = ResponseCounts + + # VPC pair details (template configuration) + VpcPairDetailsDefault = VpcPairDetailsDefault + VpcPairDetailsCustom = VpcPairDetailsCustom + + # Configuration domain + VpcPairBase = VpcPairBase + VpcPairingRequest = VpcPairingRequest + VpcUnpairingRequest = VpcUnpairingRequest + + # Monitoring domain + VpcPairsInfoBase = VpcPairsInfoBase + VpcPairHealthBase = VpcPairHealthBase + VpcPairsVxlanBase = VpcPairsVxlanBase + VpcPairsOverlayBase = VpcPairsOverlayBase + VpcPairsInventoryBase = VpcPairsInventoryBase + VpcPairsModuleBase = VpcPairsModuleBase + VpcPairAnomaliesBase = VpcPairAnomaliesBase + + # Monitoring domain wrappers + VpcPairsInfo = VpcPairsInfo + VpcPairHealth = VpcPairHealth + VpcPairsModule = VpcPairsModule + VpcPairAnomalies = VpcPairAnomalies + VpcPairsVxlan = VpcPairsVxlan + VpcPairsOverlay = VpcPairsOverlay + VpcPairsInventory = VpcPairsInventory + FullOverview = FullOverview + + # Consistency domain + CommonVpcConsistencyParams = CommonVpcConsistencyParams + VpcPairConsistency = VpcPairConsistency + + # Validation domain + VpcPairRecommendation = VpcPairRecommendation + + # Inventory domain + VpcPairBaseSwitchDetails = VpcPairBaseSwitchDetails + VpcPairIntended = VpcPairIntended + VpcPairDiscovered = VpcPairDiscovered + Metadata = Metadata + VpcPairsResponse = VpcPairsResponse diff --git a/plugins/module_utils/nd_manage_vpc_pair_actions.py b/plugins/module_utils/nd_manage_vpc_pair_actions.py new file mode 100644 index 00000000..0ec9fa2f --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_actions.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Any, Dict, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _is_update_needed, + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_validation import ( + _get_pairing_support_details, + _validate_fabric_peering_support, + _validate_switch_conflicts, + _validate_switches_exist_in_fabric, + _validate_vpc_pair_deletion, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) + +def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: + """ + Custom create function for VPC pairs using RestSend with PUT + discriminator. + - Validates switches exist in fabric (Common.validate_switches_exist) + - Checks for switch conflicts (Common.validate_no_switch_conflicts) + - Uses PUT instead of POST (non-RESTful API) + - Adds vpcAction: "pair" discriminator + - Proper error handling with NDModuleError + - Results aggregation + + Args: + nrm: NDStateMachine instance + + Returns: + API response dictionary or None + + Raises: + ValueError: If fabric_name or switch_id is not provided + AnsibleModule.fail_json: If validation fails + """ + if nrm.module.check_mode: + return nrm.proposed_config + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + if not peer_switch_id: + raise ValueError("peer_switch_id is required but was not provided") + + # Validation Step 1: both switches must exist in discovered fabric inventory. + _validate_switches_exist_in_fabric( + nrm=nrm, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + ) + + # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) + have_vpc_pairs = nrm.module.params.get("_have", []) + if have_vpc_pairs: + _validate_switch_conflicts([nrm.proposed_config], have_vpc_pairs, nrm.module) + + # Validation Step 3: Check if create is actually needed (idempotence check) + if nrm.existing_config: + want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config + have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config + + if not _is_update_needed(want_dict, have_dict): + # Already exists in desired state - return existing config without changes + nrm.module.warn( + f"VPC pair {nrm.current_identifier} already exists in desired state - skipping create" + ) + return nrm.existing_config + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + # Validate pairing support using dedicated endpoint. + # Only fail when API explicitly states pairing is not allowed. + try: + support_details = _get_pairing_support_details( + nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + component_type=ComponentTypeSupportEnum.CHECK_PAIRING.value, + ) + if support_details: + is_pairing_allowed = _get_api_field_value( + support_details, "isPairingAllowed", None + ) + if is_pairing_allowed is False: + reason = _get_api_field_value( + support_details, "reason", "pairing blocked by support checks" + ) + _raise_vpc_error( + msg=f"VPC pairing is not allowed for switch {switch_id}: {reason}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + support_details=support_details, + ) + except VpcPairResourceError: + raise + except Exception as support_error: + nrm.module.warn( + f"Pairing support check failed for switch {switch_id}: " + f"{str(support_error).splitlines()[0]}. Continuing with create operation." + ) + + # Validate fabric peering support if virtual peer link is requested. + _validate_fabric_peering_support( + nrm=nrm, + nd_v2=nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + use_virtual_peer_link=use_virtual_peer_link, + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build payload with discriminator using helper (supports vpc_pair_details) + payload = _build_vpc_pair_payload(nrm.proposed_config) + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="created", + after_data=payload, + sent_payload_data=payload + ) + + try: + # Use PUT (not POST!) for create via RestSend + response = nd_v2.request(path, HttpVerbEnum.PUT, payload) + return response + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + _raise_vpc_error( + msg=f"Failed to create VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + path=path, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to create VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: + """ + Custom update function for VPC pairs using RestSend. + + - Uses PUT with discriminator (same as create) + - Validates switches exist in fabric + - Checks for switch conflicts + - Uses normalized payload comparison to detect if update is needed + - Proper error handling + + Args: + nrm: NDStateMachine instance + + Returns: + API response dictionary or None + + Raises: + ValueError: If fabric_name or switch_id is not provided + """ + if nrm.module.check_mode: + return nrm.proposed_config + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + if not peer_switch_id: + raise ValueError("peer_switch_id is required but was not provided") + + # Validation Step 1: both switches must exist in discovered fabric inventory. + _validate_switches_exist_in_fabric( + nrm=nrm, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + ) + + # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) + have_vpc_pairs = nrm.module.params.get("_have", []) + if have_vpc_pairs: + # Filter out the current VPC pair being updated + other_vpc_pairs = [ + vpc for vpc in have_vpc_pairs + if vpc.get(VpcFieldNames.SWITCH_ID) != switch_id + ] + if other_vpc_pairs: + _validate_switch_conflicts([nrm.proposed_config], other_vpc_pairs, nrm.module) + + # Validation Step 3: Check if update is actually needed + if nrm.existing_config: + want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config + have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config + + if not _is_update_needed(want_dict, have_dict): + # No changes needed - return existing config + nrm.module.warn( + f"VPC pair {nrm.current_identifier} is already in desired state - skipping update" + ) + return nrm.existing_config + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + # Validate fabric peering support if virtual peer link is requested. + _validate_fabric_peering_support( + nrm=nrm, + nd_v2=nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + use_virtual_peer_link=use_virtual_peer_link, + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build payload with discriminator using helper (supports vpc_pair_details) + payload = _build_vpc_pair_payload(nrm.proposed_config) + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="updated", + after_data=payload, + sent_payload_data=payload + ) + + try: + # Use PUT for update via RestSend + response = nd_v2.request(path, HttpVerbEnum.PUT, payload) + return response + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + _raise_vpc_error( + msg=f"Failed to update VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to update VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def custom_vpc_delete(nrm) -> bool: + """ + Custom delete function for VPC pairs using RestSend with PUT + discriminator. + + - Pre-deletion validation (network/VRF/interface checks) + - Uses PUT instead of DELETE (non-RESTful API) + - Adds vpcAction: "unpair" discriminator + - Proper error handling with NDModuleError + + Args: + nrm: NDStateMachine instance + + Raises: + ValueError: If fabric_name or switch_id is not provided + AnsibleModule.fail_json: If validation fails (networks/VRFs attached) + """ + if nrm.module.check_mode: + return True + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.existing_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + + # CRITICAL: Pre-deletion validation to prevent data loss + # Checks for active networks, VRFs, and warns about vPC interfaces + vpc_pair_key = f"{switch_id}-{peer_switch_id}" if peer_switch_id else switch_id + + # Track whether force parameter was actually needed + force_delete = nrm.module.params.get("force", False) + validation_succeeded = False + + # Perform validation with timeout protection + try: + _validate_vpc_pair_deletion(nd_v2, fabric_name, switch_id, vpc_pair_key, nrm.module) + validation_succeeded = True + + # If force was enabled but validation succeeded, inform user it wasn't needed + if force_delete: + nrm.module.warn( + f"Force deletion was enabled for {vpc_pair_key}, but pre-deletion validation succeeded. " + f"The 'force: true' parameter was not necessary in this case. " + f"Consider removing 'force: true' to benefit from safety checks in future runs." + ) + + except ValueError as already_unpaired: + # Sentinel from _validate_vpc_pair_deletion: pair no longer exists. + # Treat as idempotent success — nothing to delete. + nrm.module.warn(str(already_unpaired)) + return False + + except (NDModuleError, Exception) as validation_error: + # Validation failed - check if force deletion is enabled + if not force_delete: + _raise_vpc_error( + msg=( + f"Pre-deletion validation failed for VPC pair {vpc_pair_key}. " + f"Error: {str(validation_error)}. " + f"If you're certain the VPC pair can be safely deleted, use 'force: true' parameter. " + f"WARNING: Force deletion bypasses safety checks and may cause data loss." + ), + vpc_pair_key=vpc_pair_key, + validation_error=str(validation_error), + force_available=True + ) + else: + # Force enabled and validation failed - this is when force was actually needed + nrm.module.warn( + f"Force deletion enabled for {vpc_pair_key} - bypassing pre-deletion validation. " + f"Validation error was: {str(validation_error)}. " + f"WARNING: Proceeding without safety checks - ensure no data loss will occur." + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build minimal payload with discriminator for delete + payload = { + VpcFieldNames.VPC_ACTION: VpcActionEnum.UNPAIR.value, # ← Discriminator for DELETE + VpcFieldNames.SWITCH_ID: nrm.existing_config.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) + } + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="deleted", + sent_payload_data=payload + ) + + try: + # Use PUT (not DELETE!) for unpair via RestSend + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("api_timeout", 30) + try: + nd_v2.request(path, HttpVerbEnum.PUT, payload) + finally: + rest_send.restore_settings() + + except NDModuleError as error: + error_msg = str(error.msg).lower() if error.msg else "" + status_code = error.status or 0 + + # Idempotent handling: if the API says the switch is not part of any + # vPC pair, the pair is already gone — treat as a successful no-op. + if status_code == 400 and "not a part of" in error_msg: + # Keep idempotent semantics: this is a no-op delete, so downgrade the + # pre-logged operation from "deleted" to "no_change". + if getattr(nrm, "logs", None): + last_log = nrm.logs[-1] + if last_log.get("identifier") == nrm.current_identifier: + last_log["status"] = "no_change" + last_log.pop("sent_payload", None) + + nrm.module.warn( + f"VPC pair {nrm.current_identifier} is already unpaired on the controller. " + f"Treating as idempotent success. API response: {error.msg}" + ) + return False + + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + _raise_vpc_error( + msg=f"Failed to delete VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to delete VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + exception_type=type(e).__name__ + ) + + return True diff --git a/plugins/module_utils/nd_manage_vpc_pair_common.py b/plugins/module_utils/nd_manage_vpc_pair_common.py new file mode 100644 index 00000000..dc744dca --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_common.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +import json +from typing import Any, Dict, List + +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) + +def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: + """ + Serialize NDConfigCollection across old/new framework variants. + + Tries multiple serialization methods in order to support different + NDConfigCollection implementations. + + Args: + collection: NDConfigCollection instance or None + + Returns: + List of dicts from the collection. Empty list if collection is None + or has no recognized serialization method. + """ + if collection is None: + return [] + if hasattr(collection, "to_list"): + return collection.to_list() + if hasattr(collection, "to_payload_list"): + return collection.to_payload_list() + if hasattr(collection, "to_ansible_config"): + return collection.to_ansible_config() + return [] + + +def _raise_vpc_error(msg: str, **details: Any) -> None: + """ + Raise a structured vpc_pair error for main() to format via fail_json. + + Args: + msg: Human-readable error message + **details: Arbitrary keyword args passed to VpcPairResourceError + + Raises: + VpcPairResourceError: Always raised with msg and details + """ + raise VpcPairResourceError(msg=msg, **details) + + +# ===== Helper Functions ===== + + +def _canonicalize_for_compare(value: Any) -> Any: + """ + Normalize nested payload data for deterministic comparison. + + Lists are sorted by canonical JSON representation so list ordering does + not trigger false-positive update detection. + + Args: + value: Any nested data structure (dict, list, or primitive) + + Returns: + Canonicalized copy with sorted dicts and sorted lists. + """ + 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: + """ + Determine if an update is needed by comparing want and have. + + Uses canonical, order-insensitive comparison that handles: + - Field additions + - Value changes + - Nested structure changes + - Ignores field order + + Args: + want: Desired VPC pair configuration (dict) + have: Current VPC pair configuration (dict) + + Returns: + bool: True if update is needed, False if already in desired state + + Example: + >>> want = {"switchId": "FDO123", "useVirtualPeerLink": True} + >>> have = {"switchId": "FDO123", "useVirtualPeerLink": False} + >>> _is_update_needed(want, have) + True + """ + normalized_want = _canonicalize_for_compare(want) + normalized_have = _canonicalize_for_compare(have) + return normalized_want != normalized_have diff --git a/plugins/module_utils/nd_manage_vpc_pair_deploy.py b/plugins/module_utils/nd_manage_vpc_pair_deploy.py new file mode 100644 index 00000000..d4b23f04 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_deploy.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Any, Dict + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) + +try: + from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.results import Results + +def _needs_deployment(result: Dict, nrm) -> bool: + """ + Determine if deployment is needed based on changes and pending operations. + + Deployment is needed if any of: + 1. There are items in the diff (configuration changes) + 2. There are pending create VPC pairs + 3. There are pending delete VPC pairs + + Args: + result: Module result dictionary with diff info + nrm: NDStateMachine instance + + Returns: + True if deployment is needed, False otherwise + """ + # Check if there are any changes in the result + has_changes = result.get("changed", False) + + # Check diff - framework stores before/after + before = result.get("before", []) + after = result.get("after", []) + has_diff_changes = before != after + + # Check pending operations + pending_create = nrm.module.params.get("_pending_create", []) + pending_delete = nrm.module.params.get("_pending_delete", []) + has_pending = bool(pending_create or pending_delete) + + needs_deploy = has_changes or has_diff_changes or has_pending + + return needs_deploy + + +def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: + """ + Return True only for known non-fatal configSave platform limitations. + + Args: + error: NDModuleError from config-save API call + + Returns: + True if the error matches a known non-fatal 500 signature + (e.g. fabric peering not supported). False otherwise. + """ + if not isinstance(error, NDModuleError): + return False + + # Keep this allowlist tight to avoid masking real config-save failures. + if error.status != 500: + return False + + message = (error.msg or "").lower() + non_fatal_signatures = ( + "vpc fabric peering is not supported", + "vpcsanitycheck", + "unexpected error generating vpc configuration", + ) + return any(signature in message for signature in non_fatal_signatures) + + +def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: + """ + Custom deploy function for fabric configuration changes using RestSend. + + - Smart deployment decision (Common.needs_deployment) + - Step 1: Save fabric configuration + - Step 2: Deploy fabric with forceShowRun=true + - Proper error handling with NDModuleError + - Results aggregation + - Only deploys if there are actual changes or pending operations + + Args: + nrm: NDStateMachine instance + fabric_name: Fabric name to deploy + result: Module result dictionary to check for changes + + Returns: + Deployment result dictionary + + Raises: + NDModuleError: If deployment fails + """ + # Smart deployment decision (from Common.needs_deployment) + if not _needs_deployment(result, nrm): + return { + "msg": "No configuration changes or pending operations detected, skipping deployment", + "fabric": fabric_name, + "deployment_needed": False, + "changed": False + } + + if nrm.module.check_mode: + # check_mode deployment preview + before = result.get("before", []) + after = result.get("after", []) + pending_create = nrm.module.params.get("_pending_create", []) + pending_delete = nrm.module.params.get("_pending_delete", []) + + deployment_info = { + "msg": "CHECK MODE: Would save and deploy fabric configuration", + "fabric": fabric_name, + "deployment_needed": True, + "changed": True, + "would_deploy": True, + "deployment_decision_factors": { + "diff_has_changes": before != after, + "pending_create_operations": len(pending_create), + "pending_delete_operations": len(pending_delete), + "actual_changes": result.get("changed", False) + }, + "planned_actions": [ + f"POST {VpcPairEndpoints.fabric_config_save(fabric_name)}", + f"POST {VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True)}" + ] + } + return deployment_info + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + results = Results() + + # Step 1: Save config + save_path = VpcPairEndpoints.fabric_config_save(fabric_name) + + try: + nd_v2.request(save_path, HttpVerbEnum.POST, {}) + + results.response_current = { + "RETURN_CODE": nd_v2.status, + "METHOD": "POST", + "REQUEST_PATH": save_path, + "MESSAGE": "Config saved successfully", + "DATA": {}, + } + results.result_current = {"success": True, "changed": True} + results.register_api_call() + + except NDModuleError as error: + if _is_non_fatal_config_save_error(error): + # Known platform limitation warning; continue to deploy step. + nrm.module.warn(f"Config save failed: {error.msg}") + + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": save_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": True, "changed": False} + results.register_api_call() + else: + # Unknown config-save failures are fatal. + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": save_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": False, "changed": False} + results.register_api_call() + results.build_final_result() + final_result = dict(results.final_result) + final_msg = final_result.pop("msg", f"Config save failed: {error.msg}") + _raise_vpc_error(msg=final_msg, **final_result) + + # Step 2: Deploy + deploy_path = VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True) + + try: + nd_v2.request(deploy_path, HttpVerbEnum.POST, {}) + + results.response_current = { + "RETURN_CODE": nd_v2.status, + "METHOD": "POST", + "REQUEST_PATH": deploy_path, + "MESSAGE": "Deployment successful", + "DATA": {}, + } + results.result_current = {"success": True, "changed": True} + results.register_api_call() + + except NDModuleError as error: + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": deploy_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": False, "changed": False} + results.register_api_call() + + # Build final result and fail + results.build_final_result() + final_result = dict(results.final_result) + final_msg = final_result.pop("msg", "Fabric deployment failed") + _raise_vpc_error(msg=final_msg, **final_result) + + # Build final result + results.build_final_result() + return results.final_result diff --git a/plugins/module_utils/nd_manage_vpc_pair_exceptions.py b/plugins/module_utils/nd_manage_vpc_pair_exceptions.py new file mode 100644 index 00000000..9c033582 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_exceptions.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Any + + +class VpcPairResourceError(Exception): + """Structured error raised by vpc_pair runtime layers.""" + + def __init__(self, msg: str, **details: Any): + """ + Initialize VpcPairResourceError. + + Args: + msg: Human-readable error message + **details: Arbitrary keyword args for structured error context + (e.g. fabric, vpc_pair_key, missing_switches) + """ + super().__init__(msg) + self.msg = msg + self.details = details diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py new file mode 100644 index 00000000..baccbfc0 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -0,0 +1,932 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +import ipaddress +from typing import Any, Dict, List, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_validation import ( + _is_switch_in_vpc_pair, + _validate_fabric_switches, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( + _get_api_field_value, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) + +def _get_recommendation_details(nd_v2, fabric_name: str, switch_id: str, timeout: Optional[int] = None) -> Optional[Dict]: + """ + Get VPC pair recommendation details from ND for a specific switch. + + Returns peer switch info and useVirtualPeerLink status. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module param if not specified) + + Returns: + Dict with peer info or None if not found (404) + + Raises: + NDModuleError: On API errors other than 404 (timeouts, 500s, etc.) + """ + # Validate inputs to prevent injection + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + try: + path = VpcPairEndpoints.switch_vpc_recommendations(fabric_name, switch_id) + + # Use query timeout from module params or override + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + vpc_recommendations = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if vpc_recommendations is None or vpc_recommendations == {}: + return None + + # Validate response structure and look for current peer + if isinstance(vpc_recommendations, list): + for sw in vpc_recommendations: + # Validate each entry + if not isinstance(sw, dict): + nd_v2.module.warn( + f"Skipping invalid recommendation entry for switch {switch_id}: " + f"expected dict, got {type(sw).__name__}" + ) + continue + + # Check for current peer indicators + if sw.get(VpcFieldNames.CURRENT_PEER) or sw.get(VpcFieldNames.IS_CURRENT_PEER): + # Validate required fields exist + if VpcFieldNames.SERIAL_NUMBER not in sw: + nd_v2.module.warn( + f"Recommendation missing serialNumber field for switch {switch_id}" + ) + continue + return sw + elif vpc_recommendations: + # Unexpected response format + nd_v2.module.warn( + f"Unexpected recommendation response format for switch {switch_id}: " + f"expected list, got {type(vpc_recommendations).__name__}" + ) + + return None + except NDModuleError as error: + # Handle expected error codes gracefully + if error.status == 404: + # No recommendations exist (expected for switches without VPC) + return None + elif error.status == 500: + # Server error - recommendation API may be unstable + # Treat as "no recommendations available" to allow graceful degradation + nd_v2.module.warn( + f"VPC recommendation API returned 500 error for switch {switch_id} - " + f"treating as no recommendations available" + ) + return None + # Let other errors (timeouts, rate limits) propagate + raise + + +def _extract_vpc_pairs_from_list_response(vpc_pairs_response: Any) -> List[Dict[str, Any]]: + """ + Extract VPC pair list entries from /vpcPairs response payload. + + Supports common response wrappers used by ND API. + + Args: + vpc_pairs_response: Raw API response dict from /vpcPairs list endpoint + + Returns: + List of dicts with switchId, peerSwitchId, useVirtualPeerLink keys. + Empty list if response is invalid or contains no pairs. + """ + if not isinstance(vpc_pairs_response, dict): + return [] + + candidates = None + for key in (VpcFieldNames.VPC_PAIRS, "items", VpcFieldNames.DATA): + value = vpc_pairs_response.get(key) + if isinstance(value, list): + candidates = value + break + + if not isinstance(candidates, list): + return [] + + extracted_pairs = [] + for item in candidates: + if not isinstance(item, dict): + continue + + switch_id = item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get(VpcFieldNames.PEER_SWITCH_ID) + + # Handle alternate response shape if switch IDs are nested under "switch"/"peerSwitch" + if isinstance(switch_id, dict) and isinstance(peer_switch_id, dict): + switch_id = switch_id.get("switch") + peer_switch_id = peer_switch_id.get("peerSwitch") + + if not switch_id or not peer_switch_id: + continue + + extracted_pairs.append( + { + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: item.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + ) + + return extracted_pairs + + +def _enrich_pairs_from_direct_vpc( + nd_v2, + fabric_name: str, + pairs: List[Dict[str, Any]], + timeout: int = 5, +) -> List[Dict[str, Any]]: + """ + Enrich pair fields from per-switch /vpcPair endpoint when available. + + The /vpcPairs list response may omit fields like useVirtualPeerLink. + This helper preserves lightweight list discovery while improving field + accuracy for gathered output. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + pairs: List of pair dicts from list endpoint + timeout: Per-switch query timeout in seconds + + Returns: + List of enriched pair dicts with updated field values from direct queries. + Original values preserved when direct query fails. + """ + if not pairs: + return [] + + enriched_pairs: List[Dict[str, Any]] = [] + for pair in pairs: + enriched = dict(pair) + switch_id = enriched.get(VpcFieldNames.SWITCH_ID) + if not switch_id: + enriched_pairs.append(enriched) + continue + + direct_vpc = None + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + direct_vpc = nd_v2.request(path, HttpVerbEnum.GET) + except (NDModuleError, Exception): + direct_vpc = None + finally: + rest_send.restore_settings() + + if isinstance(direct_vpc, dict): + peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) + if peer_switch_id: + enriched[VpcFieldNames.PEER_SWITCH_ID] = peer_switch_id + + use_virtual_peer_link = _get_api_field_value( + direct_vpc, + "useVirtualPeerLink", + enriched.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK), + ) + if use_virtual_peer_link is not None: + enriched[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = use_virtual_peer_link + + enriched_pairs.append(enriched) + + return enriched_pairs + + +def _filter_stale_vpc_pairs( + nd_v2, + fabric_name: str, + pairs: List[Dict[str, Any]], + module, +) -> List[Dict[str, Any]]: + """ + Remove stale pairs using overview membership checks. + + `/vpcPairs` can briefly lag after unpair operations. We perform a lightweight + best-effort membership check and drop entries that are explicitly reported as + not part of a vPC pair. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + pairs: List of pair dicts to validate + module: AnsibleModule instance for warnings + + Returns: + Filtered list of pair dicts with stale entries removed. + """ + if not pairs: + return [] + + pruned_pairs: List[Dict[str, Any]] = [] + for pair in pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + if not switch_id: + pruned_pairs.append(pair) + continue + + membership = _is_switch_in_vpc_pair(nd_v2, fabric_name, switch_id, timeout=5) + if membership is False: + module.warn( + f"Excluding stale vPC pair entry for switch {switch_id} " + "because overview reports it is not in a vPC pair." + ) + continue + pruned_pairs.append(pair) + + return pruned_pairs + + +def _filter_vpc_pairs_by_requested_config( + pairs: List[Dict[str, Any]], + config: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Filter queried VPC pairs by explicit pair keys provided in gathered config. + + If gathered config is empty or does not contain complete switch pairs, return + the unfiltered pair list. + + Args: + pairs: List of discovered pair dicts from API + config: List of user-requested pair dicts from playbook + + Returns: + Filtered list of pair dicts matching requested config keys. + Returns full pair list when config is empty or has no complete pairs. + """ + if not pairs or not config: + return list(pairs or []) + + requested_pair_keys = set() + for item in config: + switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if switch_id and peer_switch_id: + requested_pair_keys.add(tuple(sorted([switch_id, peer_switch_id]))) + + if not requested_pair_keys: + return list(pairs) + + filtered_pairs = [] + for item in pairs: + switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if switch_id and peer_switch_id: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + if pair_key in requested_pair_keys: + filtered_pairs.append(item) + + return filtered_pairs + + +def _is_ip_literal(value: Any) -> bool: + """ + Return True when value is a valid IPv4/IPv6 literal string. + + Args: + value: Any value to check + + Returns: + True if value is a valid IP address string, False otherwise. + """ + 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_config_switch_ips( + nd_v2, + module, + fabric_name: str, + config: List[Dict[str, Any]], +): + """ + Resolve switch identifiers from management IPs to serial numbers. + + If config contains IP literals in switch fields, query fabric switch inventory + and replace those IPs with serial numbers in both snake_case and API keys. + + Args: + nd_v2: NDModuleV2 instance for RestSend + module: AnsibleModule instance for warnings + fabric_name: Fabric name for inventory lookup + config: List of config item dicts (may contain IP-based switch IDs) + + Returns: + Tuple of (normalized_config, ip_to_sn_mapping, fabric_switches_dict). + Returns (original_config, {}, None) when no IPs are found. + """ + if not config: + return list(config or []), {}, None + + has_ip_inputs = False + for item in config: + if not isinstance(item, dict): + continue + for key in ("switch_id", VpcFieldNames.SWITCH_ID, "peer_switch_id", VpcFieldNames.PEER_SWITCH_ID): + if _is_ip_literal(item.get(key)): + has_ip_inputs = True + break + if has_ip_inputs: + break + + if not has_ip_inputs: + return list(config), {}, None + + fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) + ip_to_sn = { + str(sw.get(VpcFieldNames.FABRIC_MGMT_IP)).strip(): sw.get(VpcFieldNames.SERIAL_NUMBER) + for sw in fabric_switches.values() + if sw.get(VpcFieldNames.FABRIC_MGMT_IP) and sw.get(VpcFieldNames.SERIAL_NUMBER) + } + + if not ip_to_sn: + module.warn( + "Switch IP identifiers were provided in config, but no " + "fabricManagementIp to serialNumber mapping was discovered. " + "Continuing with identifiers as provided." + ) + return list(config), {}, fabric_switches + + normalized_config: List[Dict[str, Any]] = [] + resolved_inputs: Dict[str, str] = {} + unresolved_inputs = set() + + for item in config: + if not isinstance(item, dict): + normalized_config.append(item) + continue + + normalized_item = dict(item) + for snake_key, api_key in ( + ("switch_id", VpcFieldNames.SWITCH_ID), + ("peer_switch_id", VpcFieldNames.PEER_SWITCH_ID), + ): + raw_identifier = normalized_item.get(snake_key) + if raw_identifier is None: + raw_identifier = normalized_item.get(api_key) + if raw_identifier is None: + continue + + resolved_identifier = raw_identifier + if _is_ip_literal(raw_identifier): + ip_value = str(raw_identifier).strip() + mapped_serial = ip_to_sn.get(ip_value) + if mapped_serial: + resolved_identifier = mapped_serial + resolved_inputs[ip_value] = mapped_serial + else: + unresolved_inputs.add(ip_value) + + normalized_item[snake_key] = resolved_identifier + normalized_item[api_key] = resolved_identifier + + normalized_config.append(normalized_item) + + for ip_value, serial in sorted(resolved_inputs.items()): + module.warn( + f"Resolved playbook switch IP {ip_value} to switch serial {serial} " + f"for fabric {fabric_name}." + ) + + if unresolved_inputs: + module.warn( + "Could not resolve playbook switch IP(s) to serial numbers for " + f"fabric {fabric_name}: {', '.join(sorted(unresolved_inputs))}. " + "Those values will be processed as provided." + ) + + return normalized_config, ip_to_sn, fabric_switches + + +def normalize_vpc_playbook_switch_identifiers( + module, + nd_v2=None, + fabric_name: Optional[str] = None, + state: Optional[str] = None, +): + """ + Normalize playbook switch identifiers from management IPs to serial numbers. + + Updates module params in-place: + - merged/replaced/overridden/deleted: module.params["config"] + - gathered: module.params["_gather_filter_config"] + + Also merges resolved IP->serial mappings into module.params["_ip_to_sn_mapping"]. + + Args: + module: AnsibleModule instance + nd_v2: Optional NDModuleV2 instance (created internally if None) + fabric_name: Optional fabric name override (defaults to module param) + state: Optional state override (defaults to module param) + + Returns: + Optional[Dict[str, Dict]]: Preloaded fabric switches map when queried, else None. + """ + effective_state = state or module.params.get("state", "merged") + effective_fabric = fabric_name if fabric_name is not None else module.params.get("fabric_name") + + if effective_state == "gathered": + config = module.params.get("_gather_filter_config") or [] + else: + config = module.params.get("config") or [] + + if nd_v2 is None: + nd_v2 = NDModuleV2(module) + + config, resolved_ip_to_sn, preloaded_fabric_switches = _resolve_config_switch_ips( + nd_v2=nd_v2, + module=module, + fabric_name=effective_fabric, + config=config, + ) + + if effective_state == "gathered": + module.params["_gather_filter_config"] = list(config) + else: + module.params["config"] = list(config) + + if resolved_ip_to_sn: + existing_map = module.params.get("_ip_to_sn_mapping") or {} + merged_map = dict(existing_map) if isinstance(existing_map, dict) else {} + merged_map.update(resolved_ip_to_sn) + module.params["_ip_to_sn_mapping"] = merged_map + + return preloaded_fabric_switches + + +def custom_vpc_query_all(nrm) -> List[Dict]: + """ + Query existing VPC pairs with state-aware enrichment. + + Flow: + - Base query from /vpcPairs list (always attempted first) + - gathered/deleted: use lightweight list-only data when available + - merged/replaced/overridden: enrich with switch inventory and recommendation + APIs to build have/pending_create/pending_delete sets + + Args: + nrm: VpcPairStateMachine or query context with .module attribute + + Returns: + List of existing pair dicts for NDConfigCollection initialization. + Also populates module params: _have, _pending_create, _pending_delete, + _fabric_switches, _fabric_switches_count, _ip_to_sn_mapping. + + Raises: + VpcPairResourceError: On unrecoverable query failures + """ + fabric_name = nrm.module.params.get("fabric_name") + + if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): + raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") + + state = nrm.module.params.get("state", "merged") + nrm.module.params["_pending_state_known"] = True + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + preloaded_fabric_switches = normalize_vpc_playbook_switch_identifiers( + module=nrm.module, + nd_v2=nd_v2, + fabric_name=fabric_name, + state=state, + ) + + if state == "gathered": + config = nrm.module.params.get("_gather_filter_config") or [] + else: + config = nrm.module.params.get("config") or [] + + def _set_lightweight_context( + lightweight_have: List[Dict[str, Any]], + pending_state_known: bool = True, + ) -> List[Dict[str, Any]]: + nrm.module.params["_fabric_switches"] = [] + nrm.module.params["_fabric_switches_count"] = 0 + existing_map = nrm.module.params.get("_ip_to_sn_mapping") + nrm.module.params["_ip_to_sn_mapping"] = ( + dict(existing_map) if isinstance(existing_map, dict) else {} + ) + nrm.module.params["_have"] = lightweight_have + nrm.module.params["_pending_create"] = [] + nrm.module.params["_pending_delete"] = [] + nrm.module.params["_pending_state_known"] = pending_state_known + return lightweight_have + + try: + # Step 1: Base query from list endpoint (/vpcPairs) + have = [] + list_query_succeeded = False + try: + list_path = VpcPairEndpoints.vpc_pairs_list(fabric_name) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("query_timeout", 10) + try: + vpc_pairs_response = nd_v2.request(list_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + have.extend(_extract_vpc_pairs_from_list_response(vpc_pairs_response)) + list_query_succeeded = True + except Exception as list_error: + nrm.module.warn( + f"VPC pairs list query failed for fabric {fabric_name}: " + f"{str(list_error).splitlines()[0]}." + ) + + # Lightweight path for gathered and targeted delete workflows. + # For delete-all (state=deleted with empty config), use full switch-level + # discovery so stale/lagging list responses do not miss active pairs. + if state == "gathered" or (state == "deleted" and bool(config)): + if list_query_succeeded: + if state == "deleted" and config and not have: + fallback_have = [] + for item in config: + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id_val or not peer_switch_id_val: + continue + + use_vpl_val = item.get("use_virtual_peer_link") + if use_vpl_val is None: + use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + fallback_have.append( + { + VpcFieldNames.SWITCH_ID: switch_id_val, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, + } + ) + + if fallback_have: + nrm.module.warn( + "vPC list query returned no pairs for delete workflow. " + "Using requested delete config as fallback existing set." + ) + return _set_lightweight_context(fallback_have) + + if state == "gathered": + have = _filter_vpc_pairs_by_requested_config(have, config) + have = _enrich_pairs_from_direct_vpc( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + timeout=5, + ) + have = _filter_stale_vpc_pairs( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + module=nrm.module, + ) + if have: + return _set_lightweight_context( + lightweight_have=have, + pending_state_known=False, + ) + nrm.module.warn( + "vPC list query returned no active pairs for gathered workflow. " + "Falling back to switch-level discovery." + ) + else: + return _set_lightweight_context(have) + + nrm.module.warn( + "Skipping switch-level discovery for read-only/delete workflow because " + "the vPC list endpoint is unavailable." + ) + + if state == "gathered": + nrm.module.warn( + "vPC list endpoint unavailable for gathered workflow. " + "Falling back to switch-level discovery." + ) + else: + # Preserve explicit delete intent without full-fabric discovery. + # This keeps delete deterministic and avoids expensive inventory calls. + fallback_have = [] + for item in config: + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id_val or not peer_switch_id_val: + continue + + use_vpl_val = item.get("use_virtual_peer_link") + if use_vpl_val is None: + use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + fallback_have.append( + { + VpcFieldNames.SWITCH_ID: switch_id_val, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, + } + ) + + if fallback_have: + nrm.module.warn( + "Using requested delete config as fallback existing set because " + "vPC list query failed." + ) + return _set_lightweight_context(fallback_have) + + if config: + nrm.module.warn( + "Delete config did not contain complete vPC pairs. " + "No delete intents can be built from list-query fallback." + ) + return _set_lightweight_context([]) + + nrm.module.warn( + "Delete-all requested with no explicit pairs and unavailable list endpoint. " + "Falling back to switch-level discovery." + ) + + # Step 2 (write-state enrichment): Query and validate fabric switches. + fabric_switches = preloaded_fabric_switches + if fabric_switches is None: + fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) + + if not fabric_switches: + nrm.module.warn(f"No switches found in fabric {fabric_name}") + nrm.module.params["_fabric_switches"] = [] + nrm.module.params["_fabric_switches_count"] = 0 + nrm.module.params["_have"] = [] + nrm.module.params["_pending_create"] = [] + nrm.module.params["_pending_delete"] = [] + return [] + + # Keep only switch IDs for validation and serialize safely in module params. + fabric_switches_list = list(fabric_switches.keys()) + nrm.module.params["_fabric_switches"] = fabric_switches_list + nrm.module.params["_fabric_switches_count"] = len(fabric_switches) + + # Build IP-to-SN mapping (extract before dict is discarded). + ip_to_sn = { + sw.get(VpcFieldNames.FABRIC_MGMT_IP): sw.get(VpcFieldNames.SERIAL_NUMBER) + for sw in fabric_switches.values() + if VpcFieldNames.FABRIC_MGMT_IP in sw + } + existing_map = nrm.module.params.get("_ip_to_sn_mapping") or {} + merged_map = dict(existing_map) if isinstance(existing_map, dict) else {} + merged_map.update(ip_to_sn) + nrm.module.params["_ip_to_sn_mapping"] = merged_map + + # Step 3: Track 3-state VPC pairs (have/pending_create/pending_delete). + pending_create = [] + pending_delete = [] + processed_switches = set() + + config_switch_ids = set() + for item in config: + # Config items are normalized to snake_case in main(). + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + + if switch_id_val: + config_switch_ids.add(switch_id_val) + if peer_switch_id_val: + config_switch_ids.add(peer_switch_id_val) + + for switch_id, switch in fabric_switches.items(): + if switch_id in processed_switches: + continue + + vpc_configured = switch.get(VpcFieldNames.VPC_CONFIGURED, False) + vpc_data = switch.get("vpcData", {}) + + if vpc_configured and vpc_data: + peer_switch_id = vpc_data.get("peerSwitchId") + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + # For configured pairs, prefer direct vPC query as source of truth. + try: + vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = 5 + try: + direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + except (NDModuleError, Exception): + direct_vpc = None + + if direct_vpc: + resolved_peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) or peer_switch_id + if resolved_peer_switch_id: + processed_switches.add(resolved_peer_switch_id) + use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) + + # Direct /vpcPair can be stale for a short period after delete. + # Cross-check overview to avoid reporting stale active pairs. + membership = _is_switch_in_vpc_pair( + nd_v2, fabric_name, switch_id, timeout=5 + ) + if membership is False: + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # Direct query failed - fall back to recommendation. + try: + recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) + except Exception as rec_error: + error_msg = str(rec_error).splitlines()[0] + nrm.module.warn( + f"Recommendation query failed for switch {switch_id}: {error_msg}. " + f"Unable to read configured vPC pair details." + ) + recommendation = None + + if recommendation: + resolved_peer_switch_id = _get_api_field_value(recommendation, "serialNumber") or peer_switch_id + if resolved_peer_switch_id: + processed_switches.add(resolved_peer_switch_id) + use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # VPC configured but query failed - mark as pending delete. + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: False, + }) + elif not config_switch_ids or switch_id in config_switch_ids: + # For unconfigured switches, prefer direct vPC pair query first. + try: + vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = 5 + try: + direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + except (NDModuleError, Exception): + direct_vpc = None + + if direct_vpc: + peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) + if peer_switch_id: + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) + membership = _is_switch_in_vpc_pair( + nd_v2, fabric_name, switch_id, timeout=5 + ) + if membership is False: + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # No direct pair; check recommendation for pending create candidates. + try: + recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) + except Exception as rec_error: + error_msg = str(rec_error).splitlines()[0] + nrm.module.warn( + f"Recommendation query failed for switch {switch_id}: {error_msg}. " + f"No recommendation details available." + ) + recommendation = None + + if recommendation: + peer_switch_id = _get_api_field_value(recommendation, "serialNumber") + if peer_switch_id: + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) + pending_create.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + + # Step 4: Store all states for use in create/update/delete. + nrm.module.params["_have"] = have + nrm.module.params["_pending_create"] = pending_create + nrm.module.params["_pending_delete"] = pending_delete + + # Build effective existing set for state reconciliation: + # - Include only active pairs (have). + # - Exclude pending-delete pairs from active set to avoid stale + # idempotence false-negatives right after unpair operations. + # + # Pending-create candidates are recommendations, not configured pairs. + # Treating them as existing causes false no-change outcomes for create. + pair_by_key = {} + for pair in have: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id or not peer_switch_id: + continue + key = tuple(sorted([switch_id, peer_switch_id])) + pair_by_key[key] = pair + + for pair in pending_delete: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id or not peer_switch_id: + continue + key = tuple(sorted([switch_id, peer_switch_id])) + pair_by_key.pop(key, None) + + existing_pairs = list(pair_by_key.values()) + return existing_pairs + + except NDModuleError as error: + error_dict = error.to_dict() + if "msg" in error_dict: + error_dict["api_error_msg"] = error_dict.pop("msg") + _raise_vpc_error( + msg=f"Failed to query VPC pairs: {error.msg}", + fabric=fabric_name, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to query VPC pairs: {str(e)}", + fabric=fabric_name, + exception_type=type(e).__name__ + ) diff --git a/plugins/module_utils/nd_manage_vpc_pair_runner.py b/plugins/module_utils/nd_manage_vpc_pair_runner.py new file mode 100644 index 00000000..fdd2fdc0 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_runner.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Any, Dict + +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _collection_to_list_flex, +) + +def run_vpc_module(nrm) -> Dict[str, Any]: + """ + Run VPC module state machine with VPC-specific gathered output. + + Top-level state router. For gathered: builds read-only output filtering out + pending-delete pairs. For deleted/overridden with empty config: synthesizes + explicit delete intents. Otherwise delegates to nrm.manage_state(). + + Args: + nrm: VpcPairStateMachine instance + + Returns: + Dict with module result including current, gathered, before, after, + changed, created, deleted, updated keys. + """ + state = nrm.module.params.get("state", "merged") + config = nrm.module.params.get("config", []) + + if state == "gathered": + nrm.add_logs_and_outputs() + nrm.result["changed"] = False + + current_pairs = nrm.result.get("current", []) or [] + pending_state_known = nrm.module.params.get("_pending_state_known", True) + pending_delete = nrm.module.params.get("_pending_delete", []) or [] + + # Exclude pairs in pending-delete from active gathered set. + pending_delete_keys = set() + for pair in pending_delete: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + pending_delete_keys.add(tuple(sorted([switch_id, peer_switch_id]))) + + filtered_current = [] + for pair in current_pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + if pair_key in pending_delete_keys: + continue + filtered_current.append(pair) + + nrm.result["current"] = filtered_current + nrm.result["gathered"] = { + "vpc_pairs": filtered_current, + "pending_create_vpc_pairs": nrm.module.params.get("_pending_create", []), + "pending_delete_vpc_pairs": pending_delete, + "pending_state_known": pending_state_known, + } + if not pending_state_known: + nrm.result["gathered"]["pending_state_note"] = ( + "Pending create/delete lists are unavailable in lightweight gather mode " + "and are provided as empty placeholders." + ) + return nrm.result + + # state=deleted with empty config means "delete all existing pairs in this fabric". + # + # state=overridden with empty config has the same user intent (TC4): + # remove all existing pairs from this fabric. + if state in ("deleted", "overridden") and not config: + # Use the live existing collection from NDStateMachine. + # nrm.result["current"] is only populated after add_logs_and_outputs(), so relying on + # it here would incorrectly produce an empty delete list. + existing_pairs = _collection_to_list_flex(getattr(nrm, "existing", None)) + if not existing_pairs: + existing_pairs = nrm.result.get("current", []) or [] + + delete_all_config = [] + for pair in existing_pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + use_vpl = pair.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + if use_vpl is None: + use_vpl = pair.get("use_virtual_peer_link", True) + delete_all_config.append( + { + "switch_id": switch_id, + "peer_switch_id": peer_switch_id, + "use_virtual_peer_link": use_vpl, + } + ) + config = delete_all_config + # Force explicit delete operations instead of relying on overridden-state + # reconciliation behavior with empty desired config. + if state == "overridden": + state = "deleted" + + nrm.manage_state(state=state, new_configs=config) + nrm.add_logs_and_outputs() + return nrm.result + + +# ===== Module Entry Point ===== diff --git a/plugins/module_utils/nd_manage_vpc_pair_validation.py b/plugins/module_utils/nd_manage_vpc_pair_validation.py new file mode 100644 index 00000000..52cbd994 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_validation.py @@ -0,0 +1,654 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Any, Dict, List, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( + _get_api_field_value, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModuleError + +def _get_pairing_support_details( + nd_v2, + fabric_name: str, + switch_id: str, + component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, + timeout: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Query /vpcPairSupport endpoint to validate pairing support. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Support check type (default: checkPairing) + timeout: Optional timeout override (uses module query_timeout if not specified) + + Returns: + Dict with support details, or None if response is not a dict. + + Raises: + ValueError: If fabric_name or switch_id are invalid + NDModuleError: On API errors + """ + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + path = VpcPairEndpoints.switch_vpc_support( + fabric_name=fabric_name, + switch_id=switch_id, + component_type=component_type, + ) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + support_details = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if isinstance(support_details, dict): + return support_details + return None + + +def _validate_fabric_peering_support( + nrm, + nd_v2, + fabric_name: str, + switch_id: str, + peer_switch_id: str, + use_virtual_peer_link: bool, +) -> None: + """ + Validate fabric peering support when virtual peer link is requested. + + If API explicitly reports unsupported fabric peering, logs warning and + continues. If support API is unavailable, logs warning and continues. + + Args: + nrm: VpcPairStateMachine instance for logging warnings + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Primary switch serial number + peer_switch_id: Peer switch serial number + use_virtual_peer_link: Whether virtual peer link is requested + """ + if not use_virtual_peer_link: + return + + switches_to_check = [switch_id, peer_switch_id] + for support_switch_id in switches_to_check: + if not support_switch_id: + continue + + try: + support_details = _get_pairing_support_details( + nd_v2, + fabric_name=fabric_name, + switch_id=support_switch_id, + component_type=ComponentTypeSupportEnum.CHECK_FABRIC_PEERING_SUPPORT.value, + ) + if not support_details: + continue + + is_supported = _get_api_field_value( + support_details, "isVpcFabricPeeringSupported", None + ) + if is_supported is False: + status = _get_api_field_value( + support_details, "status", "Fabric peering not supported" + ) + nrm.module.warn( + f"VPC fabric peering is not supported for switch {support_switch_id}: {status}. " + f"Continuing, but config save/deploy may report a platform limitation. " + f"Consider setting use_virtual_peer_link=false for this platform." + ) + except Exception as support_error: + nrm.module.warn( + f"Fabric peering support check failed for switch {support_switch_id}: " + f"{str(support_error).splitlines()[0]}. Continuing with create/update operation." + ) + + +def _get_consistency_details( + nd_v2, + fabric_name: str, + switch_id: str, + timeout: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Query /vpcPairConsistency endpoint for consistency diagnostics. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module query_timeout if not specified) + + Returns: + Dict with consistency details, or None if response is not a dict. + + Raises: + ValueError: If fabric_name or switch_id are invalid + NDModuleError: On API errors + """ + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + path = VpcPairEndpoints.switch_vpc_consistency(fabric_name, switch_id) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + consistency_details = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if isinstance(consistency_details, dict): + return consistency_details + return None + + +def _is_switch_in_vpc_pair( + nd_v2, + fabric_name: str, + switch_id: str, + timeout: Optional[int] = None, +) -> Optional[bool]: + """ + Best-effort active-membership check via vPC overview endpoint. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module query_timeout if not specified) + + Returns: + True: overview query succeeded (switch is part of a vPC pair) + False: API explicitly reports switch is not in a vPC pair + None: unknown/error (do not block caller logic) + """ + if not fabric_name or not switch_id: + return None + + path = VpcPairEndpoints.switch_vpc_overview( + fabric_name, switch_id, component_type="full" + ) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + nd_v2.request(path, HttpVerbEnum.GET) + return True + except NDModuleError as error: + error_msg = (error.msg or "").lower() + if error.status == 400 and "not a part of vpc pair" in error_msg: + return False + return None + except Exception: + return None + finally: + rest_send.restore_settings() + + +def _validate_fabric_switches(nd_v2, fabric_name: str) -> Dict[str, Dict]: + """ + Query and validate fabric switch inventory. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + + Returns: + Dict mapping switch serial number to switch info + + Raises: + ValueError: If inputs are invalid + NDModuleError: If fabric switch query fails + """ + # Input validation + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + + # Use api_timeout from module params + timeout = nd_v2.module.params.get("api_timeout", 30) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + switches_path = VpcPairEndpoints.fabric_switches(fabric_name) + switches_response = nd_v2.request(switches_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if not switches_response: + return {} + + # Validate response structure + if not isinstance(switches_response, dict): + nd_v2.module.warn( + f"Unexpected switches response format: expected dict, got {type(switches_response).__name__}" + ) + return {} + + switches = switches_response.get(VpcFieldNames.SWITCHES, []) + + # Validate switches is a list + if not isinstance(switches, list): + nd_v2.module.warn( + f"Unexpected switches format: expected list, got {type(switches).__name__}" + ) + return {} + + # Build validated switch dictionary + result = {} + for sw in switches: + if not isinstance(sw, dict): + nd_v2.module.warn(f"Skipping invalid switch entry: expected dict, got {type(sw).__name__}") + continue + + serial_number = sw.get(VpcFieldNames.SERIAL_NUMBER) + if not serial_number: + continue + + # Validate serial number format + if not isinstance(serial_number, str) or len(serial_number) < 3: + nd_v2.module.warn(f"Skipping switch with invalid serial number: {serial_number}") + continue + + result[serial_number] = sw + + return result + + +def _validate_switch_conflicts(want_configs: List[Dict], have_vpc_pairs: List[Dict], module) -> None: + """ + Validate that switches in want configs aren't already in different VPC pairs. + + Optimized implementation using index-based lookup for O(n) time complexity instead of O(n²). + + Args: + want_configs: List of desired VPC pair configs + have_vpc_pairs: List of existing VPC pairs + module: AnsibleModule instance for fail_json + + Raises: + AnsibleModule.fail_json: If switch conflicts detected + """ + conflicts = [] + + # Build index of existing VPC pairs by switch ID - O(m) where m = len(have_vpc_pairs) + # Maps switch_id -> list of VPC pairs containing that switch + switch_to_vpc_index = {} + for have in have_vpc_pairs: + have_switch_id = have.get(VpcFieldNames.SWITCH_ID) + have_peer_id = have.get(VpcFieldNames.PEER_SWITCH_ID) + + if have_switch_id: + if have_switch_id not in switch_to_vpc_index: + switch_to_vpc_index[have_switch_id] = [] + switch_to_vpc_index[have_switch_id].append(have) + + if have_peer_id: + if have_peer_id not in switch_to_vpc_index: + switch_to_vpc_index[have_peer_id] = [] + switch_to_vpc_index[have_peer_id].append(have) + + # Check each want config for conflicts - O(n) where n = len(want_configs) + for want in want_configs: + want_switches = {want.get(VpcFieldNames.SWITCH_ID), want.get(VpcFieldNames.PEER_SWITCH_ID)} + want_switches.discard(None) + + # Build set of all VPC pairs that contain any switch from want_switches - O(1) lookup per switch + # Use set to track VPC IDs we've already checked to avoid duplicate processing + conflicting_vpcs = {} # vpc_id -> vpc dict + for switch in want_switches: + if switch in switch_to_vpc_index: + for vpc in switch_to_vpc_index[switch]: + # Use tuple of sorted switch IDs as unique identifier + vpc_id = tuple(sorted([vpc.get(VpcFieldNames.SWITCH_ID), vpc.get(VpcFieldNames.PEER_SWITCH_ID)])) + # Only add if we haven't seen this VPC ID before (avoids duplicate processing) + if vpc_id not in conflicting_vpcs: + conflicting_vpcs[vpc_id] = vpc + + # Check each potentially conflicting VPC pair + for vpc_id, have in conflicting_vpcs.items(): + have_switches = {have.get(VpcFieldNames.SWITCH_ID), have.get(VpcFieldNames.PEER_SWITCH_ID)} + have_switches.discard(None) + + # Same VPC pair is OK + if want_switches == have_switches: + continue + + # Check for switch overlap with different pairs + switch_overlap = want_switches & have_switches + if switch_overlap: + # Filter out None values and ensure strings for joining + overlap_list = [str(s) for s in switch_overlap if s is not None] + want_key = f"{want.get(VpcFieldNames.SWITCH_ID)}-{want.get(VpcFieldNames.PEER_SWITCH_ID)}" + have_key = f"{have.get(VpcFieldNames.SWITCH_ID)}-{have.get(VpcFieldNames.PEER_SWITCH_ID)}" + conflicts.append( + f"Switch(es) {', '.join(overlap_list)} in wanted VPC pair {want_key} " + f"are already part of existing VPC pair {have_key}" + ) + + if conflicts: + _raise_vpc_error( + msg="Switch conflicts detected. A switch can only be part of one VPC pair at a time.", + conflicts=conflicts + ) + + +def _validate_switches_exist_in_fabric( + nrm, + fabric_name: str, + switch_id: str, + peer_switch_id: str, +) -> None: + """ + Validate both switches exist in discovered fabric inventory. + + This check is mandatory for create/update. Empty inventory is treated as + a validation error to avoid bypassing guardrails and failing later with a + less actionable API error. + + Args: + nrm: VpcPairStateMachine instance with module params containing _fabric_switches + fabric_name: Fabric name for error messages + switch_id: Primary switch serial number + peer_switch_id: Peer switch serial number + + Raises: + VpcPairResourceError: If switches are missing from fabric inventory + """ + fabric_switches = nrm.module.params.get("_fabric_switches") + + if fabric_switches is None: + _raise_vpc_error( + msg=( + f"Switch validation failed for fabric '{fabric_name}': switch inventory " + "was not loaded from query_all. Unable to validate requested vPC pair." + ), + vpc_pair_key=nrm.current_identifier, + fabric=fabric_name, + ) + + valid_switches = sorted(list(fabric_switches)) + if not valid_switches: + _raise_vpc_error( + msg=( + f"Switch validation failed for fabric '{fabric_name}': no switches were " + "discovered in fabric inventory. Cannot create/update vPC pairs without " + "validated switch membership." + ), + vpc_pair_key=nrm.current_identifier, + fabric=fabric_name, + total_valid_switches=0, + ) + + missing_switches = [] + if switch_id not in fabric_switches: + missing_switches.append(switch_id) + if peer_switch_id not in fabric_switches: + missing_switches.append(peer_switch_id) + + if not missing_switches: + return + + max_switches_in_error = 10 + error_msg = ( + f"Switch validation failed: The following switch(es) do not exist in fabric '{fabric_name}':\n" + f" Missing switches: {', '.join(missing_switches)}\n" + f" Affected vPC pair: {nrm.current_identifier}\n\n" + "Please ensure:\n" + " 1. Switch serial numbers are correct (not IP addresses)\n" + " 2. Switches are discovered and present in the fabric\n" + " 3. You have the correct fabric name specified\n\n" + ) + + if len(valid_switches) <= max_switches_in_error: + error_msg += f"Valid switches in fabric: {', '.join(valid_switches)}" + else: + error_msg += ( + f"Valid switches in fabric (first {max_switches_in_error}): " + f"{', '.join(valid_switches[:max_switches_in_error])} ... and " + f"{len(valid_switches) - max_switches_in_error} more" + ) + + _raise_vpc_error( + msg=error_msg, + missing_switches=missing_switches, + vpc_pair_key=nrm.current_identifier, + total_valid_switches=len(valid_switches), + ) + + +def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pair_key: str, module) -> None: + """ + Validate VPC pair can be safely deleted by checking for dependencies. + + This function prevents data loss by ensuring the VPC pair has no active: + 1. Networks (networkCount must be 0 for all statuses) + 2. VRFs (vrfCount must be 0 for all statuses) + 3. Warns if vPC interfaces exist (vpcInterfaceCount > 0) + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + vpc_pair_key: VPC pair identifier (e.g., "FDO123-FDO456") for error messages + module: AnsibleModule instance for fail_json/warn + + Raises: + AnsibleModule.fail_json: If VPC pair has active networks or VRFs + + Example: + _validate_vpc_pair_deletion(nd_v2, "myFabric", "FDO123", "FDO123-FDO456", module) + """ + try: + # Query overview endpoint with full component data + overview_path = VpcPairEndpoints.switch_vpc_overview(fabric_name, switch_id, component_type="full") + + # Bound overview validation call by query_timeout for deterministic behavior. + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nd_v2.module.params.get("query_timeout", 10) + try: + response = nd_v2.request(overview_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + # If no response, VPC pair doesn't exist - deletion not needed + if not response: + module.warn( + f"VPC pair {vpc_pair_key} not found in overview query. " + f"It may not exist or may have already been deleted." + ) + return + + # Query consistency endpoint for additional diagnostics before deletion. + # This is best effort and should not block deletion workflows. + try: + consistency = _get_consistency_details(nd_v2, fabric_name, switch_id) + if consistency: + type2_consistency = _get_api_field_value(consistency, "type2Consistency", None) + if type2_consistency is False: + reason = _get_api_field_value( + consistency, "type2ConsistencyReason", "unknown reason" + ) + module.warn( + f"VPC pair {vpc_pair_key} reports type2 consistency issue: {reason}" + ) + except Exception as consistency_error: + module.warn( + f"Failed to query consistency details for VPC pair {vpc_pair_key}: " + f"{str(consistency_error).splitlines()[0]}" + ) + + # Validate response structure + if not isinstance(response, dict): + _raise_vpc_error( + msg=f"Expected dict response from vPC pair overview for {vpc_pair_key}, got {type(response).__name__}", + response=response + ) + + # Validate overlay data exists + overlay = response.get(VpcFieldNames.OVERLAY) + if not overlay: + _raise_vpc_error( + msg=( + f"vPC pair {vpc_pair_key} might not exist or overlay data unavailable. " + f"Cannot safely validate deletion." + ), + vpc_pair_key=vpc_pair_key, + response=response + ) + + # Check 1: Validate no networks are attached + network_count = overlay.get(VpcFieldNames.NETWORK_COUNT, {}) + if isinstance(network_count, dict): + for status, count in network_count.items(): + try: + count_int = int(count) + if count_int != 0: + _raise_vpc_error( + msg=( + f"Cannot delete vPC pair {vpc_pair_key}. " + f"{count_int} network(s) with status '{status}' still exist. " + f"Remove all networks from this vPC pair before deleting it." + ), + vpc_pair_key=vpc_pair_key, + network_count=network_count, + blocking_status=status, + blocking_count=count_int + ) + except (ValueError, TypeError) as e: + # Best effort - log warning and continue + module.warn(f"Error parsing network count for status '{status}': {e}") + elif network_count: + # Non-dict format - log warning + module.warn( + f"networkCount is not a dict for {vpc_pair_key}: {type(network_count).__name__}. " + f"Skipping network validation." + ) + + # Check 2: Validate no VRFs are attached + vrf_count = overlay.get(VpcFieldNames.VRF_COUNT, {}) + if isinstance(vrf_count, dict): + for status, count in vrf_count.items(): + try: + count_int = int(count) + if count_int != 0: + _raise_vpc_error( + msg=( + f"Cannot delete vPC pair {vpc_pair_key}. " + f"{count_int} VRF(s) with status '{status}' still exist. " + f"Remove all VRFs from this vPC pair before deleting it." + ), + vpc_pair_key=vpc_pair_key, + vrf_count=vrf_count, + blocking_status=status, + blocking_count=count_int + ) + except (ValueError, TypeError) as e: + # Best effort - log warning and continue + module.warn(f"Error parsing VRF count for status '{status}': {e}") + elif vrf_count: + # Non-dict format - log warning + module.warn( + f"vrfCount is not a dict for {vpc_pair_key}: {type(vrf_count).__name__}. " + f"Skipping VRF validation." + ) + + # Check 3: Warn if vPC interfaces exist (non-blocking) + inventory = response.get(VpcFieldNames.INVENTORY, {}) + if inventory and isinstance(inventory, dict): + vpc_interface_count = inventory.get(VpcFieldNames.VPC_INTERFACE_COUNT) + if vpc_interface_count: + try: + count_int = int(vpc_interface_count) + if count_int > 0: + module.warn( + f"vPC pair {vpc_pair_key} has {count_int} vPC interface(s). " + f"Deletion may fail or require manual cleanup of interfaces. " + f"Consider removing vPC interfaces before deleting the vPC pair." + ) + except (ValueError, TypeError) as e: + # Best effort - just log debug message + pass + elif not inventory: + # No inventory data - warn user + module.warn( + f"Inventory data not available in overview response for {vpc_pair_key}. " + f"Proceeding with deletion, but it may fail if vPC interfaces exist." + ) + + except VpcPairResourceError: + raise + except NDModuleError as error: + error_msg = str(error.msg).lower() if error.msg else "" + status_code = error.status or 0 + + # If the overview query returns 400 with "not a part of" it means + # the pair no longer exists on the controller. Signal the caller + # by raising a ValueError with a sentinel message so that the + # delete function can treat this as an idempotent no-op. + if status_code == 400 and "not a part of" in error_msg: + raise ValueError( + f"VPC pair {vpc_pair_key} is already unpaired on the controller. " + f"No deletion required." + ) + + # Best effort validation - if overview query fails, log warning and proceed + # The API will still reject deletion if dependencies exist + module.warn( + f"Could not validate vPC pair {vpc_pair_key} for deletion: {error.msg}. " + f"Proceeding with deletion attempt. API will reject if dependencies exist." + ) + + except Exception as e: + # Best effort validation - log warning and continue + module.warn( + f"Unexpected error validating VPC pair {vpc_pair_key} for deletion: {str(e)}. " + f"Proceeding with deletion attempt." + ) + + +# ===== Custom Action Functions (used by VpcPairResourceService via orchestrator) ===== diff --git a/plugins/module_utils/orchestrators/manage_vpc_pair.py b/plugins/module_utils/orchestrators/manage_vpc_pair.py new file mode 100644 index 00000000..fde20351 --- /dev/null +++ b/plugins/module_utils/orchestrators/manage_vpc_pair.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +from typing import Any, Optional + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( + VpcPairModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_actions import ( + custom_vpc_create, + custom_vpc_delete, + custom_vpc_update, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( + custom_vpc_query_all, +) + + +class _VpcPairQueryContext: + """ + Minimal context object for query_all during NDStateMachine initialization. + + Provides a .module attribute so custom_vpc_query_all can access module params + before the full state machine is constructed. + """ + + def __init__(self, module: AnsibleModule): + """ + Initialize query context. + + Args: + module: AnsibleModule instance + """ + self.module = module + + +class VpcPairOrchestrator: + """ + VPC orchestrator implementation for NDStateMachine. + + Delegates CRUD operations to vPC handlers while staying compatible with + sender/module constructor styles used by shared NDStateMachine variants. + """ + + model_class = VpcPairModel + + def __init__( + self, + module: Optional[AnsibleModule] = None, + sender: Optional[Any] = None, + **kwargs, + ): + """ + Initialize VpcPairOrchestrator. + + Args: + module: AnsibleModule instance (preferred) + sender: Optional NDModule/NDModuleV2 with .module attribute + **kwargs: Ignored (for framework compatibility) + + Raises: + ValueError: If neither module nor sender provides an AnsibleModule + """ + _ = kwargs + if module is None and sender is not None: + module = getattr(sender, "module", None) + if module is None: + raise ValueError( + "VpcPairOrchestrator requires either module=AnsibleModule " + "or sender=." + ) + + self.module = module + self.sender = sender + self.state_machine = None + + def bind_state_machine(self, state_machine: Any) -> None: + """ + Link orchestrator to its parent state machine. + + Args: + state_machine: VpcPairStateMachine instance for CRUD handler access + """ + self.state_machine = state_machine + + def query_all(self): + """ + Query all existing vPC pairs from the controller. + + Delegates to custom_vpc_query_all for discovery and runtime context. + + Returns: + List of existing pair dicts for NDConfigCollection initialization. + """ + context = ( + self.state_machine + if self.state_machine is not None + else _VpcPairQueryContext(self.module) + ) + return custom_vpc_query_all(context) + + def create(self, model_instance, **kwargs): + """ + Create a new vPC pair via custom_vpc_create handler. + + Args: + model_instance: VpcPairModel instance (unused, context from state machine) + **kwargs: Ignored + + Returns: + API response from create operation. + + Raises: + RuntimeError: If orchestrator is not bound to a state machine + """ + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return custom_vpc_create(self.state_machine) + + def update(self, model_instance, **kwargs): + """ + Update an existing vPC pair via custom_vpc_update handler. + + Args: + model_instance: VpcPairModel instance (unused, context from state machine) + **kwargs: Ignored + + Returns: + API response from update operation. + + Raises: + RuntimeError: If orchestrator is not bound to a state machine + """ + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return custom_vpc_update(self.state_machine) + + def delete(self, model_instance, **kwargs): + """ + Delete a vPC pair via custom_vpc_delete handler. + + Args: + model_instance: VpcPairModel instance (unused, context from state machine) + **kwargs: Ignored + + Returns: + API response from delete operation, or False if already unpaired. + + Raises: + RuntimeError: If orchestrator is not bound to a state machine + """ + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return custom_vpc_delete(self.state_machine) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py new file mode 100644 index 00000000..e2f289f3 --- /dev/null +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." +__author__ = "Sivakami S" + +DOCUMENTATION = """ +--- +module: nd_manage_vpc_pair +short_description: Manage vPC pairs in Nexus devices. +description: +- Create, update, delete, override, and gather vPC pairs on Nexus devices. +- Uses NDStateMachine framework with a vPC orchestrator. +- Integrates RestSend for battle-tested HTTP handling with retry logic. +- Handles VPC API quirks via custom orchestrator action handlers. +options: + state: + choices: + - merged + - replaced + - deleted + - overridden + - gathered + default: merged + description: + - The state of the vPC pair configuration after module completion. + - C(gathered) is the query/read-only mode for this module. + type: str + fabric_name: + description: + - Name of the fabric. + required: true + type: str + deploy: + description: + - Deploy configuration changes after applying them. + - Saves fabric configuration and triggers deployment. + type: bool + default: false + force: + description: + - Force deletion without pre-deletion validation checks. + - 'WARNING: Bypasses safety checks for networks, VRFs, and vPC interfaces.' + - Use only when validation API timeouts or you are certain deletion is safe. + - Only applies to deleted state. + type: bool + default: false + api_timeout: + description: + - API request timeout in seconds for primary operations (create, update, delete). + - Increase for large fabrics or slow networks. + type: int + default: 30 + query_timeout: + description: + - API request timeout in seconds for query and recommendation operations. + - Lower timeout for non-critical queries to avoid port exhaustion. + type: int + default: 10 + refresh_after_apply: + description: + - Query controller again after write operations to populate final C(after) state. + - Disable for faster execution when eventual consistency is acceptable. + type: bool + default: true + refresh_after_timeout: + description: + - Optional timeout in seconds for the post-apply refresh query. + - When omitted, C(query_timeout) is used. + type: int + suppress_verification: + description: + - Skip post-apply controller query for final C(after) state verification. + - Equivalent to setting C(refresh_after_apply=false). + - Improves performance by avoiding end-of-task query. + type: bool + default: false + config: + description: + - List of vPC pair configuration dictionaries. + type: list + elements: dict + suboptions: + peer1_switch_id: + description: + - Peer1 switch serial number or management IP address for the vPC pair. + required: true + type: str + peer2_switch_id: + description: + - Peer2 switch serial number or management IP address for the vPC pair. + required: true + type: str + use_virtual_peer_link: + description: + - Enable virtual peer link for the vPC pair. + type: bool + default: true +notes: + - This module uses NDStateMachine framework for state management + - RestSend provides protocol-based HTTP abstraction with automatic retry logic + - Results are aggregated using the Results class for consistent output format + - Check mode is fully supported via both framework and RestSend +""" + +EXAMPLES = """ +# Create a new vPC pair +- name: Create vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + use_virtual_peer_link: true + +# Delete a vPC pair +- name: Delete vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: deleted + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + +# Create a new vPC pair using management IPs +- name: Create vPC pair with switch management IPs + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + config: + - peer1_switch_id: "10.10.10.11" + peer2_switch_id: "10.10.10.12" + use_virtual_peer_link: true + +# Gather existing vPC pairs +- name: Gather all vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: gathered + +# Create and deploy +- name: Create vPC pair and deploy + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + deploy: true + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + +# Native Ansible check_mode behavior +- name: Check mode vPC pair creation + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + check_mode: true + +# Performance mode: skip final after-state verification query +- name: Create vPC pair without post-apply verification query + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + suppress_verification: true + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + +""" + +RETURN = """ +changed: + description: Whether the module made any changes + type: bool + returned: always + sample: true +before: + description: + - vPC pair state before changes. + - May contain controller read-only properties because it is queried from controller state. + type: list + returned: always + sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": false}] +after: + description: + - vPC pair state after changes. + - By default this is refreshed from controller after write operations and may include read-only properties. + - Refresh can be skipped with C(refresh_after_apply=false) or C(suppress_verification=true). + type: list + returned: always + sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": true}] +gathered: + description: Current vPC pairs (gathered state only) + type: dict + returned: when state is gathered + contains: + vpc_pairs: + description: List of configured VPC pairs + type: list + pending_create_vpc_pairs: + description: VPC pairs ready to be created (switches are paired but VPC not configured) + type: list + pending_delete_vpc_pairs: + description: VPC pairs in transitional delete state + type: list + sample: + vpc_pairs: [{"switchId": "FDO123", "peerSwitchId": "FDO456"}] + pending_create_vpc_pairs: [] + pending_delete_vpc_pairs: [] +response: + description: List of all API responses + type: list + returned: always + sample: [{"RETURN_CODE": 200, "METHOD": "PUT", "MESSAGE": "Success"}] +result: + description: List of all operation results + type: list + returned: always + sample: [{"success": true, "changed": true}] +diff: + description: List of all changes made, organized by operation + type: list + returned: always + contains: + operation: + description: Type of operation (POST/PUT/DELETE) + type: str + vpc_pair_key: + description: Identifier for the VPC pair (switchId-peerSwitchId) + type: str + path: + description: API endpoint path used + type: str + payload: + description: Request payload sent to API + type: dict + sample: [{"operation": "PUT", "vpc_pair_key": "FDO123-FDO456", "path": "/api/v1/...", "payload": {}}] +created: + description: List of created object identifiers + type: list + returned: always + sample: [["FDO123", "FDO456"]] +deleted: + description: List of deleted object identifiers + type: list + returned: always + sample: [["FDO123", "FDO456"]] +updated: + description: List of updated object identifiers and changed properties + type: list + returned: always + sample: [{"identifier": ["FDO123", "FDO456"], "changed_properties": ["useVirtualPeerLink"]}] +metadata: + description: Operation metadata with sequence and identifiers + type: dict + returned: when operations are performed + contains: + vpc_pair_key: + description: VPC pair identifier + type: str + operation: + description: Operation type (create/update/delete) + type: str + sequence_number: + description: Operation sequence in batch + type: int + sample: {"vpc_pair_key": "FDO123-FDO456", "operation": "create", "sequence_number": 1} +warnings: + description: List of warning messages from validation or operations + type: list + returned: when warnings occur + sample: ["VPC pair has 2 vPC interfaces - deletion may require manual cleanup"] +failed: + description: Whether any operation failed + type: bool + returned: when operations fail + sample: false +ip_to_sn_mapping: + description: Mapping of switch IP addresses to serial numbers + type: dict + returned: when available from fabric inventory + sample: {"10.1.1.1": "FDO123", "10.1.1.2": "FDO456"} +deployment: + description: Deployment operation results (when deploy=true) + type: dict + returned: when deploy parameter is true + contains: + deployment_needed: + description: Whether deployment was needed based on changes + type: bool + changed: + description: Whether deployment made changes + type: bool + response: + description: List of deployment API responses (save and deploy) + type: list + sample: {"deployment_needed": true, "changed": true, "response": [...]} +deployment_needed: + description: Flag indicating if deployment was needed + type: bool + returned: when deploy=true + sample: true +pending_create_pairs_not_in_delete: + description: VPC pairs in pending create state not included in delete wants (deleted state only) + type: list + returned: when state is deleted and pending create pairs exist + sample: [{"switchId": "FDO789", "peerSwitchId": "FDO012"}] +pending_delete_pairs_not_in_delete: + description: VPC pairs in pending delete state not included in delete wants (deleted state only) + type: list + returned: when state is deleted and pending delete pairs exist + sample: [] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ValidationError, +) + +# Service layer imports +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( + VpcPairResourceService, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) + +# Static imports so Ansible's AnsiballZ packager includes these files in the +# module zip. Keep them optional when framework files are intentionally absent. +try: + from ansible_collections.cisco.nd.plugins.module_utils import nd_config_collection as _nd_config_collection + from ansible_collections.cisco.nd.plugins.module_utils import utils as _nd_utils +except Exception: # pragma: no cover - compatibility for stripped framework trees + _nd_config_collection = None # noqa: F841 + _nd_utils = None # noqa: F841 + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( + VpcPairPlaybookConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_deploy import ( + _needs_deployment, + custom_vpc_deploy, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_runner import ( + run_vpc_module, +) + + +# ===== Module Entry Point ===== + + +def main(): + """ + Module entry point combining framework + RestSend. + + Builds argument spec from Pydantic models, validates state-level rules, + normalizes config keys, creates VpcPairResourceService with handler + callbacks, and delegates execution. + + Architecture: + - Thin module entrypoint delegates to VpcPairResourceService + - VpcPairResourceService handles NDStateMachine orchestration + - Custom actions use RestSend (NDModuleV2) for HTTP with retry logic + + Raises: + VpcPairResourceError: Converted to module.fail_json with structured details + """ + argument_spec = VpcPairPlaybookConfigModel.get_argument_spec() + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + setup_logging(module) + + try: + module_config = VpcPairPlaybookConfigModel.model_validate( + module.params, by_alias=True, by_name=True + ) + except ValidationError as e: + module.fail_json( + msg="Invalid nd_manage_vpc_pair playbook configuration", + validation_errors=e.errors(), + ) + + # State-specific parameter validations + state = module_config.state + deploy = module_config.deploy + suppress_verification = module_config.suppress_verification + + if state == "gathered" and deploy: + module.fail_json(msg="Deploy parameter cannot be used with 'gathered' state") + + if suppress_verification: + if module.params.get("refresh_after_apply", True): + module.warn( + "suppress_verification=true overrides refresh_after_apply=true. " + "Final after-state refresh query will be skipped." + ) + if module.params.get("refresh_after_timeout") is not None: + module.warn( + "refresh_after_timeout is ignored when suppress_verification=true." + ) + module.params["refresh_after_apply"] = False + + # Validate force parameter usage: + # - state=deleted + # - state=overridden with empty config (interpreted as delete-all) + force = module_config.force + user_config = module_config.config or [] + force_applicable = state == "deleted" or ( + state == "overridden" and len(user_config) == 0 + ) + if force and not force_applicable: + module.warn( + "Parameter 'force' only applies to state 'deleted' or to " + "state 'overridden' when config is empty (delete-all behavior). " + f"Ignoring force for state '{state}'." + ) + + # Normalize config keys for runtime/state-machine model handling. + normalized_config = [ + item.to_runtime_config() for item in (module_config.config or []) + ] + + module.params["config"] = normalized_config + + # Gather must remain strictly read-only. Preserve user-provided config as a + # query filter, but clear the framework desired config to avoid unintended + # reconciliation before run_vpc_module() handles gathered output. + if state == "gathered": + module.params["_gather_filter_config"] = list(normalized_config) + module.params["config"] = [] + else: + module.params["_gather_filter_config"] = [] + + # VpcPairResourceService bridges NDStateMachine lifecycle hooks to RestSend actions. + fabric_name = module.params.get("fabric_name") + try: + service = VpcPairResourceService( + module=module, + run_state_handler=run_vpc_module, + deploy_handler=custom_vpc_deploy, + needs_deployment_handler=_needs_deployment, + ) + result = service.execute(fabric_name=fabric_name) + + module.exit_json(**result) + + except VpcPairResourceError as e: + module.fail_json(msg=e.msg, **e.details) + except Exception as e: + module.fail_json(msg=str(e)) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_vpc_pair/tasks/base_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tasks/base_tasks.yaml new file mode 100644 index 00000000..3cb9147c --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/base_tasks.yaml @@ -0,0 +1,52 @@ +--- +# Shared base tasks for nd_vpc_pair integration tests. +# Import this at the top of each test file: +# - import_tasks: base_tasks.yaml +# tags: + +- name: BASE - Test Entry Point - [nd_manage_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ nd_vpc_pair Integration Test Base Setup +" + - "----------------------------------------------------------------" + +# -------------------------------- +# Create Dictionary of Test Data +# -------------------------------- +- name: BASE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_fabric_type: "{{ fabric_type | default('LANClassic') }}" + deploy_local: true + delegate_to: localhost + +# ------------------------------------------ +# Query Fabric Existence +# ------------------------------------------ +- name: BASE - Verify fabric is reachable via API + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}" + method: get + register: fabric_query + ignore_errors: true + +- name: BASE - Assert fabric exists + ansible.builtin.assert: + that: + - fabric_query.failed == false + fail_msg: "Fabric '{{ test_fabric }}' not found or API unreachable." + +# ------------------------------------------ +# Clean up existing vPC pairs +# ------------------------------------------ +- name: BASE - Clean up existing vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true diff --git a/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml new file mode 100644 index 00000000..c9c05ea6 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml @@ -0,0 +1,28 @@ +--- +# Shared configuration preparation tasks for nd_vpc_pair integration tests. +# +# Usage: +# - name: Import Configuration Prepare Tasks +# vars: +# file: merge # output file identifier +# import_tasks: conf_prep_tasks.yaml +# +# Requires: vpc_pair_conf variable to be set before importing. + +- name: Build vPC Pair Config Data from Template + ansible.builtin.file: + path: "{{ playbook_dir }}/../files" + state: directory + mode: "0755" + delegate_to: localhost + +- name: Build vPC Pair Config Data from Template + ansible.builtin.template: + src: "{{ playbook_dir }}/../templates/nd_vpc_pair_conf.j2" + dest: "{{ playbook_dir }}/../files/nd_vpc_pair_{{ file }}_conf.yaml" + delegate_to: localhost + +- name: Load Configuration Data into Variable + ansible.builtin.set_fact: + "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', playbook_dir + '/../files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" + delegate_to: localhost diff --git a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml new file mode 100644 index 00000000..8eda593f --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml @@ -0,0 +1,46 @@ +--- +# Test discovery and execution for nd_vpc_pair 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_vpc_pair_merge # run one +# ansible-playbook -i hosts.yaml tasks/main.yaml --tags merge # run by tag + +- name: nd_vpc_pair integration tests + hosts: nd + gather_facts: false + tasks: + - name: Test that we have a Nexus Dashboard host, username and password + 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_vpc_pair test cases + ansible.builtin.find: + paths: "{{ playbook_dir }}" + patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" + file_type: file + connection: local + register: nd_vpc_pair_testcases + + - name: Build list of test items + ansible.builtin.set_fact: + test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | sort | list }}" + + - name: Assert nd_vpc_pair test discovery has matches + ansible.builtin.assert: + that: + - test_items | length > 0 + fail_msg: >- + No nd_vpc_pair test cases matched pattern + '{{ testcase | default("nd_vpc_pair_*") }}.yaml' under '{{ playbook_dir }}'. + + - name: Display discovered tests + ansible.builtin.debug: + msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" + + - name: Run nd_vpc_pair test cases + 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_vpc_pair/tasks/nd_vpc_pair_delete.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_delete.yaml new file mode 100644 index 00000000..8a119abd --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_delete.yaml @@ -0,0 +1,284 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: delete + +############################################## +## Setup Delete TestCase Variables ## +############################################## + +- name: DELETE - Setup config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: delete + +- name: Import Configuration Prepare Tasks - delete_setup + vars: + file: delete_setup + import_tasks: conf_prep_tasks.yaml + tags: delete + +############################################## +## DELETE ## +############################################## + +# TC1 - Setup: Create vPC pair for deletion tests +- name: DELETE - TC1 - MERGE - Create vPC pair for deletion testing + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_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 vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + mode: "exists" + register: validation + tags: delete + +- name: DELETE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC2 - Delete vPC pair with specific config +- name: DELETE - TC2 - DELETE - Delete vPC pair with specific peer config + cisco.nd.nd_manage_vpc_pair: &delete_specific + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: delete + +- name: DELETE - TC2 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + +- name: DELETE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC2 - VALIDATE - Verify vPC pair deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + +- name: DELETE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC3 - Idempotence test for deletion +- name: DELETE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *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 - Create another vPC pair for bulk deletion test +- name: DELETE - TC4 - MERGE - Create vPC pair for bulk deletion testing + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC4 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + +- name: DELETE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC4 - VALIDATE - Verify vPC pair state in ND for bulk deletion setup + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + mode: "exists" + register: validation + tags: delete + +- name: DELETE - TC4 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC5 - Delete all vPC pairs without specific config +- name: DELETE - TC5 - DELETE - Delete all vPC pairs without specific config + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + register: result + tags: delete + +- name: DELETE - TC5 - ASSERT - Check if bulk deletion successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true or (result.current | length) == 0 + tags: delete + +- name: DELETE - TC5 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: verify_result + tags: delete + +- name: DELETE - TC5 - VALIDATE - Verify bulk deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + +- name: DELETE - TC5 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC6 - Delete from empty fabric (should be no-op) +- name: DELETE - TC6 - DELETE - Delete from empty fabric (no-op) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + register: result + tags: delete + +- name: DELETE - TC6 - ASSERT - Check if no change occurred + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: delete + +# TC7 - Force deletion bypass path +- name: DELETE - TC7 - MERGE - Create vPC pair for force delete test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC7 - ASSERT - Verify setup creation for force test + ansible.builtin.assert: + that: + - result.failed == false + tags: delete + +- name: DELETE - TC7 - DELETE - Delete vPC pair with force true + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + force: true + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: delete + +- name: DELETE - TC7 - ASSERT - Verify force delete execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: delete + +- name: DELETE - TC7 - GATHER - Verify force deletion result in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC7 - VALIDATE - Confirm pair deleted with force + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + +- name: DELETE - TC7 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +############################################## +## CLEAN-UP ## +############################################## + +- name: DELETE - END - ensure clean state + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: delete diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml new file mode 100644 index 00000000..178ea52d --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml @@ -0,0 +1,363 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: gather + +############################################## +## Setup Gather TestCase Variables ## +############################################## + +- name: GATHER - Setup config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: gather + +- name: Import Configuration Prepare Tasks - gather_setup + vars: + file: gather_setup + import_tasks: conf_prep_tasks.yaml + tags: gather + +############################################## +## GATHER ## +############################################## + +# TC1 - Setup: Create vPC pair for gather tests +- name: GATHER - TC1 - MERGE - Create vPC pair for testing + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_gather_setup_conf }}" + register: result + tags: gather + +- name: GATHER - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.failed == false + tags: gather + +- name: GATHER - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: gather + +- name: GATHER - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_gather_setup_conf }}" + register: validation + tags: gather + +- name: GATHER - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: gather + +# TC2 - Gather with no filters +- name: GATHER - TC2 - GATHER - Gather all vPC pairs with no filters + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: result + tags: gather + +- name: GATHER - TC2 - ASSERT - Check gather results + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + +# TC3 - Gather with both peers specified +- name: GATHER - TC3 - GATHER - Gather vPC pair with both peers specified + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: gather + +- name: GATHER - TC3 - ASSERT - Check gather results with both peers + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + +# TC4 - Gather with one peer specified (not supported in nd_manage_vpc_pair) +- name: GATHER - TC4 - GATHER - Gather vPC pair with one peer specified + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC4 - ASSERT - Verify partial peer gather is rejected + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: gather + +# TC5 - Gather with second peer specified (not supported in nd_manage_vpc_pair) +- name: GATHER - TC5 - GATHER - Gather vPC pair with second peer specified + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer2_switch_id: "{{ test_switch2 }}" + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC5 - ASSERT - Verify partial peer gather is rejected + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: gather + +# TC6 - Gather with non-existent peer +- name: GATHER - TC6 - GATHER - Gather vPC pair with non-existent peer + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "INVALID_SERIAL" + peer2_switch_id: "{{ test_switch2 }}" + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC6 - ASSERT - Check gather results with non-existent peer + ansible.builtin.assert: + that: + - result.failed == false + tags: gather + +# TC7 - Gather with custom query_timeout +- name: GATHER - TC7 - GATHER - Gather with query_timeout override + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + query_timeout: 20 + register: result + tags: gather + +- name: GATHER - TC7 - ASSERT - Verify query_timeout path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.gathered is defined + tags: gather + +# TC8 - gathered + deploy validation (must fail) +- name: GATHER - TC8 - GATHER - Gather with deploy enabled (invalid) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + deploy: true + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC8 - ASSERT - Verify gathered+deploy validation + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is search("Deploy parameter cannot be used") + tags: gather + +# TC9 - gathered + dry_run validation (must fail) +- name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + dry_run: true + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC9 - ASSERT - Verify gathered+dry_run validation + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is search("Unsupported parameters") + - result.msg is search("dry_run") + tags: gather + +# TC10 - Validate /vpcPairs list API alignment with module gathered output +- name: GATHER - TC10 - LIST - Query vPC pairs list endpoint directly + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/vpcPairs" + method: get + register: vpc_pairs_list_result + tags: gather + +- name: GATHER - TC10 - GATHER - Query module gathered output for comparison + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: gathered_result + tags: gather + +- name: GATHER - TC10 - ASSERT - Verify list and gathered payload availability + ansible.builtin.assert: + that: + - vpc_pairs_list_result.failed == false + - vpc_pairs_list_result.current.vpcPairs is defined + - vpc_pairs_list_result.current.vpcPairs is sequence + - gathered_result.failed == false + - gathered_result.gathered.vpc_pairs is defined + tags: gather + +- name: GATHER - TC10 - ASSERT - Ensure each /vpcPairs entry appears in gathered output + ansible.builtin.assert: + that: + - >- + ( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', item.switchId) + | selectattr('peer_switch_id', 'equalto', item.peerSwitchId) + | list + | length + ) > 0 + ) or + ( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', item.peerSwitchId) + | selectattr('peer_switch_id', 'equalto', item.switchId) + | list + | length + ) > 0 + ) + loop: "{{ vpc_pairs_list_result.current.vpcPairs | default([]) }}" + tags: gather + +- name: GATHER - TC10 - PREP - Extract target pair from /vpcPairs response + ansible.builtin.set_fact: + tc10_pair_from_list: >- + {{ + ( + vpc_pairs_list_result.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch1) + | selectattr('peerSwitchId', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + vpc_pairs_list_result.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch2) + | selectattr('peerSwitchId', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: gather + +- name: GATHER - TC10 - PREP - Extract target pair from gathered output + ansible.builtin.set_fact: + tc10_pair_from_gathered: >- + {{ + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch1) + | selectattr('peer_switch_id', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch2) + | selectattr('peer_switch_id', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: gather + +- name: GATHER - TC10 - ASSERT - Verify useVirtualPeerLink alignment for target pair + ansible.builtin.assert: + quiet: true + that: + - tc10_pair_from_list is mapping + - tc10_pair_from_gathered is mapping + - tc10_gather_vpl == true + - (not tc10_list_has_vpl) or (tc10_list_vpl == tc10_gather_vpl) + vars: + tc10_list_has_vpl: >- + {{ + (tc10_pair_from_list.useVirtualPeerLink is defined) + or + (tc10_pair_from_list.useVirtualPeerlink is defined) + }} + tc10_list_vpl: >- + {{ + tc10_pair_from_list.useVirtualPeerLink + | default(tc10_pair_from_list.useVirtualPeerlink | default(false)) + | bool + }} + tc10_gather_vpl: >- + {{ + tc10_pair_from_gathered.use_virtual_peer_link + | default(tc10_pair_from_gathered.useVirtualPeerLink | default(false)) + | bool + }} + tags: gather + +# TC11 - Validate normalized pair matching for reversed switch order +- name: GATHER - TC11 - GATHER - Gather with reversed/duplicate pair filters + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + - peer1_switch_id: "{{ test_switch2 }}" + peer2_switch_id: "{{ test_switch1 }}" + register: result + tags: gather + +- name: GATHER - TC11 - ASSERT - Verify one pair returned for reversed filters + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + +############################################## +## CLEAN-UP ## +############################################## + +- name: GATHER - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: gather diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml new file mode 100644 index 00000000..effdadea --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml @@ -0,0 +1,925 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: merge + +############################################## +## Setup Merge TestCase Variables ## +############################################## + +- name: MERGE - Setup full config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_full + vars: + file: merge_full + import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup modified config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_modified + vars: + file: merge_modified + import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup minimal config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_minimal + vars: + file: merge_minimal + import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup no-deploy config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_no_deploy + vars: + file: merge_no_deploy + import_tasks: conf_prep_tasks.yaml + tags: merge + +############################################## +## MERGE ## +############################################## + +# TC1 - Create vPC pair with full configuration +- name: MERGE - TC1 - MERGE - Create vPC pair with full configuration + cisco.nd.nd_manage_vpc_pair: &conf_full + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_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 vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_full_conf }}" + changed: "{{ result.changed }}" + register: validation + tags: merge + +- name: MERGE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +- name: MERGE - TC1 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *conf_full + register: result + tags: merge + +- name: MERGE - TC1 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: merge + +# TC2 - Modify existing vPC pair configuration +- name: MERGE - TC2 - MERGE - Modify vPC pair configuration + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_modified_conf }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - VALIDATE - Verify modified vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_modified_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +# TC2b - VXLANFabric specific test +- name: MERGE - TC2b - MERGE - Merge vPC pair for VXLAN fabric + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + when: test_fabric_type == "VXLANFabric" + tags: merge + +- name: MERGE - TC2b - ASSERT - Check if changed flag is false for VXLAN + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: merge + +- name: MERGE - TC2b - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + when: test_fabric_type == "VXLANFabric" + tags: merge + +# TC3 - Delete vPC pair +- name: MERGE - TC3 - DELETE - Delete vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + +- name: MERGE - TC3 - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC3 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC3 - ASSERT - Verify vPC pair deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + +- name: MERGE - TC3 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC4 - Create vPC pair with minimal configuration +- name: MERGE - TC4 - MERGE - Create vPC pair with minimal configuration + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_minimal_conf }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - VALIDATE - Verify minimal vPC pair + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" + mode: "exists" + register: validation + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +# TC4b - Delete vPC pair after minimal test +- name: MERGE - TC4b - DELETE - Delete vPC pair after minimal test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4b - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +# TC5 - Create vPC pair with defaults (state omitted) +- name: MERGE - TC5 - MERGE - Create vPC pair with defaults + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + config: "{{ nd_vpc_pair_merge_minimal_conf }}" + register: result + tags: merge + +- name: MERGE - TC5 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC5 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC5 - VALIDATE - Verify vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" + mode: "exists" + register: validation + tags: merge + +- name: MERGE - TC5 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC5b - Delete vPC pair after defaults test +- name: MERGE - TC5b - DELETE - Delete vPC pair after defaults test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + +- name: MERGE - TC5b - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +# TC6 - Create vPC pair with deploy flag false +- name: MERGE - TC6 - MERGE - Create vPC pair with deploy false + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + deploy: false + config: "{{ nd_vpc_pair_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.deployment is not defined + tags: merge + +- name: MERGE - TC6 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC6 - VALIDATE - Verify vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_no_deploy_conf }}" + register: validation + tags: merge + +- name: MERGE - TC6 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC7 - Merge with vpc_pair_details default template settings +- name: MERGE - TC7 - MERGE - Update vPC pair with default vpc_pair_details + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + vpc_pair_details: + type: default + domain_id: 10 + switch_keep_alive_local_ip: "192.0.2.11" + peer_switch_keep_alive_local_ip: "192.0.2.12" + keep_alive_vrf: management + register: result + tags: merge + +- name: MERGE - TC7 - ASSERT - Verify default vpc_pair_details path + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +# TC8 - Merge with vpc_pair_details custom template settings +- name: MERGE - TC8 - MERGE - Update vPC pair with custom vpc_pair_details + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + vpc_pair_details: + type: custom + template_name: "my_custom_template" + template_config: + domainId: "20" + customConfig: "vpc domain 20" + register: result + tags: merge + +- name: MERGE - TC8 - ASSERT - Verify custom vpc_pair_details path + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +# TC9 - Test invalid configurations +- name: MERGE - TC9 - MERGE - Create vPC pair with invalid peer switch + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "INVALID_SERIAL" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + register: result + ignore_errors: true + tags: merge + +- name: MERGE - TC9 - ASSERT - Check invalid peer switch error + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: merge + +# TC10 - Create vPC pair with deploy enabled (actual deployment path) +- name: MERGE - TC10 - DELETE - Ensure vPC pair is absent before deploy test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true + tags: merge + +- name: MERGE - TC10 - MERGE - Create vPC pair with deploy true + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + deploy: true + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + register: result + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify deploy path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.deployment is defined + tags: merge + +- name: MERGE - TC10 - 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('REQUEST_PATH', 'search', '/actions/configSave') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('REQUEST_PATH', 'search', '/actions/deploy') + | list + | length + ) > 0 + tags: merge + +- name: MERGE - TC10 - GATHER - Query gathered pair after deploy flow + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: tc10_gather_result + tags: merge + +- name: MERGE - TC10 - PREP - Extract target pair from gathered output + ansible.builtin.set_fact: + tc10_gathered_pair: >- + {{ + ( + tc10_gather_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch1) + | selectattr('peer_switch_id', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + tc10_gather_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch2) + | selectattr('peer_switch_id', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify use_virtual_peer_link is true in gathered output + ansible.builtin.assert: + that: + - tc10_gather_result.failed == false + - tc10_gathered_pair is mapping + - > + ( + tc10_gathered_pair.use_virtual_peer_link + | default(tc10_gathered_pair.useVirtualPeerLink | default(false)) + | bool + ) == true + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairSupport checkPairing for both peers + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkPairing" + method: get + loop: + - "{{ test_switch1 }}" + - "{{ test_switch2 }}" + register: tc10_pairing_support + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify checkPairing support responses + ansible.builtin.assert: + that: + - item.failed == false + - item.current is mapping + - item.current.isPairingAllowed is defined + - item.current.isPairingAllowed | bool + loop: "{{ tc10_pairing_support.results | default([]) }}" + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairSupport checkFabricPeeringSupport for both peers + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkFabricPeeringSupport" + method: get + loop: + - "{{ test_switch1 }}" + - "{{ test_switch2 }}" + register: tc10_fabric_peering_support + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify fabric peering support endpoint responses + ansible.builtin.assert: + that: + - item.failed == false + - item.current is mapping + - item.current.isVpcFabricPeeringSupported is defined + - > + ( + item.current.isVpcFabricPeeringSupported | bool + ) or ( + (item.current.status | default("") | lower) is search("not supported") + ) + quiet: true + loop: "{{ tc10_fabric_peering_support.results | default([]) }}" + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairOverview with componentType=full + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairOverview?componentType=full" + method: get + register: tc10_overview_full + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairOverview with componentType=pairsInfo + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairOverview?componentType=pairsInfo" + method: get + register: tc10_overview_pairs_info + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify vpcPairOverview endpoint responses + ansible.builtin.assert: + that: + - tc10_overview_full.failed == false + - tc10_overview_full.current is defined + - tc10_overview_pairs_info.failed == false + - tc10_overview_pairs_info.current is defined + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairConsistency for switch1 + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairConsistency" + method: get + register: tc10_consistency + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify vpcPairConsistency endpoint response + ansible.builtin.assert: + that: + - tc10_consistency.failed == false + - tc10_consistency.current is defined + tags: merge + +- name: MERGE - TC10 - API - Query vPC pairs list endpoint directly + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/vpcPairs" + method: get + register: tc10_vpc_pairs + tags: merge + +- name: MERGE - TC10 - PREP - Extract target pair from /vpcPairs response + ansible.builtin.set_fact: + tc10_pair_from_list: >- + {{ + ( + tc10_vpc_pairs.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch1) + | selectattr('peerSwitchId', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + tc10_vpc_pairs.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch2) + | selectattr('peerSwitchId', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify /vpcPairs useVirtualPeerLink alignment + ansible.builtin.assert: + that: + - tc10_vpc_pairs.failed == false + - tc10_pair_from_list is mapping + - tc10_gather_vpl == true + - not tc10_list_has_vpl or tc10_list_vpl == tc10_gather_vpl + quiet: true + vars: + tc10_list_has_vpl: >- + {{ + tc10_pair_from_list.useVirtualPeerLink is defined + or + tc10_pair_from_list.useVirtualPeerlink is defined + }} + tc10_list_vpl: >- + {{ + ( + tc10_pair_from_list.useVirtualPeerLink + | default(tc10_pair_from_list.useVirtualPeerlink | default(false)) + ) + | bool + }} + tc10_gather_vpl: >- + {{ + ( + tc10_gathered_pair.use_virtual_peer_link + | default(tc10_gathered_pair.useVirtualPeerLink | default(false)) + ) + | bool + }} + tags: merge + +# TC11 - Delete with custom api_timeout +- name: MERGE - TC11 - DELETE - Delete vPC pair with api_timeout override + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + api_timeout: 60 + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + +- name: MERGE - TC11 - ASSERT - Verify api_timeout path execution + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +# TC12 - check_mode should not apply configuration changes +- name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before check_mode test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true + tags: merge + +- name: MERGE - TC12 - MERGE - Run check_mode create for vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_full_conf }}" + check_mode: true + register: result + tags: merge + +- name: MERGE - TC12 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC12 - GATHER - Verify check_mode did not create vPC pair + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC12 - VALIDATE - Confirm no persistent changes from check_mode + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + +- name: MERGE - TC12 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC13 - Native Ansible check_mode should not apply configuration changes +- name: MERGE - TC13 - MERGE - Run check_mode create for vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_full_conf }}" + check_mode: true + register: result + tags: merge + +- name: MERGE - TC13 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC13 - GATHER - Verify check_mode did not create vPC pair + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC13 - VALIDATE - Confirm no persistent changes from check_mode + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + +- name: MERGE - TC13 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC14 - Validate vpcPairSupport enforcement path (isPairingAllowed == false) +- name: MERGE - TC14 - PREP - Query fabric switches for support validation + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches" + method: get + register: switches_result + tags: merge + +- name: MERGE - TC14 - PREP - Query pairing support for each switch + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkPairing" + method: get + loop: "{{ (switches_result.current.switches | default([])) | map(attribute='serialNumber') | select('defined') | list }}" + register: support_result + ignore_errors: true + tags: merge + +- name: MERGE - TC14 - PREP - Choose blocked and allowed switch candidates + ansible.builtin.set_fact: + blocked_switch_id: >- + {{ + ( + support_result.results + | selectattr('current', 'defined') + | selectattr('current.isPairingAllowed', 'defined') + | selectattr('current.isPairingAllowed', 'equalto', false) + | map(attribute='item') + | list + | first + ) | default('') + }} + allowed_switch_id: >- + {{ + ( + support_result.results + | selectattr('current', 'defined') + | selectattr('current.isPairingAllowed', 'defined') + | selectattr('current.isPairingAllowed', 'equalto', true) + | map(attribute='item') + | list + | first + ) | default('') + }} + tags: merge + +- name: MERGE - TC14 - PREP - Determine if support enforcement scenario is available + ansible.builtin.set_fact: + tc14_supported_scenario: >- + {{ + (blocked_switch_id | length > 0) + and (allowed_switch_id | length > 0) + and (blocked_switch_id != allowed_switch_id) + }} + tags: merge + +- name: MERGE - TC14 - INFO - Skip support enforcement validation when no blocked switch exists + ansible.builtin.debug: + msg: >- + Skipping TC14 because no switch reports isPairingAllowed=false in this lab. + blocked_switch_id='{{ blocked_switch_id }}', allowed_switch_id='{{ allowed_switch_id }}' + when: not tc14_supported_scenario + tags: merge + +- name: MERGE - TC14 - MERGE - Verify unsupported pairing is blocked by module + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ blocked_switch_id }}" + peer2_switch_id: "{{ allowed_switch_id }}" + use_virtual_peer_link: true + register: result + ignore_errors: true + when: tc14_supported_scenario + tags: merge + +- name: MERGE - TC14 - ASSERT - Validate unsupported pairing failure details + ansible.builtin.assert: + that: + - result.failed == true + - > + ( + (result.msg is search("VPC pairing is not allowed for switch")) + and (result.support_details is defined) + and (result.support_details.isPairingAllowed == false) + ) + or + ( + (result.msg is search("Switch conflicts detected")) + and (result.conflicts is defined) + and ((result.conflicts | length) > 0) + ) + when: tc14_supported_scenario + tags: merge + +############################################## +## CLEAN-UP ## +############################################## + +- name: MERGE - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: merge diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_override.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_override.yaml new file mode 100644 index 00000000..a6a1e406 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_override.yaml @@ -0,0 +1,243 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: override + +############################################## +## Setup Override TestCase Variables ## +############################################## + +- name: OVERRIDE - Setup initial config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: override + +- name: Import Configuration Prepare Tasks - override_initial + vars: + file: override_initial + import_tasks: conf_prep_tasks.yaml + tags: override + +- name: OVERRIDE - Setup overridden config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: override + +- name: Import Configuration Prepare Tasks - override_overridden + vars: + file: override_overridden + import_tasks: conf_prep_tasks.yaml + tags: override + +############################################## +## OVERRIDE ## +############################################## + +# TC1 - Override with a new vPC switch pair +- name: OVERRIDE - TC1 - OVERRIDE - Create vPC pair using override state + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: "{{ nd_vpc_pair_override_initial_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: override + +- name: OVERRIDE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_override_initial_conf }}" + register: validation + tags: override + +- name: OVERRIDE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: override + +# TC2 - Override with same vPC switch pair with changes +- name: OVERRIDE - TC2 - OVERRIDE - Override vPC pair with changes + cisco.nd.nd_manage_vpc_pair: &conf_overridden + fabric_name: "{{ test_fabric }}" + state: overridden + config: "{{ nd_vpc_pair_override_overridden_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Check if changed flag is true for LANClassic + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: override + +- name: OVERRIDE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC2 - VALIDATE - Verify overridden vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_override_overridden_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: override + +# TC3 - Idempotence test +- name: OVERRIDE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *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 existing vPC pair with no config (delete all) +- name: OVERRIDE - TC4 - OVERRIDE - Delete all vPC pairs via override with no config + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: [] + register: result + tags: override + +- name: OVERRIDE - TC4 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.failed == false + tags: override + +- name: OVERRIDE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC4 - VALIDATE - Verify vPC pair deletion via override + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: override + +- name: OVERRIDE - TC4 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: override + +# TC5 - Gather to verify deletion +- name: OVERRIDE - TC5 - GATHER - Verify vPC pair deletion + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + until: + - '(result.gathered.vpc_pairs | length) == 0' + retries: 30 + delay: 5 + tags: override + +# TC6 - Override with no vPC pair and no config (should be no-op) +- name: OVERRIDE - TC6 - OVERRIDE - Override with no vPC pairs (no-op) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: [] + register: result + tags: override + +- name: OVERRIDE - TC6 - ASSERT - Check if no change occurred + ansible.builtin.assert: + that: + - result.failed == false + tags: override + +- name: OVERRIDE - TC6 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC6 - VALIDATE - Verify no-op override + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: override + +- name: OVERRIDE - TC6 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: override + +############################################## +## CLEAN-UP ## +############################################## + +- name: OVERRIDE - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: override diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_replace.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_replace.yaml new file mode 100644 index 00000000..fbf61b39 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_replace.yaml @@ -0,0 +1,156 @@ +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: replace + +############################################## +## Setup Replace TestCase Variables ## +############################################## + +- name: REPLACE - Setup initial config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: replace + +- name: Import Configuration Prepare Tasks - replace_initial + vars: + file: replace_initial + import_tasks: conf_prep_tasks.yaml + tags: replace + +- name: REPLACE - Setup replaced config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: replace + +- name: Import Configuration Prepare Tasks - replace_replaced + vars: + file: replace_replaced + import_tasks: conf_prep_tasks.yaml + tags: replace + +############################################## +## REPLACE ## +############################################## + +# TC1 - Create initial vPC pair using replace state +- name: REPLACE - TC1 - REPLACE - Create vPC pair using replace state + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: replaced + config: "{{ nd_vpc_pair_replace_initial_conf }}" + register: result + tags: replace + +- name: REPLACE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: replace + +- name: REPLACE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: replace + +- name: REPLACE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_replace_initial_conf }}" + register: validation + tags: replace + +- name: REPLACE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: replace + +# TC2 - Replace vPC pair configuration +- name: REPLACE - TC2 - REPLACE - Replace vPC pair configuration + cisco.nd.nd_manage_vpc_pair: &conf_replaced + fabric_name: "{{ test_fabric }}" + state: replaced + config: "{{ nd_vpc_pair_replace_replaced_conf }}" + register: result + tags: replace + +- name: REPLACE - TC2 - ASSERT - Check if changed flag is true for LANClassic + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: replace + +- name: REPLACE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: replace + +- name: REPLACE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: replace + +- name: REPLACE - TC2 - VALIDATE - Verify replaced vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_replace_replaced_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: replace + +- name: REPLACE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: replace + +# TC3 - Idempotence test +- name: REPLACE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *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 + +############################################## +## CLEAN-UP ## +############################################## + +- name: REPLACE - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: replace diff --git a/tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 b/tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 new file mode 100644 index 00000000..e8115beb --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 @@ -0,0 +1,49 @@ +--- +# This nd_vpc_pair test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_vpc_pair_conf.j2 +# Variables: vpc_pair_conf (list of dicts) + +{% if vpc_pair_conf is iterable %} +{% set pair_list = [] %} +{% for pair in vpc_pair_conf %} +{% set pair_item = {} %} +{% if pair.peer1_switch_id is defined %} +{% set _ = pair_item.update({'peer1_switch_id': pair.peer1_switch_id}) %} +{% endif %} +{% if pair.peer2_switch_id is defined %} +{% set _ = pair_item.update({'peer2_switch_id': pair.peer2_switch_id}) %} +{% endif %} +{% if pair.use_virtual_peer_link is defined %} +{% set _ = pair_item.update({'use_virtual_peer_link': pair.use_virtual_peer_link}) %} +{% endif %} +{% if pair.vpc_pair_details is defined %} +{% set details_item = {} %} +{% if pair.vpc_pair_details.type is defined %} +{% set _ = details_item.update({'type': pair.vpc_pair_details.type}) %} +{% endif %} +{% if pair.vpc_pair_details.domain_id is defined %} +{% set _ = details_item.update({'domain_id': pair.vpc_pair_details.domain_id}) %} +{% endif %} +{% if pair.vpc_pair_details.switch_keep_alive_local_ip is defined %} +{% set _ = details_item.update({'switch_keep_alive_local_ip': pair.vpc_pair_details.switch_keep_alive_local_ip}) %} +{% endif %} +{% if pair.vpc_pair_details.peer_switch_keep_alive_local_ip is defined %} +{% set _ = details_item.update({'peer_switch_keep_alive_local_ip': pair.vpc_pair_details.peer_switch_keep_alive_local_ip}) %} +{% endif %} +{% if pair.vpc_pair_details.keep_alive_vrf is defined %} +{% set _ = details_item.update({'keep_alive_vrf': pair.vpc_pair_details.keep_alive_vrf}) %} +{% endif %} +{% if pair.vpc_pair_details.template_name is defined %} +{% set _ = details_item.update({'template_name': pair.vpc_pair_details.template_name}) %} +{% endif %} +{% if pair.vpc_pair_details.template_config is defined %} +{% set _ = details_item.update({'template_config': pair.vpc_pair_details.template_config}) %} +{% endif %} +{% set _ = pair_item.update({'vpc_pair_details': details_item}) %} +{% endif %} +{% set _ = pair_list.append(pair_item) %} +{% endfor %} +{{ pair_list | to_nice_yaml(indent=2) }} +{% endif %} diff --git a/tests/unit/module_utils/endpoints/test_base_path.py b/tests/unit/module_utils/endpoints/test_base_path.py new file mode 100644 index 00000000..4a4e64d6 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_path.py @@ -0,0 +1,244 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for base_path.py + +Tests the ApiPath enum defined in base_path.py +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( + ApiPath, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + + +# ============================================================================= +# Test: ApiPath Enum Values +# ============================================================================= + + +def test_base_path_00010(): + """ + # Summary + + Verify ApiPath.ANALYZE value + + ## Test + + - ApiPath.ANALYZE equals "/api/v1/analyze" + + ## Classes and Methods + + - ApiPath.ANALYZE + """ + with does_not_raise(): + result = ApiPath.ANALYZE.value + assert result == "/api/v1/analyze" + + +def test_base_path_00020(): + """ + # Summary + + Verify ApiPath.INFRA value + + ## Test + + - ApiPath.INFRA equals "/api/v1/infra" + + ## Classes and Methods + + - ApiPath.INFRA + """ + with does_not_raise(): + result = ApiPath.INFRA.value + assert result == "/api/v1/infra" + + +def test_base_path_00030(): + """ + # Summary + + Verify ApiPath.MANAGE value + + ## Test + + - ApiPath.MANAGE equals "/api/v1/manage" + + ## Classes and Methods + + - ApiPath.MANAGE + """ + with does_not_raise(): + result = ApiPath.MANAGE.value + assert result == "/api/v1/manage" + + +def test_base_path_00040(): + """ + # Summary + + Verify ApiPath.ONEMANAGE value + + ## Test + + - ApiPath.ONEMANAGE equals "/api/v1/onemanage" + + ## Classes and Methods + + - ApiPath.ONEMANAGE + """ + with does_not_raise(): + result = ApiPath.ONEMANAGE.value + assert result == "/api/v1/onemanage" + + +# ============================================================================= +# Test: ApiPath Enum Properties +# ============================================================================= + + +def test_base_path_00100(): + """ + # Summary + + Verify ApiPath enum members are strings + + ## Test + + - ApiPath enum extends str + - Enum members can be used directly in string operations + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + assert isinstance(ApiPath.INFRA, str) + assert isinstance(ApiPath.MANAGE, str) + assert isinstance(ApiPath.ANALYZE, str) + assert isinstance(ApiPath.ONEMANAGE, str) + + +def test_base_path_00110(): + """ + # Summary + + Verify all API paths start with forward slash + + ## Test + + - All ApiPath values start with "/" + - This ensures proper path concatenation + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + for member in ApiPath: + assert member.value.startswith("/"), f"{member.name} does not start with /" + + +def test_base_path_00120(): + """ + # Summary + + Verify no API paths end with trailing slash + + ## Test + + - No ApiPath values end with "/" + - This prevents double slashes when building paths + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + for member in ApiPath: + assert not member.value.endswith("/"), f"{member.name} ends with /" + + +def test_base_path_00130(): + """ + # Summary + + Verify ApiPath enum provides all expected members + + ## Test + + - All 4 API paths available as enum members + - Enum is iterable + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + paths = list(ApiPath) + + assert len(paths) == 4 + assert ApiPath.ANALYZE in paths + assert ApiPath.INFRA in paths + assert ApiPath.MANAGE in paths + assert ApiPath.ONEMANAGE in paths + + +# ============================================================================= +# Test: Path Uniqueness +# ============================================================================= + + +def test_base_path_00200(): + """ + # Summary + + Verify all ApiPath values are unique + + ## Test + + - Each enum member has a different value + - No duplicate paths exist + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + values = [member.value for member in ApiPath] + assert len(values) == len(set(values)), "Duplicate paths found" + + +# ============================================================================= +# Test: ND API Path Structure +# ============================================================================= + + +def test_base_path_00300(): + """ + # Summary + + Verify all ApiPath members follow /api/v1/ pattern + + ## Test + + - All ApiPath values start with "/api/v1/" + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + for member in ApiPath: + assert member.value.startswith("/api/v1/"), f"{member.name} does not follow /api/v1/ pattern" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py new file mode 100644 index 00000000..c3e6c778 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py @@ -0,0 +1,267 @@ +# 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 vPC pair endpoint models under plugins/module_utils/endpoints/v1/manage. + +Mirrors the style used in PR198 endpoint unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from urllib.parse import parse_qsl, urlsplit + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, + VpcPairGetEndpointParams, + VpcPairPutEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( + EpVpcPairOverviewGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, + VpcPairRecommendationEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( + EpVpcPairSupportGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( + EpVpcPairsListGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + + +def _assert_path_with_query(path: str, expected_base_path: str, expected_query: dict[str, str]) -> None: + parsed = urlsplit(path) + assert parsed.path == expected_base_path + assert dict(parse_qsl(parsed.query, keep_blank_values=True)) == expected_query + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00010(): + """Verify VpcPairGetEndpointParams query serialization.""" + with does_not_raise(): + params = VpcPairGetEndpointParams(from_cluster="cluster-a") + result = params.to_query_string() + assert result == "fromCluster=cluster-a" + + +def test_endpoints_api_v1_manage_vpc_pair_00020(): + """Verify VpcPairPutEndpointParams query serialization.""" + with does_not_raise(): + params = VpcPairPutEndpointParams(from_cluster="cluster-a", ticket_id="CHG123") + result = params.to_query_string() + parsed = dict(parse_qsl(result, keep_blank_values=True)) + assert parsed == {"fromCluster": "cluster-a", "ticketId": "CHG123"} + + +def test_endpoints_api_v1_manage_vpc_pair_00030(): + """Verify EpVpcPairGet basics.""" + with does_not_raise(): + instance = EpVpcPairGet() + assert instance.class_name == "EpVpcPairGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_vpc_pair_00040(): + """Verify EpVpcPairGet path raises when required path fields are missing.""" + instance = EpVpcPairGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_vpc_pair_00050(): + """Verify EpVpcPairGet path without query params.""" + with does_not_raise(): + instance = EpVpcPairGet(fabric_name="fab1", switch_id="SN01") + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPair" + + +def test_endpoints_api_v1_manage_vpc_pair_00060(): + """Verify EpVpcPairGet path with query params.""" + with does_not_raise(): + instance = EpVpcPairGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + result = instance.path + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPair", + {"fromCluster": "cluster-a"}, + ) + + +def test_endpoints_api_v1_manage_vpc_pair_00070(): + """Verify EpVpcPairPut basics and query path.""" + with does_not_raise(): + instance = EpVpcPairPut(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.ticket_id = "CHG1" + result = instance.path + assert instance.class_name == "EpVpcPairPut" + assert instance.verb == HttpVerbEnum.PUT + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPair", + {"fromCluster": "cluster-a", "ticketId": "CHG1"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_consistency.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00100(): + """Verify EpVpcPairConsistencyGet basics and path.""" + with does_not_raise(): + instance = EpVpcPairConsistencyGet(fabric_name="fab1", switch_id="SN01") + result = instance.path + assert instance.class_name == "EpVpcPairConsistencyGet" + assert instance.verb == HttpVerbEnum.GET + assert result == "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairConsistency" + + +def test_endpoints_api_v1_manage_vpc_pair_00110(): + """Verify EpVpcPairConsistencyGet query params.""" + with does_not_raise(): + instance = EpVpcPairConsistencyGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + result = instance.path + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairConsistency", + {"fromCluster": "cluster-a"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_overview.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00200(): + """Verify EpVpcPairOverviewGet query params.""" + with does_not_raise(): + instance = EpVpcPairOverviewGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.component_type = "health" + result = instance.path + assert instance.class_name == "EpVpcPairOverviewGet" + assert instance.verb == HttpVerbEnum.GET + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairOverview", + {"fromCluster": "cluster-a", "componentType": "health"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_recommendation.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00300(): + """Verify recommendation params keep use_virtual_peer_link optional.""" + with does_not_raise(): + params = VpcPairRecommendationEndpointParams() + assert params.use_virtual_peer_link is None + assert params.to_query_string() == "" + + +def test_endpoints_api_v1_manage_vpc_pair_00310(): + """Verify EpVpcPairRecommendationGet path with optional useVirtualPeerLink.""" + with does_not_raise(): + instance = EpVpcPairRecommendationGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.use_virtual_peer_link = True + result = instance.path + assert instance.class_name == "EpVpcPairRecommendationGet" + assert instance.verb == HttpVerbEnum.GET + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairRecommendation", + {"useVirtualPeerLink": "true"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_support.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00400(): + """Verify EpVpcPairSupportGet query params.""" + with does_not_raise(): + instance = EpVpcPairSupportGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.component_type = "checkPairing" + result = instance.path + assert instance.class_name == "EpVpcPairSupportGet" + assert instance.verb == HttpVerbEnum.GET + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairSupport", + {"fromCluster": "cluster-a", "componentType": "checkPairing"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_vpc_pairs.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00500(): + """Verify EpVpcPairsListGet basics.""" + with does_not_raise(): + instance = EpVpcPairsListGet() + assert instance.class_name == "EpVpcPairsListGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_vpc_pair_00510(): + """Verify EpVpcPairsListGet raises when fabric_name is missing.""" + instance = EpVpcPairsListGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_vpc_pair_00520(): + """Verify EpVpcPairsListGet full query serialization.""" + with does_not_raise(): + instance = EpVpcPairsListGet(fabric_name="fab1") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.filter = "switchId:SN01" + instance.endpoint_params.max = 50 + instance.endpoint_params.offset = 10 + instance.endpoint_params.sort = "switchId:asc" + instance.endpoint_params.view = "discoveredPairs" + result = instance.path + + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/vpcPairs", + { + "fromCluster": "cluster-a", + "filter": "switchId:SN01", + "max": "50", + "offset": "10", + "sort": "switchId:asc", + "view": "discoveredPairs", + }, + ) diff --git a/tests/unit/module_utils/test_manage_vpc_pair_model.py b/tests/unit/module_utils/test_manage_vpc_pair_model.py new file mode 100644 index 00000000..6ea055a3 --- /dev/null +++ b/tests/unit/module_utils/test_manage_vpc_pair_model.py @@ -0,0 +1,109 @@ +# 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 manage_vpc_pair model layer. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import VpcFieldNames + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( + VpcPairModel, + VpcPairPlaybookConfigModel, + VpcPairPlaybookItemModel, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + + +def test_manage_vpc_pair_model_00010(): + """Verify VpcPairModel.from_config accepts snake_case keys.""" + with does_not_raise(): + model = VpcPairModel.from_config( + { + "switch_id": "SN01", + "peer_switch_id": "SN02", + "use_virtual_peer_link": True, + } + ) + assert model.switch_id == "SN01" + assert model.peer_switch_id == "SN02" + assert model.use_virtual_peer_link is True + + +def test_manage_vpc_pair_model_00020(): + """Verify VpcPairModel identifier is order-independent.""" + with does_not_raise(): + model = VpcPairModel.from_config( + { + "switch_id": "SN02", + "peer_switch_id": "SN01", + } + ) + assert model.get_identifier_value() == ("SN01", "SN02") + + +def test_manage_vpc_pair_model_00030(): + """Verify merge handles reversed switch order without transient validation failure.""" + with does_not_raise(): + base = VpcPairModel.from_config( + { + "switch_id": "SN01", + "peer_switch_id": "SN02", + "use_virtual_peer_link": True, + } + ) + incoming = VpcPairModel.from_config( + { + "switch_id": "SN02", + "peer_switch_id": "SN01", + "use_virtual_peer_link": False, + } + ) + merged = base.merge(incoming) + + assert merged.switch_id == "SN02" + assert merged.peer_switch_id == "SN01" + assert merged.use_virtual_peer_link is False + + +def test_manage_vpc_pair_model_00040(): + """Verify playbook item normalization includes both snake_case and API keys.""" + with does_not_raise(): + item = VpcPairPlaybookItemModel( + peer1_switch_id="SN01", + peer2_switch_id="SN02", + use_virtual_peer_link=False, + ) + runtime = item.to_runtime_config() + + assert runtime["switch_id"] == "SN01" + assert runtime["peer_switch_id"] == "SN02" + assert runtime["use_virtual_peer_link"] is False + assert runtime[VpcFieldNames.SWITCH_ID] == "SN01" + assert runtime[VpcFieldNames.PEER_SWITCH_ID] == "SN02" + assert runtime[VpcFieldNames.USE_VIRTUAL_PEER_LINK] is False + + +def test_manage_vpc_pair_model_00050(): + """Verify playbook item model rejects identical peer switch IDs.""" + with pytest.raises(ValidationError): + VpcPairPlaybookItemModel(peer1_switch_id="SN01", peer2_switch_id="SN01") + + +def test_manage_vpc_pair_model_00060(): + """Verify argument_spec keeps vPC pair config aliases.""" + with does_not_raise(): + spec = VpcPairPlaybookConfigModel.get_argument_spec() + + config_options = spec["config"]["options"] + assert config_options["peer1_switch_id"]["aliases"] == ["switch_id"] + assert config_options["peer2_switch_id"]["aliases"] == ["peer_switch_id"]