Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e14537f
nd42_rest_send: apply rest_send branch changes
sivakasi-cisco Mar 9, 2026
565a069
nd42_smart_endpoints: apply smart endpoints branch changes
sivakasi-cisco Mar 9, 2026
1169c00
Changes ported from old PR for VPC pair \
sivakasi-cisco Mar 9, 2026
d29a7d1
Endpoint mixins, and param alignment
sivakasi-cisco Mar 9, 2026
1ffc0f3
Changes in imports and pydantic fixes
sivakasi-cisco Mar 9, 2026
83a948c
ep to endpoints migration
sivakasi-cisco Mar 9, 2026
bf13c77
Revert "nd42_smart_endpoints: apply smart endpoints branch changes"
sivakasi-cisco Mar 10, 2026
8b59b5f
Revert "nd42_rest_send: apply rest_send branch changes"
sivakasi-cisco Mar 10, 2026
6dd0001
Renaming folders
sivakasi-cisco Mar 10, 2026
5798e80
Renaming folders
sivakasi-cisco Mar 10, 2026
31b5878
Merge branch 'vpc_pair_4x_nd' of https://github.com/sivakasi-cisco/an…
sivakasi-cisco Mar 10, 2026
e44c3eb
Intermediate changes for Ep, model and utils
sivakasi-cisco Mar 10, 2026
980ff2e
Folder/File restructure
sivakasi-cisco Mar 11, 2026
d507f3a
Removal of obsolete files
sivakasi-cisco Mar 11, 2026
e2ee0ea
Rename/Removal of obsolete files
sivakasi-cisco Mar 11, 2026
268fb5c
Merge branch 'vpc_pair_4x_nd' of https://github.com/sivakasi-cisco/an…
sivakasi-cisco Mar 11, 2026
015237c
Aligning with ND Orchestrator style layering
sivakasi-cisco Mar 11, 2026
498f26a
Integration tests related changes
sivakasi-cisco Mar 11, 2026
a672b36
Merge branch 'vpc_pair_4x_nd' into nd42_integration
sivakasi-cisco Mar 12, 2026
9df5e6a
Intermediate fixes
sivakasi-cisco Mar 12, 2026
ce6dd01
Intermediate changes
sivakasi-cisco Mar 12, 2026
554f3fd
Interim changes
sivakasi-cisco Mar 12, 2026
f97be5a
Integ test fixes
sivakasi-cisco Mar 12, 2026
601846b
Merge remote-tracking branch 'origin/nd42_integration' into nd42_inte…
sivakasi-cisco Mar 13, 2026
2f4b5b8
Merge branch 'nd42_integration' into vpc_pair_4x_nd
sivakasi-cisco Mar 13, 2026
722c583
Adhering to the latest changes
sivakasi-cisco Mar 13, 2026
2eb73e0
Aligning with the latest modularisation
sivakasi-cisco Mar 13, 2026
7190b74
Integ test fixes
sivakasi-cisco Mar 13, 2026
40dac2c
Interim changes
sivakasi-cisco Mar 13, 2026
1dd2668
Fragmenting the module.
sivakasi-cisco Mar 13, 2026
0e3cdb4
Interim changes to test with on ND output extraction changes in VPC
sivakasi-cisco Mar 18, 2026
2f54119
Changes to relocate files under models
sivakasi-cisco Mar 18, 2026
c44cd67
Interim changes for endpoint folder contents
sivakasi-cisco Mar 18, 2026
fe6e664
Interim changes to move across folders
sivakasi-cisco Mar 18, 2026
90b2a1b
Renamed ep names and corresponding imports, info on paths
sivakasi-cisco Mar 18, 2026
30ad3b9
Interim changes
sivakasi-cisco Mar 18, 2026
6ca4b7f
Adhereing to a common standard
sivakasi-cisco Mar 19, 2026
af11f9f
Interim changes
sivakasi-cisco Mar 19, 2026
71acd94
Addressing review comments and other few
sivakasi-cisco Mar 23, 2026
2527186
Fine tuning the comments
sivakasi-cisco Mar 24, 2026
1ba1cf7
Intermediate fixes
sivakasi-cisco Mar 25, 2026
78b9093
Intermediate fix removing suppress_previous
sivakasi-cisco Mar 25, 2026
8752f5f
Merge remote-tracking branch 'origin/nd42_integration' into vpc_pair_…
sivakasi-cisco Mar 25, 2026
ea2e89c
Intermediate fixes
sivakasi-cisco Mar 25, 2026
fcc42a5
UT and small corrections in IT
sivakasi-cisco Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions plugins/action/tests/integration/nd_vpc_pair_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# -*- 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

__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
64 changes: 64 additions & 0 deletions plugins/module_utils/endpoints/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
43 changes: 43 additions & 0 deletions plugins/module_utils/endpoints/v1/manage/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Original file line number Diff line number Diff line change
@@ -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"]
Loading