Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3ef8a2d
Add shared module utility support
sivakasi-cisco May 27, 2026
7416d66
Addressing review comments and adding UT
sivakasi-cisco Jun 1, 2026
76042ba
Restored the rest_send params
sivakasi-cisco Jun 4, 2026
24ba1f4
Align common files with vrf lite branch
sivakasi-cisco Jun 8, 2026
1fc4329
Removing UT as the source file is removed.
sivakasi-cisco Jun 10, 2026
d579233
Addressing review comments
sivakasi-cisco Jun 15, 2026
4f2ee41
Refactored execute_operation
sivakasi-cisco Jun 15, 2026
75189dc
Reusable helper API redesign
sivakasi-cisco Jun 15, 2026
6318e7c
pep8 compliance fix
sivakasi-cisco Jun 17, 2026
fa24bb6
Addressing the review comments
sivakasi-cisco Jun 30, 2026
8e1f9a1
Merge remote-tracking branch 'origin/develop' into nd_vrf_lite_common…
sivakasi-cisco Jun 30, 2026
3266c05
Add NDStateMachine operation unit tests
sivakasi-cisco Jun 30, 2026
98e683f
Fixing pep8 sanity issue
sivakasi-cisco Jul 1, 2026
646dbd0
Merge remote-tracking branch 'origin/develop' into nd_vrf_lite_common…
sivakasi-cisco Jul 1, 2026
aea7114
Fix black formatting in test_nd_state_machine_operations.py
sivakasi-cisco Jul 1, 2026
f44ee06
Addressing review comments
sivakasi-cisco Jul 13, 2026
da2c435
Merge remote-tracking branch 'origin/develop' into nd_vrf_lite_common…
sivakasi-cisco Jul 15, 2026
b3d5a01
Fix Black formatting in state machine
sivakasi-cisco Jul 15, 2026
472ac2a
Updated the documentation for get_diff
sivakasi-cisco Jul 23, 2026
7a1a551
UT for NDConfigCollection
sivakasi-cisco Jul 23, 2026
1c226f2
Merge remote-tracking branch 'origin/develop' into nd_vrf_lite_common…
sivakasi-cisco Jul 23, 2026
1b14547
Reject empty config so overridden can't silently delete everything
sivakasi-cisco Jul 30, 2026
e7d2363
Merge branch 'develop' into nd_vrf_lite_common_files
sivakasi-cisco Jul 30, 2026
e7a8938
Fix Black and pylint sanity issues
sivakasi-cisco Jul 30, 2026
c2bdb2c
Set serialization profile in networks/vpc_pair unit tests for ansible…
sivakasi-cisco Jul 31, 2026
e642547
Addressing review comments
sivakasi-cisco Aug 4, 2026
c3507e8
Fixing sanity issues
sivakasi-cisco Aug 4, 2026
6b35952
Merge branch 'develop' into nd_vrf_lite_common_files
sivakasi-cisco Aug 12, 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
33 changes: 26 additions & 7 deletions plugins/module_utils/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,23 +221,42 @@ def to_diff_dict(self, **kwargs) -> Dict[str, Any]:
**kwargs,
)

def get_diff(self, other: "NDBaseModel", exclude_unset: bool = False) -> bool:
def get_diff(self, other: "NDBaseModel", exclude_unset: bool = False, allow_superset: bool = False) -> bool:
"""Diff comparison.

Subclass contract:
Any subclass that overrides ``get_diff`` MUST accept both the
``exclude_unset`` and ``allow_superset`` keyword arguments.
``NDConfigCollection.get_diff_config`` always forwards them to the
concrete model, and Python dispatches to the subclass override
rather than to this base method. An override may ignore
``allow_superset`` when its comparison does not need superset
semantics (see ``MaintenanceModeModel``), but it must still accept
the keyword. A non-conforming override raises ``TypeError`` at diff
time; that failure is intentional and must not be masked by catching
``TypeError`` or inspecting the method signature at runtime.

Args:
other: The model to compare against.
exclude_unset: When True, only compare fields explicitly set in
``other`` (via Pydantic's ``exclude_unset``). This prevents
default values from triggering false diffs during merge
operations. This is the merge-path comparison, so a subset
match is additionally cross-checked with ``merge_would_change``
to catch merge side effects the one-way subset test cannot see
(e.g. mutually exclusive counterpart fields that the merge
would clear).
operations.
allow_superset: When True, list elements are matched
one-directionally so that an existing item with extra fields
(e.g. ``deploy``) does not trigger a spurious diff when the
proposed item omits those fields. This is independent of
``exclude_unset``: the former controls which of ``other``'s
fields are compared, while this controls how list elements are
matched.

This is also the merge-path comparison, so a subset match is
additionally cross-checked with ``merge_would_change`` to catch merge
side effects the one-way subset test cannot see.
"""
self_data = self.to_diff_dict()
other_data = other.to_diff_dict(exclude_unset=exclude_unset)
is_subset = issubset(other_data, self_data)
is_subset = issubset(other_data, self_data, allow_superset=allow_superset)
if is_subset and exclude_unset and self.merge_would_change(other):
return False
return is_subset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def _require_switches_when_mode_set(self) -> "MaintenanceModeModel":

# --- Custom Diff (per-switch mode comparison) ---

def get_diff(self, other: "NDBaseModel", exclude_unset: bool = False) -> bool:
def get_diff(self, other: "NDBaseModel", exclude_unset: bool = False, allow_superset: bool = False) -> bool:
"""
# Summary

Expand All @@ -127,6 +127,8 @@ def get_diff(self, other: "NDBaseModel", exclude_unset: bool = False) -> bool:

`self` is the snapshot (existing); `other` is the proposed config. Default `NDBaseModel.get_diff`
does a generic subset check that does not understand the per-switch mode comparison we need.
``exclude_unset`` and ``allow_superset`` are accepted for NDConfigCollection compatibility;
this custom per-switch comparison intentionally ignores both.

## Raises

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
IpVersionEnum,
PrefixListActionEnum,
)
from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset

# Allowed characters for prefix list / tenant names (from OpenAPI pattern)
_NAME_RE = re.compile(r"^[a-zA-Z0-9~_-]+$")
Expand Down Expand Up @@ -357,7 +358,7 @@ def get_identifier_value(self) -> tuple[str, str | None, str]:
"""Return the tenant-aware composite identifier."""
return (str(self.ip_version), self.tenant_name, self.name)

def get_diff(self, other: NDBaseModel, exclude_unset: bool = False) -> bool:
def get_diff(self, other: NDBaseModel, exclude_unset: bool = False, allow_superset: bool = False) -> bool:
"""
Compare prefix-list config, treating omitted description as empty in replace-style states.

Expand All @@ -366,11 +367,32 @@ def get_diff(self, other: NDBaseModel, exclude_unset: bool = False) -> bool:
``overridden`` it passes ``exclude_unset=False``; in that path an omitted
description means the desired value is empty/absent, so a stale controller
description must trigger an update.

``allow_superset`` is part of the ``NDBaseModel.get_diff`` subclass
contract and must be forwarded. Authoritative entry-list changes that
superset matching would otherwise hide are detected by
``merge_would_change`` below.
"""
if not exclude_unset and isinstance(other, PrefixListModel):
if other.description is None and self.description not in (None, ""):
return False
return super().get_diff(other, exclude_unset=exclude_unset)
return super().get_diff(other, exclude_unset=exclude_unset, allow_superset=allow_superset)

def merge_would_change(self, other: NDBaseModel) -> bool:
"""
Detect authoritative prefix-list entry changes during merged comparison.

Entry models are serialized with defaults included so an omitted
``action`` remains equivalent to its documented ``permit`` default.
List matching remains order-independent but requires the same entries
and fields in both directions.
"""
if isinstance(other, PrefixListModel) and "entries" in other.model_fields_set:
current_entries = [entry.to_diff_dict() for entry in self.entries or []]
proposed_entries = [entry.to_diff_dict() for entry in other.entries or []]
if not issubset(proposed_entries, current_entries, allow_superset=False):
return True
return super().merge_would_change(other)

@classmethod
def validate_config_for_state(cls, config: list[dict[str, Any]], state: str) -> None:
Expand Down
27 changes: 22 additions & 5 deletions plugins/module_utils/nd_config_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# 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 __future__ import absolute_import, annotations, division, print_function

from copy import deepcopy
from typing import Any, Dict, List, Literal, Optional
Expand Down Expand Up @@ -150,7 +150,7 @@ def delete_many(self, keys: List[IdentifierKey]) -> List[IdentifierKey]:

# Diff Operations

def get_diff_config(self, new_item: NDBaseModel, exclude_unset: bool = False) -> Literal["new", "no_diff", "changed"]:
def get_diff_config(self, new_item: NDBaseModel, exclude_unset: bool = False, allow_superset: bool = False) -> Literal["new", "no_diff", "changed"]:
"""
Compare single item against collection.

Expand All @@ -159,6 +159,14 @@ def get_diff_config(self, new_item: NDBaseModel, exclude_unset: bool = False) ->
exclude_unset: When True, only compare fields explicitly set in
``new_item``. Useful for merge operations where unspecified
fields should not trigger a diff.
allow_superset: When True, list elements are matched
one-directionally so an existing item carrying extra list
elements (or extra dict keys) is not flagged as changed.

Both ``exclude_unset`` and ``allow_superset`` are forwarded to the
concrete model's ``get_diff``. Any model that overrides ``get_diff``
must accept these keywords; see the subclass contract on
``NDBaseModel.get_diff``.
"""
try:
key = self._extract_key(new_item)
Expand All @@ -170,7 +178,11 @@ def get_diff_config(self, new_item: NDBaseModel, exclude_unset: bool = False) ->
if existing is None:
return "new"

is_subset = existing.get_diff(new_item, exclude_unset=exclude_unset)
# ``get_diff`` is dispatched to the concrete model, so every override
# must accept ``exclude_unset`` and ``allow_superset`` (see the subclass
# contract on ``NDBaseModel.get_diff``). A non-conforming override raises
# TypeError here by design; do not catch it or inspect the signature.
is_subset = existing.get_diff(new_item, exclude_unset=exclude_unset, allow_superset=allow_superset)
Comment thread
sivakasi-cisco marked this conversation as resolved.
Comment thread
sivakasi-cisco marked this conversation as resolved.

return "no_diff" if is_subset else "changed"

Expand Down Expand Up @@ -240,11 +252,16 @@ def to_payload_list(self, **kwargs) -> List[Dict[str, Any]]:
return [item.to_payload(**kwargs) for item in self._items]

@staticmethod
def from_ansible_config(data: List[Dict], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection":
def from_ansible_config(data: list[dict] | None, model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection":
"""
Create collection from Ansible config.

``data`` may be ``None`` for callers that intentionally treat absent
config as an empty collection. Callers whose state semantics distinguish
omitted/null config from an explicit empty list must validate that
before calling this helper.
"""
items = [model_class.from_config(item_data, **kwargs) for item_data in data]
items = [model_class.from_config(item_data, **kwargs) for item_data in (data or [])]
return NDConfigCollection(model_class=model_class, items=items)

@staticmethod
Expand Down
94 changes: 73 additions & 21 deletions plugins/module_utils/nd_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ class NDStateMachine:
Generic State Machine for Nexus Dashboard (Bulk Support).
"""

WRITE_STATES_REQUIRING_CONFIG = frozenset({"merged", "replaced", "overridden"})

@classmethod
def validate_config_presence(cls, state: str, config: Any) -> None:
"""
Reject omitted or null config before production wrappers normalize it.

An explicit empty list remains valid because it can represent an
intentional empty desired set.
"""
if state in cls.WRITE_STATES_REQUIRING_CONFIG and config is None:
raise NDStateMachineError(
f"config must be provided and cannot be null for state '{state}'. " "Use config: [] only when intentionally managing an explicit empty set."
)

def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchestrator] | NDBaseOrchestrator):
"""
Initialize the ND State Machine.
Expand Down Expand Up @@ -58,6 +73,8 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest

self.model_class = self.model_orchestrator.model_class
self.state = self.module.params["state"]
raw_config = self.module.params.get("config")
self.validate_config_presence(self.state, raw_config)

# Cached flags
self.check_mode = self.module.check_mode
Expand All @@ -79,7 +96,9 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest
# state-aware validation (e.g. require certain fields for write states while accepting
# identifier-only items for ``deleted``). Models that do not read the context ignore it.
self.proposed = NDConfigCollection.from_ansible_config(
data=self.module.params.get("config", []), model_class=self.model_class, context={"state": self.state}
data=raw_config,
model_class=self.model_class,
context={"state": self.state},
)

self.output.assign(after=self.existing, before=self.before, proposed=self.proposed)
Expand Down Expand Up @@ -140,17 +159,27 @@ def _execute_operation(
*args: Any,
error_msg_prefix: str = "Operation failed",
**kwargs: Any,
) -> ResponseType | None:
"""Execute an API operation with standardized error handling."""
) -> bool:
Comment thread
sivakasi-cisco marked this conversation as resolved.
"""Execute an API operation with standardized error handling.

Returns True if the operation completed without raising. In check mode
the API call is skipped but still reported as completed (True) so that
the previewed 'after' state can be built. Returns False only when the
operation raised and the error was suppressed via ``ignore_errors``.

The orchestrator's return value is deliberately not used as the success
signal: some orchestrators defer the actual API call (queue-and-deploy)
and legitimately return None on success.
"""
try:
if not self.check_mode:
return operation(*args, **kwargs)
return None
operation(*args, **kwargs)
return True
except Exception as e:
error_msg = f"{error_msg_prefix}: {e}"
if not self.ignore_errors:
raise NDStateMachineError(error_msg) from e
return None
return False

def _manage_create_update_state(self) -> None:
"""
Expand All @@ -167,9 +196,11 @@ def _manage_create_update_state(self) -> None:
# Determine diff status
# For merged state, only compare fields explicitly provided by
# the user so that Pydantic default values do not trigger false
# diffs or overwrite existing configuration.
# diffs or overwrite existing configuration. Merged also matches
# list elements one-directionally (allow_superset) so an
# existing item with extra list entries is not seen as changed.
exclude_unset = self.state == "merged"
diff_status = self.existing.get_diff_config(proposed_item, exclude_unset=exclude_unset)
diff_status = self.existing.get_diff_config(proposed_item, exclude_unset=exclude_unset, allow_superset=exclude_unset)

# No changes needed
if diff_status == "no_diff":
Expand Down Expand Up @@ -204,20 +235,29 @@ def _manage_create_update_state(self) -> None:
# The policy-required-on-create guard (issue #350) runs in manage_state, before the capability
# preflight and before this method mutates self.existing (PR #362 review).

# Execute updates (always individual)
# Execute updates (always individual). An operation that does not fail
# is counted as sent; check mode skips the API call but returns True.
successfully_sent: list[NDBaseModel] = []
for item in items_to_update:
self._execute_operation(self.model_orchestrator.update, item, error_msg_prefix=f"Failed to update {item.get_identifier_value()}")
if self._execute_operation(self.model_orchestrator.update, item, error_msg_prefix=f"Failed to update {item.get_identifier_value()}"):
successfully_sent.append(item)

# Execute creates (bulk or individual)
if items_to_create:
if self.supports_bulk_create:
self._execute_operation(self.model_orchestrator.create_bulk, items_to_create, error_msg_prefix="Failed to create in bulk")
if self._execute_operation(self.model_orchestrator.create_bulk, items_to_create, error_msg_prefix="Failed to create in bulk"):
successfully_sent.extend(items_to_create)
else:
for item in items_to_create:
self._execute_operation(self.model_orchestrator.create, item, error_msg_prefix=f"Failed to create {item.get_identifier_value()}")

# Mark as sent only after successful API operations
successfully_sent = items_to_update + items_to_create
if self._execute_operation(self.model_orchestrator.create, item, error_msg_prefix=f"Failed to create {item.get_identifier_value()}"):
successfully_sent.append(item)

# Mark successfully-processed items as sent. This stays populated in
# check mode (PR #225) so downstream config-save/deploy consumers that
# gate on len(sent) > 0 can still preview what a real run would send;
# execute_config_actions() is itself check-mode-safe (it simulates
# rather than sends). Per-item gating keeps items whose operation raised
# under ignore_errors out of 'sent'.
if successfully_sent:
self.sent.add_many(successfully_sent)

Expand All @@ -244,16 +284,28 @@ def _delete_items(self, items: list[NDBaseModel]) -> None:
if not items:
return

# Execute deletes (bulk or individual)
# Execute deletes (bulk or individual). An item is counted as deleted
# when the operation does not fail; check mode skips the API call but
# returns True so the deletion is still previewed. Items whose delete
# failed under ignore_errors are left in 'existing' so the reported
# 'after' state stays accurate.
deleted: list[NDBaseModel] = []
if self.supports_bulk_delete:
self._execute_operation(self.model_orchestrator.delete_bulk, items, error_msg_prefix="Failed to delete in bulk")
if self._execute_operation(self.model_orchestrator.delete_bulk, items, error_msg_prefix="Failed to delete in bulk"):
deleted.extend(items)
else:
for item in items:
self._execute_operation(self.model_orchestrator.delete, item, error_msg_prefix=f"Failed to delete {item.get_identifier_value()}")
if self._execute_operation(self.model_orchestrator.delete, item, error_msg_prefix=f"Failed to delete {item.get_identifier_value()}"):
deleted.append(item)

# Batch remove from collection (single index rebuild).
self.existing.delete_many([item.get_identifier_value() for item in deleted])

# Batch remove from collection (single index rebuild)
keys_to_delete = [item.get_identifier_value() for item in items]
self.existing.delete_many(keys_to_delete)
# Mark successfully-deleted items as sent. Stays populated in check mode
# (PR #225) so downstream config-save/deploy previews are not skipped;
# per-item gating keeps failed deletes (under ignore_errors) out of 'sent'.
if deleted:
self.sent.add_many(deleted)

# Log deletion
self.output.assign(after=self.existing)
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ def run(self) -> dict[str, Any]:
Returns a result dict suitable for module.exit_json(**result).
"""
module_args: dict = dict(self.module.params)
NDStateMachine.validate_config_presence(
module_args.get("state", "merged"),
module_args.get("config"),
)
if self.strategy is None:
self.strategy = self._resolve_strategy(module_args)
self._trace(
Expand Down
Loading
Loading