Skip to content

Add shared module utility support - #294

Open
sivakasi-cisco wants to merge 27 commits into
CiscoDevNet:developfrom
sivakasi-cisco:nd_vrf_lite_common_files
Open

Add shared module utility support#294
sivakasi-cisco wants to merge 27 commits into
CiscoDevNet:developfrom
sivakasi-cisco:nd_vrf_lite_common_files

Conversation

@sivakasi-cisco

@sivakasi-cisco sivakasi-cisco commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Related Issue(s)

Fixes #450

Problem Description

This PR separates out a few shared framework changes that are needed before modules like nd_manage_vrf_lite can cleanly follow the generic ND 4.x module architecture - #281.

Some ND 4.x resources are simple: one playbook item maps directly to one controller object. In those cases, the existing generic state machine can compare “what the user wants” with “what exists on the controller” and decide create, update, delete, or no-op.

But some resources are nested. VRF Lite is a good example: the user gives VRF-level config, but the actual work happens under smaller child objects like VRF attachments and VRF Lite links. Without small shared framework improvements, each nested module has to write its own custom comparison logic.

This PR adds the common support needed for that style of module.

Why These Common Changes Are Needed

This PR updates the common state machine and utility logic used by the VRF Lite workflow.

The main change is to make merged state behave correctly when the controller already has extra data that the user did not provide in the playbook. In that case, the module should not report a change just because existing data has additional fields.

This PR also tracks deleted items in the state machine, so follow-up actions like config save and deploy can know which VRFs were changed by a delete operation.

Comment thread plugins/module_utils/nd_state_machine.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/common/data.py Outdated
Comment thread plugins/module_utils/nd_state_machine.py Outdated

@sivakasi-cisco sivakasi-cisco left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed your comments.

For the comment on data.py and utils.py files, I can combine them if required.

You can consider the latest changes for review and subsequent approval.

@sivakasi-cisco
sivakasi-cisco requested a review from akinross June 2, 2026 06:58
@sivakasi-cisco sivakasi-cisco added the ready for review Submitter is requesting a PR review label Jun 2, 2026
Comment thread plugins/module_utils/nd_state_machine.py

@sivakasi-cisco sivakasi-cisco left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved the comments

@sivakasi-cisco
sivakasi-cisco requested a review from samiib June 4, 2026 05:51
akinross
akinross previously approved these changes Jun 4, 2026
@sivakasi-cisco sivakasi-cisco removed the ready for review Submitter is requesting a PR review label Jun 4, 2026
@sivakasi-cisco
sivakasi-cisco marked this pull request as draft June 4, 2026 16:35
@sivakasi-cisco
sivakasi-cisco marked this pull request as ready for review June 10, 2026 10:35
@sivakasi-cisco sivakasi-cisco added the ready for review Submitter is requesting a PR review label Jun 10, 2026
gmicol
gmicol previously approved these changes Jun 10, 2026

@gmicol gmicol left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@allenrobel

allenrobel commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Code review

Found 4 issues:

  1. _delete_items adds items to self.sent unconditionally. _execute_operation returns None without calling the API in check_mode, and orchestrators may skip items during delete (see NDStateMachine._delete_items removes orchestrator-skipped items from existing, corrupting 'after' state #307 for the same unconditional pattern with existing.delete_many), yet all items are still added to sent. This contradicts the guard used for creates/updates in _manage_create_update_state ("Mark as sent only after successful API operations", L182-L185). Since the PR's stated purpose for sent is to drive follow-up config save/deploy, check_mode runs and orchestrator-skipped items would produce false deploy triggers.

# 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)
self.sent.add_many(items)

  1. The new issubset(allow_superset=True) path has no unit tests. It relaxes the bidirectional list-element match that was deliberately added as a bug fix in Ansible ND 4.X Fabric Modules for iBGP, eBGP and External Fabric Types #209, and the final diff contains no test files (the tests added earlier in this PR were removed along with common/data.py). An earlier review comment on this PR asked for unit tests for all the helper functions; that still applies to issubset and to get_diff(exclude_unset=True) with list-valued fields.

def issubset(subset: Any, superset: Any, allow_superset: bool = False) -> bool:
"""Check if subset is contained in superset.
Args:
subset: The value to check.
superset: The value to check against.
allow_superset: When True, list element matching is one-directional:
an element in ``subset`` is considered matched when it is a subset
of a candidate in ``superset``, even if the candidate has
additional keys. When False (default) both directions are
required, which is equivalent to equality for lists of dicts.
"""

  1. Greedy first-match list matching can report false diffs with allow_superset=True. Each subset item consumes the first matching candidate (del remaining[index]), so a less-specific proposed item can consume a candidate needed by a more-specific one. Example: proposed [{"a": 1}, {"a": 1, "b": 2}] vs existing [{"a": 1, "b": 2}, {"a": 1, "b": 3}]{"a": 1} consumes {"a": 1, "b": 2}, then {"a": 1, "b": 2} fails against {"a": 1, "b": 3}, reporting a diff although a valid pairing exists. One-directional matching makes this ambiguity much more likely than the bidirectional default. Result is spurious changed=True (broken idempotency) in merged state for lists of similar dicts — exactly the shape of VRF attachments.

remaining = list(superset)
for item in subset:
for index, candidate in enumerate(remaining):
if allow_superset:
match = issubset(item, candidate, allow_superset=True)
else:
match = issubset(item, candidate) and issubset(candidate, item)
if match:
del remaining[index]
break
else:
return False
return True

  1. The new docstring claim "When False (default) both directions are required, which is equivalent to equality for lists of dicts" is inaccurate: the dict branch skips None-valued keys (L68-L69), so {"a": 1, "b": None} and {"a": 1} match bidirectionally despite not being equal. Harmless for current callers (to_diff_dict uses exclude_none=True) but misleading for future direct callers of issubset.

allow_superset: When True, list element matching is one-directional:
an element in ``subset`` is considered matched when it is a subset
of a candidate in ``superset``, even if the candidate has
additional keys. When False (default) both directions are
required, which is equivalent to equality for lists of dicts.
"""

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread plugins/module_utils/models/base.py Outdated
Comment thread plugins/module_utils/models/base.py Outdated
@sivakasi-cisco

Copy link
Copy Markdown
Collaborator Author

Code review

Found 4 issues:

  1. _delete_items adds items to self.sent unconditionally. _execute_operation returns None without calling the API in check_mode, and orchestrators may skip items during delete (see NDStateMachine._delete_items removes orchestrator-skipped items from existing, corrupting 'after' state #307 for the same unconditional pattern with existing.delete_many), yet all items are still added to sent. This contradicts the guard used for creates/updates in _manage_create_update_state ("Mark as sent only after successful API operations", L182-L185). Since the PR's stated purpose for sent is to drive follow-up config save/deploy, check_mode runs and orchestrator-skipped items would produce false deploy triggers.

# 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)
self.sent.add_many(items)

Reply: Fixed — only items actually pushed are marked as sent now, so check-mode and skipped/failed items no longer trigger a deploy.

  1. The new issubset(allow_superset=True) path has no unit tests. It relaxes the bidirectional list-element match that was deliberately added as a bug fix in Ansible ND 4.X Fabric Modules for iBGP, eBGP and External Fabric Types #209, and the final diff contains no test files (the tests added earlier in this PR were removed along with common/data.py). An earlier review comment on this PR asked for unit tests for all the helper functions; that still applies to issubset and to get_diff(exclude_unset=True) with list-valued fields.

def issubset(subset: Any, superset: Any, allow_superset: bool = False) -> bool:
"""Check if subset is contained in superset.
Args:
subset: The value to check.
superset: The value to check against.
allow_superset: When True, list element matching is one-directional:
an element in ``subset`` is considered matched when it is a subset
of a candidate in ``superset``, even if the candidate has
additional keys. When False (default) both directions are
required, which is equivalent to equality for lists of dicts.
"""

Reply: Added tests/unit/module_utils/test_utils.py covering 'issubset' and 'get_diff'

  1. Greedy first-match list matching can report false diffs with allow_superset=True. Each subset item consumes the first matching candidate (del remaining[index]), so a less-specific proposed item can consume a candidate needed by a more-specific one. Example: proposed [{"a": 1}, {"a": 1, "b": 2}] vs existing [{"a": 1, "b": 2}, {"a": 1, "b": 3}]{"a": 1} consumes {"a": 1, "b": 2}, then {"a": 1, "b": 2} fails against {"a": 1, "b": 3}, reporting a diff although a valid pairing exists. One-directional matching makes this ambiguity much more likely than the bidirectional default. Result is spurious changed=True (broken idempotency) in merged state for lists of similar dicts — exactly the shape of VRF attachments.

remaining = list(superset)
for item in subset:
for index, candidate in enumerate(remaining):
if allow_superset:
match = issubset(item, candidate, allow_superset=True)
else:
match = issubset(item, candidate) and issubset(candidate, item)
if match:
del remaining[index]
break
else:
return False
return True

Reply:: Sure. Swapped the greedy first-match for proper bipartite matching so as to find a valid pairing when it exists. Added a regression test as well.

  1. The new docstring claim "When False (default) both directions are required, which is equivalent to equality for lists of dicts" is inaccurate: the dict branch skips None-valued keys (L68-L69), so {"a": 1, "b": None} and {"a": 1} match bidirectionally despite not being equal. Harmless for current callers (to_diff_dict uses exclude_none=True) but misleading for future direct callers of issubset.

allow_superset: When True, list element matching is one-directional:
an element in ``subset`` is considered matched when it is a subset
of a candidate in ``superset``, even if the candidate has
additional keys. When False (default) both directions are
required, which is equivalent to equality for lists of dicts.
"""

Reply:: As the dict comparison ignores 'None' keys it was not strict ==. Corrected the docstring now.

🤖 Generated with Claude Code

  • If this code review was useful, please react with 👍. Otherwise, react with 👎.

@sivakasi-cisco sivakasi-cisco added the nac01 NaC ND release 0.0.1 label Jul 17, 2026
Comment thread plugins/module_utils/nd_config_collection.py
@mikewiebe

Copy link
Copy Markdown
Collaborator

High: PRs #281 and #360 require semantic conflict reconciliation after #294

“Conflict reconciliation” means manually combining both PRs’ behaviors after #294 merges. Selecting Git’s entire “ours” or “theirs” version would break one side.

Currently, PR #281 already conflicts with develop. PR #360 merges with current develop, but conflicts after applying #294.

PR #360

Both PRs modify NDBaseModel.get_diff():

The combined implementation needs both:

def get_diff(
    self,
    other,
    exclude_unset=False,
    allow_superset=False,
):
    self_data = self.to_diff_dict()
    other_data = other.to_diff_dict(exclude_unset=exclude_unset)

    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

Keep #360’s merge_would_change() implementation too.

Taking only #294 would lose storm-control transition detection. Taking only #360 would remove allow_superset, causing the same keyword-argument compatibility failure discussed for #286 and #312.

The combined tests should prove that:

  • Existing percentage can transition to PPS.
  • Existing PPS can transition to percentage.
  • A controller response containing both values is remediated.
  • Add shared module utility support #294’s merged-list behavior still works.

PR #281

PR #281 conflicts in three shared files and also has semantic overlaps that Git may auto-merge incorrectly.

models/base.py

nd_config_collection.py

  • The direct conflict is mostly documentation.
  • Keep the contract that None can be normalized to an empty collection only after the caller has validated whether omitted config is legal for the requested state.

nd_state_machine.py

Two additional #281 behaviors need deliberate adjustment:

Its keyed VRF Lite attachment merge behavior must also remain intact.

Recommended integration order

  1. Finalize and merge Add shared module utility support #294.
  2. Rebase Enforce storm-control percentage/pps mutual exclusivity (#351) #360 and manually combine both get_diff() behaviors.
  3. Rebase Vrf_lite module for Ansible ND 4.x #281, dropping duplicated shared-framework changes where Add shared module utility support #294 now supplies them.
  4. Resolve the three Vrf_lite module for Ansible ND 4.x #281 shared files according to the contracts above.
  5. Run each module’s tests together with Add shared module utility support #294’s shared state-machine and diff tests.

@sivakasi-cisco

Copy link
Copy Markdown
Collaborator Author

Have tested the nd_vrf_lite module with the latest changes in #294.

- Drop default=[] on nd_manage_networks config so an omitted value stays
  None instead of being coerced into an empty list
- Add NDStateMachine.validate_config_presence and call it on the raw config
  (before normalization) in the network coordinator and the vpc_pair wrapper,
  plus in the state machine itself
- Explicit config: [] still works for intentional delete-all under overridden
- Fix PrefixListModel.get_diff to accept exclude_unset/allow_superset so it
  matches the shared NDBaseModel signature used by NDConfigCollection
- Add composed wrapper tests covering omitted/null/explicit-empty config
  (overridden, check mode, existing resources) and a get_diff regression
@sivakasi-cisco
sivakasi-cisco force-pushed the nd_vrf_lite_common_files branch from 1b14547 to d22cf55 Compare July 30, 2026 10:30
@sivakasi-cisco
sivakasi-cisco force-pushed the nd_vrf_lite_common_files branch from d22cf55 to e7d2363 Compare July 30, 2026 11:15
@sivakasi-cisco

Copy link
Copy Markdown
Collaborator Author

Hi Mike,

This is really helpful with the integration order.

For PR294 - the current one - base merged to 'develop' branch.

Now the combined version of base.py is below with allow_superset and merge_would_change.

self_data = self.to_diff_dict()
other_data = other.to_diff_dict(exclude_unset=exclude_unset)
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

Both #294 and #360 touch NDBaseModel.get_diff()

#294 added allow_superset for list comparison
#360 added merge_would_change to catch storm-control percentag

@sivakasi-cisco
sivakasi-cisco dismissed stale reviews from samiib, allenrobel, gmicol, and akinross via e7a8938 July 30, 2026 22:24
…-core 2.19

ansible-core 2.19 requires a non-empty _ANSIBLE_PROFILE to decode _ANSIBLE_ARGS when constructing AnsibleModule. Patch _ANSIBLE_PROFILE="legacy" (create=True) in the composed-module tests so they pass on 2.19 while staying compatible with 2.18.

@allenrobel allenrobel left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Fresh pass over the current head (my June review was dismissed by subsequent pushes). One behavioral concern with check-mode sent tracking and two type-annotation notes, each as an inline comment.

🤖 Generated with Claude Code

Comment thread plugins/module_utils/nd_state_machine.py Outdated
Comment thread plugins/module_utils/nd_config_collection.py Outdated
Comment thread plugins/module_utils/utils.py Outdated

@allenrobel allenrobel left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM after comments addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nac01 NaC ND release 0.0.1 ready for review Submitter is requesting a PR review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ND 4.x] Add shared module utility support for nested resources

6 participants