Skip to content

Add ND 4.2 Links module (nd_links)#312

Open
shrsr wants to merge 4 commits into
CiscoDevNet:developfrom
shrsr:nd_links
Open

Add ND 4.2 Links module (nd_links)#312
shrsr wants to merge 4 commits into
CiscoDevNet:developfrom
shrsr:nd_links

Conversation

@shrsr

@shrsr shrsr commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Adds the nd_links module for ND 4.2 with manage/one_manage link scopes, template models, endpoints, orchestrator, strategies and fabric inventory support, plus integration tests.

Related Issue(s)

Proposed Changes

Test Notes

Cisco Nexus Dashboard Version

Related ND API Resource Category

  • analyze
  • infa
  • manage
  • onemanage
  • other

Checklist

  • Latest commit is rebased from develop with merge conflicts resolved
  • New or updates to documentation has been made accordingly
  • Assigned the proper reviewers

@shrsr shrsr self-assigned this Jun 11, 2026
@shrsr
shrsr force-pushed the nd_links branch 2 times, most recently from 3d0806c to 135f6b0 Compare June 11, 2026 13:24
@mikewiebe

Copy link
Copy Markdown
Collaborator

@shrsr Merge conflicts need to be resolved

@shrsr

shrsr commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

@mikewiebe yes, a few PRs were merged just now causing the conflicts. Let me look into it

@shrsr
shrsr force-pushed the nd_links branch 2 times, most recently from def7450 to bfabb58 Compare June 16, 2026 12:25
@shrsr shrsr added the ready for review Submitter is requesting a PR review label Jun 16, 2026
Comment thread plugins/modules/nd_manage_links.py
Comment thread plugins/module_utils/orchestrators/links.py
Comment thread plugins/module_utils/models/links/links.py
Comment thread plugins/modules/nd_manage_links.py
@mikewiebe

Copy link
Copy Markdown
Collaborator

Issue

There is a potential performance and scale problem.

Small link changes appear to start with a full fabric link inventory read.

Evidence

Impact

On large data center fabrics, small changes pay the cost of a full-fabric link
scan before every create, update, or delete.

Suggested fix

Prepare enough proposed identity data before querying, then use scoped query
params for merged, replaced, and deleted when the task names a small set
of links. Keep full-scope reads for overridden and broad gathered behavior.

@mikewiebe

Copy link
Copy Markdown
Collaborator

Issue

The PR does not include unit tests for the new model,
orchestrator, strategy, or endpoint behavior.

Evidence

  • The PR adds substantial Pydantic model behavior, endpoint strategy behavior,
    bulk create/delete handling, and query-scope behavior.
  • I did not find tests/unit coverage for NDLinkModel, link template models,
    link endpoints, or NDLinkOrchestrator.
  • The new tests are primarily integration tasks under
    tests/integration/targets/nd_links/.

Impact

The most important runtime contracts here live in Pydantic and orchestrator call
shape. Those regressions should be caught without requiring a live Nexus
Dashboard fabric.

Suggested fix

Add unit tests for discriminated-union validation, secret masking,
require_pydantic module behavior, strategy endpoint params, bulk create/delete
call shape, query scoping, policy-type change rejection, and gathered
no-mutation behavior.

Comment thread plugins/modules/nd_links.py Outdated
Comment thread plugins/modules/nd_links.py Outdated
Comment thread plugins/module_utils/orchestrators/links.py
Comment thread plugins/module_utils/orchestrators/links.py Outdated
Comment thread plugins/modules/nd_manage_links.py
Comment thread plugins/modules/nd_manage_links.py
Comment thread plugins/modules/nd_links.py Outdated
@shrsr
shrsr force-pushed the nd_links branch 2 times, most recently from 01c4b37 to 5c7d364 Compare July 14, 2026 14:13
@shrsr
shrsr requested a review from gmicol July 14, 2026 15:26

@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

Six findings, ordered by severity. The first is a cross-module regression that reaches nd_vrf / nd_network; the second and sixth are correctness bugs in the links write path. The rest are convention/clarity issues.

Note on the HTTP 207 helper: I am deliberately not re-raising the coverage concern from the (now resolved) thread on _raise_on_bulk_failures. My comment there is additive and only about the missing workaround marker. Separately, and purely as a heads-up rather than a review ask: #295 centralizes per-item 207 failure detection in NdV1Strategy.is_success(). That work is not on develop yet, so nothing here conflicts today, but the two will need reconciling once it lands — the strategy scans results / switchIds, while this helper scans a wider key set.

🤖 Generated with Claude Code

Comment thread plugins/module_utils/nd_state_machine.py Outdated
Comment thread plugins/module_utils/models/links/links.py
Comment thread plugins/module_utils/models/links/links.py Outdated
Comment thread plugins/module_utils/orchestrators/links.py
Comment thread plugins/module_utils/models/links/links.py Outdated
Comment thread plugins/module_utils/models/base.py Outdated
from .user_defined import UserDefinedTemplateInputs
from .vpc_peer_keepalive import VpcPeerKeepaliveTemplateInputs

LinkTemplateInputs = Annotated[

@mikewiebe mikewiebe Jul 14, 2026

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.

High: Valid controller links using unsupported policy schemas abort every state

Issue

@shrsr After we spoke I went back and verified that the schema does contain 22 supported policy types but only documents the 13 we see in devnet. I was able to test and demonstrate this problem using a campus ibgp vxlan fabric which we need to support. See details below under Live Campus reproduction

The strict response model supports 13 policy types, while the ND 4.2 linkGet
schema can return 22. A single valid link using one of the other policies, or a
supported policy containing a valid field absent from its local model, causes
the initial full-fabric read to fail before any requested operation runs.

Evidence

Concrete example

Assume a fabric already contains these two valid controller links:

  1. LINK-A uses the supported numbered policy. The user asks the module to
    gather, update, or delete this link.
  2. LINK-B uses iosXeNumbered. It was created through the ND UI or another
    supported controller workflow and is unrelated to the requested operation.

Before processing LINK-A, the module performs a full-scope query_all() and
passes both controller responses to NDConfigCollection.from_api_response().
Pydantic attempts to deserialize every item as NDLinkModel. The local
discriminated union has no iosXeNumbered member, so parsing LINK-B raises a
ValidationError. State-machine initialization stops at that point: the
module never builds its proposed state, calculates a diff, or dispatches the
requested state for LINK-A.

The same failure can occur when the policy type is locally supported but the
controller returns an additional valid field that the local model does not
declare, because the response template models use extra="forbid".

This means the problem is broader than an inability to manage an unsupported
policy. One valid, unrelated controller object can prevent the module from
reading or managing every other link in the scope. For example, a gathered
request cannot report the supported links, and a deleted request cannot
remove a specifically requested supported link.

Live Campus reproduction

This problem is reproduced against a real Campus VXLAN EVPN fabric using the
exact PR head 5c7d3647ff50328213990f01e75dd7fb7a2c7f25.

  • ND: 10.122.84.112
  • Fabric: mike-campus
  • Fabric type returned by ND: vxlanCampus
  • Source: 10.122.84.181, nac-msd-fabric1-leaf1, N9K-C9300v, Ethernet1/15
  • Destination: 10.122.84.182, nac-msd-fabric1-leaf2, N9K-C9300v, Ethernet1/15
  • Link addresses: 192.168.77.1 and 192.168.77.2

The link was created through ND using the IOS-XE numbered link policy.
Although it was initially described as unnumbered, ND stored and returned:

displayName: IOS-XE numbered
policyType: iosXeNumbered
linkId: LINK-UUID-2569120

There is no iosXeUnnumbered discriminator in the ND 4.2+ schema.

Recreation steps

  1. Create or use a fabric whose Manage API type is vxlanCampus.
  2. Add two managed switches and select an available interface on each.
  3. In the ND topology, create an intra-fabric link using IOS-XE numbered.
  4. The link only needs to exist as a planned link; deployment is not required.
  5. Confirm GET /api/v1/manage/links?fabricName=<fabric> returns HTTP 200 and
    policyType: iosXeNumbered.
  6. Run the playbook below using the PR collection and the cisco.nd.nd HTTPAPI
    plugin.
---
- name: Reproduce unsupported Campus link parsing
  hosts: nd
  gather_facts: false

  tasks:
    - name: Gather links from the Campus fabric
      cisco.nd.nd_manage_links:
        fabric_name: mike-campus
        link_scope: manage
        state: gathered

The inventory host must use:

ansible_connection: ansible.netcommon.httpapi
ansible_network_os: cisco.nd.nd
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false

Observed module failure

Initialization failed: 1 validation error for NDLinkModel
configData.templateInputs
  Input tag 'iosXeNumbered' found using 'policy_type_marker' does not
  match any of the expected tags

The task returns changed: false and no gathered data. Every module state uses
this same initialization path, so merged, replaced, overridden, and
deleted are also blocked before their requested operation runs.

Unsupported policy and fabric matrix

The ND 4.2+ Create Link schema
defines 22 policy discriminators; this PR models 13. The remaining nine require
these topology families:

Unsupported policy Fabric/topology needed
iosXeNumbered vxlanCampus; two intra-fabric IOS-XE/Catalyst endpoints. Live failure confirmed on mike-campus.
ipfmNumbered IPFM family: ipfm, ipfmEnhanced, or applicable IPFM variant; two intra-fabric IPFM endpoints.
ospfVrfLite VXLAN or Campus fabric connected to externalConnectivity/vxlanExternal using OSPF VRF-lite.
csrMultisiteUnderlay Campus/IOS-XE multisite fabric plus an external-connectivity fabric; physical underlay link to a Cat9K/CSR endpoint.
csrEvpnMultisiteOverlay Campus/IOS-XE multisite fabric plus an external-connectivity fabric; EVPN overlay/loopback link.
csrBgpOverIpsec External fabrics containing on-premises and cloud CSR1000v endpoints connected using BGP over IPsec.
vxlanAciMultisiteUnderlay NDFC VXLAN EVPN fabric plus an aci fabric; physical multisite underlay link.
vxlanAciEvpnOverlay NDFC VXLAN EVPN fabric plus an aci fabric; EVPN multisite overlay link.
routedFabric eBGP-based fabric plus an external fabric using the routed-fabric inter-fabric policy.

First-release requirement

Campus support requires iosXeNumbered at minimum because ND emits it for a
normal vxlanCampus intra-fabric link. Campus multisite scope may additionally
require csrMultisiteUnderlay and csrEvpnMultisiteOverlay.

Even when write support is intentionally deferred, response parsing must
tolerate all valid ND policy types. Otherwise, one valid controller-created
link prevents the module from reading or managing every other link returned
for that fabric.

Recommended handling when full policy support is out of scope

The module does not need create or update support for all 22 policy types in
the first release. Unsupported controller policies should be tolerated rather
than silently discarded:

  • Parse the common link fields for every controller link.
  • Fully validate and manage supported policy types.
  • Preserve unsupported links as opaque/raw records, including policyType,
    endpoints, link ID, and templateInputs.
  • Warn when an unsupported policy is encountered.
  • Continue operations that target supported links.
  • Never delete or modify an unsupported link implicitly.

For example, if a fabric contains nine supported links and one unsupported
link, state: gathered and operations targeting the nine supported links
should still work. A request explicitly targeting the unsupported link should
fail with a focused message such as:

Link LINK-UUID-2569120 uses unsupported policy type iosXeNumbered

state: overridden requires particular care. If unsupported links are simply
omitted from the parsed controller state, reconciliation could treat them as
extraneous and delete them. They must remain represented and protected from
implicit modification or deletion.

A permissive response fallback such as
SupportedPolicyModel | UnsupportedPolicyModel can preserve the raw policy
data. Strict policy validation should apply to desired user input and supported
links, not abort parsing of the controller's entire response. Because Campus is
a first-release target, iosXeNumbered should be fully supported rather than
handled only by the fallback.

Existing PR overlap

No matching existing PR comment found.

Existing open issue overlap

No matching open issue found.

Impact

gathered, merged, replaced, overridden, and deleted can all become
unusable on a fabric merely because an unrelated link was created by ND, the
UI, or another supported workflow using a valid policy the module does not
model.

Suggested fix

Separate tolerant response parsing from strict desired-input validation. The
read path should use a permissive fallback that preserves common and raw policy
data for unsupported controller objects, while the write argspec can continue
to advertise and strictly validate only policies the module can safely create.
Add response fixtures for all 22 discriminator values and the full fields of
each supported policy. Include a collection-level regression test in which an
unsupported-but-valid response object is returned alongside a supported link,
and verify that operations on the supported link are not aborted. Add an
overridden regression proving the unsupported link is not implicitly deleted.


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.

Done. Reads tolerate any ND policy as an opaque record, iosXeNumbered is now first class, and unsupported links are warned about and never modified or deleted.

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.

Thanks, the opaque fallback fixes the original full-fabric read failure for genuinely unsupported policy types, but live testing confirms the supported-policy case remains.

I tested current PR head 369ceb45d9d64992fd0af84a15f36ce98a91b3ca on ND 10.122.84.112 using an isolated, undeployed VXLAN_EVPN fabric. The two test devices were onboarded with preserve_config: false.

  1. ND accepted an OpenAPI-valid preprovision link containing mtu: 9216 and speed: auto.

  2. A raw GET returned both fields on LINK-UUID-2569180.

  3. nd_manage_links with state: gathered retained the link but warned:

    Link LINK-UUID-2569180 uses unsupported policy type 'preprovision'; it is read-only and will not be modified or deleted by this module.
    
  4. An explicit state: deleted request for the same link failed:

    Link LINK-UUID-2569180 uses unsupported policy type 'preprovision'; this module cannot delete it.
    
  5. A subsequent raw GET confirmed that the link remained.

This happens because PreprovisionTemplateInputs omits the valid mtu and speed response fields. Their presence causes validation to fall back to UnsupportedTemplateInputs, even though preprovision is an advertised supported policy.

The response model should accept the complete controller response for every supported policy. The opaque/read-only fallback should be reserved for genuinely unsupported policy types, or additional response fields should be tolerated without making the entire link immutable.

The test link, devices, and temporary fabric were removed afterward, and all four test ND instances report no designated devices.

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.

Thanks looks like the behavior is fixed, but can you add tests for requested all-policy, override-preservation, and explicit-delete?

@shrsr shrsr Jul 22, 2026

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.

Let me look into the tests for these

module swaps in MANAGE_SCOPE_IDENTIFIERS (no cluster names) at runtime.
"""

identifiers: ClassVar[list[str] | None] = [

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.

High: Reversed endpoints are treated as a different physical link

Issue

A physical switch link has no inherent direction. The composite identifier is
ordered source-to-destination, so the module treats the same cable as two
different objects when the playbook and ND use opposite endpoint orientations.
The current NaC link workflow treats a physical link as the same object in
either orientation and swaps endpoint-specific values when the controller
returns it reversed.

Concrete example

These two records describe the same physical cable:

# Playbook orientation
src: LEAF1 Ethernet1/49
dst: SPINE1 Ethernet1/1
# Possible ND response orientation
src: SPINE1 Ethernet1/1
dst: LEAF1 Ethernet1/49

The PR builds an ordered identity from the source fields followed by the
destination fields. Abbreviating the complete fabric-aware key for clarity,
the two identities become:

(LEAF1, SPINE1, Ethernet1/49, Ethernet1/1)
(SPINE1, LEAF1, Ethernet1/1, Ethernet1/49)

Those tuples are unequal. The collection lookup therefore concludes that the
requested link does not already exist even though ND already contains the same
physical link.

Evidence

Existing PR overlap

No matching existing PR comment found.

Existing open issue overlap

No matching open issue found.

Impact

When ND returns the same physical link in the opposite orientation:

  • merged treats it as new and attempts to create the same physical link
    again.
  • deleted cannot find the reversed identity and leaves the link behind.
  • overridden can classify the controller link as stale and the requested
    orientation as new, causing an unnecessary delete and recreate.
  • Repeated runs may report changes or fail with a duplicate-link or
    interface-in-use controller error instead of being idempotent.

Suggested fix

Match links using an orientation-independent identity. Conceptually:

endpoint_a = (fabric, switch, interface)
endpoint_b = (fabric, switch, interface)
identity = sorted(endpoint_a, endpoint_b)

The implementation must also retain which physical endpoint corresponds to
each requested side. If ND reverses the link, directional values such as
src_ip, dst_ip, descriptions, ASNs, and other source/destination template
inputs must be swapped before comparison. Simply sorting the complete model
would attach values to the wrong switch.

Add unit and live tests where source and destination switch names, interfaces,
and directional values differ and the controller response orientation is
reversed. Verify that merged is idempotent, deleted finds the link, and
overridden does not delete and recreate it.


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.

Done. Intra fabric links use an orientation independent identity and canonical diff, so a reversed link is matched instead of duplicated or recreated.

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.

Jumping into Mike's thread with one observation from re-review, since the fix is close: the orientation-independent identity plus canonicalized diff view looks right, and keeping payloads in the user's orientation is the correct call.

One gap in the swap list: _TEMPLATE_DIRECTIONAL_ALIAS_PAIRS (links.py:213) covers srcIp/dstIp, srcIpv6/dstIpv6, the interface descriptions, and interface configs — but not dhcpRelayOnSrcInterface/dhcpRelayOnDstInterface or bfdEchoOnSrcInterface/bfdEchoOnDstInterface, which are directional fields on the intra-fabric numbered/unnumbered policies. A reversed link with asymmetric settings (e.g. dhcp_relay_on_src_interface: true, dst false) will canonicalize the identity but leave those toggles attached to the wrong end, producing a spurious diff (non-idempotent update) or masking a real one. Suggest adding those two pairs and extending the reversed-orientation test with an asymmetric toggle. Deferring to Mike on whether that's a blocker for this thread or a follow-up.

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.

Good catch, added the dhcpRelayOnSrc/DstInterface and bfdEchoOnSrc/DstInterface pairs to the swap list and extended the reversed orientation test with an asymmetric toggle, plus a companion test asserting a genuinely asymmetric difference still diffs.

@mikewiebe mikewiebe Jul 20, 2026

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.

Thanks, the intra-fabric portion of this finding is addressed, including the additional DHCP relay and BFD echo directional alias pairs. However, the original finding also applies to physical inter-fabric links.

The current implementation explicitly limits orientation-independent identity to links where src_fabric_name == dst_fabric_name. For VRF-lite, DCI, and multisite links, get_identifier_value() still falls back to the ordered source/destination identity. Declaring the same inter-fabric cable with its endpoints reversed can therefore still be treated as a different object, causing merged to attempt a duplicate, deleted to miss the link, or overridden to delete and recreate it.

Could we please keep this thread open until:

inter-fabric physical links also use orientation-independent identity with the appropriate policy-specific directional field swaps; or

The intra-fabric fix is good; this remaining point is specifically about links whose source and destination fabric names differ.

template_inputs: LinkTemplateInputs | None = Field(default=None, alias="templateInputs")
template_name: str | None = Field(default=None, alias="templateName")

def merge(self, other: LinkConfigDataModel) -> LinkConfigDataModel:

@mikewiebe mikewiebe Jul 14, 2026

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.

High: Controller-realized preprovision links make the original declaration non-idempotent

@shrsr Let's discuss this when you get a chance. This is an interesting one and we need to determine what behavior we should support.

@shrsr I Updated this comment after checking our legacy / NaC behavior and discussing with Matt and Pete

Issue

ND correctly turns a planned preprovision link into a real numbered link
with controller-assigned settings after discovery and save/recalculation. This
finding is not asking the module to change that real link back to
preprovision.

The problem is that rerunning the unchanged declarative source now fails. The
module compares the original policy_type: preprovision declaration with ND's
realized policy_type: numbered state and treats the expected lifecycle as a
new, unsupported policy conversion. It recommends deleting and recreating the
valid realized link instead of defining how the original intent should be
reconciled.

This matters for NaC and other Git-driven workflows that repeatedly apply the
same source configuration. The current NaC workflow specifically recognizes
this transition and preserves the controller-assigned values.

Evidence

Live testing on ND 4.2 with exact PR head
5c7d3647ff50328213990f01e75dd7fb7a2c7f25 confirmed:

  1. The initial task created the link as preprovision.

  2. After the physical link came up, ND configSave returned HTTP 200 and
    changed the same identity to numbered, assigning 10.4.0.1 and
    10.4.0.2.

  3. The unchanged original task failed with changed: false and:

    Cannot change policy_type from 'numbered' to 'preprovision' on an existing link.
    ND requires deleting the link first and recreating with the new policy_type.
    

Minimum reproducer

Prerequisite: start with two onboarded switches whose selected physical link is
down. At the pause, bring the link up and run Save/Recalculate in ND so the
controller realizes it as a numbered link.

---
- name: Reproduce realized preprovision reconciliation failure
  hosts: nd
  gather_facts: false

  vars:
    test_fabric: example_fabric
    test_link: &test_link
      src_fabric_name: "{{ test_fabric }}"
      dst_fabric_name: "{{ test_fabric }}"
      src_switch_ip: 192.0.2.10
      dst_switch_ip: 192.0.2.11
      src_interface_name: Ethernet1/1
      dst_interface_name: Ethernet1/1

  module_defaults:
    cisco.nd.nd_manage_links:
      fabric_name: "{{ test_fabric }}"
      link_scope: manage

  tasks:
    - name: Create the planned link
      cisco.nd.nd_manage_links:
        state: merged
        config:
          - <<: *test_link
            config_data:
              policy_type: preprovision
              template_inputs:
                src_interface_description: planned source
                dst_interface_description: planned destination

    - name: Realize the physical link in ND
      ansible.builtin.pause:
        prompt: >-
          Bring both interfaces up, run Save/Recalculate in ND, then press Enter.

    - name: Wait for ND to report the realized link as numbered
      cisco.nd.nd_manage_links:
        state: gathered
      register: realized_links
      until: >-
        realized_links.gathered
        | selectattr('src_interface_name', 'equalto', 'Ethernet1/1')
        | selectattr('dst_interface_name', 'equalto', 'Ethernet1/1')
        | selectattr('config_data.policy_type', 'equalto', 'numbered')
        | list
        | length == 1
      retries: 30
      delay: 10

    - name: Repeat the identical original declaration
      cisco.nd.nd_manage_links:
        state: merged
        config:
          - <<: *test_link
            config_data:
              policy_type: preprovision
              template_inputs:
                src_interface_description: planned source
                dst_interface_description: planned destination

The final task fails with the numbered-to-preprovision message even though the
playbook input did not change and ND performed the policy transition.

Legacy module and NaC cross-reference

The history confirms that this lifecycle behavior currently lives in NaC as an
adapter around legacy dcnm_links, with one provenance distinction: the action
plugin itself began as a performance refactor of an existing prefilter, while
its numbered-link enrichment was added later as the compatibility behavior.

  • In PR #194,
    the workflow queried links with dcnm_links, removed links that already
    matched the physical endpoints, and passed only the remaining list back to
    dcnm_links. The YAML loop was moved into an action plugin for
    performance.
  • Commit 38f4a4f
    added the realized-link handling: when ND reports int_intra_fabric_num_link,
    NaC changes the desired template to numbered and copies ND-assigned
    PEER1_IP and PEER2_IP into the configuration before invoking
    dcnm_links.
  • The current adapter
    also suppresses the downstream module call when the enriched profile already
    matches. The current task flow
    still queries with dcnm_links, runs this adapter, and gives only its
    transformed required_links to dcnm_links.
  • The contemporaneous legacy module treated every template mismatch as
    DCNM_LINK_MERGE
    and then sent a PUT
    for the existing link UUID. It did not distinguish a controller-realized
    preprovision link from a user-requested template change.

Functionally the adapter prevents dcnm_links from
changing the realized link back to preprovision and supplies the controller
values that the original declaration does not contain.

Existing PR overlap

No matching existing PR comment found.

Existing open issue overlap

No matching open issue found.

Impact

If preprovision is persistent declarative intent, the same source cannot be
applied after Day 0: every later run fails, and the suggested deletion can
discard a valid physical link and ND-assigned settings.

If preprovision is intentionally only a one-time bootstrap instruction, the
runtime behavior may be expected, but that lifecycle is not currently defined
in the module documentation. Automation would need a documented way to detect
the transition, acquire ND-assigned values, and rewrite its source as
numbered. The current error instead directs users to delete and recreate the
link, which repeats the same lifecycle.

Suggested fix

Absorb the lifecycle behavior currently implemented by NaC directly into
nd_manage_links instead of requiring another external adapter. The module has
both desired and controller state and is the correct ownership boundary for
recognizing this controller-realized equivalence, preserving ND-assigned
values, and remaining idempotent while still rejecting arbitrary policy
changes.

Please define the intended public contract and cover it with documentation and
tests:

  • If preprovision is persistent declarative intent, treat an existing
    numbered link with the same physical identity as the realized form of that
    intent. Preserve ND-assigned values and return changed: false; do not send
    an update that changes it back to preprovision.
  • If it is a one-time bootstrap instruction, explicitly document that users
    must replace it with numbered after realization, document how automation
    obtains the assigned values, add a two-phase example, and replace the current
    delete-and-recreate guidance with an actionable lifecycle message.

The finding is valid as a runtime idempotency defect under the first contract.
Under the second contract, the required outcome is a clearly documented and
automatable lifecycle rather than silently treating an unchanged declarative
run as an arbitrary policy-change attempt.


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.

Done. An existing numbered link is recognized as the realized form of a preprovision declaration and left unchanged with changed false.

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.

Thanks, the unchanged-rerun case is now addressed: an ND-realized numbered link is no longer treated as an illegal attempt to change it back to preprovision.

One lifecycle question remains. _is_realized_preprovision_of() checks only that the existing policy is numbered and the proposed policy is preprovision; get_diff() then immediately returns no diff without comparing the user-managed preprovision fields. As a result, after realization, a later change to src_interface_description, dst_interface_description, src_interface_config, or dst_interface_config is silently ignored and the module returns changed: false.

For example, if the original preprovision declaration used src_interface_description: planned source, ND realizes the link as numbered, and the declaration is later changed to src_interface_description: updated description, the current shortcut still reports no difference.

The eventual behavior likely needs to be one of the following:

  1. Treat preprovision as persistent declarative intent: normalize the desired declaration into the realized numbered form, preserve ND-assigned addresses and numbered-only fields, and continue comparing/updating the user-controlled description and interface-configuration fields.
  2. Treat preprovision as a one-time bootstrap instruction: explicitly document that automation must replace it with a numbered declaration after realization and explain how it acquires ND-assigned values.

We should discuss which lifecycle contract we want during our next standup call before deciding the exact implementation. Please keep this thread open until that behavior is agreed and documented.

@shrsr shrsr Jul 22, 2026

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.

@mikewiebe Went with persistent intent. After realization the declaration still governs the interface description and config fields, so a later edit is a real change applied as a numbered PUT that preserves ND assigned addresses. An unchanged reapply stays idempotent. Is this ok?

Comment thread plugins/modules/nd_manage_links.py
)
return super().merge(other)

@model_validator(mode="before")

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.

Medium: Policy-specific validation is caller-controlled and alias-sensitive

Issue

The internal policy_type_marker can be supplied through the free-form input
and is trusted over the outer policy_type. The injection logic also handles
an empty alias-form dictionary differently from an empty snake-case dictionary,
and userDefined does not enforce its required template_name.

The public policy_type should be the sole authority that selects the
policy-specific template_inputs model. policy_type_marker is an internal
Pydantic discriminator and should never let caller input select a different
schema.

Concrete behavior

  1. A caller can bypass policy-specific validation. This input should be
    rejected because unexpected is not a valid numbered field:

    config_data:
      policy_type: numbered
      template_inputs:
        policy_type_marker: userDefined
        unexpected: value

    _inject_policy_marker() preserves the supplied marker, so Pydantic parses
    the dictionary as the open-ended UserDefinedTemplateInputs model while
    the outgoing link still declares policy_type: numbered.

  2. Equivalent supported aliases produce different results:

    template_inputs: {}

    fails validation, while:

    templateInputs: {}

    succeeds. The expression data.get("template_inputs") or data.get("templateInputs") treats the first empty dictionary as false and
    falls through to None; when the alias is used, the second lookup returns
    the empty dictionary. The discriminator is therefore injected only for one
    spelling of the same logical input.

  3. A custom policy is accepted without identifying its custom template:

    config_data:
      policy_type: userDefined
      template_inputs:
        custom_setting: value

    The documentation and OpenAPI contract require template_name for
    userDefined, but template_name is optional in LinkConfigDataModel and
    no policy-dependent validator enforces it.

Evidence

Existing PR overlap

No matching existing PR comment found.

Existing open issue overlap

No matching open issue found.

Impact

Users can bypass policy-specific validation and send incompatible fields under
the wrong policyType. Equivalent documented snake-case and API-alias inputs
also have different outcomes, and malformed custom-template requests fail at
the controller instead of locally. This defeats the module's stated local
validation contract and replaces actionable input errors with later, less
specific controller failures.

Suggested fix

Derive and overwrite the internal discriminator exclusively from the outer
policy_type, or remove any caller-supplied marker before model selection.
Select template_inputs by key presence rather than truthiness, and validate
template_name when policy_type=userDefined. Add tests through
NDLinkModel.from_config() for conflicting markers, empty dictionaries under
both aliases, missing user-defined template names, and required
policy-specific fields.


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.

Done. The discriminator is derived only from policy_type, template_inputs is selected by key presence, and template_name is required for userDefined.

@shrsr
shrsr force-pushed the nd_links branch 2 times, most recently from 9cddbaa to 7ff0365 Compare July 18, 2026 01:20
@shrsr
shrsr requested review from allenrobel and mikewiebe July 18, 2026 01:39
@shrsr
shrsr force-pushed the nd_links branch 2 times, most recently from 44bc963 to 9b569f1 Compare July 19, 2026 17:23

@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. One last straggler not worth holding the PR unapproved: The _NO_DEFAULT sentinel comment at plugins/module_utils/models/links/templates/base.py:26 still says "(omitted from the payload when unset)", but an unset field with no documented default is sent as a typed empty, not omitted. Could you fix that one-liner in your next push?

class InterfaceBasicsMixin(BaseModel):
"""Common interface level settings shared by most policy types."""

interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState", json_schema_extra=pd(True))

@mikewiebe mikewiebe Jul 20, 2026

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.

High: Policy-specific Pydantic models do not enforce the OpenAPI write contract

Issue

template_inputs is intentionally a generic dictionary in the Ansible argument
specification, which makes the selected policy-specific Pydantic model the
authoritative deep-validation boundary for user configuration. Those models
currently enforce basic Python types and reject properties belonging to a
different policy, but they do not enforce required fields, enum choices, numeric
bounds, or conditional requirements from OpenAPI.

For example, InterfaceBasicsMixin.mtu is an unconstrained int | None, while
speed and fec are unrestricted str | None. Required
vpcPeerKeepalive properties such as src_ip, dst_ip, and interface_vrf
are optional. apply_payload_defaults() then converts omitted fields without a
documented default into typed empty values after model validation.

Evidence

I reproduced the resulting behavior on ND 10.122.84.112 using exact PR head
369ceb45d9d64992fd0af84a15f36ce98a91b3ca in an isolated, undeployed
VXLAN_EVPN fabric:

  1. A numbered link with mtu: 0 passed check mode with changed: true.

  2. The live request also succeeded, and state: gathered returned the link
    with mtu: 0.

  3. ND then refused to delete that link:

    Template [int_routed_host] - Validation failed for following fields: [MTU]
    
  4. The link had to be repaired to mtu: 9216 before deletion succeeded.

An invalid enum demonstrates the other failure mode: speed: invalid-speed
passed check mode but failed only after reaching ND:

PolicyType [numbered] - Validation failed for following fields: [speed]

Existing PR overlap

No matching existing PR comment found. The earlier typed-empty/default thread
addresses values synthesized for omitted properties and was corrected by
sending documented defaults. This finding covers explicit invalid user values
and required per-policy constraints that still pass Pydantic validation and
check mode. The resolved policy-discriminator thread likewise does not cover
required fields, ranges, or enums.

Existing open issue overlap

No matching open issue found.

Impact

Check mode reports invalid intent as a viable change, some bad values fail only
after a controller API call, and others can be persisted into a link that ND
then refuses to delete. Users receive inconsistent late failures and may need
to repair invalid controller state before normal lifecycle operations work.

Suggested fix

Make the policy-specific Pydantic configuration models enforce the applicable
OpenAPI write contract, including required fields, enum choices, numeric bounds,
nullability, defaults, and conditional requirements. Invalid user intent should
fail before check mode proposes a change or any mutating API request is sent.

Controller-response parsing should remain tolerant so state: gathered can
still inspect legacy, forward-compatible, or already-invalid ND records. This
could use separate request/response models, validation context, or the existing
opaque-response fallback. Add invalid from_config tests for every advertised
policy, response-compatibility tests, and check-mode coverage.


@shrsr shrsr Jul 22, 2026

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.

Done.

ticket_id=ticket_id,
)

if link_scope == "auto":

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.

Medium: Default state: gathered crashes during scope auto-detection

Issue

The documented minimal state: gathered call fails when both config and
link_scope are omitted. Ansible supplies the optional config value as
None, while the default link_scope: auto path iterates it during strategy
selection.

Evidence

  • determine_strategy() lines 727-729
    uses module.params.get("config", []); because the key exists with value
    None, the list fallback is not used and the following any(... for item in config) raises.
  • Live testing on 2026-07-20 used ND 10.122.84.112, fabric
    pr338-manual-ibgp-0717, and exact PR head
    369ceb45d9d64992fd0af84a15f36ce98a91b3ca. The one-task reproducer failed
    with changed: false and:
Unexpected error: 'NoneType' object is not iterable
  • The same live call succeeded with changed: false when only
    link_scope: manage was added, confirming that authentication, fabric lookup,
    and link gathering work and that the failure is isolated to auto-scope
    selection.

Minimum reproducer

---
- name: Reproduce gathered auto-scope failure
  hosts: nd
  gather_facts: false

  tasks:
    - name: Gather links with config and link_scope omitted
      cisco.nd.nd_manage_links:
        fabric_name: example_fabric
        state: gathered

The integration gathered case forces link_scope: manage, bypassing the
default auto branch and therefore does not detect this failure.

Existing PR overlap

No matching existing PR comment found.

Existing open issue overlap

No matching open issue found. Broader issue
#340 tracks consistent
repository-wide state: gathered support and explicitly shows a no-config
call, but it does not record this PR-specific auto-scope exception or its
one-line repair.

Impact

The shortest documented way to gather links crashes before the module selects
a strategy or sends a controller request. Users must know to provide the
otherwise optional link_scope: manage workaround.

Suggested fix

Normalize an omitted configuration before iterating it, for example
config = module.params.get("config") or []. Add a module-wrapper test and an
integration task for state: gathered with both config and link_scope
omitted, plus a OneManage auto-detection control containing cluster fields.


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.

Thanks, the crash is fixed, but please add tests to exercise omitted config with default link_scope: auto

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.

Let me add a test


def update(self, model_instance: NDLinkModel, **kwargs: Any) -> ResponseType:
"""PUT /links/{linkId}; rejects cross policy updates (needs delete and recreate)."""
if self._is_policy_type_change(model_instance):

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.

Medium: Check mode approves policy transitions that normal mode rejects

Check mode and normal mode do not apply the same policy-transition validation. An existing link cannot be changed from numbered to unnumbered with a PUT, but state: replaced in check mode reports that the request is valid and would change the controller.

The state machine skips the supplied operation in check mode. For state: replaced, it calculates a change and queues update() without otherwise validating the policy transition. The numbered-to-unnumbered guard is inside LinksOrchestrator.update(), which check mode never calls.

I reproduced this against PR head 369ceb45d9d64992fd0af84a15f36ce98a91b3ca on a live ND:

  1. Created a valid numbered link.
  2. Requested the same physical link as unnumbered using state: replaced and check mode.
  3. Repeated the identical request in normal mode.
  4. Gathered the final controller state.

Observed result:

check_mode_changed: true
check_mode_failed: false
check_mode_proposed_policy: unnumbered
normal_mode_failed: true
normal_mode_message: Cannot change policy_type from 'numbered' to 'unnumbered' ...
final_controller_policy: numbered

The essential reproduction is:

- name: Request numbered to unnumbered replacement in check mode
  cisco.nd.nd_manage_links: &transition
    fabric_name: "{{ test_fabric }}"
    link_scope: manage
    output_level: info
    state: replaced
    config:
      - src_fabric_name: "{{ test_fabric }}"
        dst_fabric_name: "{{ test_fabric }}"
        src_switch_ip: "{{ src_switch_ip }}"
        dst_switch_ip: "{{ dst_switch_ip }}"
        src_interface_name: Ethernet1/34
        dst_interface_name: Ethernet1/34
        config_data:
          policy_type: unnumbered
          template_inputs:
            mtu: 9216
            speed: auto
            fec: auto
            interface_admin_state: true
  check_mode: true
  register: check_transition

- name: Request the identical replacement in normal mode
  cisco.nd.nd_manage_links:
    <<: *transition
  register: normal_transition
  ignore_errors: true

This means check mode can successfully approve a change that will certainly fail when applied, making it unreliable as a preflight gate for link replacements.

Move policy-transition validation into a shared preflight path that executes before _execute_operation() and therefore runs in both modes. Keep the validation in update() as defense in depth, and add paired normal/check-mode tests for merged, replaced, and overridden policy transitions.

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.

The policy transition guard moved into a preflight path the state machine runs in both modes, so check mode no longer approves a numbered to unnumbered change that normal mode rejects. The update guard stays as defense in depth.

options=dict(
src_cluster_name=dict(type="str"),
dst_cluster_name=dict(type="str"),
src_fabric_name=dict(type="str"),

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.

Medium: Required link identity fields are absent from the argspec contract

Issue

The public Ansible argument specification accepts a link item that does not
contain enough information for the module to identify a link. It requires one
switch selector for each endpoint, but leaves source and destination fabric and
interface names optional even though all four are mandatory parts of the
Manage-scope composite identity.

For example, this passes argument-spec and Pydantic model validation:

config:
  - src_switch_name: leaf1
    dst_switch_name: leaf2
    config_data:
      policy_type: unnumbered

It cannot identify a physical link because neither endpoint includes a fabric
or interface.

Evidence

  • links.py line 419
    declares src_fabric_name, dst_fabric_name, src_interface_name, and
    dst_interface_name without required: true.
  • links.py lines 456-459
    require only one source and destination switch selector.
  • links.py lines 124-134
    include all fabric, switch, and interface fields in the composite identity,
    while the corresponding Pydantic fields remain optional.
  • base.py lines 238-248
    detects missing composite fields only when the runtime asks for the identifier.

An exact-head model probe with the configuration above produced:

model_validation=accepted
ValueError: Composite identifier fields ['src_fabric_name',
'dst_fabric_name', 'src_interface_name', 'dst_interface_name'] are None.
All required: ['src_fabric_name', 'dst_fabric_name', 'src_switch_name',
'dst_switch_name', 'src_interface_name', 'dst_interface_name']

The valid minimum shape is instead:

config:
  - src_fabric_name: fabric1
    dst_fabric_name: fabric1
    src_switch_name: leaf1
    dst_switch_name: leaf2
    src_interface_name: Ethernet1/1
    dst_interface_name: Ethernet1/1
    config_data:
      policy_type: unnumbered

Existing PR overlap

No matching existing PR comment found. The earlier
switch-selector thread
covers conflicting name/IP/ID selectors and their priority. It does not cover
the missing fabric and interface fields required by every link identity.

Existing open issue overlap

No matching open issue found. Issue
#378 mentions composite
identifiers in the different context of port-channel name normalization and
does not cover nd_manage_links identity requiredness.

Impact

The module advertises incomplete input as structurally valid, then fails later
with an implementation-oriented composite-identifier exception. Check mode has
the same late failure. The defect normally occurs before a controller mutation,
so the primary risks are an inaccurate public contract, poor diagnostics, and
automation that cannot validate its input at the Ansible boundary.

Suggested fix

Mark src_fabric_name, dst_fabric_name, src_interface_name, and
dst_interface_name as required within every supplied config item. Keep the
top-level config option optional so state: gathered can still run without
configuration. For OneManage scope, validate the additional source and
destination cluster identity after link_scope is resolved. Add argspec and
module-wrapper tests proving incomplete items fail early with a field-specific
message for write and delete states, while no-config gathered remains valid.


shrsr and others added 4 commits July 21, 2026 17:24
Adds the nd_links module for ND 4.2 with manage / one_manage link scopes,
template models, endpoints, orchestrator, strategies and fabric inventory
support, plus integration tests.
…d policies on read, add iosXeNumbered, orientation independent identity, realized preprovision, and address remaining review feedback
…, identity, preflight, preprovision, and orientation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

NaC-CapSet1 ready for review Submitter is requesting a PR review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants