Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 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
0e8e65d
Changes made for
sivakasi-cisco Mar 31, 2026
9f27819
Setting up deploy to be true by default, use_virtual_peer_link to fal…
sivakasi-cisco Apr 1, 2026
4260acc
VpcPairDetails Addition
sivakasi-cisco Apr 1, 2026
1dbea16
path changes and NDBaseModel and NDNestedModel direct usage
sivakasi-cisco Apr 2, 2026
f5489f2
Changes in manage_vpc_pair files and corresponding imports
sivakasi-cisco Apr 2, 2026
9da294a
Reducing the timers - now having only api_timeout, query_timeout, ref…
sivakasi-cisco Apr 2, 2026
f057770
Renaming api_timeout to vpc_put_timeout. setting up the query_timeout…
sivakasi-cisco Apr 2, 2026
374f222
Modifying refresh_after_apply with an inversion of suppress_verification
sivakasi-cisco Apr 2, 2026
cd5afe0
Timer changes, pending variables changes
sivakasi-cisco Apr 2, 2026
b9a819a
getting sync status for deploy
sivakasi-cisco Apr 2, 2026
2b4b376
Few sanity fixes and cleanups
sivakasi-cisco Apr 7, 2026
0be49bf
Fixing sanity failures
sivakasi-cisco Apr 7, 2026
c0e0ad5
Addressing the review comments to\
sivakasi-cisco Apr 7, 2026
18103db
Instead of re-exporting with __init__, it is made empty as per guidel…
sivakasi-cisco Apr 7, 2026
1069c96
Fixing sanity failures
sivakasi-cisco Apr 7, 2026
0782b5e
Fixture for Deploy skip issue
sivakasi-cisco Apr 7, 2026
c8c271f
Integ test changes for sanity and checks
sivakasi-cisco Apr 7, 2026
c318d93
Merge remote-tracking branch 'origin/nd42_integration' into nd42_inte…
sivakasi-cisco Apr 8, 2026
49ac09e
Merge branch 'nd42_integration' into vpc_pair_4x_nd
sivakasi-cisco Apr 8, 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
344 changes: 344 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,344 @@
# -*- 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 annotations

from typing import Any, Optional
from ansible.plugins.action import ActionBase
from ansible.utils.display import Display

display = Display()


def _normalize_pair(pair: dict[str, Any]) -> frozenset[str]:
"""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: dict[str, Any]) -> Optional[Any]:
"""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


def _get_vpc_pair_details(pair: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Extract vpc_pair_details / vpcPairDetails from a pair dict."""
for key in ("vpc_pair_details", "vpcPairDetails"):
if key in pair:
return pair[key]
return None


def _coerce_scalar(value: Any) -> Any:
"""Normalize scalar value types for stable comparisons across API/model formats."""
if isinstance(value, str):
text = value.strip()
lower = text.lower()
if lower in ("true", "false"):
return lower == "true"
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
try:
return int(text)
except Exception:
return text
return text
return value


def _values_equal(expected: Any, actual: Any) -> bool:
"""Compare values with lightweight normalization for bool/int/string drift."""
if isinstance(expected, list) and isinstance(actual, list):
if len(expected) != len(actual):
return False
return all(_values_equal(e, a) for e, a in zip(expected, actual))
return _coerce_scalar(expected) == _coerce_scalar(actual)


def _detail_value_with_alias(details: dict[str, Any], key: str) -> tuple[Any, Optional[str]]:
"""
Fetch detail value supporting snake_case/camelCase alias forms.
Returns tuple(value, resolved_key). value is None if not found.
"""
aliases = {
"type": ["type"],
"domain_id": ["domain_id", "domainId"],
"switch_keep_alive_local_ip": ["switch_keep_alive_local_ip", "switchKeepAliveLocalIp"],
"peer_switch_keep_alive_local_ip": ["peer_switch_keep_alive_local_ip", "peerSwitchKeepAliveLocalIp"],
"keep_alive_vrf": ["keep_alive_vrf", "keepAliveVrf"],
"template_name": ["template_name", "templateName"],
"template_config": ["template_config", "templateConfig"],
}
for alias in aliases.get(key, [key]):
if alias in details:
return details.get(alias), alias
return None, None


def _compare_vpc_pair_details(expected_details: Any, actual_details: Any) -> list[dict[str, Any]]:
"""Compare expected details as a subset of actual details and return mismatches."""
mismatches = []

if not isinstance(expected_details, dict):
return mismatches

if not isinstance(actual_details, dict):
mismatches.append(
{
"field": "vpc_pair_details",
"expected": expected_details,
"actual": actual_details,
}
)
return mismatches

for exp_key, exp_value in expected_details.items():
act_value, resolved_key = _detail_value_with_alias(actual_details, exp_key)
if resolved_key is None:
mismatches.append(
{
"field": "vpc_pair_details.{0}".format(exp_key),
"expected": exp_value,
"actual": "MISSING",
}
)
continue

if isinstance(exp_value, dict):
if not isinstance(act_value, dict):
mismatches.append(
{
"field": "vpc_pair_details.{0}".format(exp_key),
"expected": exp_value,
"actual": act_value,
}
)
continue

for nested_key, nested_expected in exp_value.items():
if nested_key not in act_value:
mismatches.append(
{
"field": "vpc_pair_details.{0}.{1}".format(exp_key, nested_key),
"expected": nested_expected,
"actual": "MISSING",
}
)
continue

if not _values_equal(nested_expected, act_value.get(nested_key)):
mismatches.append(
{
"field": "vpc_pair_details.{0}.{1}".format(exp_key, nested_key),
"expected": nested_expected,
"actual": act_value.get(nested_key),
}
)
else:
if not _values_equal(exp_value, act_value):
mismatches.append(
{
"field": "vpc_pair_details.{0}".format(exp_key),
"expected": exp_value,
"actual": act_value,
}
)

return mismatches


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: Any = None, task_vars: Optional[dict[str, Any]] = None) -> dict[str, Any]:
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()
validate_vpc_pair_details = bool(self._task.args.get("validate_vpc_pair_details", False))

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),
}
)

if validate_vpc_pair_details:
expected_details = _get_vpc_pair_details(expected)
gathered_details = _get_vpc_pair_details(gathered_pair)
if expected_details is not None:
details_mismatches = _compare_vpc_pair_details(expected_details, gathered_details)
for item in details_mismatches:
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": item.get("field"),
"expected": item.get("expected"),
"actual": item.get("actual"),
}
)

# ------------------------------------------------------------------
# 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
45 changes: 45 additions & 0 deletions plugins/module_utils/endpoints/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,48 @@ 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 ViewMixin(BaseModel):
"""Mixin for endpoints that support view parameter."""

view: Optional[str] = Field(default=None, description="Optional view type for filtering results")
Loading