nd_interface_port_channel_routed: new module for routed port-channels (NX-OS l3Po, IOS-XE iosXeL3PortChannel) - #577
Conversation
…(NX-OS l3Po, IOS-XE iosXeL3PortChannel) New Gen-3 module for issue #549, the third member of the port-channel family. - PortChannelRoutedInterfaceModel: required network_os_type union; the NX-OS l3Po policy carries all 24 int_l3_port_channel template fields and the IOS-XE iosXeL3PortChannel policy the 9 of ios_xe_int_l3_port_channel (IPv4 only, mtu 1500-9216, prefix 8-31, PAgP modes). Field sets and ranges are identical on ND 4.2.1 and 4.3.1. policy_type is injected from network_os_type when omitted. reverse_diff_defaults come from the lab echoes. - PortChannelInterfaceBaseModel (port_channel_common.py): the identifier, name normalizer, policy_type accessor and IOS-XE Port-channel<N> create-name rewrite shared by port-channel models, instead of a third copy. - PortChannelRoutedInterfaceOrchestrator: model_class plus the managed policy types; system-provisioned routed port-channels (l3PoInternal, mplsUplinkPo) and userDefined are never read or modified. - PortChannelBaseOrchestrator.XE_MEMBER_HOST_POLICY gains the iosXeL3PortChannel row. Lab-verified 2026-09-18 on 4.2.1.10 and 4.3.1.175: ND refuses the create unless each member is already iosXeRoutedHost (flat 500 on 4.2.1, 207 failed item on 4.3.1); a joined member reads iosXeL3PoMember. The module adds no mutation stage or queue, so the base stage order and queue invariants apply unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tuj4Zw7rafTcLY7ndpxNnj
…els onto PortChannelInterfaceBaseModel No behaviour change. The composite identifier, frozen interface_type, lowercase interface_name normalizer, policy_type accessor and the IOS-XE Port-channel<N> create-name rewrite were identical in both models and are now inherited from port_channel_common.PortChannelInterfaceBaseModel, which the routed model (issue #549) also uses. The xe-port-channel-create-requires-canonical-name workaround now has a single site and marker. Guarded by the existing model and orchestrator suites for both modules (unchanged, all passing). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tuj4Zw7rafTcLY7ndpxNnj
…policy (ND 4.3.1 requires it)
ND 4.3.1.175 refuses an l3Po create body that omits mtu ("Policy [l3Po] - Validation failed for following
fields: [mtu]", a 207 failed item) where 4.2.1 defaulted it to 9216; neither spec marks it required. Found by
the first 4.3.1 run of the new integration target: one bulk POST accepted the item that carried mtu and
refused the one that did not. Same class as issue #564, same mechanism: PortChannelRoutedPolicyModel.payload_defaults
= {"mtu": 9216}, payload-only, so diff classification and before/after output are untouched and 4.2.1 stays
idempotent (identical task counts on both releases). The IOS-XE iosXeL3PortChannel create succeeds without mtu
on both releases and gets no default. Marker TODO(4.3.1) ethernet-create-required-fields-431; vault note extended.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tuj4Zw7rafTcLY7ndpxNnj
…tates, subinterface parent, IOS-XE) - merged / replaced / overridden / deleted on NX-OS with port-channel IDs 520-523, including a defaults-only port-channel, member removal, the foreign-owner refusal and the platform-mismatch refusal. - overridden is fabric-wide, so the scenario reads the routed port-channels outside its reserved range with a check-mode run and replays them in the config; they are asserted retained (the lab's ToR port-channel10 is one). - subinterface.yaml: creates and deploys a routed port-channel, waits until ND reports it as a discovered routed interface, creates a managed subinterface on it, checks both modules are idempotent, tears both down (issue #549's last acceptance item). - xe.yaml: converts the Catalyst members to routed hosts, asserts the trunk-host refusal in check and normal mode, runs every state with the discovery waits of the sibling targets, then restores the members to the fabric-default trunk host and verifies it against the switch running config ND holds. Cleanup waits for ND to report the members as unbundled (operData.portChannelId lags a removal deploy, over two minutes once on 4.3.1). Lab 2026-09-18: ok=106 changed=30 failed=0 ignored=5 on both ND 4.2.1.10 and ND 4.3.1.175 (the five ignored results are the negative tests). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tuj4Zw7rafTcLY7ndpxNnj
f02099c to
d0f3491
Compare
70ba9de to
c3dbc70
Compare
| extra_config: str | None = Field(default=None, alias="extraConfig", description="Additional CLI for the interface") | ||
| ip: IPv4Host = Field(default=None, alias="ip", description="Interface IPv4 address (bare host form, e.g. 10.1.1.1; CIDR input is accepted and normalized)") | ||
| ip_redirects: bool | None = Field(default=None, alias="ipRedirects", description="Disable IPv4 and IPv6 redirects on the interface") | ||
| ipv6: str | None = Field(default=None, alias="ipv6", description="IPv6 address of the interface") |
There was a problem hiding this comment.
Medium: Validate routed port-channel IPv6 syntax locally
Issue
The NX-OS routed port-channel model defines ipv6 as an unrestricted string. A malformed IPv6 address therefore passes model validation and check mode even though both supported ND specifications identify the field as IPv6.
Evidence
port_channel_routed_interface.py:124-125definesipv6asstr.- An exact-head model probe accepts and serializes
ipv6: not-an-ipv6. - Both ND 4.2.1 and ND 4.3.1 declare
intL3PortChannelTemplate.ipv6withformat: ipv6.
Impact
Malformed configuration can pass check mode and fail only after a real controller request.
Practical example
cisco.nd.nd_interface_port_channel_routed:
fabric_name: SITE1
config:
- switch_ip: 10.122.84.181
interface_name: port-channel501
config_data:
network_os:
network_os_type: nx-os
policy:
policy_type: l3Po
ipv6: not-an-ipv6
ipv6_prefix: 64
config_actions:
deploy: false
state: mergedCurrent behavior:
ipv6 and ipv6_prefix are both present: accepted
model validation: passes
check mode: can report the configuration as viable
normal mode: sends "not-an-ipv6" to ND
controller: rejects the request
Expected behavior:
Invalid IPv6 address for ipv6: not-an-ipv6
No changes were made.
Valid input:
policy:
policy_type: l3Po
ipv6: 2001:db8:501::1
ipv6_prefix: 64Suggested fix
Use the shared strict IPv6 host type for proposed configuration, matching routed IPv4. Add invalid and valid cases, normal/check-mode parity, legitimate controller-response parsing, and verification that validation introduces no extra controller requests.
ND version applicability
This applies to both ND 4.2.1 and ND 4.3.1.
| copy_description: bool | None = Field(default=None, alias="copyDescription", description="Propagate the port-channel description to all member interfaces") | ||
| description: AsciiDescription = Field(default=None, alias="description", min_length=1, max_length=254, description="Interface description") | ||
| extra_config: str | None = Field(default=None, alias="extraConfig", description="Additional CLI for the interface") | ||
| ip: IPv4Host = Field(default=None, alias="ip", description="Interface IPv4 address (bare host form, e.g. 10.1.1.1; CIDR input is accepted and normalized)") |
There was a problem hiding this comment.
Medium: Enforce address/prefix pairs without silently losing CIDR masks
Issue
The routed port-channel model does not enforce IPv4 or IPv6 address/prefix pairs. It also accepts CIDR notation for ip but silently removes the mask when prefix is omitted.
Impact
Check mode can accept configuration that becomes incomplete before it reaches ND. Normal mode may then send an address without the prefix the user supplied.
Practical example
cisco.nd.nd_interface_port_channel_routed:
fabric_name: SITE1
config:
- switch_ip: 10.122.84.181
interface_name: port-channel501
config_data:
network_os:
network_os_type: nx-os
policy:
policy_type: l3Po
ip: 10.1.1.1/30
# prefix omitted
config_actions:
deploy: false
state: mergedCurrent behavior:
# User supplied:
ip: 10.1.1.1/30
# Model serializes:
ip: 10.1.1.1The /30 is lost. The resulting request no longer represents the requested configuration.
The reverse case also passes validation:
policy:
policy_type: l3Po
prefix: 30
# ip omittedThe same problem exists for IPv6:
policy:
policy_type: l3Po
ipv6: 2001:db8:501::1
# ipv6_prefix omittedExpected behavior should be consistent: either derive the sibling prefix from CIDR input,
ip: 10.1.1.1
prefix: 30or reject the incomplete input locally:
ip and prefix must be provided together.
No changes were made.
Conflicting inputs must also fail before any controller request:
ip: 10.1.1.1/30
prefix: 24The prefix in ip (30) conflicts with prefix (24).
No changes were made.
Evidence
- Fields at
port_channel_routed_interface.py:122-135and:222-228have no address/prefix pair validator. - The public module documentation says CIDR input is supported.
- SVI and managed-subinterface models already enforce equivalent address/mask relationships.
- An exact-head model probe reproduced the prefix loss.
Suggested fix
Require ip with prefix and ipv6 with ipv6_prefix, or derive the sibling prefix from CIDR input. Reject conflicting CIDR and explicit prefixes. Add half-pair, CIDR-only, and conflict tests for both operating-system branches, including normal/check-mode parity.
ND version applicability
This affects both ND 4.2.1 and ND 4.3.1. The defect is in local normalization and validation before the controller contract is used.
| - Via inherited methods. See `PortChannelBaseOrchestrator` for full details. | ||
| """ | ||
|
|
||
| model_class: ClassVar[type[NDBaseModel]] = PortChannelRoutedInterfaceModel |
There was a problem hiding this comment.
Medium: Enable the existing capable-switch preflight for routed port-channels
Issue
PortChannelRoutedInterfaceOrchestrator does not declare its interface type and mode. The shared capability preflight therefore returns without checking whether each target switch supports routed port-channels.
This is the same missing-opt-in pattern already reported for access/trunk port-channels in PR #570 and SVIs in PR #571. The routed subclass introduced here needs the corresponding opt-in.
Practical example
Assume the existing ND capability lookup for portChannel/routed includes switch .181 but excludes .182:
{
"capableSwitches": [
{"serialNumber": "SERIAL-181"}
]
}The user targets .182:
cisco.nd.nd_interface_port_channel_routed:
fabric_name: SITE1
config:
- switch_ip: 10.122.84.182
interface_name: port-channel501
config_data:
network_os:
network_os_type: nx-os
policy:
policy_type: l3Po
ip: 10.1.1.1
prefix: 30
config_actions:
deploy: false
state: mergedCurrent normal-mode behavior:
capability preflight: skipped
POST for port-channel501: attempted
ND: rejects the unsupported switch
Current check-mode behavior:
capability preflight: skipped
changed: true
warning: none
Expected normal-mode behavior:
Switch 10.122.84.182 does not support routed port-channel interfaces.
No changes were made.
Expected check-mode behavior, following the established capability policy:
changed: true
warning: Switch 10.122.84.182 does not support routed port-channel interfaces.
mutation requests: 0
Evidence
- This class defines
model_classand_managed_policy_types()but leaves the inherited capability identifiers empty. - The shared base skips capable-switch validation when
interface_typeis empty. - The existing capability helper already supports the
portChannelandroutedpair.
Suggested fix
Enable the shared preflight in this subclass:
interface_type: ClassVar[str] = "portChannel"
interface_mode: ClassVar[str] = "routed"As required by the #570 and #571 precedents, reuse the existing cached capability helper. Do not introduce an independent GET path or perform one GET per interface. Add tests covering supported and unsupported switches, normal mode, check mode, and multiple interfaces sharing the cached lookup.
ND version applicability
This affects both ND 4.2.1 and ND 4.3.1.
Related Issue(s)
Closes #549
Stacked on #572 (
feat_541_subinterface_iosxe); refs #409 (grouped bulk create), #569 (IOS-XE port-channel delete-side name).Proposed Changes
New Gen-3 module
nd_interface_port_channel_routed: routed (L3) port-channels for NX-OS and IOS-XE, the third member of the port-channel family from #570, with the L3 field conventions ofnd_interface_ethernet_routed.Applicability (ND 4.2.1 and 4.3.1 OpenAPI; enums, fields and ranges identical on both, 4.3.1 only adds a
descriptioncharacter pattern on NX-OS):l3PoiosXeL3PortChannell3PoInternal,mplsUplinkPooverriddencannot touch them)l3PoMember,iosXeL3PoMemberfreeform,userDefinedThe issue text names the NX-OS discriminator
l3PortChannel; the wire value on both releases isl3Po.PortChannelRoutedInterfaceModel:network_os_typeis required (new module, no legacy playbooks to default for) and selects the policy branch;policy_typeis injected from it when omitted. Both policies sit onInterfacePolicyStrictBase, so a cross-OSpolicy_typeor an NX-OS-only field on IOS-XE (IPv6, PIM, QoS, netflow, speed, routing tag) fails before any controller call, while ND's undeclaredportChannelIdecho is tolerated on read.reverse_diff_defaultscome from the lab echoes on both releases.PortChannelInterfaceBaseModel(port_channel_common.py): the composite identifier, lowercase name normalizer,policy_typeaccessor and the IOS-XEPort-channel<N>create-name rewrite, shared instead of copied a third time. The second commit moves the access and trunk-host models onto it with no behaviour change, soTODO(4.2.1) xe-port-channel-create-requires-canonical-namehas one site.PortChannelRoutedInterfaceOrchestrator:model_classplus the two managed policy types; everything else (grouped bulk create, member-ownership preflight, canonical delete-side name, bulk remove, deploy) is inherited fromPortChannelBaseOrchestrator.XE_MEMBER_HOST_POLICYgains theiosXeL3PortChannelrow. ND refuses the create unless every member is alreadyiosXeRoutedHost(flat HTTP 500 on 4.2.1, 207 failed item on 4.3.1); the preflight predicts it before any write, in check mode too, and namesnd_interface_ethernet_routedas the module that converts the member. Existing markerTODO(4.2.1) xe-port-channel-member-mode-mismatch; vault note extended with the routed evidence. The joined member readsiosXeL3PoMember, not the spec'siosXeInternalPoMember.payload_defaults = {"mtu": 9216}because ND 4.3.1 refuses anl3Pocreate that omitsmtuwhere 4.2.1 defaulted it (TODO(4.3.1) ethernet-create-required-fields-431, the nd_interface_{ethernet_trunk_host,ethernet_routed,ethernet_access (XE),vpc_trunk_host}: ND 4.3.1 rejects create/update bodies that omit fields 4.2.1 defaulted (allowedVlans, mtu) #564 class; vault note extended). Found by the first 4.3.1 run of the new target: one bulk POST accepted the item that carriedmtuand refused the one that did not. Payload-only, so diffs and before/after output are untouched; the IOS-XE create needs nomtuon either release.policy_typeit applies to and the ND default where there is one (Document ND schema defaults in interface module option docs (collection-wide) #382), EXAMPLES cover merged / replaced / overridden / deleted / check mode on both platforms including the IOS-XE member conversion, RETURN documented.Four questions (no new bulk stage or queue): the module adds no stage and no queue. A mixed 207 on the grouped create is handled per item by the inherited
create_bulk; the finalizer cannot resubmit anything new becausedeploy_pending/deploy_accepted_mutationsare untouched; there is no new except-path reader; result items are keyed exactly as for the sibling modules (nameon create,interfaceName+switchIdon remove).Test Notes
tests/unit/5039 passed. New: 48 model tests (enums, both lab echoes, strict write side, ranges per template, defaults scrub, create-name rewrite, the payload-onlymtudefault, argspec), 8 orchestrator tests (ownership filter incl.l3PoInternal/mplsUplinkPo/userDefined, the routed preflight row incl. own-member re-apply and foreign owner, routed wire shape through grouped create, canonical delete-side name in both queues), 8 module tests (argspec wiring, opt-in deploy, check mode, remove-before-deploy ordering, failure-path finalizer for both exception kinds). The module is also registered in the two cross-module contract suites (deploy default, finalizer wiring).validate-modules,pylint,import,compile,yamllintpass viandtest; black / isort clean; mypy clean on the new files.nd_interface_port_channel_routedtarget (NX-OS scenarios on SITE1 S1_BG1 / S3_BG1Ethernet1/10-13,xe.yamlon CAMPUS1 C1_LE1 / C3_LE1 Catalyst 9000vGigabitEthernet1/0/2-4):ok=106 changed=30 failed=0 ignored=5ok=106 changed=30 failed=0 ignored=5(the five ignored results are the negative tests)overriddenis fabric-wide, so the scenario reads the routed port-channels outside its reserved 520-523 range with a check-mode run and replays them in the config; the lab's ToRport-channel10(the subinterface target's parent) is retained and the test asserts it.subinterface.yamlcovers the issue's last acceptance item: creates and deploys a routed port-channel with this module, waits until ND reports it as a discovered routed interface, creates a managed subinterface on it withnd_interface_subinterface_managed, checks both modules are idempotent, and tears both down.xe.yamlconverts the members withnd_interface_ethernet_routed, asserts the trunk-host refusal in check and normal mode, runs merged / replaced / overridden / deleted with the discovery waits of nd_interface_port_channel_{access,trunk_host}: add IOS-XE policy types (iosXeAccessPoHost, iosXeTrunkPoHost) #570, then restores the members to the fabric-default trunk host (the two-step trunk-host teardown of nd_interface_subinterface_managed: add the IOS-XE iosXeSubinterface and iosXeSubinterfaceShutNoshut policy types #572) and verifies the result against the switch running config ND holds (GET .../switches/{id}/diff), which both Catalysts confirmed over SSH. The cleanup waits for ND to report the members as unbundled first:operData.portChannelIdlags a removal deploy, by more than two minutes once on 4.3.1.iosXeL3PoMemberecho, and that aniosXeL3PortChannelcreate needs nomtuon 4.3.1.xe-port-channel-removal-inventory-lag). The module ignores policy-less records, so idempotency is unaffected.Cisco Nexus Dashboard Version
4.2.1 (also verified on 4.3.1)
Related ND API Resource Category
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01Tuj4Zw7rafTcLY7ndpxNnj