Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
117 commits
Select commit Hold shift + click to select a range
5c8b04d
[ignore] Add generic logger facility through the Log class and loggin…
allenrobel Mar 2, 2026
b00aebe
Initial Commit : ND Manage Switches ( Smart Endpoints + Pydantic Mode…
AKDRG Mar 11, 2026
756bdcc
[ignore] Add Endpoints framework for ND API v1 (#186)
allenrobel Mar 11, 2026
b1fe939
Add Hostname/DNS support in lieu of IP + Handle Switch Inconsistent S…
AKDRG Mar 12, 2026
2a135ae
Update Endpoints Inheritance, Directory Structure and Imports
AKDRG Mar 12, 2026
081afe0
Update Module Imports
AKDRG Mar 12, 2026
fbe00a5
Merge branch 'nd42_integration' of https://github.com/CiscoDevNet/ans…
AKDRG Mar 12, 2026
f5b1655
[ignore] Add RestSend framework, enums, and shared unit test infrastr…
allenrobel Mar 12, 2026
06633f6
[ignore] Fix broken imports after nd42_rest_send restructuring
allenrobel Mar 13, 2026
888ee57
Add constants, and fix comparison of constants in RMA.
AKDRG Mar 13, 2026
b784eff
Merge branch 'nd42_integration' of https://github.com/CiscoDevNet/ans…
AKDRG Mar 13, 2026
267846a
Add further POAP Bootstrap Validation and Fixes
AKDRG Mar 13, 2026
9bd95af
Rebasing Mixins and Endpoints with Latest Endpoint Changes
AKDRG Mar 13, 2026
522f360
Refactor Endpoints for consistency
AKDRG Mar 16, 2026
095e474
Add NDOutput for displaying the result
AKDRG Mar 16, 2026
b0f3f34
Update Results object API calls from Module + Operation Handling
AKDRG Mar 16, 2026
47db2b1
Fix ConfigSync Status Error
AKDRG Mar 16, 2026
28703b5
Add duplicate ip validation in configs, fix api changes in module
AKDRG Mar 16, 2026
2372a74
RMA, POAP Bootstrap and Diff Fixes
AKDRG Mar 16, 2026
3bfc072
Fix Module and Models Parameters, Imports, Docstrings. Add Idempotenc…
AKDRG Mar 18, 2026
1569e41
Change folder structure for models, remove query handling and allow R…
AKDRG Mar 19, 2026
824b6c9
Fixing paths, docstrings, class names, adding UT for endpoints
AKDRG Mar 19, 2026
f9900f8
Remove NDOutput Changes
AKDRG Mar 19, 2026
36488a8
Module Cleanup + Check Mode
AKDRG Mar 20, 2026
e5ae068
Add gathered state support to the module
AKDRG Mar 20, 2026
79394df
Integration Tests + Fixes
AKDRG Mar 23, 2026
1c3b3c6
[minor_change] Add nd_local_user as a new network resource module for…
gmicol Aug 19, 2025
c70a93f
[ignore] First Pydantic implementation: Add Pydantic Models for nd_lo…
gmicol Jan 15, 2026
267e2a8
[ignore] Second Pydantic Implementation: Create a NDBaseModel to be i…
gmicol Jan 20, 2026
9b6bebd
[ignore] Pydantic Models: Modify and Clean both local_user.py and bas…
gmicol Jan 22, 2026
019c8e3
[ignore] Pydantic ND base models and local_user models: Final proposi…
gmicol Jan 23, 2026
427f33f
[ignore] Pydantic ND Config Collection: Final proposition of core des…
gmicol Jan 23, 2026
6fe3bbf
[ignore] Pydantic Base ND Network Resource Module: Final proposition …
gmicol Jan 23, 2026
0b36b2d
[ignore] Modify nd_local_user based on Pydantic implementation and ch…
gmicol Jan 23, 2026
e37636f
[ignore] Add api_endpoints for configuring endpoints and orchestrator…
gmicol Feb 17, 2026
fcde8c9
[ignore] Modifiy models/local_user to take full advantage of Pydantic…
gmicol Feb 18, 2026
5cf2a48
[ignore] Adapt the Network Resource Module architecture for ND to sma…
gmicol Feb 24, 2026
6c411bc
[ignore] Default to none and update condition for regarding in mod…
gmicol Feb 25, 2026
8ed627c
[ignore] Add choice for when no identifier is needed. Add quick com…
gmicol Feb 26, 2026
1ddd995
[ignore] Complete orchestrators/base.py by making simple CRUD operati…
gmicol Feb 26, 2026
aa99bbf
[ignore] Fix and in nd_config_collections.py. Move to utils.py.
gmicol Feb 26, 2026
04c73ff
[ignore] Rename NDNetworkResourceModule to NDStateMachine. Add file f…
gmicol Feb 26, 2026
85c36e8
[ignore] Make a small change to NDModule request function.
gmicol Feb 26, 2026
034b49f
[ignore] Modify nd_state_machine to work with orchestrators/models/ap…
gmicol Mar 2, 2026
b6ddee4
[ignore] Add proper path dependencies and Ran black formatting.
gmicol Mar 3, 2026
1d96db1
[ignore] Clean code for sanity purposes (except Pydantic import checks.
gmicol Mar 3, 2026
5d1f52f
[ignore] Restructure api_endpoints folder into endpoints -> v1. Fix s…
gmicol Mar 3, 2026
039103e
[ignore] Remove NDModule inheritence from NDStateMachine. Add first i…
gmicol Mar 3, 2026
c1774d1
[ignore] Rename NDBaseSmartEndpoint to NDBaseEndpoint. Fix importatio…
gmicol Mar 3, 2026
872b5f4
[ignore] Replace all pydantic imports with pydantic_compat. Fix sanit…
gmicol Mar 4, 2026
24c0686
[ignore] Add NDOutput class. Modify NDStateMachine and nd_local_user …
gmicol Mar 6, 2026
dedc958
[ignore] Update NDOutput class. Remove all fail_json dependencies in …
gmicol Mar 10, 2026
2d472d9
[ignore] Fix serialization of model with minimal changes to base.py …
gmicol Mar 11, 2026
de81d56
[ignore] Complete nd_local_user integration test for creation and upd…
gmicol Mar 11, 2026
401deed
[ignore] Finish integration test file for nd_local_user module. Remov…
gmicol Mar 12, 2026
a325955
[ignore] Fix sanity issues by enhancing pydantic_compat.py. Fix Black…
gmicol Mar 12, 2026
ea06769
[ignore] Remove all TODO comments.
gmicol Mar 12, 2026
47af5f4
[ignore] Update endpoints to match latest nd42_integration branch. Up…
gmicol Mar 12, 2026
2f6de8a
[ignore] Update pydantic_compat.py to support extra Pydantic methods …
gmicol Mar 12, 2026
79dc000
[ignore] Remove Python 2.7 compatibilities.
gmicol Mar 17, 2026
2c7ec78
[ignore] Fix comments and docstrings. made and static methods for …
gmicol Mar 17, 2026
7142c43
[ignore] Slightly modify Exceptions handling in NDStateMachine. Remov…
gmicol Mar 18, 2026
bc5cfb0
[ignore] Rename aaa_local_users.py to infra_aaa_local_users.py. Move …
gmicol Mar 18, 2026
ed33a5a
[ignore] Update integration tests for nd_local_user module.
gmicol Mar 19, 2026
a859d13
[ignore] Revert local users endpoints filename to aaa_local_users.py.
gmicol Mar 19, 2026
96a492d
[ignore] Change in NDStateMachine initialization to take advantage o…
gmicol Mar 19, 2026
4c593df
[ignore] Remove ValidationError import from nd_state_machine.py.
gmicol Mar 19, 2026
f3a0eff
[ignore] Add function to nd_local_user module. Slighty fix Documenta…
gmicol Mar 24, 2026
a8073b6
[ignore] Make NDBaseOrchestrator a Generic class.
gmicol Mar 24, 2026
b4ecddc
Rename inventory validate to switches validate
AKDRG Mar 25, 2026
76426ac
Property changes for POAP, RMA.
AKDRG Mar 26, 2026
4aa9b7e
Splitting POAP into Preprovision/Poap and Lucene Params Fix
AKDRG Mar 26, 2026
4670f00
Merge branch 'nd42_integration' of https://github.com/CiscoDevNet/ans…
AKDRG Mar 26, 2026
c53e6fc
NDOutput Integration
AKDRG Mar 26, 2026
91a05c6
Utils restructuring
AKDRG Mar 26, 2026
d872937
Documentation updates
AKDRG Mar 26, 2026
5621cb4
Doc update
AKDRG Mar 26, 2026
4f0773c
Black Formatting Changes
AKDRG Mar 30, 2026
00298cc
Import Fixes + ArgSpec Changes
AKDRG Mar 30, 2026
87fb5a2
Sanity Fixes V1
AKDRG Mar 30, 2026
261bdbe
Black Formatting R2
AKDRG Mar 30, 2026
48d2248
Handle Logging Inconsistencies
AKDRG Mar 30, 2026
ca3ee28
Sanity, Doc Fixes
AKDRG Mar 30, 2026
9bb268b
Black Formatting R3
AKDRG Mar 30, 2026
e000775
Black + Sanity Fix
AKDRG Mar 31, 2026
3c54a5c
[ignore] Enable CI unit tests
samiib Mar 17, 2026
0af63e6
[ignore] Add pydantic to requirements.txt
samiib Mar 23, 2026
62d1069
Fix Overridden + Config Model Identifier + Docs
AKDRG Mar 31, 2026
03eba1b
Complete Overhaul of Switch Resources to Support POAP/Preprovision Ov…
AKDRG Apr 1, 2026
7f7ddc2
Black Formatting Fix
AKDRG Apr 1, 2026
0338517
Doc Fixes + Config Model Streamlining for POAP/RMA
AKDRG Apr 1, 2026
2ab0290
Validators Additions + Fix
AKDRG Apr 1, 2026
a36bc51
Black + Sanity Fixes
AKDRG Apr 1, 2026
78604ab
Overridden fixes for Normal Switches, POAP/Preprovision
AKDRG Apr 2, 2026
9adee56
Black Fix + Sanity
AKDRG Apr 2, 2026
e0476f8
merge develop
mtarking Apr 2, 2026
932a186
temp update to pr branch list for ci
mtarking Apr 2, 2026
ddb3bb8
State Fix + Config Output Changes
AKDRG Apr 5, 2026
8119ba5
Add Replaced State + Tests Restructuring + Deploy Config Interdependence
AKDRG Apr 5, 2026
9fd18b2
Black Formatting Fix
AKDRG Apr 6, 2026
ed218e9
Ansible ND 4.X Fabric Modules for iBGP, eBGP and External Fabric Type…
mikewiebe Apr 8, 2026
ebabd78
Deploy + Config Save Changes
AKDRG Apr 9, 2026
eb80a4a
Merge_Prep
AKDRG Apr 9, 2026
00f2045
Merge branch 'nd42_integration' of https://github.com/CiscoDevNet/ans…
AKDRG Apr 9, 2026
a35bd16
Branch Merge
AKDRG Apr 9, 2026
35eca19
Check Mode Changes
AKDRG Apr 9, 2026
65fc1b2
Black and Sanity Fix
AKDRG Apr 9, 2026
a88cea4
Revert Changes to Rest about Check Mode, Make the Fix in Switch Resou…
AKDRG Apr 9, 2026
17239dd
Merge branch 'develop' into nd42_integration
mtarking Apr 9, 2026
cacdeb2
update actions
mtarking Apr 9, 2026
292c234
Merge branch 'nd42_integration' of https://github.com/CiscoDevNet/ans…
AKDRG Apr 9, 2026
17f139b
Merge branch 'develop' into switch_int_pr
AKDRG Apr 9, 2026
331fba7
Pydantic V2 POS Field Fixes
AKDRG Apr 9, 2026
2a7be2c
Fix Lint
AKDRG Apr 9, 2026
f4fe618
Handle NDRequests and Retry Timeouts
AKDRG Apr 10, 2026
0ea6d21
Fix Rest Send Initialization
AKDRG Apr 10, 2026
3ae57ed
Fix for timeout + check_mode
AKDRG Apr 10, 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
255 changes: 255 additions & 0 deletions plugins/action/nd_switches_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2026, Akshayanat C S (@achengam) <achengam@cisco.com>

# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)

"""ND Switches Validation Action Plugin.

Validates switch data returned from nd_rest against expected
configuration entries. Checks that every entry in test_data has a matching
switch in the ND API response (fabricManagementIp == seed_ip,
switchRole == role).

Supports an optional ``mode`` argument:
- ``"both"`` (default): match by seed_ip AND role.
- ``"ip"``: match by seed_ip only (role is ignored).
- ``"role"``: match by role only (seed_ip is ignored).
"""

from __future__ import absolute_import, division, print_function

__metaclass__ = type # pylint: disable=invalid-name

import json
from typing import Any, Dict, List, Optional, Union

from ansible.plugins.action import ActionBase
from ansible.utils.display import Display
from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import (
BaseModel,
HAS_PYDANTIC,
ValidationError,
field_validator,
model_validator,
)

try:
from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.config_models import (
SwitchConfigModel,
)
from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_data_models import (
SwitchDataModel,
)

HAS_MODELS = True
except ImportError:
HAS_MODELS = False

display = Display()


# ---------------------------------------------------------------------------
# Validation orchestration model
# ---------------------------------------------------------------------------


class SwitchesValidate(BaseModel):
"""Orchestrates the match between playbook config entries and live ND inventory."""

config_data: Optional[List[Any]] = None
nd_data: Optional[List[Any]] = None
ignore_fields: Optional[Dict[str, int]] = None
response: Union[bool, None] = None

@field_validator("config_data", mode="before")
@classmethod
def parse_config_data(cls, value):
"""Coerce raw dicts into SwitchConfigModel instances.

Accepts a single dict or a list of dicts.
"""
if isinstance(value, dict):
return [SwitchConfigModel.model_validate(value)]
if isinstance(value, list):
try:
return [(SwitchConfigModel.model_validate(item) if isinstance(item, dict) else item) for item in value]
except (ValidationError, ValueError) as e:
raise ValueError("Invalid format in Config Data: {0}".format(e))
if value is None:
return None
raise ValueError("Config Data must be a single/list of dictionary, or None.")

@field_validator("nd_data", mode="before")
@classmethod
def parse_nd_data(cls, value):
"""Coerce raw ND API switch dicts into SwitchDataModel instances."""
if isinstance(value, list):
try:
return [(SwitchDataModel.from_response(item) if isinstance(item, dict) else item) for item in value]
except (ValidationError, ValueError) as e:
raise ValueError("Invalid format in ND Response: {0}".format(e))
if value is None:
return None
raise ValueError("ND Response must be a list of dictionaries.")

@model_validator(mode="after")
def validate_lists_equality(self):
"""Match every config entry against the live ND switch inventory.

Sets ``self.response = True`` when all entries match, ``False`` otherwise.
Respects ``ignore_fields`` to support ip-only or role-only matching modes.

Role comparison uses SwitchRole enum equality — no string normalization needed.
"""
config_data = self.config_data
nd_data_list = self.nd_data
ignore_fields = self.ignore_fields

# Both empty → nothing to validate, treat as success.
# Exactly one empty → mismatch, treat as failure.
if not config_data and not nd_data_list:
self.response = True
return self
if not config_data or not nd_data_list:
self.response = False
return self

missing_ips = []
role_mismatches = {}
nd_data_copy = nd_data_list.copy()
matched_indices = set()

for config_item in config_data:
found_match = False
seed_ip = config_item.seed_ip
role_expected = config_item.role # SwitchRole enum or None

for i, nd_item in enumerate(nd_data_copy):
if i in matched_indices:
continue

ip_address = nd_item.fabric_management_ip
switch_role = nd_item.switch_role # SwitchRole enum or None

seed_ip_match = (seed_ip is not None and ip_address is not None and ip_address == seed_ip) or bool(ignore_fields["seed_ip"])
role_match = (role_expected is not None and switch_role is not None and switch_role == role_expected) or bool(ignore_fields["role"])

if seed_ip_match and role_match:
matched_indices.add(i)
found_match = True
if ignore_fields["seed_ip"]:
break
elif (seed_ip_match and role_expected is not None and switch_role is not None and switch_role != role_expected) or ignore_fields["role"]:
role_mismatches.setdefault(
seed_ip or ip_address,
{
"expected_role": (role_expected.value if role_expected else None),
"response_role": switch_role.value if switch_role else None,
},
)
matched_indices.add(i)
found_match = True
if ignore_fields["seed_ip"]:
break

if not found_match and seed_ip is not None:
missing_ips.append(seed_ip)

if not missing_ips and not role_mismatches:
self.response = True
else:
display.display("Invalid Data:")
if missing_ips:
display.display(" Missing IPs: {0}".format(missing_ips))
if role_mismatches:
display.display(" Role mismatches: {0}".format(json.dumps(role_mismatches, indent=2)))
self.response = False

return self


# ---------------------------------------------------------------------------
# Action plugin
# ---------------------------------------------------------------------------


class ActionModule(ActionBase):
"""Ansible action plugin for validating ND switch inventory data.

Arguments (task args):
nd_data (dict): The registered result of a cisco.nd.nd_rest GET call.
test_data (list|dict): Expected switch entries, each with ``seed_ip``
and optionally ``role``.
changed (bool, optional): If provided and False, the task fails
immediately (used to assert an upstream
operation produced a change).
mode (str, optional): ``"both"`` (default), ``"ip"``, or ``"role"``.
"""

def run(self, tmp=None, task_vars=None):
results = super(ActionModule, self).run(tmp, task_vars)
results["failed"] = False

if not HAS_PYDANTIC or not HAS_MODELS:
results["failed"] = True
results["msg"] = "pydantic and the ND collection models are required for nd_switches_validate"
return results

nd_data = self._task.args["nd_data"]
test_data = self._task.args["test_data"]

# Fail fast if the caller signals that no change occurred when one was expected.
if "changed" in self._task.args and not self._task.args["changed"]:
results["failed"] = True
results["msg"] = 'Changed is "false"'
return results

# Fail fast if the upstream nd_rest task itself failed.
if nd_data.get("failed"):
results["failed"] = True
results["msg"] = nd_data.get("msg", "ND module returned a failure")
return results

# Extract switch list from nd_data.current.switches
switches = nd_data.get("current", {}).get("switches", [])

# Normalise test_data to a list.
if isinstance(test_data, dict):
test_data = [test_data]

# If both are empty treat as success; if only nd response is empty it's a failure.
if not switches and not test_data:
results["msg"] = "Validation Successful!"
return results

if not switches:
results["failed"] = True
results["msg"] = "No switches found in ND response"
return results

# Resolve matching mode via ignore_fields flags.
ignore_fields = {"seed_ip": 0, "role": 0}
if "mode" in self._task.args:
mode = self._task.args["mode"].lower()
if mode == "ip":
# IP mode: only match by seed_ip, ignore role
ignore_fields["role"] = 1
elif mode == "role":
# Role mode: only match by role, ignore seed_ip
ignore_fields["seed_ip"] = 1

validation = SwitchesValidate(
config_data=test_data,
nd_data=switches,
ignore_fields=ignore_fields,
response=None,
)

if validation.response:
results["msg"] = "Validation Successful!"
else:
results["failed"] = True
results["msg"] = "Validation Failed! Please check output above."

return results
11 changes: 11 additions & 0 deletions plugins/module_utils/common/pydantic_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
StrictBool,
SecretStr,
ValidationError,
ValidationInfo,
field_serializer,
model_serializer,
field_validator,
Expand All @@ -73,6 +74,7 @@
StrictBool,
SecretStr,
ValidationError,
ValidationInfo,
field_serializer,
model_serializer,
field_validator,
Expand Down Expand Up @@ -191,6 +193,14 @@ def __init__(self, message="A custom error occurred."):
def __str__(self):
return f"ValidationError: {self.message}"

# Fallback: ValidationInfo placeholder class that does nothing
class ValidationInfo:
"""Pydantic ValidationInfo fallback when pydantic is not available."""

def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)

# Fallback: model_validator decorator that does nothing
def model_validator(*args, **kwargs): # pylint: disable=unused-argument
"""Pydantic model_validator fallback when pydantic is not available."""
Expand Down Expand Up @@ -276,6 +286,7 @@ def main():
"StrictBool",
"SecretStr",
"ValidationError",
"ValidationInfo",
"field_serializer",
"model_serializer",
"field_validator",
Expand Down
24 changes: 24 additions & 0 deletions plugins/module_utils/endpoints/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class FabricNameMixin(BaseModel):
fabric_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Fabric name")


class FilterMixin(BaseModel):
"""Mixin for endpoints that require a Lucene filter expression."""

filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression")


class ForceShowRunMixin(BaseModel):
"""Mixin for endpoints that require force_show_run parameter."""

Expand Down Expand Up @@ -62,6 +68,12 @@ class LoginIdMixin(BaseModel):
login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID")


class MaxMixin(BaseModel):
"""Mixin for endpoints that require a max results parameter."""

max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results")


class NetworkNameMixin(BaseModel):
"""Mixin for endpoints that require network_name parameter."""

Expand All @@ -74,12 +86,24 @@ class NodeNameMixin(BaseModel):
node_name: Optional[str] = Field(default=None, min_length=1, description="Node name")


class OffsetMixin(BaseModel):
"""Mixin for endpoints that require a pagination offset parameter."""

offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset")


class SwitchSerialNumberMixin(BaseModel):
"""Mixin for endpoints that require switch_sn parameter."""

switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number")


class TicketIdMixin(BaseModel):
"""Mixin for endpoints that require ticket_id parameter."""

ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID")


class VrfNameMixin(BaseModel):
"""Mixin for endpoints that require vrf_name parameter."""

Expand Down
12 changes: 10 additions & 2 deletions plugins/module_utils/endpoints/query_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,16 @@ def to_query_string(self, url_encode: bool = True) -> str:
params = []
for field_name, field_value in self.model_dump(exclude_none=True).items():
if field_value is not None:
# URL-encode the value if requested
encoded_value = quote(str(field_value), safe="") if url_encode else str(field_value)
# URL-encode the value if requested.
# Lucene filter expressions require ':' and ' ' to remain unencoded
# so the server-side parser can recognise the field:value syntax.
if url_encode:
# Keep ':' unencoded so Lucene field:value syntax is preserved.
# Spaces are encoded as %20 so the query string is valid in URLs.
safe_chars = ":" if field_name == "filter" else ""
encoded_value = quote(str(field_value), safe=safe_chars)
else:
encoded_value = str(field_value)
params.append(f"{field_name}={encoded_value}")
return "&".join(params)

Expand Down
Loading
Loading