Add ND 4.2 Links module (nd_links)#312
Conversation
3d0806c to
135f6b0
Compare
|
@shrsr Merge conflicts need to be resolved |
|
@mikewiebe yes, a few PRs were merged just now causing the conflicts. Let me look into it |
def7450 to
bfabb58
Compare
|
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 Suggested fix Prepare enough proposed identity data before querying, then use scoped query |
|
Issue The PR does not include unit tests for the new model, Evidence
Impact The most important runtime contracts here live in Pydantic and orchestrator call Suggested fix Add unit tests for discriminated-union validation, secret masking, |
01c4b37 to
5c7d364
Compare
allenrobel
left a comment
There was a problem hiding this comment.
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
| from .user_defined import UserDefinedTemplateInputs | ||
| from .vpc_peer_keepalive import VpcPeerKeepaliveTemplateInputs | ||
|
|
||
| LinkTemplateInputs = Annotated[ |
There was a problem hiding this comment.
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
plugins/module_utils/models/links/templates/discriminated_union.pyline 31
enumerates only 13 policy models.plugins/module_utils/models/links/templates/base.pyline 107
forbids extra fields for all policies exceptuserDefined.plugins/module_utils/nd_state_machine.pyline 83
queries and validates all returned links before building the proposed state.- ND 4.2 additionally maps
ipfmNumbered,iosXeNumbered,ospfVrfLite,
csrMultisiteUnderlay,vxlanAciMultisiteUnderlay,
csrEvpnMultisiteOverlay,vxlanAciEvpnOverlay,csrBgpOverIpsec, and
routedFabric. Supported local models also omit valid fields such as
srcKmeServerPortNumberanddstKmeServerPortNumber. - A direct
NDLinkModel.from_response()probe using a valid
policyType: iosXeNumberedresponse raisedValidationError.
Concrete example
Assume a fabric already contains these two valid controller links:
LINK-Auses the supportednumberedpolicy. The user asks the module to
gather, update, or delete this link.LINK-BusesiosXeNumbered. 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.1and192.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
- Create or use a fabric whose Manage API type is
vxlanCampus. - Add two managed switches and select an available interface on each.
- In the ND topology, create an intra-fabric link using IOS-XE numbered.
- The link only needs to exist as a planned link; deployment is not required.
- Confirm
GET /api/v1/manage/links?fabricName=<fabric>returns HTTP 200 and
policyType: iosXeNumbered. - Run the playbook below using the PR collection and the
cisco.nd.ndHTTPAPI
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: gatheredThe inventory host must use:
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: cisco.nd.nd
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: falseObserved 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, andtemplateInputs. - 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
-
ND accepted an OpenAPI-valid
preprovisionlink containingmtu: 9216andspeed: auto. -
A raw GET returned both fields on
LINK-UUID-2569180. -
nd_manage_linkswithstate: gatheredretained 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. -
An explicit
state: deletedrequest for the same link failed:Link LINK-UUID-2569180 uses unsupported policy type 'preprovision'; this module cannot delete it. -
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.
There was a problem hiding this comment.
Thanks looks like the behavior is fixed, but can you add tests for requested all-policy, override-preservation, and explicit-delete?
There was a problem hiding this comment.
Let me look into the tests for these
| module swaps in MANAGE_SCOPE_IDENTIFIERS (no cluster names) at runtime. | ||
| """ | ||
|
|
||
| identifiers: ClassVar[list[str] | None] = [ |
There was a problem hiding this comment.
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/49The 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
plugins/module_utils/models/links/links.pyline 60
builds identity as an ordered source tuple followed by a destination tuple.- NaC
existing_links_check.pylines 134-177
matches either endpoint orientation and swaps source/destination values when
needed. - The new integration assertions use identical interface names on both sides,
so they do not detect orientation reversal.
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:
mergedtreats it as new and attempts to create the same physical link
again.deletedcannot find the reversed identity and leaves the link behind.overriddencan 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.
There was a problem hiding this comment.
Done. Intra fabric links use an orientation independent identity and canonical diff, so a reversed link is matched instead of duplicated or recreated.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
plugins/module_utils/models/links/links.pyline 22
raises whenever existing and proposedpolicy_typevalues differ, including
the controller-drivennumberedversus originalpreprovisioncase.plugins/module_utils/orchestrators/links.pyline 233
rejects the difference again before update.- NaC
existing_links_check.pylines 182-203
recognizes ND's preprovision-to-numbered conversion and retains the
controller-assigned addresses. - The integration tests exercise links that remain
preprovision; they do not
rerun the original declaration after ND returns the same identity as
numbered.
Live testing on ND 4.2 with exact PR head
5c7d3647ff50328213990f01e75dd7fb7a2c7f25 confirmed:
-
The initial task created the link as
preprovision. -
After the physical link came up, ND
configSavereturned HTTP 200 and
changed the same identity tonumbered, assigning10.4.0.1and
10.4.0.2. -
The unchanged original task failed with
changed: falseand: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 destinationThe 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 withdcnm_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 reportsint_intra_fabric_num_link,
NaC changes the desired template to numbered and copies ND-assigned
PEER1_IPandPEER2_IPinto 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 withdcnm_links, runs this adapter, and gives only its
transformedrequired_linkstodcnm_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
preprovisionis persistent declarative intent, treat an existing
numberedlink with the same physical identity as the realized form of that
intent. Preserve ND-assigned values and returnchanged: false; do not send
an update that changes it back topreprovision. - If it is a one-time bootstrap instruction, explicitly document that users
must replace it withnumberedafter 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.
There was a problem hiding this comment.
Done. An existing numbered link is recognized as the realized form of a preprovision declaration and left unchanged with changed false.
There was a problem hiding this comment.
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:
- 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.
- 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.
There was a problem hiding this comment.
@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?
| ) | ||
| return super().merge(other) | ||
|
|
||
| @model_validator(mode="before") |
There was a problem hiding this comment.
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
-
A caller can bypass policy-specific validation. This input should be
rejected becauseunexpectedis not a validnumberedfield: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-endedUserDefinedTemplateInputsmodel while
the outgoing link still declarespolicy_type: numbered. -
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 toNone; 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. -
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_namefor
userDefined, buttemplate_nameis optional inLinkConfigDataModeland
no policy-dependent validator enforces it.
Evidence
plugins/module_utils/models/links/links.pyline 33
preserves a caller-supplied marker and usesorwhen selecting
template_inputs, so explicit snake-case{}becomesNone.plugins/module_utils/models/links/templates/user_defined.pyline 13
permits arbitrary extra values.tests/unit/module_utils/models/test_links.pyline 72
explicitly acceptsuserDefinedwithout thetemplate_namerequired by the
docs and OpenAPI.- A direct probe sent outer
policy_type: numberedwith
policy_type_marker: userDefined; it was accepted as
UserDefinedTemplateInputswith an otherwise invalidunexpectedkey. The
equivalent snake-casetemplate_inputs: {}path raisedValidationError.
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.
There was a problem hiding this comment.
Done. The discriminator is derived only from policy_type, template_inputs is selected by key presence, and template_name is required for userDefined.
9cddbaa to
7ff0365
Compare
44bc963 to
9b569f1
Compare
allenrobel
left a comment
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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
templates/base.pylines 38-41
declaremtuas an unconstrainedint | Noneandspeed/fecas
unrestrictedstr | Nonevalues.vpc_peer_keepalive.pylines 21-29
make the policy's addresses andinterface_vrfoptional.templates/base.pylines 160-186
fill fields without documented defaults with typed empty values after model
validation.
I reproduced the resulting behavior on ND 10.122.84.112 using exact PR head
369ceb45d9d64992fd0af84a15f36ce98a91b3ca in an isolated, undeployed
VXLAN_EVPN fabric:
-
A
numberedlink withmtu: 0passed check mode withchanged: true. -
The live request also succeeded, and
state: gatheredreturned the link
withmtu: 0. -
ND then refused to delete that link:
Template [int_routed_host] - Validation failed for following fields: [MTU] -
The link had to be repaired to
mtu: 9216before 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.
| ticket_id=ticket_id, | ||
| ) | ||
|
|
||
| if link_scope == "auto": |
There was a problem hiding this comment.
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
usesmodule.params.get("config", []); because the key exists with value
None, the list fallback is not used and the followingany(... 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
withchanged: falseand:
Unexpected error: 'NoneType' object is not iterable
- The same live call succeeded with
changed: falsewhen only
link_scope: managewas 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: gatheredThe 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.
There was a problem hiding this comment.
Thanks, the crash is fixed, but please add tests to exercise omitted config with default link_scope: auto
|
|
||
| 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): |
There was a problem hiding this comment.
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:
- Created a valid
numberedlink. - Requested the same physical link as
unnumberedusingstate: replacedand check mode. - Repeated the identical request in normal mode.
- 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: trueThis 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.
There was a problem hiding this comment.
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"), |
There was a problem hiding this comment.
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: unnumberedIt cannot identify a physical link because neither endpoint includes a fabric
or interface.
Evidence
links.pyline 419
declaressrc_fabric_name,dst_fabric_name,src_interface_name, and
dst_interface_namewithoutrequired: true.links.pylines 456-459
require only one source and destination switch selector.links.pylines 124-134
include all fabric, switch, and interface fields in the composite identity,
while the corresponding Pydantic fields remain optional.base.pylines 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: unnumberedExisting 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.
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>
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
Checklist