Skip to content

nd_interface_port_channel_routed: new module for routed port-channels (NX-OS l3Po, IOS-XE iosXeL3PortChannel) - #577

Open
allenrobel wants to merge 4 commits into
feat_541_subinterface_iosxefrom
feat_549_port_channel_routed
Open

allenrobel wants to merge 4 commits into
feat_541_subinterface_iosxefrom
feat_549_port_channel_routed

Conversation

@allenrobel

@allenrobel allenrobel commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

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 of nd_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 description character pattern on NX-OS):

policyType Template Classification
l3Po int_l3_port_channel Supported: all 24 template fields (IPv4 + IPv6, mtu 576-9216, PIM, QoS, netflow, speed, routing tag, VRF, members, mode)
iosXeL3PortChannel ios_xe_int_l3_port_channel Supported: all 9 template fields (IPv4 only, mtu 1500-9216, prefix 8-31, description 1-200, LACP + PAgP modes, VRF, members)
l3PoInternal, mplsUplinkPo n/a Not applicable (system-provisioned; never read or modified, so overridden cannot touch them)
l3PoMember, iosXeL3PoMember n/a Not applicable (member types ND provisions on the ethernet members)
freeform, userDefined n/a Out of scope (as in every interface module)

The issue text names the NX-OS discriminator l3PortChannel; the wire value on both releases is l3Po.

  • PortChannelRoutedInterfaceModel: network_os_type is required (new module, no legacy playbooks to default for) and selects the policy branch; policy_type is injected from it when omitted. Both policies sit on InterfacePolicyStrictBase, so a cross-OS policy_type or an NX-OS-only field on IOS-XE (IPv6, PIM, QoS, netflow, speed, routing tag) fails before any controller call, while ND's undeclared portChannelId echo is tolerated on read. reverse_diff_defaults come from the lab echoes on both releases.
  • PortChannelInterfaceBaseModel (port_channel_common.py): the composite identifier, lowercase name normalizer, policy_type accessor and the IOS-XE Port-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, so TODO(4.2.1) xe-port-channel-create-requires-canonical-name has one site.
  • PortChannelRoutedInterfaceOrchestrator: model_class plus the two managed policy types; everything else (grouped bulk create, member-ownership preflight, canonical delete-side name, bulk remove, deploy) is inherited from PortChannelBaseOrchestrator.
  • XE_MEMBER_HOST_POLICY gains the iosXeL3PortChannel row. ND refuses the create unless every member is already iosXeRoutedHost (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 names nd_interface_ethernet_routed as the module that converts the member. Existing marker TODO(4.2.1) xe-port-channel-member-mode-mismatch; vault note extended with the routed evidence. The joined member reads iosXeL3PoMember, not the spec's iosXeInternalPoMember.
  • NX-OS branch: payload_defaults = {"mtu": 9216} because ND 4.3.1 refuses an l3Po create that omits mtu where 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 carried mtu and refused the one that did not. Payload-only, so diffs and before/after output are untouched; the IOS-XE create needs no mtu on either release.
  • Docs: every option states which policy_type it 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 because deploy_pending / deploy_accepted_mutations are untouched; there is no new except-path reader; result items are keyed exactly as for the sibling modules (name on create, interfaceName + switchId on remove).

Test Notes

  • Unit: 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-only mtu default, 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).
  • Sanity: validate-modules, pylint, import, compile, yamllint pass via ndtest; black / isort clean; mypy clean on the new files.
  • Lab, new nd_interface_port_channel_routed target (NX-OS scenarios on SITE1 S1_BG1 / S3_BG1 Ethernet1/10-13, xe.yaml on CAMPUS1 C1_LE1 / C3_LE1 Catalyst 9000v GigabitEthernet1/0/2-4):
    • ND 4.2.1.10: ok=106 changed=30 failed=0 ignored=5
    • ND 4.3.1.175: ok=106 changed=30 failed=0 ignored=5 (the five ignored results are the negative tests)
    • overridden is 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 ToR port-channel10 (the subinterface target's parent) is retained and the test asserts it.
    • subinterface.yaml covers 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 with nd_interface_subinterface_managed, checks both modules are idempotent, and tears both down.
    • xe.yaml converts the members with nd_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.portChannelId lags a removal deploy, by more than two minutes once on 4.3.1.
  • Raw-REST probes (intent-only, both controllers, 2026-09-18) established the IOS-XE member rule, the iosXeL3PoMember echo, and that an iosXeL3PortChannel create needs no mtu on 4.3.1.
  • Observed but not changed here: ND 4.3.1 can keep a stale policy-less discovered record for a deleted IOS-XE port-channel for at least 90 s (the switch is clean; 4.2.1 drops the record at once; vault note 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

  • analyze
  • infra
  • manage
  • onemanage
  • other

Checklist

🤖 Generated with Claude Code

https://claude.ai/code/session_01Tuj4Zw7rafTcLY7ndpxNnj

allenrobel and others added 4 commits September 18, 2026 08:39
…(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
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")

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: 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-125 defines ipv6 as str.
  • An exact-head model probe accepts and serializes ipv6: not-an-ipv6.
  • Both ND 4.2.1 and ND 4.3.1 declare intL3PortChannelTemplate.ipv6 with format: 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: merged

Current 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: 64

Suggested 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)")

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: 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: merged

Current behavior:

# User supplied:
ip: 10.1.1.1/30

# Model serializes:
ip: 10.1.1.1

The /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 omitted

The same problem exists for IPv6:

policy:
  policy_type: l3Po
  ipv6: 2001:db8:501::1
  # ipv6_prefix omitted

Expected behavior should be consistent: either derive the sibling prefix from CIDR input,

ip: 10.1.1.1
prefix: 30

or 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: 24
The prefix in ip (30) conflicts with prefix (24).
No changes were made.

Evidence

  • Fields at port_channel_routed_interface.py:122-135 and :222-228 have 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

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: 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: merged

Current 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_class and _managed_policy_types() but leaves the inherited capability identifiers empty.
  • The shared base skips capable-switch validation when interface_type is empty.
  • The existing capability helper already supports the portChannel and routed pair.

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.

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

Labels

2.0.0 Release 2.0.0 ready for review Submitter is requesting a PR review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants