From 5a96a4e5eb73c635128bcd49452aac454014da58 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 4 May 2026 08:59:35 -1000 Subject: [PATCH 01/14] Add nd_interface_port_channel_trunk_host module Mirrors nd_interface_port_channel_access structure for the trunkPoHost policy type. 42-field policy model covers the full ND OpenAPI intPortChannelTrunkHostTemplate superset, including bandwidth, linkType, negotiateAuto, vlan_mapping/dot1q-tunnel, and storm-control float fields. policyType is hardcoded to "trunkPoHost" and not exposed in the argspec. Integration tests cover merged/replaced/overridden/deleted, member uniqueness ordering, and a non-9000v vlan_mapping block. Co-Authored-By: Claude Opus 4.7 --- .../module_utils/models/interfaces/enums.py | 22 + .../port_channel_trunk_host_interface.py | 424 +++++++++++++++ .../port_channel_trunk_host_interface.py | 53 ++ .../nd_interface_port_channel_trunk_host.py | 489 ++++++++++++++++++ .../tasks/deleted.yaml | 151 ++++++ .../tasks/main.yaml | 75 +++ .../tasks/merged.yaml | 256 +++++++++ .../tasks/overridden.yaml | 134 +++++ .../tasks/replaced.yaml | 132 +++++ .../tasks/vlan_mapping.yaml | 82 +++ .../vars/main.yaml | 132 +++++ 11 files changed, 1950 insertions(+) create mode 100644 plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py create mode 100644 plugins/module_utils/orchestrators/port_channel_trunk_host_interface.py create mode 100644 plugins/modules/nd_interface_port_channel_trunk_host.py create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/vars/main.yaml diff --git a/plugins/module_utils/models/interfaces/enums.py b/plugins/module_utils/models/interfaces/enums.py index 1170aecac..9fa06c5ed 100644 --- a/plugins/module_utils/models/interfaces/enums.py +++ b/plugins/module_utils/models/interfaces/enums.py @@ -27,6 +27,16 @@ class AccessPoHostPolicyTypeEnum(str, Enum): ACCESS_PO_HOST = "accessPoHost" +class TrunkPoHostPolicyTypeEnum(str, Enum): + """ + # Summary + + Policy type for port-channel trunk host interfaces. + """ + + TRUNK_PO_HOST = "trunkPoHost" + + class BpduFilterEnum(str, Enum): """ # Summary @@ -74,6 +84,18 @@ class LacpRateEnum(str, Enum): FAST = "fast" +class LinkTypeEnum(str, Enum): + """ + # Summary + + Spanning-tree link type. + """ + + AUTO = "auto" + POINT_TO_POINT = "pointToPoint" + SHARED = "shared" + + class MtuEnum(str, Enum): """ # Summary diff --git a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py new file mode 100644 index 000000000..dfe0b06b2 --- /dev/null +++ b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py @@ -0,0 +1,424 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Port-channel trunk host (trunkPoHost) interface Pydantic models for Nexus Dashboard. + +This module defines nested Pydantic models that mirror the ND Manage Interfaces API payload +structure for port-channel trunkPoHost interfaces. The playbook config uses the same nesting +so that `to_payload()` and `from_response()` work via standard Pydantic serialization with no +custom wrapping or flattening. + +The port-channel policy is the single source of truth for member configuration. Member ethernet +interfaces inherit trunk-mode settings from the port-channel; users do not pre-configure members. + +## Model Hierarchy + +- `PortChannelTrunkHostInterfaceModel` (top-level, `NDBaseModel`) + - `switch_ip` (composite identifier) + - `interface_name` (composite identifier; e.g. `port-channel501`) + - `interface_type` (default: "portChannel") + - `config_data` -> `PortChannelTrunkHostConfigDataModel` + - `mode` (default: "trunk") + - `network_os` -> `PortChannelTrunkHostNetworkOSModel` + - `network_os_type` (default: "nx-os") + - `policy` -> `PortChannelTrunkHostPolicyModel` + - `admin_state`, `allowed_vlans`, `native_vlan`, `ports`, `port_channel_mode`, + `lacp_rate`, `bpdu_guard`, `description`, `policy_type`, `vlan_mapping`, + `vlan_mapping_entries`, etc. +""" + +from __future__ import annotations + +import re +from typing import ClassVar, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import ( + BpduFilterEnum, + BpduGuardEnum, + DuplexModeEnum, + LacpRateEnum, + LinkTypeEnum, + MtuEnum, + PortChannelModeEnum, + SpeedEnum, + StormControlActionEnum, + TrunkPoHostPolicyTypeEnum, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.types import AsciiDescription + +# Accepts: "none", "all", or comma-separated VLAN ids/ranges (e.g. "1-200,500-2000,3000"). +ALLOWED_VLANS_PATTERN = re.compile(r"^(none|all|(\d+(-\d+)?)(,\d+(-\d+)?)*)$") + + +class PortChannelTrunkHostVlanMappingEntryModel(NDNestedModel): + """ + # Summary + + A single VLAN mapping entry for a trunk port-channel. Maps to one element of `policy.vlanMappingEntries` + in the ND API. Use entries to translate customer VLAN ids to provider VLAN ids, optionally with selective + dot1q-tunnel mode. + + ## Raises + + None + """ + + customer_inner_vlan_id: int | None = Field( + default=None, alias="customerInnerVlanId", ge=1, le=4094, description="Inner customer VLAN id (selective dot1q-tunnel only)" + ) + customer_vlan_id: list[str] | None = Field(default=None, alias="customerVlanId", description="Customer VLAN id list (single id or range strings)") + dot1q_tunnel: bool | None = Field(default=None, alias="dot1qTunnel", description="Use selective dot1q-tunnel mode for this entry") + provider_vlan_id: int | None = Field(default=None, alias="providerVlanId", ge=1, le=4094, description="Provider VLAN id") + + +class PortChannelTrunkHostPolicyModel(NDNestedModel): + """ + # Summary + + Policy fields for a port-channel trunkPoHost interface. Maps directly to the `configData.networkOS.policy` + object in the ND API. + + The `ports` field carries the list of member interface names (e.g. `Ethernet1/1`). Member interfaces inherit + trunk-mode configuration from this policy; modifying a member's standalone configuration while it is a + port-channel member is restricted by the ethernet orchestrators. + + ## Raises + + ### ValueError + + - If `allowed_vlans` is set and does not match `none`, `all`, or comma-separated VLAN ranges. + """ + + admin_state: bool | None = Field(default=None, alias="adminState", description="Enable or disable the interface") + allowed_vlans: str | None = Field( + default=None, + alias="allowedVlans", + description="Trunk allowed VLANs ('none', 'all', or comma-separated VLAN ids/ranges, e.g. '100-200,300')", + ) + bandwidth: int | None = Field(default=None, alias="bandwidth", ge=1, le=100000000, description="Interface bandwidth in kilobits per second") + bpdu_filter: BpduFilterEnum | None = Field(default=None, alias="bpduFilter", description="Configure spanning-tree BPDU filter") + bpdu_guard: BpduGuardEnum | None = Field(default=None, alias="bpduGuard", description="Enable spanning-tree BPDU guard") + cdp: bool | None = Field(default=None, alias="cdp", description="Enable CDP on the interface") + 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", max_length=254, description="Interface description") + duplex_mode: DuplexModeEnum | None = Field(default=None, alias="duplexMode", description="Port duplex mode") + extra_config: str | None = Field(default=None, alias="extraConfig", description="Additional CLI for the interface") + inherit_bandwidth: int | None = Field( + default=None, alias="inheritBandwidth", ge=1, le=100000000, description="Inherited interface bandwidth in kilobits per second" + ) + lacp_port_priority: int | None = Field(default=None, alias="lacpPortPriority", ge=1, le=65535, description="LACP port priority (1-65535, default 32768)") + lacp_rate: LacpRateEnum | None = Field(default=None, alias="lacpRate", description="LACP rate (normal=30s, fast=1s)") + lacp_suspend: bool | None = Field(default=None, alias="lacpSuspend", description="Suspend port if LACP PDUs not received") + link_type: LinkTypeEnum | None = Field(default=None, alias="linkType", description="Spanning-tree link type") + monitor: bool | None = Field(default=None, alias="monitor", description="Enable switchport monitor for SPAN/ERSPAN") + mtu: MtuEnum | None = Field(default=None, alias="mtu", description="Interface MTU") + native_vlan: int | None = Field(default=None, alias="nativeVlan", ge=1, le=4094, description="Trunk native VLAN id") + negotiate_auto: bool | None = Field(default=None, alias="negotiateAuto", description="Enable link auto-negotiation") + netflow: bool | None = Field(default=None, alias="netflow", description="Enable Netflow on the interface") + netflow_monitor: str | None = Field(default=None, alias="netflowMonitor", description="Layer 2 Netflow monitor name") + netflow_sampler: str | None = Field(default=None, alias="netflowSampler", description="Netflow sampler name") + orphan_port: bool | None = Field( + default=None, alias="orphanPort", description="Configure as a vPC orphan port (suspended by secondary peer on vPC failure)" + ) + pfc: bool | None = Field(default=None, alias="pfc", description="Enable Priority Flow Control") + policy_type: TrunkPoHostPolicyTypeEnum = Field(default=TrunkPoHostPolicyTypeEnum.TRUNK_PO_HOST, alias="policyType", description="Interface policy type") + port_channel_id: str | None = Field(default=None, alias="portChannelId", description="Port-channel id (response-only echo of interface_name)") + port_channel_mode: PortChannelModeEnum | None = Field(default=None, alias="portChannelMode", description="Port-channel mode (on/active/passive)") + port_type_edge_trunk: bool | None = Field(default=None, alias="portTypeEdgeTrunk", description="Configure as edge trunk port (PortFast on trunk)") + ports: list[str] | None = Field(default=None, alias="ports", description="Member interface names (e.g. ['Ethernet1/1', 'Ethernet1/2'])") + ptp: bool | None = Field(default=None, alias="ptp", description="Enable Precision Time Protocol on the interface") + qos: bool | None = Field(default=None, alias="qos", description="Enable QoS configuration for this interface") + qos_policy: str | None = Field(default=None, alias="qosPolicy", description="Custom QoS policy name") + queuing_policy: str | None = Field(default=None, alias="queuingPolicy", description="Custom queuing policy name") + speed: SpeedEnum | None = Field(default=None, alias="speed", description="Interface speed") + storm_control: bool | None = Field(default=None, alias="stormControl", description="Enable traffic storm control") + storm_control_action: StormControlActionEnum | None = Field( + default=None, alias="stormControlAction", description="Storm control action on threshold violation" + ) + storm_control_broadcast_level: float | None = Field( + default=None, + alias="stormControlBroadcastLevel", + ge=0.0, + le=100.0, + description="Broadcast storm control level in percentage (0.00-100.00)", + ) + storm_control_broadcast_level_pps: int | None = Field( + default=None, + alias="stormControlBroadcastLevelPps", + ge=0, + le=200000000, + description="Broadcast storm control level in packets per second", + ) + storm_control_multicast_level: float | None = Field( + default=None, + alias="stormControlMulticastLevel", + ge=0.0, + le=100.0, + description="Multicast storm control level in percentage (0.00-100.00)", + ) + storm_control_multicast_level_pps: int | None = Field( + default=None, + alias="stormControlMulticastLevelPps", + ge=0, + le=200000000, + description="Multicast storm control level in packets per second", + ) + storm_control_unicast_level: float | None = Field( + default=None, + alias="stormControlUnicastLevel", + ge=0.0, + le=100.0, + description="Unicast storm control level in percentage (0.00-100.00)", + ) + storm_control_unicast_level_pps: int | None = Field( + default=None, + alias="stormControlUnicastLevelPps", + ge=0, + le=200000000, + description="Unicast storm control level in packets per second", + ) + vlan_mapping: bool | None = Field(default=None, alias="vlanMapping", description="Enable VLAN mapping on the trunk") + vlan_mapping_entries: list[PortChannelTrunkHostVlanMappingEntryModel] | None = Field( + default=None, alias="vlanMappingEntries", description="VLAN mapping entries (used when vlan_mapping is enabled)" + ) + + # --- Validators --- + + @field_validator("allowed_vlans", mode="before") + @classmethod + def validate_allowed_vlans(cls, value): + """ + # Summary + + Coerce int responses to str, then validate `allowed_vlans` matches `"none"`, `"all"`, or a comma-separated + list of VLAN ids/ranges. ND returns single-id values as JSON ints (e.g. `250`) but accepts both forms on + input; the model normalizes on str so round-trips and idempotency comparisons are stable. + + ## Raises + + ### ValueError + + - If `value` is a non-empty string that does not match the expected pattern. + """ + if value is None or value == "": + return value + if isinstance(value, int) and not isinstance(value, bool): + value = str(value) + if not isinstance(value, str): + return value + if not ALLOWED_VLANS_PATTERN.match(value): + raise ValueError(f"allowed_vlans must be 'none', 'all', or a comma-separated list of VLAN ids/ranges (e.g. '1-200,500-2000,3000'); got {value!r}") + return value + + @field_validator("ports", mode="before") + @classmethod + def normalize_ports(cls, value): + """ + # Summary + + Normalize each member interface name to ND API convention (e.g. `ethernet1/1` -> `Ethernet1/1`). + + ## Raises + + None + """ + if value is None: + return value + if not isinstance(value, list): + return value + normalized = [] + for name in value: + if isinstance(name, str) and name: + normalized.append(name[0].upper() + name[1:]) + else: + normalized.append(name) + return normalized + + +class PortChannelTrunkHostNetworkOSModel(NDNestedModel): + """ + # Summary + + Network OS container for a port-channel trunkPoHost interface. Maps to `configData.networkOS` in the ND API. + + ## Raises + + None + """ + + network_os_type: str = Field(default="nx-os", alias="networkOSType") + policy: PortChannelTrunkHostPolicyModel | None = Field(default=None, alias="policy") + + +class PortChannelTrunkHostConfigDataModel(NDNestedModel): + """ + # Summary + + Config data container for a port-channel trunkPoHost interface. Maps to `configData` in the ND API. + + ## Raises + + None + """ + + mode: str = Field(default="trunk", alias="mode") + network_os: PortChannelTrunkHostNetworkOSModel = Field(alias="networkOS") + + +class PortChannelTrunkHostInterfaceModel(NDBaseModel): + """ + # Summary + + Port-channel trunkPoHost interface configuration for Nexus Dashboard. + + Uses a composite identifier (`switch_ip`, `interface_name`). The nested model structure mirrors the ND Manage + Interfaces API payload, so `to_payload()` and `from_response()` work via standard Pydantic serialization. + + The `interface_name` is the port-channel's own name (e.g. `port-channel501`), not a member interface. Member + interfaces are listed in `config_data.network_os.policy.ports`. + + ## Raises + + None + """ + + # --- Identifier Configuration --- + + identifiers: ClassVar[list[str] | None] = ["switch_ip", "interface_name"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "composite" + + # --- Serialization Configuration --- + + payload_exclude_fields: ClassVar[set[str]] = {"switch_ip"} + + # --- Fields --- + + switch_ip: str = Field(alias="switchIp") + interface_name: str = Field(alias="interfaceName") + interface_type: str = Field(default="portChannel", alias="interfaceType") + config_data: PortChannelTrunkHostConfigDataModel | None = Field(default=None, alias="configData") + + @field_validator("interface_name", mode="before") + @classmethod + def normalize_interface_name(cls, value): + """ + # Summary + + Normalize the port-channel interface name to lowercase to match ND API convention (e.g. `Port-Channel501` -> + `port-channel501`). + + ## Raises + + None + """ + if isinstance(value, str): + return value.lower() + return value + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> dict: + """ + # Summary + + Return the Ansible argument spec for the `nd_interface_port_channel_trunk_host` module. + + ## Raises + + None + """ + return dict( + fabric_name=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + switch_ip=dict(type="str", required=True), + interface_name=dict(type="str", required=True), + interface_type=dict(type="str", default="portChannel"), + config_data=dict( + type="dict", + options=dict( + mode=dict(type="str", default="trunk"), + network_os=dict( + type="dict", + options=dict( + network_os_type=dict(type="str", default="nx-os"), + policy=dict( + type="dict", + options=dict( + admin_state=dict(type="bool"), + allowed_vlans=dict(type="str"), + bandwidth=dict(type="int"), + bpdu_filter=dict(type="str", choices=[e.value for e in BpduFilterEnum]), + bpdu_guard=dict(type="str", choices=[e.value for e in BpduGuardEnum]), + cdp=dict(type="bool"), + copy_description=dict(type="bool"), + description=dict(type="str"), + duplex_mode=dict(type="str", choices=[e.value for e in DuplexModeEnum]), + extra_config=dict(type="str"), + inherit_bandwidth=dict(type="int"), + lacp_port_priority=dict(type="int"), + lacp_rate=dict(type="str", choices=[e.value for e in LacpRateEnum]), + lacp_suspend=dict(type="bool"), + link_type=dict(type="str", choices=[e.value for e in LinkTypeEnum]), + monitor=dict(type="bool"), + mtu=dict(type="str", choices=[e.value for e in MtuEnum]), + native_vlan=dict(type="int"), + negotiate_auto=dict(type="bool"), + netflow=dict(type="bool"), + netflow_monitor=dict(type="str"), + netflow_sampler=dict(type="str"), + orphan_port=dict(type="bool"), + pfc=dict(type="bool"), + port_channel_mode=dict(type="str", choices=[e.value for e in PortChannelModeEnum]), + port_type_edge_trunk=dict(type="bool"), + ports=dict(type="list", elements="str"), + ptp=dict(type="bool"), + qos=dict(type="bool"), + qos_policy=dict(type="str"), + queuing_policy=dict(type="str"), + speed=dict(type="str", choices=[e.value for e in SpeedEnum]), + storm_control=dict(type="bool"), + storm_control_action=dict(type="str", choices=[e.value for e in StormControlActionEnum]), + storm_control_broadcast_level=dict(type="float"), + storm_control_broadcast_level_pps=dict(type="int"), + storm_control_multicast_level=dict(type="float"), + storm_control_multicast_level_pps=dict(type="int"), + storm_control_unicast_level=dict(type="float"), + storm_control_unicast_level_pps=dict(type="int"), + vlan_mapping=dict(type="bool"), + vlan_mapping_entries=dict( + type="list", + elements="dict", + options=dict( + customer_inner_vlan_id=dict(type="int"), + customer_vlan_id=dict(type="list", elements="str"), + dot1q_tunnel=dict(type="bool"), + provider_vlan_id=dict(type="int"), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/orchestrators/port_channel_trunk_host_interface.py b/plugins/module_utils/orchestrators/port_channel_trunk_host_interface.py new file mode 100644 index 000000000..14245bc73 --- /dev/null +++ b/plugins/module_utils/orchestrators/port_channel_trunk_host_interface.py @@ -0,0 +1,53 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Port-channel trunkPoHost interface orchestrator for Nexus Dashboard. + +This module provides `PortChannelTrunkHostInterfaceOrchestrator`, which manages CRUD operations +for port-channel trunkPoHost interfaces. It inherits all shared port-channel logic from +`PortChannelBaseOrchestrator` and only defines the model class and managed policy types. +""" + +from __future__ import annotations + +from typing import ClassVar, Type + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import TrunkPoHostPolicyTypeEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.port_channel_trunk_host_interface import ( + PortChannelTrunkHostInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.port_channel_base import PortChannelBaseOrchestrator + + +class PortChannelTrunkHostInterfaceOrchestrator(PortChannelBaseOrchestrator): + """ + # Summary + + Orchestrator for port-channel trunkPoHost interface CRUD operations on Nexus Dashboard. + + Inherits all shared port-channel logic from `PortChannelBaseOrchestrator`. Defines `model_class` as + `PortChannelTrunkHostInterfaceModel` and manages the `trunkPoHost` policy type. + + ## Raises + + ### RuntimeError + + - Via inherited methods. See `PortChannelBaseOrchestrator` for full details. + """ + + model_class: ClassVar[Type[NDBaseModel]] = PortChannelTrunkHostInterfaceModel + + def _managed_policy_types(self) -> set[str]: + """ + # Summary + + Return the set of API-side policy type values managed by this orchestrator. + + ## Raises + + None + """ + return {e.value for e in TrunkPoHostPolicyTypeEnum} diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py new file mode 100644 index 000000000..0892233c6 --- /dev/null +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -0,0 +1,489 @@ +#!/usr/bin/python + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_interface_port_channel_trunk_host +version_added: "1.4.0" +short_description: Manage port-channel trunkPoHost interfaces on Cisco Nexus Dashboard +description: +- Manage port-channel trunkPoHost interfaces on Cisco Nexus Dashboard. +- It supports creating, updating, and deleting trunkPoHost port-channel configurations on switches within a fabric. +- Each config item represents one port-channel interface. Member ethernet interfaces are listed in + O(config[].config_data.network_os.policy.ports) and inherit trunk-mode configuration from the port-channel policy. +- Member interface field mutability is restricted while members of a port-channel; only description, admin_state, and + extra_config can be modified on members via the C(nd_interface_ethernet_trunk_host) module. +author: +- Allen Robel (@allenrobel) +options: + fabric_name: + description: + - The name of the fabric containing the target switches. + type: str + required: true + config: + description: + - The list of port-channel trunkPoHost interfaces to configure. + - Each item specifies the target switch, the port-channel interface name, and its configuration. + - Multiple switches can be configured in a single task. + - The structure mirrors the ND Manage Interfaces API payload. + type: list + elements: dict + required: true + suboptions: + switch_ip: + description: + - The management IP address of the switch on which to manage the port-channel. + - This is resolved to the switch serial number (switchId) internally. + type: str + required: true + interface_name: + description: + - The port-channel interface name (e.g. C(port-channel501)). + type: str + required: true + interface_type: + description: + - The type of the interface. + - Defaults to C(portChannel) for this module. + type: str + default: portChannel + config_data: + description: + - The configuration data for the port-channel, following the ND API structure. + type: dict + suboptions: + mode: + description: + - The interface operational mode. + - Defaults to C(trunk) for this module. The ND API uses this as a discriminator + to select the trunk-mode port-channel configuration schema. + type: str + default: trunk + network_os: + description: + - Network OS specific configuration. + type: dict + suboptions: + network_os_type: + description: + - The network OS type of the switch. + type: str + default: nx-os + policy: + description: + - The policy configuration for the trunkPoHost port-channel. + type: dict + suboptions: + admin_state: + description: + - The administrative state of the port-channel. + type: bool + allowed_vlans: + description: + - Trunk allowed VLANs. + - Accepts V(none), V(all), or a comma-separated list of VLAN ids/ranges (e.g. V(100-200,300)). + type: str + bandwidth: + description: + - Interface bandwidth in kilobits per second. + - Valid range is 1-100000000. + type: int + bpdu_filter: + description: + - BPDU filter setting for the port-channel. + type: str + choices: [ enable, disable, default ] + bpdu_guard: + description: + - BPDU guard setting for the port-channel. + type: str + choices: [ enable, disable, default ] + cdp: + description: + - Whether Cisco Discovery Protocol is enabled on the port-channel. + type: bool + copy_description: + description: + - Whether to propagate the port-channel description to all member interfaces. + type: bool + description: + description: + - The description of the port-channel. + - Maximum 254 characters. + type: str + duplex_mode: + description: + - The duplex mode of the port-channel. + type: str + choices: [ auto, full, half ] + extra_config: + description: + - Additional CLI configuration commands to apply to the port-channel. + type: str + inherit_bandwidth: + description: + - Inherited interface bandwidth in kilobits per second. + - Valid range is 1-100000000. + type: int + lacp_port_priority: + description: + - LACP port priority. + - Valid range is 1-65535. Default 32768. + type: int + lacp_rate: + description: + - LACP rate (PDU transmit interval). + - V(normal) = 30 seconds, V(fast) = 1 second. + type: str + choices: [ normal, fast ] + lacp_suspend: + description: + - Whether to suspend the port if LACP PDUs are not received. + type: bool + link_type: + description: + - Spanning-tree link type for the port-channel. + type: str + choices: [ auto, pointToPoint, shared ] + monitor: + description: + - Whether the port-channel is configured as a SPAN/ERSPAN monitor source. + type: bool + mtu: + description: + - The MTU setting for the port-channel. + type: str + choices: [ default, jumbo ] + native_vlan: + description: + - Trunk native VLAN id. + - Valid range is 1-4094. + type: int + negotiate_auto: + description: + - Whether link auto-negotiation is enabled. + type: bool + netflow: + description: + - Whether netflow is enabled on the port-channel. + type: bool + netflow_monitor: + description: + - The netflow Layer-2 monitor name for the port-channel. + type: str + netflow_sampler: + description: + - The netflow Layer-2 sampler name for the port-channel. + type: str + orphan_port: + description: + - Configure the port-channel as a vPC orphan port. + - When V(true), the port is suspended by the secondary peer on vPC failure. + type: bool + pfc: + description: + - Whether Priority Flow Control is enabled on the port-channel. + type: bool + port_channel_mode: + description: + - The port-channel mode. + type: str + choices: [ on, active, passive ] + port_type_edge_trunk: + description: + - Configure the port-channel as an edge trunk port (PortFast on trunk). + type: bool + ports: + description: + - The list of member ethernet interface names for this port-channel. + - Each name should be in the format C(Ethernet1/1), C(Ethernet1/2), etc. + - The port-channel policy is the single source of truth for member configuration; member + interfaces inherit trunk-mode settings from this policy. + type: list + elements: str + ptp: + description: + - Whether Precision Time Protocol is enabled on the port-channel. + type: bool + qos: + description: + - Whether a QoS policy is applied to the port-channel. + type: bool + qos_policy: + description: + - Custom QoS policy name associated with the port-channel. + type: str + queuing_policy: + description: + - Custom queuing policy name associated with the port-channel. + type: str + speed: + description: + - The speed setting for the port-channel. + type: str + choices: [ auto, 10Mb, 100Mb, 1Gb, 2.5Gb, 5Gb, 10Gb, 25Gb, 40Gb, 50Gb, 100Gb, 200Gb, 400Gb, 800Gb ] + storm_control: + description: + - Whether traffic storm control is enabled on the port-channel. + type: bool + storm_control_action: + description: + - Storm control action on threshold violation. + type: str + choices: [ shutdown, trap, default ] + storm_control_broadcast_level: + description: + - Broadcast storm control level in percentage (0.00-100.00). + type: float + storm_control_broadcast_level_pps: + description: + - Broadcast storm control level in packets per second (0-200000000). + type: int + storm_control_multicast_level: + description: + - Multicast storm control level in percentage (0.00-100.00). + type: float + storm_control_multicast_level_pps: + description: + - Multicast storm control level in packets per second (0-200000000). + type: int + storm_control_unicast_level: + description: + - Unicast storm control level in percentage (0.00-100.00). + type: float + storm_control_unicast_level_pps: + description: + - Unicast storm control level in packets per second (0-200000000). + type: int + vlan_mapping: + description: + - Whether VLAN mapping is enabled on the trunk. + - Use with O(config[].config_data.network_os.policy.vlan_mapping_entries) to translate customer VLAN ids to provider VLAN ids. + - Note that virtual switches (e.g. N9K-C9300v) may reject VLAN mapping with selective dot1q-tunnel. + type: bool + vlan_mapping_entries: + description: + - VLAN mapping entries. Used when O(config[].config_data.network_os.policy.vlan_mapping=true). + type: list + elements: dict + suboptions: + customer_inner_vlan_id: + description: + - Inner customer VLAN id (selective dot1q-tunnel only). + - Valid range is 1-4094. + type: int + customer_vlan_id: + description: + - Customer VLAN id list (single id or range strings). + type: list + elements: str + dot1q_tunnel: + description: + - Use selective dot1q-tunnel mode for this entry. + type: bool + provider_vlan_id: + description: + - Provider VLAN id. + - Valid range is 1-4094. + type: int + deploy: + description: + - Whether to deploy port-channel changes after mutations are complete. + - When V(true), all queued port-channel changes are deployed in a single bulk API call at the end of module + execution via the C(interfaceActions/deploy) API. Only the port-channels modified by this task are deployed. + - When V(false), changes are staged but not deployed. Use a separate deploy module or task to deploy later. + - Setting O(deploy=false) is useful when batching changes across multiple interface tasks before a single deploy. + type: bool + default: true + state: + description: + - The desired state of the network resources on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new resources and update existing ones as defined in your configuration. + Resources on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the resources specified in the configuration. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + The resources on ND will be modified to exactly match the configuration. + Any resource existing on ND but not present in the configuration will be deleted. Use with extra caution. + - Use O(state=deleted) to remove the specified port-channels via the C(interfaceActions/remove) API. + Member ethernet interfaces are reverted to their fabric default configuration. + type: str + default: merged + choices: [ merged, replaced, overridden, deleted ] +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module is only supported on Nexus Dashboard. +- This module manages NX-OS port-channel trunkPoHost interfaces only. +- The port-channel policy is the source of truth for member interface configuration. +""" + +EXAMPLES = r""" +- name: Create a trunkPoHost port-channel with two members + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + ports: + - Ethernet1/1 + - Ethernet1/2 + port_channel_mode: active + lacp_rate: fast + description: Server trunk bundle + state: merged + register: result + +- name: Add a third member and update allowed VLANs + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + config_data: + network_os: + policy: + allowed_vlans: "100-200,300" + ports: + - Ethernet1/1 + - Ethernet1/2 + - Ethernet1/3 + state: merged + +- name: Configure VLAN mapping with selective dot1q-tunnel + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + config_data: + network_os: + policy: + vlan_mapping: true + vlan_mapping_entries: + - customer_vlan_id: ["100"] + provider_vlan_id: 200 + dot1q_tunnel: true + state: merged + +- name: Delete a port-channel + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + state: deleted + +- name: Stage port-channel changes without deploying + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "all" + ports: + - Ethernet1/1 + deploy: false + state: merged + +""" + +RETURN = r""" +""" + +import logging +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDStateMachineError +from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.port_channel_trunk_host_interface import ( + PortChannelTrunkHostInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base_interface import NDBaseInterfaceOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.port_channel_trunk_host_interface import ( + PortChannelTrunkHostInterfaceOrchestrator, +) + + +def main(): + """ + # Summary + + Entry point for the `nd_interface_port_channel_trunk_host` Ansible module. Initializes the + `NDStateMachine` with `PortChannelTrunkHostInterfaceOrchestrator` and executes the requested state operation. + + ## Raises + + None (catches all exceptions and calls `module.fail_json`). + """ + argument_spec = nd_argument_spec() + argument_spec.update(PortChannelTrunkHostInterfaceModel.get_argument_spec()) + argument_spec.update( + deploy=dict(type="bool", default=True), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + setup_logging(module) + module_log = logging.getLogger("nd.nd_interface_port_channel_trunk_host") + + nd_state_machine = None + + try: + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=PortChannelTrunkHostInterfaceOrchestrator, + ) + if not isinstance(nd_state_machine.model_orchestrator, NDBaseInterfaceOrchestrator): + raise AssertionError(f"Expected NDBaseInterfaceOrchestrator, got {type(nd_state_machine.model_orchestrator)}") + nd_state_machine.model_orchestrator.deploy = module.params["deploy"] + + module_log.debug( + "manage_state begin state=%s check_mode=%s deploy=%s", + module.params.get("state"), + module.check_mode, + module.params["deploy"], + ) + nd_state_machine.manage_state() + module_log.debug("manage_state end") + + if not module.check_mode: + nd_state_machine.model_orchestrator.remove_pending() + nd_state_machine.model_orchestrator.deploy_pending() + + module.exit_json(**nd_state_machine.output.format()) + + except NDStateMachineError as e: + module_log.exception("NDStateMachineError during module execution") + output = nd_state_machine.output.format() if nd_state_machine else {} + error_msg = f"Module execution failed: {str(e)}" + if module.params.get("output_level") == "debug": + error_msg += f"\nTraceback:\n{traceback.format_exc()}" + module.fail_json(msg=error_msg, **output) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml new file mode 100644 index 000000000..9feb0b089 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml @@ -0,0 +1,151 @@ +--- +# Deleted state tests for nd_interface_port_channel_trunk_host +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point port-channel502 and port-channel503 exist from overridden tests. + +# --- DELETED: SINGLE PORT-CHANNEL --- + +- name: "DELETED: Delete port-channel502 (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + state: deleted + check_mode: true + register: cm_deleted_502 + +- name: "DELETED: Delete port-channel502 (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + state: deleted + register: nm_deleted_502 + +- name: "DELETED: Verify port-channel502 was deleted" + ansible.builtin.assert: + that: + - cm_deleted_502 is changed + - nm_deleted_502 is changed + - nm_deleted_502.after | selectattr('interface_name', 'equalto', 'port-channel502') | list | length == 0 + +# --- DELETED: IDEMPOTENCY --- + +- name: "DELETED IDEMPOTENT: Delete port-channel502 again" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + state: deleted + register: nm_deleted_502_idem + +- name: "DELETED IDEMPOTENT: Verify no change when already absent" + ansible.builtin.assert: + that: + - nm_deleted_502_idem is not changed + +# --- DELETED: MEMBER REVERT --- +# After deleting a port-channel, its former member ethernet interfaces should be reverted +# to fabric defaults. We can't directly assert that here without the ethernet module, +# but we verify the port-channel itself is gone and that the API accepted the remove. + +- name: "DELETED MEMBER REVERT: Recreate port-channel501 with two members" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + state: merged + +- name: "DELETED MEMBER REVERT: Delete port-channel501" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + state: deleted + register: nm_deleted_501_with_members + +- name: "DELETED MEMBER REVERT: Verify port-channel501 was removed" + ansible.builtin.assert: + that: + - nm_deleted_501_with_members is changed + - nm_deleted_501_with_members.after | selectattr('interface_name', 'equalto', 'port-channel501') | list | length == 0 + +# --- DELETED: MULTIPLE PORT-CHANNELS --- + +# Recreate port-channel501 and port-channel502 so we can test multi-delete +- name: "SETUP: Recreate port-channel501 and port-channel502 for multi-delete test" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + - "{{ test_pc_502 }}" + state: merged + +- name: "DELETED MULTI: Delete port-channel501, port-channel502, port-channel503 (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel503 + state: deleted + check_mode: true + register: cm_deleted_multi + +- name: "DELETED MULTI: Delete port-channel501, port-channel502, port-channel503 (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel503 + state: deleted + register: nm_deleted_multi + +- name: "DELETED MULTI: Verify all test port-channels were deleted" + ansible.builtin.assert: + that: + - cm_deleted_multi is changed + - nm_deleted_multi is changed + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'port-channel501') | list | length == 0 + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'port-channel502') | list | length == 0 + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'port-channel503') | list | length == 0 + +# --- DELETED: NON-EXISTENT PORT-CHANNEL --- + +- name: "DELETED NON-EXISTENT: Delete port-channel that does not exist" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel599 + state: deleted + register: nm_deleted_nonexistent + +- name: "DELETED NON-EXISTENT: Verify no change" + ansible.builtin.assert: + that: + - nm_deleted_nonexistent is not changed diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml new file mode 100644 index 000000000..a045b1c84 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml @@ -0,0 +1,75 @@ +--- +# Test code for the nd_interface_port_channel_trunk_host module +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# +# --- Usage --- +# +# Run the test suite with ansible-test: +# +# ansible-test network-integration nd_interface_port_channel_trunk_host +# +# --- Required testbed configuration --- +# +# In tests/integration/inventory.networking [nd:vars]: +# +# nd_test_fabric_name= +# nd_test_switch_ip= +# nd_test_pc_member_a=Ethernet1/10 +# nd_test_pc_member_b=Ethernet1/11 +# nd_test_pc_member_c=Ethernet1/12 +# nd_test_pc_member_d=Ethernet1/13 +# +# Members must exist on the target switch and must not already be in another +# port-channel. The cleanup at the start of merged.yaml resets them. +# +# --- Optional test variables --- +# +# nd_logging_config (str, default: "") +# Path to a JSON file conforming to logging.config.dictConfig (see +# plugins/module_utils/common/log.py for an example). +# +# nd_test_platform (str, default: "9000v") +# Identifies the testbed platform. When "9000v", the vlan_mapping test block +# is skipped because virtual switches reject `switchport vlan mapping ... dot1q-tunnel` +# (ND surfaces this as HTTP 400 from the trunkPoHost validator). + +- name: Test that we have a Nexus Dashboard host, username and password + ansible.builtin.fail: + msg: 'Please define the following variables: ansible_host, ansible_user and ansible_password.' + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + +- name: Set vars + ansible.builtin.set_fact: + nd_info: + output_level: '{{ api_key_output_level | default("debug") }}' + +- name: Run nd_interface_port_channel_trunk_host state tests with optional logging env + # The `timeout` module arg propagates via sender_nd.set_params() into the + # httpapi plugin, which calls connection.set_option("persistent_command_timeout", ...). + # 300s gives the switch enough time to build the PC bundle, pull members, + # and apply LACP on slow testbeds (e.g. 9000v) where the default 30s is too + # short. Scoped to this test target via module_defaults so we don't modify + # shared argspec defaults. + module_defaults: + cisco.nd.nd_interface_port_channel_trunk_host: + timeout: 300 + block: + - name: Run nd_interface_port_channel_trunk_host merged state tests + ansible.builtin.include_tasks: merged.yaml + + - name: Run nd_interface_port_channel_trunk_host replaced state tests + ansible.builtin.include_tasks: replaced.yaml + + - name: Run nd_interface_port_channel_trunk_host overridden state tests + ansible.builtin.include_tasks: overridden.yaml + + - name: Run nd_interface_port_channel_trunk_host deleted state tests + ansible.builtin.include_tasks: deleted.yaml + + - name: Run nd_interface_port_channel_trunk_host vlan_mapping tests (non-9000v only) + ansible.builtin.include_tasks: vlan_mapping.yaml + when: nd_test_platform | default('9000v') != '9000v' + environment: + ND_LOGGING_CONFIG: "{{ nd_logging_config | default('') }}" diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml new file mode 100644 index 000000000..9e4447b08 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml @@ -0,0 +1,256 @@ +--- +# Merged state tests for nd_interface_port_channel_trunk_host +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Test order: single-PC tests first, then multi-PC. The MEMBER ADD test pulls +# `test_pc_member_c` into port-channel501, so it must run BEFORE PC 502 (which +# claims member_c) is created — ND rejects adding a port to a PC if it's +# already a member of another. After MEMBER REMOVE, all members except a are +# free, so the multi-create that follows succeeds. + +# --- CLEANUP --- + +- name: "SETUP: Remove test port-channels before merged tests" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel503 + state: deleted + tags: always + +# --- MERGED CREATE: SINGLE PC --- + +- name: "MERGED CREATE: Create port-channel501 with two members (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + state: merged + check_mode: true + register: cm_merged_create_501 + +- name: "MERGED CREATE: Create port-channel501 with two members (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + state: merged + register: nm_merged_create_501 + +- name: "MERGED CREATE: Verify port-channel501 creation" + vars: + pc501_after: "{{ nm_merged_create_501.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + ansible.builtin.assert: + that: + - cm_merged_create_501 is changed + - nm_merged_create_501 is changed + - nm_merged_create_501.after | selectattr('interface_name', 'equalto', 'port-channel501') | list | length == 1 + - pc501_after.config_data.network_os.policy.allowed_vlans == "100-200" + - pc501_after.config_data.network_os.policy.native_vlan == 99 + - pc501_after.config_data.network_os.policy.port_channel_mode == "active" + - pc501_after.config_data.network_os.policy.lacp_rate == "fast" + - pc501_after.config_data.network_os.policy.ports | length == 2 + - test_pc_member_a in pc501_after.config_data.network_os.policy.ports + - test_pc_member_b in pc501_after.config_data.network_os.policy.ports + +# --- MERGED IDEMPOTENCY --- + +- name: "MERGED IDEMPOTENT: Re-apply port-channel501 creation (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + state: merged + check_mode: true + register: cm_merged_idem_501 + +- name: "MERGED IDEMPOTENT: Re-apply port-channel501 creation (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + state: merged + register: nm_merged_idem_501 + +- name: "MERGED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - cm_merged_idem_501 is not changed + - nm_merged_idem_501 is not changed + +# --- MERGED UPDATE: change allowed_vlans and native_vlan --- + +- name: "MERGED UPDATE: Change port-channel501 allowed_vlans/native_vlan/description (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_updated_vlan }}" + state: merged + check_mode: true + register: cm_merged_update_501 + +- name: "MERGED UPDATE: Change port-channel501 allowed_vlans/native_vlan/description (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_updated_vlan }}" + state: merged + register: nm_merged_update_501 + +- name: "MERGED UPDATE: Verify port-channel501 was updated" + vars: + pc501_after: "{{ nm_merged_update_501.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + ansible.builtin.assert: + that: + - cm_merged_update_501 is changed + - nm_merged_update_501 is changed + - pc501_after.config_data.network_os.policy.allowed_vlans == "100-200,500" + - pc501_after.config_data.network_os.policy.native_vlan == 88 + - pc501_after.config_data.network_os.policy.description == "Ansible integration test PC 501 updated" + +- name: "MERGED UPDATE: Re-apply update for idempotency" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_updated_vlan }}" + state: merged + register: nm_merged_update_501_idem + +- name: "MERGED UPDATE: Verify idempotency after update" + ansible.builtin.assert: + that: + - nm_merged_update_501_idem is not changed + +# --- MERGED MEMBERSHIP: add a third member --- +# Must run BEFORE PC 502/503 are created, so the third member (member_c) is free. + +- name: "SETUP: Reset port-channel501 to baseline before membership tests" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501 }}" + state: merged + +- name: "MERGED MEMBER ADD: Add a third member to port-channel501 (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_added_member }}" + state: merged + register: nm_merged_add_member + +- name: "MERGED MEMBER ADD: Verify member was added" + vars: + pc501_after: "{{ nm_merged_add_member.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + ansible.builtin.assert: + that: + - nm_merged_add_member is changed + - pc501_after.config_data.network_os.policy.ports | length == 3 + - test_pc_member_c in pc501_after.config_data.network_os.policy.ports + +# --- MERGED MEMBERSHIP: remove members --- + +- name: "MERGED MEMBER REMOVE: Reduce port-channel501 to a single member (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_removed_member }}" + state: merged + register: nm_merged_remove_member + +- name: "MERGED MEMBER REMOVE: Verify members were removed" + vars: + pc501_after: "{{ nm_merged_remove_member.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + ansible.builtin.assert: + that: + - nm_merged_remove_member is changed + - pc501_after.config_data.network_os.policy.ports | length == 1 + - test_pc_member_a in pc501_after.config_data.network_os.policy.ports + - test_pc_member_b not in pc501_after.config_data.network_os.policy.ports + - test_pc_member_c not in pc501_after.config_data.network_os.policy.ports + +# --- MERGED CREATE: MULTIPLE PCs --- +# Runs AFTER membership tests so that member_c (used by PC 502) and member_d +# (used by PC 503) are not currently claimed by PC 501. + +- name: "MERGED CREATE: Create multiple port-channels in a single task (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_502 }}" + - "{{ test_pc_503 }}" + state: merged + check_mode: true + register: cm_merged_create_multi + +- name: "MERGED CREATE: Create multiple port-channels in a single task (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_502 }}" + - "{{ test_pc_503 }}" + state: merged + register: nm_merged_create_multi + +- name: "MERGED CREATE: Verify multiple port-channel creation" + ansible.builtin.assert: + that: + - cm_merged_create_multi is changed + - nm_merged_create_multi is changed + - nm_merged_create_multi.after | selectattr('interface_name', 'equalto', 'port-channel502') | list | length == 1 + - nm_merged_create_multi.after | selectattr('interface_name', 'equalto', 'port-channel503') | list | length == 1 + +# --- MERGED WITH deploy: false --- + +- name: "MERGED NO-DEPLOY: Stage a port-channel without deploying" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel504 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "all" + ports: [] + description: "No-deploy test port-channel504" + deploy: false + state: merged + register: nm_merged_no_deploy + +- name: "MERGED NO-DEPLOY: Verify change was staged" + ansible.builtin.assert: + that: + - nm_merged_no_deploy is changed + +# Cleanup the no-deploy test port-channel +- name: "CLEANUP: Remove port-channel504" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel504 + state: deleted diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml new file mode 100644 index 000000000..a56cc61c0 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml @@ -0,0 +1,134 @@ +--- +# Overridden state tests for nd_interface_port_channel_trunk_host +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point port-channel501 (replaced), port-channel502 (replaced), port-channel503 (replaced) exist. +# Override will reduce the set to only what is specified in config. + +# --- OVERRIDDEN: REDUCE TO SINGLE PORT-CHANNEL --- + +- name: "OVERRIDDEN: Override to only port-channel501 (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "175" + native_vlan: 17 + ports: + - "{{ test_pc_member_a }}" + description: "Overridden port-channel501" + state: overridden + check_mode: true + register: cm_overridden_single + +- name: "OVERRIDDEN: Override to only port-channel501 (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "175" + native_vlan: 17 + ports: + - "{{ test_pc_member_a }}" + description: "Overridden port-channel501" + state: overridden + register: nm_overridden_single + +- name: "OVERRIDDEN: Verify override removed extra port-channels" + vars: + pc501_after: "{{ nm_overridden_single.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + ansible.builtin.assert: + that: + - cm_overridden_single is changed + - nm_overridden_single is changed + # After should contain only port-channel501. port-channel502 and 503 should have been removed. + - nm_overridden_single.after | selectattr('interface_name', 'equalto', 'port-channel501') | list | length == 1 + - nm_overridden_single.after | selectattr('interface_name', 'equalto', 'port-channel502') | list | length == 0 + - nm_overridden_single.after | selectattr('interface_name', 'equalto', 'port-channel503') | list | length == 0 + - pc501_after.config_data.network_os.policy.allowed_vlans == "175" + - pc501_after.config_data.network_os.policy.description == "Overridden port-channel501" + +# --- OVERRIDDEN: IDEMPOTENCY --- + +- name: "OVERRIDDEN IDEMPOTENT: Re-apply same overridden config" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "175" + native_vlan: 17 + ports: + - "{{ test_pc_member_a }}" + description: "Overridden port-channel501" + state: overridden + register: nm_overridden_idem + +- name: "OVERRIDDEN IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_overridden_idem is not changed + +# --- OVERRIDDEN: SWAP --- +# Restore port-channel502 and port-channel503 to a known baseline by overriding to a new set. + +- name: "OVERRIDDEN SWAP: Override to port-channel502 and port-channel503, removing port-channel501" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_502 }}" + - "{{ test_pc_503 }}" + state: overridden + register: nm_overridden_swap + +- name: "OVERRIDDEN SWAP: Verify port-channel501 removed and port-channel502/503 present" + ansible.builtin.assert: + that: + - nm_overridden_swap is changed + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'port-channel501') | list | length == 0 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'port-channel502') | list | length == 1 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'port-channel503') | list | length == 1 + +# --- OVERRIDDEN: NON-PORT-CHANNEL FILTERING --- +# Verifies that overridden state only operates on portChannel interfaces with managed policy types. +# Standalone ethernet interfaces and other interface types must NOT be touched by this module. +# Likewise, accessPoHost port-channels must NOT be visible to or removed by this module. + +- name: "OVERRIDDEN FILTER: Re-apply prior swap config to confirm idempotency / no leakage" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_502 }}" + - "{{ test_pc_503 }}" + state: overridden + register: nm_overridden_filter + +- name: "OVERRIDDEN FILTER: Verify only port-channel interfaces appear in before collection" + ansible.builtin.assert: + that: + - nm_overridden_filter is not changed + # Before collection must contain only portChannel interfaces; non-PC types would + # indicate the orchestrator's interfaceType filter is broken. + - nm_overridden_filter.before | rejectattr('interface_type', 'equalto', 'portChannel') | list | length == 0 diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml new file mode 100644 index 000000000..735075c88 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml @@ -0,0 +1,132 @@ +--- +# Replaced state tests for nd_interface_port_channel_trunk_host +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point port-channel501 (single member, allowed_vlans 100-200), port-channel502, port-channel503 exist from merged tests. + +# --- REPLACED: FULL REPLACE --- + +- name: "REPLACED: Replace port-channel501 with a minimal config (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "250" + native_vlan: 50 + ports: + - "{{ test_pc_member_a }}" + description: "Replaced port-channel501" + state: replaced + check_mode: true + register: cm_replaced_501 + +- name: "REPLACED: Replace port-channel501 with a minimal config (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "250" + native_vlan: 50 + ports: + - "{{ test_pc_member_a }}" + description: "Replaced port-channel501" + state: replaced + register: nm_replaced_501 + +- name: "REPLACED: Verify port-channel501 was replaced" + vars: + pc501_after: "{{ nm_replaced_501.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + ansible.builtin.assert: + that: + - cm_replaced_501 is changed + - nm_replaced_501 is changed + - pc501_after.config_data.network_os.policy.allowed_vlans == "250" + - pc501_after.config_data.network_os.policy.native_vlan == 50 + - pc501_after.config_data.network_os.policy.description == "Replaced port-channel501" + - pc501_after.config_data.network_os.policy.ports | length == 1 + - test_pc_member_a in pc501_after.config_data.network_os.policy.ports + +# --- REPLACED: IDEMPOTENCY --- + +- name: "REPLACED IDEMPOTENT: Re-apply same replaced config" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "250" + native_vlan: 50 + ports: + - "{{ test_pc_member_a }}" + description: "Replaced port-channel501" + state: replaced + register: nm_replaced_501_idem + +- name: "REPLACED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_replaced_501_idem is not changed + +# --- REPLACED: MULTIPLE PORT-CHANNELS --- + +- name: "REPLACED MULTI: Replace port-channel502 and port-channel503 (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "222" + native_vlan: 22 + ports: + - "{{ test_pc_member_c }}" + description: "Replaced port-channel502" + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel503 + config_data: + network_os: + policy: + admin_state: false + allowed_vlans: "333,444" + native_vlan: 33 + ports: + - "{{ test_pc_member_d }}" + description: "Replaced port-channel503" + state: replaced + register: nm_replaced_multi + +- name: "REPLACED MULTI: Verify both port-channels were replaced" + vars: + pc502_after: "{{ nm_replaced_multi.after | selectattr('interface_name', 'equalto', 'port-channel502') | first }}" + pc503_after: "{{ nm_replaced_multi.after | selectattr('interface_name', 'equalto', 'port-channel503') | first }}" + ansible.builtin.assert: + that: + - nm_replaced_multi is changed + - pc502_after.config_data.network_os.policy.allowed_vlans == "222" + - pc502_after.config_data.network_os.policy.description == "Replaced port-channel502" + - pc503_after.config_data.network_os.policy.allowed_vlans == "333,444" + - pc503_after.config_data.network_os.policy.admin_state == false diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml new file mode 100644 index 000000000..7229e180a --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml @@ -0,0 +1,82 @@ +--- +# vlan_mapping tests for nd_interface_port_channel_trunk_host (non-9000v testbeds only) +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This file is included from main.yaml only when nd_test_platform != "9000v". +# Virtual switches reject `switchport vlan mapping ... dot1q-tunnel`; ND surfaces +# this as HTTP 400 from the trunkPoHost validator +# ("Validation failed for following fields: [vlanMappingEntries-dot1qTunnel]"). + +# --- SETUP --- + +- name: "SETUP: Remove test port-channels before vlan_mapping tests" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + state: deleted + +# --- VLAN_MAPPING CREATE --- + +- name: "VLAN_MAPPING: Create port-channel501 with vlan_mapping enabled (check mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_with_vlan_mapping }}" + state: merged + check_mode: true + register: cm_vlan_mapping_create + +- name: "VLAN_MAPPING: Create port-channel501 with vlan_mapping enabled (normal mode)" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_with_vlan_mapping }}" + state: merged + register: nm_vlan_mapping_create + +- name: "VLAN_MAPPING: Verify vlan_mapping was applied" + vars: + pc501_after: "{{ nm_vlan_mapping_create.after | selectattr('interface_name', 'equalto', 'port-channel501') | first }}" + entries: "{{ pc501_after.config_data.network_os.policy.vlan_mapping_entries }}" + ansible.builtin.assert: + that: + - cm_vlan_mapping_create is changed + - nm_vlan_mapping_create is changed + - pc501_after.config_data.network_os.policy.vlan_mapping == true + - entries | length == 1 + - entries[0].provider_vlan_id == 1100 + - entries[0].dot1q_tunnel == true + +# --- VLAN_MAPPING IDEMPOTENCY --- + +- name: "VLAN_MAPPING IDEMPOTENT: Re-apply same vlan_mapping config" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ test_pc_501_with_vlan_mapping }}" + state: merged + register: nm_vlan_mapping_idem + +- name: "VLAN_MAPPING IDEMPOTENT: Verify no change" + ansible.builtin.assert: + that: + - nm_vlan_mapping_idem is not changed + +# --- CLEANUP --- + +- name: "CLEANUP: Remove vlan_mapping test port-channel" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + state: deleted diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/vars/main.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/vars/main.yaml new file mode 100644 index 000000000..b8e637456 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/vars/main.yaml @@ -0,0 +1,132 @@ +--- +# Variables for nd_interface_port_channel_trunk_host integration tests. +# +# Override these in your inventory or extra-vars to match a real ND 4.2 testbed. +# Member ethernet interfaces (test_pc_member_*) MUST exist on the target switch and +# MUST NOT be members of any other port-channel before tests run, or the cleanup +# at the start of each state file will revert them to fabric defaults. + +test_fabric_name: "{{ nd_test_fabric_name | default('test_fabric') }}" +test_switch_ip: "{{ nd_test_switch_ip | default('192.168.1.1') }}" + +# Member ethernet interface names. Override per-testbed. +test_pc_member_a: "{{ nd_test_pc_member_a | default('Ethernet1/10') }}" +test_pc_member_b: "{{ nd_test_pc_member_b | default('Ethernet1/11') }}" +test_pc_member_c: "{{ nd_test_pc_member_c | default('Ethernet1/12') }}" +test_pc_member_d: "{{ nd_test_pc_member_d | default('Ethernet1/13') }}" + +# Port-channel IDs 501-509 are reserved for these integration tests. + +test_pc_501: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + ports: + - "{{ test_pc_member_a }}" + - "{{ test_pc_member_b }}" + port_channel_mode: active + lacp_rate: fast + description: "Ansible integration test PC 501" + +test_pc_502: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "300-400" + native_vlan: 199 + ports: + - "{{ test_pc_member_c }}" + port_channel_mode: active + description: "Ansible integration test PC 502" + +test_pc_503: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel503 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "all" + ports: + - "{{ test_pc_member_d }}" + port_channel_mode: passive + description: "Ansible integration test PC 503" + +# Updated variants for merge/replace tests + +test_pc_501_updated_vlan: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200,500" + native_vlan: 88 + ports: + - "{{ test_pc_member_a }}" + - "{{ test_pc_member_b }}" + port_channel_mode: active + lacp_rate: fast + description: "Ansible integration test PC 501 updated" + +test_pc_501_added_member: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + ports: + - "{{ test_pc_member_a }}" + - "{{ test_pc_member_b }}" + - "{{ test_pc_member_c }}" + port_channel_mode: active + lacp_rate: fast + description: "Ansible integration test PC 501" + +test_pc_501_removed_member: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + ports: + - "{{ test_pc_member_a }}" + port_channel_mode: active + lacp_rate: fast + description: "Ansible integration test PC 501" + +# vlan_mapping fixture for non-9000v testbeds (used by tasks/vlan_mapping.yaml). + +test_pc_501_with_vlan_mapping: + switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + ports: + - "{{ test_pc_member_a }}" + port_channel_mode: active + description: "Ansible integration test PC 501 vlan-mapping" + vlan_mapping: true + vlan_mapping_entries: + - customer_vlan_id: ["100"] + provider_vlan_id: 1100 + dot1q_tunnel: true From bbce721eefb61c1077f665d86d59db8f844d61b8 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 4 May 2026 09:00:15 -1000 Subject: [PATCH 02/14] Add unit tests for nd_interface_port_channel_trunk_host Covers PortChannelTrunkHostInterfaceModel and PortChannelTrunkHostInterfaceOrchestrator: field defaults, snake_case/camelCase construction, allowed_vlans regex (with int->str coercion for ND's single-id-as-int responses), native_vlan/bandwidth/inherit_bandwidth range checks, AsciiDescription, storm-control float-from-string coercion, vlan_mapping entries, composite identifier round-trips, hardcoded policyType, query_all filtering to trunkPoHost only. Co-Authored-By: Claude Opus 4.7 --- ...est_port_channel_trunk_host_interface.json | 271 +++ .../test_port_channel_trunk_host_interface.py | 1779 +++++++++++++++++ .../test_port_channel_trunk_host_interface.py | 320 +++ 3 files changed, 2370 insertions(+) create mode 100644 tests/unit/module_utils/fixtures/fixture_data/test_port_channel_trunk_host_interface.json create mode 100644 tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py create mode 100644 tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_port_channel_trunk_host_interface.json b/tests/unit/module_utils/fixtures/fixture_data/test_port_channel_trunk_host_interface.json new file mode 100644 index 000000000..ead9198ab --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_port_channel_trunk_host_interface.json @@ -0,0 +1,271 @@ +{ + "TEST_NOTES": [ + "Fixture data for tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py", + "Each key matches a test function + suffix (a/b/c/...) per CLAUDE.md unit-test conventions.", + "The fixtures simulate ND responses for:", + " - GET /api/v1/manage/fabrics/{fabric_name}/summary (fabric_summary via FabricContext)", + " - GET /api/v1/manage/fabrics/{fabric_name}/deploymentFreeze (deploymentFreeze via FabricContext)", + " - GET /api/v1/manage/fabrics/{fabric_name}/switches (switch_map via FabricContext)", + " - GET /api/v1/manage/fabrics/{fabric_name}/switches/{sn}/interfaces (EpManageInterfacesListGet per switch)" + ], + "test_query_all_happy_path_00400a": { + "TEST_NOTES": ["Fabric summary: fabric exists (for validate_prerequisites)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/summary", + "MESSAGE": "OK", + "DATA": { + "name": "fabric_1", + "ownerCluster": "cluster_a" + } + }, + "test_query_all_happy_path_00400_freeze": { + "TEST_NOTES": ["Deployment freeze: disabled (for validate_prerequisites)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/deploymentFreeze", + "MESSAGE": "OK", + "DATA": { + "deploymentFreeze": false + } + }, + "test_query_all_happy_path_00400b": { + "TEST_NOTES": ["Switch list: two switches in fabric_1"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + { + "fabricManagementIp": "192.168.1.1", + "switchId": "FDO11111AAA" + }, + { + "fabricManagementIp": "192.168.1.2", + "switchId": "FDO22222BBB" + } + ] + } + }, + "test_query_all_happy_path_00400c": { + "TEST_NOTES": [ + "Interfaces for switch FDO11111AAA: configured trunkPoHost portChannel, accessPoHost portChannel, ethernet trunkHost.", + "Expect: only the trunkPoHost port-channel is retained after filtering." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "port-channel501", + "interfaceType": "portChannel", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkPoHost", + "allowedVlans": "100-200", + "nativeVlan": 99, + "ports": ["Ethernet1/1", "Ethernet1/2"], + "portChannelMode": "active" + } + } + } + }, + { + "interfaceName": "port-channel502", + "interfaceType": "portChannel", + "configData": { + "mode": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "accessPoHost", + "accessVlan": 100, + "ports": ["Ethernet1/3"] + } + } + } + }, + { + "interfaceName": "Ethernet1/1", + "interfaceType": "ethernet", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkHost", + "allowedVlans": "1-100" + } + } + } + } + ] + } + }, + "test_query_all_happy_path_00400d": { + "TEST_NOTES": ["Interfaces for switch FDO22222BBB: one configured trunkPoHost port-channel."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO22222BBB/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "port-channel601", + "interfaceType": "portChannel", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkPoHost", + "allowedVlans": "all", + "ports": ["Ethernet1/5"], + "portChannelMode": "passive" + } + } + } + } + ] + } + }, + "test_query_all_no_match_00410a": { + "TEST_NOTES": ["Fabric summary: fabric exists"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/summary", + "MESSAGE": "OK", + "DATA": { + "name": "fabric_1", + "ownerCluster": "cluster_a" + } + }, + "test_query_all_no_match_00410_freeze": { + "TEST_NOTES": ["Deployment freeze: disabled"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/deploymentFreeze", + "MESSAGE": "OK", + "DATA": { + "deploymentFreeze": false + } + }, + "test_query_all_no_match_00410b": { + "TEST_NOTES": ["Switch list: one switch"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + { + "fabricManagementIp": "192.168.1.1", + "switchId": "FDO11111AAA" + } + ] + } + }, + "test_query_all_no_match_00410c": { + "TEST_NOTES": [ + "Interfaces for switch FDO11111AAA: only ethernet and non-trunkPoHost port-channel interfaces.", + "Expect: filter returns an empty list." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "port-channel700", + "interfaceType": "portChannel", + "configData": { + "mode": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "accessPoHost", + "accessVlan": 10 + } + } + } + }, + { + "interfaceName": "Ethernet1/1", + "interfaceType": "ethernet", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkHost", + "allowedVlans": "1-100" + } + } + } + } + ] + } + }, + "test_query_all_fabric_not_found_00420a": { + "TEST_NOTES": ["Fabric summary: 404 not found; validate_prerequisites should raise RuntimeError."], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/missing_fabric/summary", + "MESSAGE": "Not Found", + "DATA": {} + }, + "test_query_all_switch_404_00430a": { + "TEST_NOTES": ["Fabric summary: fabric exists"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/summary", + "MESSAGE": "OK", + "DATA": { + "name": "fabric_1", + "ownerCluster": "cluster_a" + } + }, + "test_query_all_switch_404_00430_freeze": { + "TEST_NOTES": ["Deployment freeze: disabled"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/deploymentFreeze", + "MESSAGE": "OK", + "DATA": { + "deploymentFreeze": false + } + }, + "test_query_all_switch_404_00430b": { + "TEST_NOTES": ["Switch list: one switch"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + { + "fabricManagementIp": "192.168.1.1", + "switchId": "FDO11111AAA" + } + ] + } + }, + "test_query_all_switch_404_00430c": { + "TEST_NOTES": [ + "Interfaces endpoint returns 404 for the switch (no interfaces present).", + "PortChannelBaseOrchestrator.query_all uses not_found_ok=True; the empty result is skipped." + ], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/interfaces", + "MESSAGE": "Not Found", + "DATA": {} + } +} diff --git a/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py b/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py new file mode 100644 index 000000000..4431e442e --- /dev/null +++ b/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py @@ -0,0 +1,1779 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for port_channel_trunk_host_interface.py + +Tests the Port-channel trunkPoHost Interface Pydantic model classes. +""" + +# pylint: disable=line-too-long +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-lines + +from __future__ import annotations + +import copy +from contextlib import contextmanager + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import ( + BpduFilterEnum, + BpduGuardEnum, + DuplexModeEnum, + LacpRateEnum, + LinkTypeEnum, + MtuEnum, + PortChannelModeEnum, + SpeedEnum, + StormControlActionEnum, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.port_channel_trunk_host_interface import ( + PortChannelTrunkHostConfigDataModel, + PortChannelTrunkHostInterfaceModel, + PortChannelTrunkHostNetworkOSModel, + PortChannelTrunkHostPolicyModel, + PortChannelTrunkHostVlanMappingEntryModel, +) +from pydantic import ValidationError + + +@contextmanager +def does_not_raise(): + """A context manager that does not raise an exception.""" + yield + + +# ============================================================================= +# Test data constants +# ============================================================================= + +SAMPLE_API_RESPONSE = { + "switchIp": "192.168.1.1", + "interfaceName": "port-channel501", + "interfaceType": "portChannel", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "adminState": True, + "allowedVlans": "100-200,300", + "nativeVlan": 99, + "ports": ["Ethernet1/1", "Ethernet1/2"], + "portChannelMode": "active", + "lacpRate": "fast", + "lacpPortPriority": 32768, + "bpduGuard": "enable", + "bpduFilter": "disable", + "linkType": "auto", + "description": "trunk to host", + "policyType": "trunkPoHost", + "speed": "10Gb", + "duplexMode": "auto", + "mtu": "jumbo", + "copyDescription": True, + "portTypeEdgeTrunk": True, + "negotiateAuto": True, + "vlanMapping": False, + }, + }, + }, +} + +SAMPLE_ANSIBLE_CONFIG = { + "switch_ip": "192.168.1.1", + "interface_name": "port-channel501", + "interface_type": "portChannel", + "config_data": { + "mode": "trunk", + "network_os": { + "network_os_type": "nx-os", + "policy": { + "admin_state": True, + "allowed_vlans": "100-200,300", + "native_vlan": 99, + "ports": ["Ethernet1/1", "Ethernet1/2"], + "port_channel_mode": "active", + "lacp_rate": "fast", + "lacp_port_priority": 32768, + "bpdu_guard": "enable", + "bpdu_filter": "disable", + "link_type": "auto", + "description": "trunk to host", + "policy_type": "trunkPoHost", + "speed": "10Gb", + "duplex_mode": "auto", + "mtu": "jumbo", + "copy_description": True, + "port_type_edge_trunk": True, + "negotiate_auto": True, + "vlan_mapping": False, + }, + }, + }, +} + + +# ============================================================================= +# Test: PortChannelTrunkHostPolicyModel — initialization +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00100(): + """ + # Summary + + Verify every policy field defaults to None (except hardcoded `policy_type`). + + ## Test + + - Instantiate with no arguments + - Every field is None except `policy_type` which is "trunkPoHost" + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel() + assert instance.admin_state is None + assert instance.allowed_vlans is None + assert instance.bandwidth is None + assert instance.bpdu_filter is None + assert instance.bpdu_guard is None + assert instance.cdp is None + assert instance.copy_description is None + assert instance.description is None + assert instance.duplex_mode is None + assert instance.extra_config is None + assert instance.inherit_bandwidth is None + assert instance.lacp_port_priority is None + assert instance.lacp_rate is None + assert instance.lacp_suspend is None + assert instance.link_type is None + assert instance.monitor is None + assert instance.mtu is None + assert instance.native_vlan is None + assert instance.negotiate_auto is None + assert instance.netflow is None + assert instance.netflow_monitor is None + assert instance.netflow_sampler is None + assert instance.orphan_port is None + assert instance.pfc is None + assert instance.policy_type == "trunkPoHost" + assert instance.port_channel_id is None + assert instance.port_channel_mode is None + assert instance.port_type_edge_trunk is None + assert instance.ports is None + assert instance.ptp is None + assert instance.qos is None + assert instance.qos_policy is None + assert instance.queuing_policy is None + assert instance.speed is None + assert instance.storm_control is None + assert instance.storm_control_action is None + assert instance.storm_control_broadcast_level is None + assert instance.storm_control_broadcast_level_pps is None + assert instance.storm_control_multicast_level is None + assert instance.storm_control_multicast_level_pps is None + assert instance.storm_control_unicast_level is None + assert instance.storm_control_unicast_level_pps is None + assert instance.vlan_mapping is None + assert instance.vlan_mapping_entries is None + + +def test_port_channel_trunk_host_interface_00110(): + """ + # Summary + + Verify construction with snake_case field names. + + ## Test + + - Construct with Python field names + - Values accessible + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel( + admin_state=True, + allowed_vlans="100-200", + native_vlan=99, + ports=["Ethernet1/1"], + port_channel_mode="active", + lacp_rate="fast", + description="test", + speed="10Gb", + link_type="auto", + ) + assert instance.admin_state is True + assert instance.allowed_vlans == "100-200" + assert instance.native_vlan == 99 + assert instance.ports == ["Ethernet1/1"] + assert instance.port_channel_mode == "active" + assert instance.lacp_rate == "fast" + assert instance.description == "test" + # Hardcoded model default; user no longer supplies this field. + assert instance.policy_type == "trunkPoHost" + assert instance.speed == "10Gb" + assert instance.link_type == "auto" + + +def test_port_channel_trunk_host_interface_00120(): + """ + # Summary + + Verify construction with camelCase aliases. + + ## Test + + - Construct with API alias names + - Values accessible by Python names + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel( + adminState=True, + allowedVlans="all", + nativeVlan=200, + ports=["Ethernet1/3", "Ethernet1/4"], + portChannelMode="passive", + policyType="trunkPoHost", + bpduGuard="enable", + duplexMode="full", + copyDescription=True, + portTypeEdgeTrunk=True, + negotiateAuto=False, + ) + assert instance.admin_state is True + assert instance.allowed_vlans == "all" + assert instance.native_vlan == 200 + assert instance.ports == ["Ethernet1/3", "Ethernet1/4"] + assert instance.port_channel_mode == "passive" + assert instance.policy_type == "trunkPoHost" + assert instance.bpdu_guard == "enable" + assert instance.duplex_mode == "full" + assert instance.copy_description is True + assert instance.port_type_edge_trunk is True + assert instance.negotiate_auto is False + + +# ============================================================================= +# Test: PortChannelTrunkHostPolicyModel — validators +# ============================================================================= + + +@pytest.mark.parametrize( + "value,expected", + [ + (["ethernet1/1", "ethernet1/2"], ["Ethernet1/1", "Ethernet1/2"]), + (["Ethernet1/1"], ["Ethernet1/1"]), + (["e1/1"], ["E1/1"]), + ([], []), + (None, None), + # Mixed and breakout-style names preserve everything after the first character. + (["ethernet1/1/1", "ETHERNET1/2"], ["Ethernet1/1/1", "ETHERNET1/2"]), + ], + ids=[ + "lowercase_to_capitalized", + "already_capitalized_passthrough", + "single_letter", + "empty_list", + "none_passthrough", + "mixed_breakout", + ], +) +def test_port_channel_trunk_host_interface_00180(value, expected): + """ + # Summary + + Verify `normalize_ports` capitalizes the first character of each member interface name. + + ## Test + + - Lowercase member names are capitalized + - Already-capitalized values pass through + - Empty list and None pass through + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.normalize_ports() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(ports=value) + assert instance.ports == expected + + +def test_port_channel_trunk_host_interface_00190(): + """ + # Summary + + Verify `normalize_ports` passes a non-list, non-None value through to fail Pydantic type validation + rather than coercing it. This keeps misconfigured input surfaced as a `ValidationError`. + + ## Test + + - A scalar string for `ports` raises ValidationError + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.normalize_ports() + """ + with pytest.raises(ValidationError): + PortChannelTrunkHostPolicyModel(ports="Ethernet1/1") + + +@pytest.mark.parametrize( + "value,should_raise", + [ + ("none", False), + ("all", False), + ("100", False), + ("100-200", False), + ("1-200,500-2000,3000", False), + ("1,2,3,4-10", False), + ("", False), + (None, False), + ("abc", True), + ("1-", True), + ("100-200,", True), + ("None", True), # case-sensitive + ("ALL", True), + ], + ids=[ + "none_keyword", + "all_keyword", + "single_id", + "single_range", + "multiple_ranges", + "mixed_ids_ranges", + "empty_passthrough", + "none_passthrough", + "non_numeric_rejected", + "trailing_dash_rejected", + "trailing_comma_rejected", + "case_none_rejected", + "case_all_rejected", + ], +) +def test_port_channel_trunk_host_interface_00200(value, should_raise): + """ + # Summary + + Verify `allowed_vlans` regex validator accepts `none`, `all`, or comma-separated VLAN ids/ranges and + rejects malformed input. + + ## Test + + - Valid forms are accepted + - Invalid strings raise ValidationError + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.validate_allowed_vlans() + """ + if should_raise: + with pytest.raises(ValidationError, match=r"allowed_vlans"): + PortChannelTrunkHostPolicyModel(allowed_vlans=value) + else: + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(allowed_vlans=value) + assert instance.allowed_vlans == value + + +@pytest.mark.parametrize( + "value,expected", + [ + (250, "250"), + (1, "1"), + (4094, "4094"), + ], + ids=["single_vlan_int", "min_vlan_int", "max_vlan_int"], +) +def test_port_channel_trunk_host_interface_00205(value, expected): + """ + # Summary + + Verify `allowed_vlans` coerces JSON int responses to str. ND returns single-id values as JSON ints + (e.g. `250` instead of `"250"`) when the field is set to a single VLAN with no commas/dashes. The model + normalizes to str so round-trips and idempotency comparisons stay stable. + + ## Test + + - JSON int 250 is coerced to "250" + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.validate_allowed_vlans() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(allowed_vlans=value) + assert instance.allowed_vlans == expected + assert isinstance(instance.allowed_vlans, str) + + +# ============================================================================= +# Test: PortChannelTrunkHostPolicyModel — range and enum validation +# ============================================================================= + + +@pytest.mark.parametrize( + "field,value,should_raise", + [ + ("native_vlan", 1, False), + ("native_vlan", 4094, False), + ("native_vlan", 0, True), + ("native_vlan", 4095, True), + ("bandwidth", 1, False), + ("bandwidth", 100000000, False), + ("bandwidth", 0, True), + ("bandwidth", 100000001, True), + ("inherit_bandwidth", 1, False), + ("inherit_bandwidth", 100000000, False), + ("inherit_bandwidth", 0, True), + ("inherit_bandwidth", 100000001, True), + ("lacp_port_priority", 1, False), + ("lacp_port_priority", 65535, False), + ("lacp_port_priority", 0, True), + ("lacp_port_priority", 65536, True), + ("storm_control_broadcast_level_pps", 0, False), + ("storm_control_broadcast_level_pps", 200000000, False), + ("storm_control_broadcast_level_pps", -1, True), + ("storm_control_broadcast_level_pps", 200000001, True), + ("storm_control_multicast_level_pps", 0, False), + ("storm_control_multicast_level_pps", 200000000, False), + ("storm_control_multicast_level_pps", -1, True), + ("storm_control_multicast_level_pps", 200000001, True), + ("storm_control_unicast_level_pps", 0, False), + ("storm_control_unicast_level_pps", 200000000, False), + ("storm_control_unicast_level_pps", -1, True), + ("storm_control_unicast_level_pps", 200000001, True), + ], + ids=lambda v: str(v) if not isinstance(v, bool) else ("raise" if v else "ok"), +) +def test_port_channel_trunk_host_interface_00220(field, value, should_raise): + """ + # Summary + + Verify ge/le constraints on every numeric policy field. + + ## Test + + - At-min and at-max values accepted + - Below-min and above-max values rejected with ValidationError + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + if should_raise: + with pytest.raises(ValidationError): + PortChannelTrunkHostPolicyModel(**{field: value}) + else: + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(**{field: value}) + assert getattr(instance, field) == value + + +@pytest.mark.parametrize( + "field,value,should_raise", + [ + ("storm_control_broadcast_level", 0.0, False), + ("storm_control_broadcast_level", 100.0, False), + ("storm_control_broadcast_level", -0.1, True), + ("storm_control_broadcast_level", 100.01, True), + ("storm_control_multicast_level", 0.0, False), + ("storm_control_multicast_level", 100.0, False), + ("storm_control_multicast_level", -0.1, True), + ("storm_control_multicast_level", 100.01, True), + ("storm_control_unicast_level", 0.0, False), + ("storm_control_unicast_level", 100.0, False), + ("storm_control_unicast_level", -0.1, True), + ("storm_control_unicast_level", 100.01, True), + ], + ids=lambda v: str(v) if not isinstance(v, bool) else ("raise" if v else "ok"), +) +def test_port_channel_trunk_host_interface_00225(field, value, should_raise): + """ + # Summary + + Verify storm-control percentage fields enforce the 0.0-100.0 range. + + ## Test + + - 0.0 and 100.0 accepted + - -0.1 and 100.01 rejected + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + if should_raise: + with pytest.raises(ValidationError): + PortChannelTrunkHostPolicyModel(**{field: value}) + else: + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(**{field: value}) + assert getattr(instance, field) == value + + +@pytest.mark.parametrize( + "field,value,expected", + [ + ("storm_control_broadcast_level", "50.5", 50.5), + ("storm_control_multicast_level", "75.25", 75.25), + ("storm_control_unicast_level", "0.0", 0.0), + ], + ids=["broadcast_str_to_float", "multicast_str_to_float", "unicast_str_to_float"], +) +def test_port_channel_trunk_host_interface_00227(field, value, expected): + """ + # Summary + + Verify Pydantic coerces JSON-string storm-control levels to float. ND returns these fields as quoted + strings on the wire even though they are typed as `float | None` in the model. + + ## Test + + - String "50.5" parses to float 50.5 + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(**{field: value}) + assert getattr(instance, field) == expected + assert isinstance(getattr(instance, field), float) + + +def test_port_channel_trunk_host_interface_00230(): + """ + # Summary + + Verify `description` max_length=254. + + ## Test + + - 254-char description accepted + - 255-char description rejected with ValidationError + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + at_limit = "a" * 254 + over_limit = "a" * 255 + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(description=at_limit) + assert instance.description == at_limit + + with pytest.raises(ValidationError): + PortChannelTrunkHostPolicyModel(description=over_limit) + + +@pytest.mark.parametrize( + "value,should_raise", + [ + ("plain ASCII", False), + ("with-hyphen and 123", False), + ("em — dash", True), + ("smart “quotes”", True), + ("emoji \U0001f600", True), + ("latin-1 \xe9", True), + ], + ids=[ + "ascii_ok", + "ascii_punct_digits", + "em_dash_rejected", + "smart_quotes_rejected", + "emoji_rejected", + "latin1_rejected", + ], +) +def test_port_channel_trunk_host_interface_00235(value, should_raise): + """ + # Summary + + Verify `description` (typed `AsciiDescription`) rejects any non-ASCII character. + + Cisco backend pipes interface descriptions through CLI generators that 500 on UTF-8. Catching this client-side + gives users a clear error instead of a generic "unexpected error during policy execution" 500. + + ## Test + + - ASCII strings accepted + - Non-ASCII characters (em-dash, smart quotes, emoji, latin-1) raise + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + - models.types.ascii_only() + """ + if should_raise: + with pytest.raises(ValidationError, match="description must contain only ASCII"): + PortChannelTrunkHostPolicyModel(description=value) + else: + instance = PortChannelTrunkHostPolicyModel(description=value) + assert instance.description == value + + +@pytest.mark.parametrize( + "field,enum_cls", + [ + ("speed", SpeedEnum), + ("duplex_mode", DuplexModeEnum), + ("bpdu_guard", BpduGuardEnum), + ("bpdu_filter", BpduFilterEnum), + ("mtu", MtuEnum), + ("storm_control_action", StormControlActionEnum), + ("port_channel_mode", PortChannelModeEnum), + ("lacp_rate", LacpRateEnum), + ("link_type", LinkTypeEnum), + ], + ids=["speed", "duplex_mode", "bpdu_guard", "bpdu_filter", "mtu", "storm_control_action", "port_channel_mode", "lacp_rate", "link_type"], +) +def test_port_channel_trunk_host_interface_00240(field, enum_cls): + """ + # Summary + + Verify enum-constrained fields accept any valid enum value and reject invalid strings. + + ## Test + + - Valid enum value sets the stored value + - Invalid value raises ValidationError + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + valid_value = next(iter(enum_cls)).value + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel(**{field: valid_value}) + assert getattr(instance, field) == valid_value + + with pytest.raises(ValidationError): + PortChannelTrunkHostPolicyModel(**{field: "not_a_real_value"}) + + +# ============================================================================= +# Test: PortChannelTrunkHostVlanMappingEntryModel +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00300(): + """ + # Summary + + Verify VLAN mapping entry construction with all fields. + + ## Test + + - All fields accessible + - camelCase aliases populate snake_case fields + + ## Classes and Methods + + - PortChannelTrunkHostVlanMappingEntryModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostVlanMappingEntryModel( + customer_inner_vlan_id=10, + customer_vlan_id=["100", "200"], + dot1q_tunnel=True, + provider_vlan_id=1000, + ) + assert instance.customer_inner_vlan_id == 10 + assert instance.customer_vlan_id == ["100", "200"] + assert instance.dot1q_tunnel is True + assert instance.provider_vlan_id == 1000 + + +@pytest.mark.parametrize( + "field,value,should_raise", + [ + ("customer_inner_vlan_id", 1, False), + ("customer_inner_vlan_id", 4094, False), + ("customer_inner_vlan_id", 0, True), + ("customer_inner_vlan_id", 4095, True), + ("provider_vlan_id", 1, False), + ("provider_vlan_id", 4094, False), + ("provider_vlan_id", 0, True), + ("provider_vlan_id", 4095, True), + ], + ids=lambda v: str(v) if not isinstance(v, bool) else ("raise" if v else "ok"), +) +def test_port_channel_trunk_host_interface_00310(field, value, should_raise): + """ + # Summary + + Verify VLAN mapping entry id range constraints. + + ## Test + + - At-min and at-max values accepted + - Below-min and above-max values rejected + + ## Classes and Methods + + - PortChannelTrunkHostVlanMappingEntryModel.__init__() + """ + if should_raise: + with pytest.raises(ValidationError): + PortChannelTrunkHostVlanMappingEntryModel(**{field: value}) + else: + with does_not_raise(): + instance = PortChannelTrunkHostVlanMappingEntryModel(**{field: value}) + assert getattr(instance, field) == value + + +def test_port_channel_trunk_host_interface_00320(): + """ + # Summary + + Verify policy accepts a list of VLAN mapping entries via dict input. + + ## Test + + - vlan_mapping_entries accepts a list of dicts + - Each dict is coerced to PortChannelTrunkHostVlanMappingEntryModel + + ## Classes and Methods + + - PortChannelTrunkHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostPolicyModel( + vlan_mapping=True, + vlan_mapping_entries=[ + {"customer_vlan_id": ["100"], "provider_vlan_id": 200, "dot1q_tunnel": True}, + {"customer_vlan_id": ["300"], "provider_vlan_id": 400}, + ], + ) + assert instance.vlan_mapping is True + assert len(instance.vlan_mapping_entries) == 2 + assert isinstance(instance.vlan_mapping_entries[0], PortChannelTrunkHostVlanMappingEntryModel) + assert instance.vlan_mapping_entries[0].provider_vlan_id == 200 + assert instance.vlan_mapping_entries[0].dot1q_tunnel is True + assert instance.vlan_mapping_entries[1].dot1q_tunnel is None + + +# ============================================================================= +# Test: PortChannelTrunkHostNetworkOSModel +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00400(): + """ + # Summary + + Verify `network_os_type` defaults to "nx-os". + + ## Test + + - Instantiate without args + - network_os_type is "nx-os" + - policy is None + + ## Classes and Methods + + - PortChannelTrunkHostNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostNetworkOSModel() + assert instance.network_os_type == "nx-os" + assert instance.policy is None + + +def test_port_channel_trunk_host_interface_00410(): + """ + # Summary + + Verify nested `policy` assignment accepts a dict and coerces to PortChannelTrunkHostPolicyModel. + + ## Test + + - Construct with policy as dict + - policy is a PortChannelTrunkHostPolicyModel instance + + ## Classes and Methods + + - PortChannelTrunkHostNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostNetworkOSModel(policy={"admin_state": True, "allowed_vlans": "100-200"}) + assert isinstance(instance.policy, PortChannelTrunkHostPolicyModel) + assert instance.policy.admin_state is True + assert instance.policy.allowed_vlans == "100-200" + + +def test_port_channel_trunk_host_interface_00420(): + """ + # Summary + + Verify camelCase alias `networkOSType` populates network_os_type. + + ## Test + + - Construct with camelCase alias + - Python field accessible + + ## Classes and Methods + + - PortChannelTrunkHostNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostNetworkOSModel(networkOSType="ios-xe") + assert instance.network_os_type == "ios-xe" + + +# ============================================================================= +# Test: PortChannelTrunkHostConfigDataModel +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00450(): + """ + # Summary + + Verify `mode` defaults to "trunk". + + ## Test + + - Construct with only network_os + - mode is "trunk" + + ## Classes and Methods + + - PortChannelTrunkHostConfigDataModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostConfigDataModel(network_os=PortChannelTrunkHostNetworkOSModel()) + assert instance.mode == "trunk" + + +def test_port_channel_trunk_host_interface_00460(): + """ + # Summary + + Verify camelCase alias `networkOS` populates network_os. + + ## Test + + - Construct with camelCase alias + - Python field accessible + + ## Classes and Methods + + - PortChannelTrunkHostConfigDataModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostConfigDataModel(networkOS={"networkOSType": "nx-os"}) + assert isinstance(instance.network_os, PortChannelTrunkHostNetworkOSModel) + assert instance.network_os.network_os_type == "nx-os" + + +def test_port_channel_trunk_host_interface_00470(): + """ + # Summary + + Verify `network_os` is a required field. + + ## Test + + - Construct without network_os + - ValidationError raised + + ## Classes and Methods + + - PortChannelTrunkHostConfigDataModel.__init__() + """ + with pytest.raises(ValidationError, match=r"network_os|networkOS"): + PortChannelTrunkHostConfigDataModel() + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — initialization and ClassVars +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00500(): + """ + # Summary + + Verify ClassVar `identifiers` and `identifier_strategy`. + + ## Test + + - identifiers == ["switch_ip", "interface_name"] + - identifier_strategy == "composite" + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel + """ + assert PortChannelTrunkHostInterfaceModel.identifiers == ["switch_ip", "interface_name"] + assert PortChannelTrunkHostInterfaceModel.identifier_strategy == "composite" + + +def test_port_channel_trunk_host_interface_00510(): + """ + # Summary + + Verify `payload_exclude_fields` excludes `switch_ip`. + + ## Test + + - payload_exclude_fields == {"switch_ip"} + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel + """ + assert PortChannelTrunkHostInterfaceModel.payload_exclude_fields == {"switch_ip"} + + +def test_port_channel_trunk_host_interface_00520(): + """ + # Summary + + Verify `switch_ip` and `interface_name` are required. + + ## Test + + - Missing switch_ip raises ValidationError + - Missing interface_name raises ValidationError + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.__init__() + """ + with pytest.raises(ValidationError, match=r"switch_ip|switchIp"): + PortChannelTrunkHostInterfaceModel(interface_name="port-channel501") + + with pytest.raises(ValidationError, match=r"interface_name|interfaceName"): + PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1") + + +def test_port_channel_trunk_host_interface_00530(): + """ + # Summary + + Verify `interface_type` defaults to "portChannel" and `config_data` defaults to None. + + ## Test + + - Minimal construction + - Defaults applied + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.__init__() + """ + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1", interface_name="port-channel501") + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "port-channel501" + assert instance.interface_type == "portChannel" + assert instance.config_data is None + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — normalize_interface_name +# ============================================================================= + + +@pytest.mark.parametrize( + "value,expected", + [ + ("port-channel501", "port-channel501"), + ("Port-Channel501", "port-channel501"), + ("PORT-CHANNEL501", "port-channel501"), + ("Port-channel1", "port-channel1"), + ("", ""), + ], + ids=["already_lower", "title_case", "upper", "mixed_case", "empty_passthrough"], +) +def test_port_channel_trunk_host_interface_00550(value, expected): + """ + # Summary + + Verify `normalize_interface_name` lowercases the entire interface name (port-channel convention). + + ## Test + + - Mixed-case inputs normalized to lowercase + - Already-lowercase input unchanged + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.normalize_interface_name() + """ + instance = PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1", interface_name=value) + assert instance.interface_name == expected + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — to_payload +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00600(): + """ + # Summary + + Verify `to_payload` emits camelCase keys and excludes `switch_ip`. + + ## Test + + - Top-level keys are camelCase + - switchIp / switch_ip not present + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_payload() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + assert "interfaceName" in result + assert "interfaceType" in result + assert "configData" in result + assert "switchIp" not in result + assert "switch_ip" not in result + + +def test_port_channel_trunk_host_interface_00610(): + """ + # Summary + + Verify deeply nested structure preserves camelCase aliases throughout. + + ## Test + + - configData.networkOS.policy has camelCase keys + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_payload() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + policy = result["configData"]["networkOS"]["policy"] + assert "adminState" in policy + assert "allowedVlans" in policy + assert "nativeVlan" in policy + assert "ports" in policy + assert "portChannelMode" in policy + assert "lacpRate" in policy + assert "lacpPortPriority" in policy + assert "policyType" in policy + assert "bpduGuard" in policy + assert "copyDescription" in policy + assert "linkType" in policy + assert "portTypeEdgeTrunk" in policy + assert "negotiateAuto" in policy + + +def test_port_channel_trunk_host_interface_00620(): + """ + # Summary + + Verify `policyType` is the API camelCase value in payload mode. + + ## Test + + - Hardcoded model default for `policy_type` serializes as `"trunkPoHost"` under the `policyType` alias. + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_payload() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + assert result["configData"]["networkOS"]["policy"]["policyType"] == "trunkPoHost" + + +def test_port_channel_trunk_host_interface_00630(): + """ + # Summary + + Verify member ports list survives serialization with normalized names. + + ## Test + + - ports list survives in payload as a list of capitalized member names + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_payload() + - PortChannelTrunkHostPolicyModel.normalize_ports() + """ + config = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config["config_data"]["network_os"]["policy"]["ports"] = ["ethernet1/5", "ethernet1/6"] + instance = PortChannelTrunkHostInterfaceModel.from_config(config) + result = instance.to_payload() + assert result["configData"]["networkOS"]["policy"]["ports"] == ["Ethernet1/5", "Ethernet1/6"] + + +def test_port_channel_trunk_host_interface_00640(): + """ + # Summary + + Verify None-valued fields are excluded from payload output. + + ## Test + + - Minimal model with config_data=None + - configData not present in payload + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_payload() + """ + instance = PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1", interface_name="port-channel501") + result = instance.to_payload() + assert "configData" not in result + assert "interfaceName" in result + + +def test_port_channel_trunk_host_interface_00650(): + """ + # Summary + + Verify VLAN mapping entries survive serialization. + + ## Test + + - vlan_mapping_entries is preserved in payload as a list of camelCase dicts + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_payload() + """ + config = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config["config_data"]["network_os"]["policy"]["vlan_mapping"] = True + config["config_data"]["network_os"]["policy"]["vlan_mapping_entries"] = [ + {"customer_vlan_id": ["100"], "provider_vlan_id": 200, "dot1q_tunnel": True}, + ] + instance = PortChannelTrunkHostInterfaceModel.from_config(config) + result = instance.to_payload() + entries = result["configData"]["networkOS"]["policy"]["vlanMappingEntries"] + assert len(entries) == 1 + assert entries[0]["customerVlanId"] == ["100"] + assert entries[0]["providerVlanId"] == 200 + assert entries[0]["dot1qTunnel"] is True + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — to_config +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00700(): + """ + # Summary + + Verify `to_config` emits snake_case keys throughout. + + ## Test + + - Top-level keys are snake_case + - Nested keys are snake_case + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_config() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_config() + assert "interface_name" in result + assert "interface_type" in result + assert "config_data" in result + policy = result["config_data"]["network_os"]["policy"] + assert "admin_state" in policy + assert "allowed_vlans" in policy + assert "native_vlan" in policy + assert "ports" in policy + assert "port_channel_mode" in policy + assert "lacp_rate" in policy + assert "copy_description" in policy + assert "link_type" in policy + + +def test_port_channel_trunk_host_interface_00710(): + """ + # Summary + + Verify `policy_type` round-trips as the API value in config output. + + ## Test + + - Stored "trunkPoHost" -> output "trunkPoHost" (no Ansible<->API translation; field is hardcoded by the model) + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_config() + """ + instance = PortChannelTrunkHostInterfaceModel.from_response(copy.deepcopy(SAMPLE_API_RESPONSE)) + result = instance.to_config() + assert result["config_data"]["network_os"]["policy"]["policy_type"] == "trunkPoHost" + + +def test_port_channel_trunk_host_interface_00720(): + """ + # Summary + + Verify `switch_ip` is included in config output (differs from payload). + + ## Test + + - switch_ip present at top level of config + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.to_config() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_config() + assert result["switch_ip"] == "192.168.1.1" + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — from_response +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00800(): + """ + # Summary + + Verify `from_response` constructs a model from the ND API response. + + ## Test + + - All fields accessible by Python names + - Nested structure populated + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_response() + """ + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel.from_response(copy.deepcopy(SAMPLE_API_RESPONSE)) + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "port-channel501" + assert instance.interface_type == "portChannel" + assert instance.config_data.mode == "trunk" + assert instance.config_data.network_os.policy.admin_state is True + assert instance.config_data.network_os.policy.allowed_vlans == "100-200,300" + assert instance.config_data.network_os.policy.native_vlan == 99 + assert instance.config_data.network_os.policy.ports == ["Ethernet1/1", "Ethernet1/2"] + assert instance.config_data.network_os.policy.port_channel_mode == "active" + assert instance.config_data.network_os.policy.policy_type == "trunkPoHost" + + +def test_port_channel_trunk_host_interface_00810(): + """ + # Summary + + Verify `from_response` re-serialized via `to_payload` yields an equivalent dict (minus switchIp). + + ## Test + + - API response -> model -> payload matches original (except switchIp which is excluded) + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_response() + - PortChannelTrunkHostInterfaceModel.to_payload() + """ + original = copy.deepcopy(SAMPLE_API_RESPONSE) + instance = PortChannelTrunkHostInterfaceModel.from_response(original) + result = instance.to_payload() + expected = {k: v for k, v in original.items() if k != "switchIp"} + assert result == expected + + +def test_port_channel_trunk_host_interface_00820(): + """ + # Summary + + Verify `from_response` tolerates missing `configData`. + + ## Test + + - Response with only switchIp + interfaceName constructs valid model + - config_data is None + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_response() + """ + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel.from_response({"switchIp": "192.168.1.1", "interfaceName": "port-channel501"}) + assert instance.config_data is None + + +def test_port_channel_trunk_host_interface_00830(): + """ + # Summary + + Verify `from_response` ignores unknown top-level and nested keys (extra="ignore"). + + ## Test + + - Response with extra keys constructs valid model + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_response() + """ + response = copy.deepcopy(SAMPLE_API_RESPONSE) + response["unknownField"] = "ignored" + response["configData"]["somethingExtra"] = "also_ignored" + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel.from_response(response) + assert instance.interface_name == "port-channel501" + + +def test_port_channel_trunk_host_interface_00840(): + """ + # Summary + + Verify `from_response` accepts the wire-format response shape with `portChannelId` and `ptp` fields that + ND auto-fills. These fields are present in actual API responses but not always in the OpenAPI spec. + + ## Test + + - Response with portChannelId and ptp fields constructs valid model + - portChannelId stored as response-only echo of interface_name + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_response() + """ + response = copy.deepcopy(SAMPLE_API_RESPONSE) + response["configData"]["networkOS"]["policy"]["portChannelId"] = "port-channel501" + response["configData"]["networkOS"]["policy"]["ptp"] = False + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel.from_response(response) + assert instance.config_data.network_os.policy.port_channel_id == "port-channel501" + assert instance.config_data.network_os.policy.ptp is False + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — from_config +# ============================================================================= + + +def test_port_channel_trunk_host_interface_00900(): + """ + # Summary + + Verify `from_config` constructs a model from an Ansible snake_case config. + + ## Test + + - All fields accessible + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_config() + """ + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "port-channel501" + assert instance.config_data.network_os.policy.allowed_vlans == "100-200,300" + assert instance.config_data.network_os.policy.native_vlan == 99 + assert instance.config_data.network_os.policy.description == "trunk to host" + assert instance.config_data.network_os.policy.ports == ["Ethernet1/1", "Ethernet1/2"] + + +def test_port_channel_trunk_host_interface_00910(): + """ + # Summary + + Verify model hardcodes the `trunkPoHost` policy type regardless of input. + + ## Test + + - After from_config (no policy_type in input), stored policy_type is the API value "trunkPoHost" + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_config() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance.config_data.network_os.policy.policy_type == "trunkPoHost" + + +def test_port_channel_trunk_host_interface_00920(): + """ + # Summary + + Verify `from_config` -> `to_config` round-trip preserves original data. + + ## Test + + - Input config equals to_config() output + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_config() + - PortChannelTrunkHostInterfaceModel.to_config() + """ + original = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + instance = PortChannelTrunkHostInterfaceModel.from_config(original) + result = instance.to_config() + assert result == original + + +def test_port_channel_trunk_host_interface_00930(): + """ + # Summary + + Verify `from_config` accepts a minimal config with just identifiers. + + ## Test + + - Construct with switch_ip + interface_name only + - config_data is None + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_config() + """ + with does_not_raise(): + instance = PortChannelTrunkHostInterfaceModel.from_config({"switch_ip": "192.168.1.1", "interface_name": "port-channel501"}) + assert instance.switch_ip == "192.168.1.1" + assert instance.config_data is None + + +def test_port_channel_trunk_host_interface_00940(): + """ + # Summary + + Verify full round-trip through all serialization methods. + + ## Test + + - config -> from_config -> to_payload -> from_response (with switchIp injected) -> to_config + matches original config + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.from_config() + - PortChannelTrunkHostInterfaceModel.to_payload() + - PortChannelTrunkHostInterfaceModel.from_response() + - PortChannelTrunkHostInterfaceModel.to_config() + """ + original = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + instance = PortChannelTrunkHostInterfaceModel.from_config(original) + payload = instance.to_payload() + payload["switchIp"] = original["switch_ip"] + instance2 = PortChannelTrunkHostInterfaceModel.from_response(payload) + result = instance2.to_config() + assert result == original + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — identifier, diff, merge +# ============================================================================= + + +def test_port_channel_trunk_host_interface_01000(): + """ + # Summary + + Verify `get_identifier_value` returns the composite `(switch_ip, interface_name)` tuple. + + ## Test + + - Composite tuple returned + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_identifier_value() + """ + instance = PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1", interface_name="port-channel501") + assert instance.get_identifier_value() == ("192.168.1.1", "port-channel501") + + +def test_port_channel_trunk_host_interface_01010(): + """ + # Summary + + Verify `get_diff` returns True when two models are identical. + + ## Test + + - Two identical models + - get_diff returns True + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_diff() + """ + instance1 = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + instance2 = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance1.get_diff(instance2) is True + + +def test_port_channel_trunk_host_interface_01020(): + """ + # Summary + + Verify `get_diff` returns False when a nested field differs. + + ## Test + + - Allowed VLANs differ between two models + - get_diff returns False + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_diff() + """ + config1 = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config2 = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config2["config_data"]["network_os"]["policy"]["allowed_vlans"] = "999" + instance1 = PortChannelTrunkHostInterfaceModel.from_config(config1) + instance2 = PortChannelTrunkHostInterfaceModel.from_config(config2) + assert instance1.get_diff(instance2) is False + + +def test_port_channel_trunk_host_interface_01030(): + """ + # Summary + + Verify `merge` applies non-None values from `other` into `self`. + + ## Test + + - Other sets a field self did not have + - After merge, self has the field + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.merge() + """ + base = { + "switch_ip": "192.168.1.1", + "interface_name": "port-channel501", + "config_data": { + "network_os": { + "policy": {"admin_state": True}, + }, + }, + } + other = { + "switch_ip": "192.168.1.1", + "interface_name": "port-channel501", + "config_data": { + "network_os": { + "policy": {"allowed_vlans": "100-200"}, + }, + }, + } + instance = PortChannelTrunkHostInterfaceModel.from_config(base) + instance.merge(PortChannelTrunkHostInterfaceModel.from_config(other)) + assert instance.config_data.network_os.policy.admin_state is True + assert instance.config_data.network_os.policy.allowed_vlans == "100-200" + + +def test_port_channel_trunk_host_interface_01040(): + """ + # Summary + + Verify `merge` preserves existing values when `other` has unset fields (model_fields_set semantics). + + ## Test + + - Self has a value, other does not mention that field + - After merge, self still has the original value + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.merge() + """ + instance = PortChannelTrunkHostInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + other = PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1", interface_name="port-channel501") + instance.merge(other) + assert instance.config_data.network_os.policy.allowed_vlans == "100-200,300" + + +def test_port_channel_trunk_host_interface_01050(): + """ + # Summary + + Verify `merge` raises TypeError when given a model of the wrong type. + + ## Test + + - Passing a policy model to the interface model merge raises TypeError + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.merge() + """ + instance = PortChannelTrunkHostInterfaceModel(switch_ip="192.168.1.1", interface_name="port-channel501") + with pytest.raises(TypeError, match=r"Cannot merge"): + instance.merge(PortChannelTrunkHostPolicyModel()) + + +# ============================================================================= +# Test: PortChannelTrunkHostInterfaceModel — get_argument_spec +# ============================================================================= + + +def test_port_channel_trunk_host_interface_01100(): + """ + # Summary + + Verify top-level structural layout of the Ansible argument spec. + + ## Test + + - fabric_name, config, state keys present + - switch_ip is under config.options, not top-level + - config.type == "list", elements == "dict" + - state choices and default + - policy_type is not exposed in the argspec (hardcoded by the model) + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_argument_spec() + """ + spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() + assert "fabric_name" in spec + assert "config" in spec + assert "state" in spec + assert "switch_ip" not in spec + assert "switch_ip" in spec["config"]["options"] + assert spec["config"]["type"] == "list" + assert spec["config"]["elements"] == "dict" + assert spec["state"]["choices"] == ["merged", "replaced", "overridden", "deleted"] + assert spec["state"]["default"] == "merged" + policy_spec = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + assert "policy_type" not in policy_spec + + +def test_port_channel_trunk_host_interface_01110(): + """ + # Summary + + Verify `interface_type` default is "portChannel" in the argument spec. + + ## Test + + - config.options.interface_type.default == "portChannel" + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_argument_spec() + """ + spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() + assert spec["config"]["options"]["interface_type"]["default"] == "portChannel" + + +def test_port_channel_trunk_host_interface_01115(): + """ + # Summary + + Verify the `mode` default in the argument spec is "trunk" (distinct from access PC). + + ## Test + + - config.options.config_data.options.mode.default == "trunk" + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_argument_spec() + """ + spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() + assert spec["config"]["options"]["config_data"]["options"]["mode"]["default"] == "trunk" + + +@pytest.mark.parametrize( + "field,enum_cls", + [ + ("bpdu_filter", BpduFilterEnum), + ("bpdu_guard", BpduGuardEnum), + ("duplex_mode", DuplexModeEnum), + ("lacp_rate", LacpRateEnum), + ("link_type", LinkTypeEnum), + ("mtu", MtuEnum), + ("port_channel_mode", PortChannelModeEnum), + ("speed", SpeedEnum), + ("storm_control_action", StormControlActionEnum), + ], + ids=[ + "bpdu_filter", + "bpdu_guard", + "duplex_mode", + "lacp_rate", + "link_type", + "mtu", + "port_channel_mode", + "speed", + "storm_control_action", + ], +) +def test_port_channel_trunk_host_interface_01120(field, enum_cls): + """ + # Summary + + Verify enum-constrained policy fields expose correct `choices` in the argument spec. + + ## Test + + - Each enum field's choices list exactly matches the enum values + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_argument_spec() + """ + spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() + policy_spec = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + expected = [e.value for e in enum_cls] + assert policy_spec[field]["choices"] == expected + + +def test_port_channel_trunk_host_interface_01130(): + """ + # Summary + + Verify `vlan_mapping_entries` in argspec is a list of dicts with the expected suboptions. + + ## Test + + - vlan_mapping_entries.type == "list" + - vlan_mapping_entries.elements == "dict" + - All four entry suboptions present + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceModel.get_argument_spec() + """ + spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() + policy_spec = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + entries_spec = policy_spec["vlan_mapping_entries"] + assert entries_spec["type"] == "list" + assert entries_spec["elements"] == "dict" + assert "customer_inner_vlan_id" in entries_spec["options"] + assert "customer_vlan_id" in entries_spec["options"] + assert "dot1q_tunnel" in entries_spec["options"] + assert "provider_vlan_id" in entries_spec["options"] diff --git a/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py b/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py new file mode 100644 index 000000000..18f9c347c --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for port_channel_trunk_host_interface orchestrator. + +Verifies that `PortChannelTrunkHostInterfaceOrchestrator` correctly: +- declares the right `model_class` and `_managed_policy_types` +- inherits bulk-support flags from `PortChannelBaseOrchestrator` +- filters fabric-wide interface results to `interfaceType: "portChannel"` plus the managed + policy types (so non-port-channel and accessPoHost port-channels are excluded) +- propagates `RuntimeError` from the inherited `validate_prerequisites` path + +Uses the file-based `Sender` from `tests/unit/module_utils/sender_file.py` as the +`sender` dependency injected into a real `RestSend`. Responses are read from +`tests/unit/module_utils/fixtures/fixture_data/test_port_channel_trunk_host_interface.json`. +""" + +# pylint: disable=line-too-long +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-lines + +from __future__ import annotations + +import inspect + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.port_channel_trunk_host_interface import ( + PortChannelTrunkHostInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.port_channel_trunk_host_interface import ( + PortChannelTrunkHostInterfaceOrchestrator, +) +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender + + +def responses_pc_trunk_host(key: str): + """Load fixture data for the orchestrator's test_port_channel_trunk_host_interface.json file.""" + return load_fixture("test_port_channel_trunk_host_interface")[key] + + +def _build_rest_send(gen_responses: ResponseGenerator, fabric_name: str = "fabric_1") -> RestSend: + """Build a RestSend wired to the file-based Sender and the real ResponseHandler.""" + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + + rest_send = RestSend({"check_mode": False, "fabric_name": fabric_name}) + rest_send.sender = sender + rest_send.response_handler = response_handler + rest_send.unit_test = True + rest_send.timeout = 1 + return rest_send + + +def _build_orchestrator(gen_responses: ResponseGenerator, fabric_name: str = "fabric_1") -> PortChannelTrunkHostInterfaceOrchestrator: + """Construct an orchestrator with the file-based RestSend injected.""" + rest_send = _build_rest_send(gen_responses, fabric_name=fabric_name) + return PortChannelTrunkHostInterfaceOrchestrator(rest_send=rest_send) + + +# ============================================================================= +# Test: ClassVar / model_class +# ============================================================================= + + +def test_port_channel_trunk_host_orchestrator_00010() -> None: + """ + # Summary + + Verify `model_class` points to `PortChannelTrunkHostInterfaceModel`. + + ## Test + + - model_class is PortChannelTrunkHostInterfaceModel + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceOrchestrator.model_class + """ + assert PortChannelTrunkHostInterfaceOrchestrator.model_class is PortChannelTrunkHostInterfaceModel + + +def test_port_channel_trunk_host_orchestrator_00020() -> None: + """ + # Summary + + Verify bulk-support flags inherited from `PortChannelBaseOrchestrator`. + + ## Test + + - supports_bulk_create is True + - supports_bulk_delete is True + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceOrchestrator + """ + assert PortChannelTrunkHostInterfaceOrchestrator.supports_bulk_create is True + assert PortChannelTrunkHostInterfaceOrchestrator.supports_bulk_delete is True + + +# ============================================================================= +# Test: _managed_policy_types +# ============================================================================= + + +def test_port_channel_trunk_host_orchestrator_00100() -> None: + """ + # Summary + + Verify `_managed_policy_types` returns the single `"trunkPoHost"` API value. + + ## Test + + - Returned set contains exactly "trunkPoHost" + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceOrchestrator._managed_policy_types() + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + assert orchestrator._managed_policy_types() == {"trunkPoHost"} + + +def test_port_channel_trunk_host_orchestrator_00110() -> None: + """ + # Summary + + Verify `_managed_policy_types` returns a set (supports set membership for `in` checks). + + ## Test + + - Return type is set + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceOrchestrator._managed_policy_types() + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + result = orchestrator._managed_policy_types() + assert isinstance(result, set) + assert "trunkPoHost" in result + + +# ============================================================================= +# Test: query_all — happy path with filtering +# ============================================================================= + + +def test_port_channel_trunk_host_orchestrator_00400() -> None: + """ + # Summary + + Verify `query_all` validates the fabric, iterates all switches, filters to interfaceType=="portChannel" + and policyType=="trunkPoHost", and injects `switchIp` onto each kept interface. accessPoHost + port-channels and ethernet interfaces are excluded. + + ## Test + + - Fabric summary (validate_prerequisites) returns 200 + - Switches list returns two switches + - Switch 1 returns: configured trunkPoHost portChannel, accessPoHost portChannel, ethernet trunkHost + - Switch 2 returns: one configured trunkPoHost portChannel + - Result contains exactly the two trunkPoHost port-channels + - Each has switchIp injected with the fabricManagementIp + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceOrchestrator._managed_policy_types() + - PortChannelBaseOrchestrator.query_all() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_pc_trunk_host("test_query_all_happy_path_00400a") + yield responses_pc_trunk_host("test_query_all_happy_path_00400_freeze") + yield responses_pc_trunk_host("test_query_all_happy_path_00400b") + yield responses_pc_trunk_host("test_query_all_happy_path_00400c") + yield responses_pc_trunk_host("test_query_all_happy_path_00400d") + + gen_responses = ResponseGenerator(responses()) + + with does_not_raise(): + orchestrator = _build_orchestrator(gen_responses) + result = orchestrator.query_all() + + assert isinstance(result, list) + assert len(result) == 2 + + by_name = {iface["interfaceName"]: iface for iface in result} + assert set(by_name) == {"port-channel501", "port-channel601"} + + # switchIp is injected by the base query_all + assert by_name["port-channel501"]["switchIp"] == "192.168.1.1" + assert by_name["port-channel601"]["switchIp"] == "192.168.1.2" + + # Filtered out: accessPoHost (port-channel502) and ethernet trunkHost (Ethernet1/1) + assert "port-channel502" not in by_name + assert "Ethernet1/1" not in by_name + + # method_name is used for clearer pytest failure messages; keep as a sanity reference + assert method_name.endswith("00400") + + +def test_port_channel_trunk_host_orchestrator_00410() -> None: + """ + # Summary + + Verify `query_all` returns an empty list when no switch reports any trunkPoHost port-channel. + + ## Test + + - Switch returns only non-port-channel and non-trunkPoHost port-channel interfaces + - Result is an empty list + + ## Classes and Methods + + - PortChannelTrunkHostInterfaceOrchestrator._managed_policy_types() + - PortChannelBaseOrchestrator.query_all() + """ + + def responses(): + yield responses_pc_trunk_host("test_query_all_no_match_00410a") + yield responses_pc_trunk_host("test_query_all_no_match_00410_freeze") + yield responses_pc_trunk_host("test_query_all_no_match_00410b") + yield responses_pc_trunk_host("test_query_all_no_match_00410c") + + gen_responses = ResponseGenerator(responses()) + + with does_not_raise(): + orchestrator = _build_orchestrator(gen_responses) + result = orchestrator.query_all() + + assert result == [] + + +def test_port_channel_trunk_host_orchestrator_00420() -> None: + """ + # Summary + + Verify `query_all` raises `RuntimeError` when the fabric does not exist. + + ## Test + + - Fabric summary returns 404 + - query_all raises RuntimeError with "Query all failed" (wrapping the inner "Fabric ... not found") + + ## Classes and Methods + + - PortChannelBaseOrchestrator.query_all() + - FabricContext.validate_for_mutation() + """ + + def responses(): + yield responses_pc_trunk_host("test_query_all_fabric_not_found_00420a") + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses, fabric_name="missing_fabric") + + with pytest.raises(RuntimeError, match=r"Query all failed.*missing_fabric"): + orchestrator.query_all() + + +def test_port_channel_trunk_host_orchestrator_00430() -> None: + """ + # Summary + + Verify `query_all` returns an empty list when a switch's interfaces endpoint returns no body + (the `not_found_ok=True` branch in `PortChannelBaseOrchestrator.query_all`). + + ## Test + + - Switch's interface list returns 404 (treated as no interfaces present) + - query_all skips the switch and yields [] + + ## Classes and Methods + + - PortChannelBaseOrchestrator.query_all() + """ + + def responses(): + yield responses_pc_trunk_host("test_query_all_switch_404_00430a") + yield responses_pc_trunk_host("test_query_all_switch_404_00430_freeze") + yield responses_pc_trunk_host("test_query_all_switch_404_00430b") + yield responses_pc_trunk_host("test_query_all_switch_404_00430c") + + gen_responses = ResponseGenerator(responses()) + + with does_not_raise(): + orchestrator = _build_orchestrator(gen_responses) + result = orchestrator.query_all() + + assert result == [] From 2b53eb498dd29e8aa9bbb868cfcc07c1823bbbaf Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 4 May 2026 09:24:00 -1000 Subject: [PATCH 03/14] Fix pylint warnings in nd_interface_port_channel_trunk_host Add module docstring, scope a wrong-import-position disable to the post-DOCUMENTATION import block, and replace dict() call with a literal. Pylint score now 10.00/10. Co-Authored-By: Claude Opus 4.7 --- plugins/modules/nd_interface_port_channel_trunk_host.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 0892233c6..6fc7a200a 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -4,6 +4,8 @@ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +"""Ansible module for managing port-channel trunkPoHost interfaces on Cisco Nexus Dashboard.""" + ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} DOCUMENTATION = r""" @@ -407,6 +409,7 @@ RETURN = r""" """ +# pylint: disable=wrong-import-position import logging import traceback @@ -439,7 +442,7 @@ def main(): argument_spec = nd_argument_spec() argument_spec.update(PortChannelTrunkHostInterfaceModel.get_argument_spec()) argument_spec.update( - deploy=dict(type="bool", default=True), + deploy={"type": "bool", "default": True}, ) module = AnsibleModule( From f3af6e86db3178b186eba972c743244386202ea7 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 10:59:12 -1000 Subject: [PATCH 04/14] Address review comment Addressed https://github.com/CiscoDevNet/ansible-nd/pull/270#discussion_r3186753338 --- plugins/modules/nd_interface_port_channel_trunk_host.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 6fc7a200a..48e9bda27 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -4,7 +4,7 @@ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -"""Ansible module for managing port-channel trunkPoHost interfaces on Cisco Nexus Dashboard.""" +"""Ansible module for managing port-channel (trunkPoHost) interfaces on Cisco Nexus Dashboard.""" ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} @@ -12,10 +12,10 @@ --- module: nd_interface_port_channel_trunk_host version_added: "1.4.0" -short_description: Manage port-channel trunkPoHost interfaces on Cisco Nexus Dashboard +short_description: Manage port-channel (trunkPoHost) interfaces on Cisco Nexus Dashboard description: -- Manage port-channel trunkPoHost interfaces on Cisco Nexus Dashboard. -- It supports creating, updating, and deleting trunkPoHost port-channel configurations on switches within a fabric. +- Manage port-channel (trunkPoHost) interfaces on Cisco Nexus Dashboard. +- It supports creating, updating, and deleting (trunkPoHost) port-channel configurations on switches within a fabric. - Each config item represents one port-channel interface. Member ethernet interfaces are listed in O(config[].config_data.network_os.policy.ports) and inherit trunk-mode configuration from the port-channel policy. - Member interface field mutability is restricted while members of a port-channel; only description, admin_state, and From 6d061c7d6be7f9a9e106b205c377054096bc9bdc Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 11:33:24 -1000 Subject: [PATCH 05/14] Tighten allowed_vlans and customer_vlan_id validation Replace shape-only regex with Annotated types (AllowedVlans, CustomerVlanIdList) backed by a shared per-token validator that enforces VLAN ids in 1..4094 and rejects reversed ranges. Addresses review feedback on lines 57-58 of port_channel_trunk_host_interface.py and closes the same gap on customer_vlan_id. Mark both types with a TODO to consolidate into models/types.py once sibling branches merge. Co-Authored-By: Claude Opus 4.7 --- .../port_channel_trunk_host_interface.py | 149 +++++++++++--- .../test_port_channel_trunk_host_interface.py | 185 +++++++++++++++++- 2 files changed, 297 insertions(+), 37 deletions(-) diff --git a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py index dfe0b06b2..3bd0fcaea 100644 --- a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py +++ b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py @@ -32,9 +32,10 @@ from __future__ import annotations import re -from typing import ClassVar, Literal +from typing import Annotated, ClassVar, Literal, Optional # Optional needed for Annotated runtime expr (see types.py) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BeforeValidator, Field, field_validator, ) @@ -54,8 +55,114 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel from ansible_collections.cisco.nd.plugins.module_utils.models.types import AsciiDescription -# Accepts: "none", "all", or comma-separated VLAN ids/ranges (e.g. "1-200,500-2000,3000"). -ALLOWED_VLANS_PATTERN = re.compile(r"^(none|all|(\d+(-\d+)?)(,\d+(-\d+)?)*)$") +# Shape regex: "none", "all", or comma-separated VLAN ids/ranges. Range bounds are validated separately. +_ALLOWED_VLANS_SHAPE = re.compile(r"^(none|all|(\d+(-\d+)?)(,\d+(-\d+)?)*)$") +# Single VLAN id or range token (e.g. "100" or "100-200"). Range bounds are validated separately. +_VLAN_ID_OR_RANGE_SHAPE = re.compile(r"^\d+(-\d+)?$") + + +def _validate_vlan_id_or_range(token: str, field_name: str) -> None: + """ + # Summary + + Validate a single VLAN id or range token (e.g. `"100"` or `"100-200"`) and confirm every id is in 1..4094 and any range has start <= end. + + Shared helper used by `_validate_allowed_vlans` (per comma-split token) and `_validate_customer_vlan_id_list` (per list element). + `field_name` is interpolated into error messages so callers see which field surfaced the failure. + + ## Raises + + ### ValueError + + - If `token` does not match `\\d+(-\\d+)?`. + - If any VLAN id is outside 1..4094. + - If a range has start greater than end. + """ + if not _VLAN_ID_OR_RANGE_SHAPE.match(token): + raise ValueError(f"{field_name} entry {token!r} must be a VLAN id or range (e.g. '100' or '100-200')") + if "-" in token: + start_str, end_str = token.split("-", 1) + start, end = int(start_str), int(end_str) + if not 1 <= start <= 4094 or not 1 <= end <= 4094: + raise ValueError(f"{field_name} range {token!r} is out of bounds; VLAN ids must be in 1..4094") + if start > end: + raise ValueError(f"{field_name} range {token!r} has start greater than end") + else: + vid = int(token) + if not 1 <= vid <= 4094: + raise ValueError(f"{field_name} id {vid} is out of bounds; VLAN ids must be in 1..4094") + + +def _validate_allowed_vlans(value): + """ + # Summary + + Validate `allowed_vlans` matches `"none"`, `"all"`, or a comma-separated list of VLAN ids/ranges where every id is in 1..4094 and every range start <= end. + ND returns single-id values as JSON ints (e.g. `250`) but accepts both forms on input; this validator coerces int -> str so round-trips and idempotency + comparisons are stable. + + Used as the `BeforeValidator` payload for the `AllowedVlans` Annotated type. + + ## Raises + + ### ValueError + + - If `value` is a non-empty string that does not match the expected shape. + - If any VLAN id is outside 1..4094. + - If any range has start greater than end. + """ + if value is None or value == "": + return value + if isinstance(value, int) and not isinstance(value, bool): + value = str(value) + if not isinstance(value, str): + return value + if value in ("none", "all"): + return value + if not _ALLOWED_VLANS_SHAPE.match(value): + raise ValueError(f"allowed_vlans must be 'none', 'all', or a comma-separated list of VLAN ids/ranges (e.g. '1-200,500-2000,3000'); got {value!r}") + for token in value.split(","): + _validate_vlan_id_or_range(token, "allowed_vlans") + return value + + +def _validate_customer_vlan_id_list(value): + """ + # Summary + + Validate `customer_vlan_id` is a list of non-empty VLAN id or range strings where every id is in 1..4094 and every range start <= end. + Used in `vlanMappingEntries` to identify which customer VLAN ids map to a provider VLAN id. + + Used as the `BeforeValidator` payload for the `CustomerVlanIdList` Annotated type. + + ## Raises + + ### ValueError + + - If any list entry is not a non-empty string. + - If any entry is not a VLAN id or range, has an id outside 1..4094, or has a reversed range. + """ + if value is None: + return value + if not isinstance(value, list): + return value + for entry in value: + if not isinstance(entry, str) or not entry: + raise ValueError(f"customer_vlan_id entries must be non-empty strings (VLAN id or range); got {entry!r}") + _validate_vlan_id_or_range(entry, "customer_vlan_id") + return value + + +# TODO: After all per-policy interface modules (ethernet_trunk_host, svi, ...) merge to develop, consolidate +# AllowedVlans and CustomerVlanIdList (and the _validate_vlan_id_or_range helper) into models/types.py so the +# sibling modules can share a single source of truth. Each branch currently carries its own copy because adding +# VLAN-specific code to the loopback base branch (where types.py lives) is out of that branch's scope. +# See AsciiDescription comment in models/types.py for why Optional[...] is used at runtime instead of `... | None`. +AllowedVlans = Annotated[Optional[str], BeforeValidator(_validate_allowed_vlans)] +"""Trunk allowed-VLANs spec (`str | None`): 'none', 'all', or comma-separated VLAN ids/ranges in 1..4094.""" + +CustomerVlanIdList = Annotated[Optional[list[str]], BeforeValidator(_validate_customer_vlan_id_list)] +"""Customer VLAN id list (`list[str] | None`): each entry is a VLAN id or range in 1..4094 (e.g. `['100', '200-300']`).""" class PortChannelTrunkHostVlanMappingEntryModel(NDNestedModel): @@ -74,7 +181,11 @@ class PortChannelTrunkHostVlanMappingEntryModel(NDNestedModel): customer_inner_vlan_id: int | None = Field( default=None, alias="customerInnerVlanId", ge=1, le=4094, description="Inner customer VLAN id (selective dot1q-tunnel only)" ) - customer_vlan_id: list[str] | None = Field(default=None, alias="customerVlanId", description="Customer VLAN id list (single id or range strings)") + customer_vlan_id: CustomerVlanIdList = Field( + default=None, + alias="customerVlanId", + description="Customer VLAN id list; each entry is a VLAN id or range string in 1..4094 (e.g. ['100', '200-300'])", + ) dot1q_tunnel: bool | None = Field(default=None, alias="dot1qTunnel", description="Use selective dot1q-tunnel mode for this entry") provider_vlan_id: int | None = Field(default=None, alias="providerVlanId", ge=1, le=4094, description="Provider VLAN id") @@ -98,10 +209,10 @@ class PortChannelTrunkHostPolicyModel(NDNestedModel): """ admin_state: bool | None = Field(default=None, alias="adminState", description="Enable or disable the interface") - allowed_vlans: str | None = Field( + allowed_vlans: AllowedVlans = Field( default=None, alias="allowedVlans", - description="Trunk allowed VLANs ('none', 'all', or comma-separated VLAN ids/ranges, e.g. '100-200,300')", + description="Trunk allowed VLANs ('none', 'all', or comma-separated VLAN ids/ranges in 1..4094, e.g. '100-200,300')", ) bandwidth: int | None = Field(default=None, alias="bandwidth", ge=1, le=100000000, description="Interface bandwidth in kilobits per second") bpdu_filter: BpduFilterEnum | None = Field(default=None, alias="bpduFilter", description="Configure spanning-tree BPDU filter") @@ -192,32 +303,6 @@ class PortChannelTrunkHostPolicyModel(NDNestedModel): # --- Validators --- - @field_validator("allowed_vlans", mode="before") - @classmethod - def validate_allowed_vlans(cls, value): - """ - # Summary - - Coerce int responses to str, then validate `allowed_vlans` matches `"none"`, `"all"`, or a comma-separated - list of VLAN ids/ranges. ND returns single-id values as JSON ints (e.g. `250`) but accepts both forms on - input; the model normalizes on str so round-trips and idempotency comparisons are stable. - - ## Raises - - ### ValueError - - - If `value` is a non-empty string that does not match the expected pattern. - """ - if value is None or value == "": - return value - if isinstance(value, int) and not isinstance(value, bool): - value = str(value) - if not isinstance(value, str): - return value - if not ALLOWED_VLANS_PATTERN.match(value): - raise ValueError(f"allowed_vlans must be 'none', 'all', or a comma-separated list of VLAN ids/ranges (e.g. '1-200,500-2000,3000'); got {value!r}") - return value - @field_validator("ports", mode="before") @classmethod def normalize_ports(cls, value): diff --git a/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py b/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py index 4431e442e..a1e9e8f78 100644 --- a/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py +++ b/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py @@ -339,8 +339,11 @@ def test_port_channel_trunk_host_interface_00190(): [ ("none", False), ("all", False), + ("1", False), + ("4094", False), ("100", False), ("100-200", False), + ("1-4094", False), ("1-200,500-2000,3000", False), ("1,2,3,4-10", False), ("", False), @@ -350,12 +353,22 @@ def test_port_channel_trunk_host_interface_00190(): ("100-200,", True), ("None", True), # case-sensitive ("ALL", True), + ("0", True), # below valid VLAN range + ("4095", True), # above valid VLAN range + ("5000", True), # above valid VLAN range + ("1-4095", True), # range end out of bounds + ("0-100", True), # range start out of bounds + ("200-100", True), # reversed range + ("4094-1", True), # reversed range ], ids=[ "none_keyword", "all_keyword", + "min_vlan_id", + "max_vlan_id", "single_id", "single_range", + "full_range", "multiple_ranges", "mixed_ids_ranges", "empty_passthrough", @@ -365,23 +378,33 @@ def test_port_channel_trunk_host_interface_00190(): "trailing_comma_rejected", "case_none_rejected", "case_all_rejected", + "vlan_zero_rejected", + "vlan_4095_rejected", + "vlan_5000_rejected", + "range_end_out_of_bounds_rejected", + "range_start_out_of_bounds_rejected", + "reversed_range_rejected", + "reversed_range_max_rejected", ], ) def test_port_channel_trunk_host_interface_00200(value, should_raise): """ # Summary - Verify `allowed_vlans` regex validator accepts `none`, `all`, or comma-separated VLAN ids/ranges and - rejects malformed input. + Verify `allowed_vlans` validator accepts `none`, `all`, or comma-separated VLAN ids/ranges where every id is in 1..4094 and every range start <= end, + and rejects malformed input or out-of-range values. ## Test - - Valid forms are accepted - - Invalid strings raise ValidationError + - Valid forms (including 1 and 4094 boundary values) are accepted + - Out-of-range ids (0, 4095+) raise ValidationError + - Out-of-range range bounds raise ValidationError + - Reversed ranges raise ValidationError + - Shape-invalid strings raise ValidationError ## Classes and Methods - - PortChannelTrunkHostPolicyModel.validate_allowed_vlans() + - port_channel_trunk_host_interface._validate_allowed_vlans() """ if should_raise: with pytest.raises(ValidationError, match=r"allowed_vlans"): @@ -392,6 +415,44 @@ def test_port_channel_trunk_host_interface_00200(value, should_raise): assert instance.allowed_vlans == value +@pytest.mark.parametrize( + "value,match", + [ + ("0", r"out of bounds"), + ("4095", r"out of bounds"), + ("1-4095", r"out of bounds"), + ("0-100", r"out of bounds"), + ("200-100", r"start greater than end"), + ("abc", r"comma-separated list"), + ], + ids=[ + "vlan_zero_message", + "vlan_4095_message", + "range_end_out_of_bounds_message", + "range_start_out_of_bounds_message", + "reversed_range_message", + "shape_error_message", + ], +) +def test_port_channel_trunk_host_interface_00202(value, match): + """ + # Summary + + Verify `allowed_vlans` validator raises `ValueError` with a specific, actionable message identifying + the failure mode (out-of-bounds id, out-of-bounds range, reversed range, or shape mismatch). + + ## Test + + - Each failure mode surfaces a distinguishable message substring + + ## Classes and Methods + + - port_channel_trunk_host_interface._validate_allowed_vlans() + """ + with pytest.raises(ValidationError, match=match): + PortChannelTrunkHostPolicyModel(allowed_vlans=value) + + @pytest.mark.parametrize( "value,expected", [ @@ -739,6 +800,120 @@ def test_port_channel_trunk_host_interface_00310(field, value, should_raise): assert getattr(instance, field) == value +@pytest.mark.parametrize( + "value,should_raise", + [ + (None, False), + ([], False), + (["1"], False), + (["4094"], False), + (["100"], False), + (["100", "200"], False), + (["100-200"], False), + (["1-4094", "500"], False), + (["100-200", "300", "500-600"], False), + (["0"], True), + (["4095"], True), + (["5000"], True), + (["1-4095"], True), + (["0-100"], True), + (["200-100"], True), + (["abc"], True), + (["1-"], True), + (["100,200"], True), # comma not allowed inside list element + ([""], True), + ([100], True), # non-string entry rejected + ], + ids=[ + "none_passthrough", + "empty_list", + "min_vlan_id", + "max_vlan_id", + "single_id", + "multiple_ids", + "single_range", + "range_and_id", + "ranges_and_ids", + "vlan_zero_rejected", + "vlan_4095_rejected", + "vlan_5000_rejected", + "range_end_out_of_bounds_rejected", + "range_start_out_of_bounds_rejected", + "reversed_range_rejected", + "non_numeric_rejected", + "trailing_dash_rejected", + "comma_in_element_rejected", + "empty_string_rejected", + "non_string_entry_rejected", + ], +) +def test_port_channel_trunk_host_interface_00315(value, should_raise): + """ + # Summary + + Verify `customer_vlan_id` validator accepts a list of VLAN id or range strings where every id is in 1..4094 and every range start <= end, + and rejects malformed entries, out-of-range values, reversed ranges, comma-joined elements, empty strings, and non-string entries. + + ## Test + + - Valid entries (boundary values, ids, ranges, mixed) are accepted + - Out-of-range ids and range bounds raise ValidationError + - Reversed ranges raise ValidationError + - Shape-invalid entries raise ValidationError + + ## Classes and Methods + + - port_channel_trunk_host_interface._validate_customer_vlan_id_list() + """ + if should_raise: + with pytest.raises(ValidationError, match=r"customer_vlan_id"): + PortChannelTrunkHostVlanMappingEntryModel(customer_vlan_id=value) + else: + with does_not_raise(): + instance = PortChannelTrunkHostVlanMappingEntryModel(customer_vlan_id=value) + assert instance.customer_vlan_id == value + + +@pytest.mark.parametrize( + "value,match", + [ + (["0"], r"out of bounds"), + (["4095"], r"out of bounds"), + (["1-4095"], r"out of bounds"), + (["200-100"], r"start greater than end"), + (["abc"], r"VLAN id or range"), + ([""], r"non-empty strings"), + ([42], r"non-empty strings"), + ], + ids=[ + "vlan_zero_message", + "vlan_4095_message", + "range_out_of_bounds_message", + "reversed_range_message", + "shape_error_message", + "empty_string_message", + "non_string_message", + ], +) +def test_port_channel_trunk_host_interface_00316(value, match): + """ + # Summary + + Verify `customer_vlan_id` validator raises `ValueError` with a specific, actionable message identifying the failure mode (out-of-bounds id, + out-of-bounds range, reversed range, shape mismatch, empty string, or non-string entry). + + ## Test + + - Each failure mode surfaces a distinguishable message substring + + ## Classes and Methods + + - port_channel_trunk_host_interface._validate_customer_vlan_id_list() + """ + with pytest.raises(ValidationError, match=match): + PortChannelTrunkHostVlanMappingEntryModel(customer_vlan_id=value) + + def test_port_channel_trunk_host_interface_00320(): """ # Summary From ee6a3a1328b6b45e64833fffbda1bf324c93d250 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 11:59:55 -1000 Subject: [PATCH 06/14] Extend VLAN-types consolidation TODO to mention VlanId VlanId (1..4094) recurs as Field(ge=1, le=4094) on multiple fields across the interface family. Defer the cleanup to the same post-merge PR that introduces AllowedVlans / CustomerVlanIdList in models/types.py. Co-Authored-By: Claude Opus 4.7 --- .../interfaces/port_channel_trunk_host_interface.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py index 3bd0fcaea..0d29601f6 100644 --- a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py +++ b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py @@ -154,9 +154,12 @@ def _validate_customer_vlan_id_list(value): # TODO: After all per-policy interface modules (ethernet_trunk_host, svi, ...) merge to develop, consolidate -# AllowedVlans and CustomerVlanIdList (and the _validate_vlan_id_or_range helper) into models/types.py so the -# sibling modules can share a single source of truth. Each branch currently carries its own copy because adding -# VLAN-specific code to the loopback base branch (where types.py lives) is out of that branch's scope. +# AllowedVlans, CustomerVlanIdList, and the _validate_vlan_id_or_range helper into models/types.py so the +# sibling modules can share a single source of truth. Also introduce a shared `VlanId` type (1..4094) in the +# same PR and replace per-field `Field(ge=1, le=4094)` constraints (native_vlan, customer_inner_vlan_id, +# provider_vlan_id, access_vlan, ...) — the constraint is already enforced today, this is cosmetic/consistency +# cleanup. Each branch currently carries its own copy of the validators because adding VLAN-specific code to the +# loopback base branch (where types.py lives) is out of that branch's scope. # See AsciiDescription comment in models/types.py for why Optional[...] is used at runtime instead of `... | None`. AllowedVlans = Annotated[Optional[str], BeforeValidator(_validate_allowed_vlans)] """Trunk allowed-VLANs spec (`str | None`): 'none', 'all', or comma-separated VLAN ids/ranges in 1..4094.""" From 0ea80f0887e37c975920556614e751e63931f893 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 12:12:02 -1000 Subject: [PATCH 07/14] Document VLAN id range in allowed_vlans and customer_vlan_id Address review comment requesting that the valid VLAN id range (1-4094) be stated in the module docstring. The model validators already enforce this range; this only updates the user-facing documentation for parity with native_vlan and similar fields. Co-Authored-By: Claude Opus 4.7 --- plugins/modules/nd_interface_port_channel_trunk_host.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 48e9bda27..9ba630d07 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -90,6 +90,7 @@ description: - Trunk allowed VLANs. - Accepts V(none), V(all), or a comma-separated list of VLAN ids/ranges (e.g. V(100-200,300)). + - VLAN ids must be in the range 1-4094. type: str bandwidth: description: @@ -282,7 +283,8 @@ type: int customer_vlan_id: description: - - Customer VLAN id list (single id or range strings). + - Customer VLAN id list (single id or range strings, e.g. V(["100", "200-300"])). + - VLAN ids must be in the range 1-4094. type: list elements: str dot1q_tunnel: From be5ac299bf92ce2dcd5c1ee560b5001b352f7744 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 12:38:08 -1000 Subject: [PATCH 08/14] Add EXAMPLES for replaced and overridden states Address review comment asking why only merged and deleted examples were shown. Co-Authored-By: Claude Opus 4.7 --- .../nd_interface_port_channel_trunk_host.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 9ba630d07..13f157ab8 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -382,6 +382,40 @@ dot1q_tunnel: true state: merged +- name: Replace a port-channel configuration + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "all" + ports: + - Ethernet1/1 + port_channel_mode: active + description: Replaced port-channel configuration + state: replaced + +- name: Override all port-channels in the fabric to match this configuration + cisco.nd.nd_interface_port_channel_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: port-channel501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + ports: + - Ethernet1/1 + - Ethernet1/2 + port_channel_mode: active + state: overridden + - name: Delete a port-channel cisco.nd.nd_interface_port_channel_trunk_host: fabric_name: my_fabric From 5bca90c1d50083edb3c883c88a16f73e5c229a3c Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 13:14:17 -1000 Subject: [PATCH 09/14] Use YAML anchors to dedupe trunk-host integration tests Replace verbatim-repeated module argument blocks (check / normal / idempotent runs of the same call) with YAML anchors. Reduces ~140 lines of duplication across deleted, merged, replaced, overridden, and vlan_mapping task files, and removes a class of typo where one of the duplicated blocks could drift from its siblings. Anchors are defined at first use within each section to keep them local to the reader. --- .../tasks/deleted.yaml | 32 ++--------- .../tasks/merged.yaml | 56 ++++--------------- .../tasks/overridden.yaml | 46 ++------------- .../tasks/replaced.yaml | 36 +----------- .../tasks/vlan_mapping.yaml | 26 ++------- 5 files changed, 28 insertions(+), 168 deletions(-) diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml index 9feb0b089..cfa0b7b15 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/deleted.yaml @@ -10,7 +10,7 @@ # --- DELETED: SINGLE PORT-CHANNEL --- - name: "DELETED: Delete port-channel502 (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &delete_pc502 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -21,13 +21,7 @@ register: cm_deleted_502 - name: "DELETED: Delete port-channel502 (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel502 - state: deleted + cisco.nd.nd_interface_port_channel_trunk_host: *delete_pc502 register: nm_deleted_502 - name: "DELETED: Verify port-channel502 was deleted" @@ -40,13 +34,7 @@ # --- DELETED: IDEMPOTENCY --- - name: "DELETED IDEMPOTENT: Delete port-channel502 again" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel502 - state: deleted + cisco.nd.nd_interface_port_channel_trunk_host: *delete_pc502 register: nm_deleted_502_idem - name: "DELETED IDEMPOTENT: Verify no change when already absent" @@ -96,7 +84,7 @@ state: merged - name: "DELETED MULTI: Delete port-channel501, port-channel502, port-channel503 (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &delete_multi output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -111,17 +99,7 @@ register: cm_deleted_multi - name: "DELETED MULTI: Delete port-channel501, port-channel502, port-channel503 (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel502 - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel503 - state: deleted + cisco.nd.nd_interface_port_channel_trunk_host: *delete_multi register: nm_deleted_multi - name: "DELETED MULTI: Verify all test port-channels were deleted" diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml index 9e4447b08..80607cb52 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml @@ -29,7 +29,7 @@ # --- MERGED CREATE: SINGLE PC --- - name: "MERGED CREATE: Create port-channel501 with two members (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &merge_pc501 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -39,12 +39,7 @@ register: cm_merged_create_501 - name: "MERGED CREATE: Create port-channel501 with two members (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501 }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501 register: nm_merged_create_501 - name: "MERGED CREATE: Verify port-channel501 creation" @@ -66,22 +61,12 @@ # --- MERGED IDEMPOTENCY --- - name: "MERGED IDEMPOTENT: Re-apply port-channel501 creation (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501 }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501 check_mode: true register: cm_merged_idem_501 - name: "MERGED IDEMPOTENT: Re-apply port-channel501 creation (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501 }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501 register: nm_merged_idem_501 - name: "MERGED IDEMPOTENT: Verify no change on second run" @@ -93,7 +78,7 @@ # --- MERGED UPDATE: change allowed_vlans and native_vlan --- - name: "MERGED UPDATE: Change port-channel501 allowed_vlans/native_vlan/description (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &merge_pc501_updated output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -103,12 +88,7 @@ register: cm_merged_update_501 - name: "MERGED UPDATE: Change port-channel501 allowed_vlans/native_vlan/description (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501_updated_vlan }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501_updated register: nm_merged_update_501 - name: "MERGED UPDATE: Verify port-channel501 was updated" @@ -123,12 +103,7 @@ - pc501_after.config_data.network_os.policy.description == "Ansible integration test PC 501 updated" - name: "MERGED UPDATE: Re-apply update for idempotency" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501_updated_vlan }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501_updated register: nm_merged_update_501_idem - name: "MERGED UPDATE: Verify idempotency after update" @@ -140,12 +115,7 @@ # Must run BEFORE PC 502/503 are created, so the third member (member_c) is free. - name: "SETUP: Reset port-channel501 to baseline before membership tests" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501 }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501 - name: "MERGED MEMBER ADD: Add a third member to port-channel501 (normal mode)" cisco.nd.nd_interface_port_channel_trunk_host: @@ -192,7 +162,7 @@ # (used by PC 503) are not currently claimed by PC 501. - name: "MERGED CREATE: Create multiple port-channels in a single task (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &merge_pc502_503 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -203,13 +173,7 @@ register: cm_merged_create_multi - name: "MERGED CREATE: Create multiple port-channels in a single task (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_502 }}" - - "{{ test_pc_503 }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc502_503 register: nm_merged_create_multi - name: "MERGED CREATE: Verify multiple port-channel creation" diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml index a56cc61c0..68ca55eb7 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/overridden.yaml @@ -11,7 +11,7 @@ # --- OVERRIDDEN: REDUCE TO SINGLE PORT-CHANNEL --- - name: "OVERRIDDEN: Override to only port-channel501 (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &override_pc501 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -31,22 +31,7 @@ register: cm_overridden_single - name: "OVERRIDDEN: Override to only port-channel501 (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - config_data: - network_os: - policy: - admin_state: true - allowed_vlans: "175" - native_vlan: 17 - ports: - - "{{ test_pc_member_a }}" - description: "Overridden port-channel501" - state: overridden + cisco.nd.nd_interface_port_channel_trunk_host: *override_pc501 register: nm_overridden_single - name: "OVERRIDDEN: Verify override removed extra port-channels" @@ -66,22 +51,7 @@ # --- OVERRIDDEN: IDEMPOTENCY --- - name: "OVERRIDDEN IDEMPOTENT: Re-apply same overridden config" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - config_data: - network_os: - policy: - admin_state: true - allowed_vlans: "175" - native_vlan: 17 - ports: - - "{{ test_pc_member_a }}" - description: "Overridden port-channel501" - state: overridden + cisco.nd.nd_interface_port_channel_trunk_host: *override_pc501 register: nm_overridden_idem - name: "OVERRIDDEN IDEMPOTENT: Verify no change on second run" @@ -93,7 +63,7 @@ # Restore port-channel502 and port-channel503 to a known baseline by overriding to a new set. - name: "OVERRIDDEN SWAP: Override to port-channel502 and port-channel503, removing port-channel501" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &override_pc502_503 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -116,13 +86,7 @@ # Likewise, accessPoHost port-channels must NOT be visible to or removed by this module. - name: "OVERRIDDEN FILTER: Re-apply prior swap config to confirm idempotency / no leakage" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_502 }}" - - "{{ test_pc_503 }}" - state: overridden + cisco.nd.nd_interface_port_channel_trunk_host: *override_pc502_503 register: nm_overridden_filter - name: "OVERRIDDEN FILTER: Verify only port-channel interfaces appear in before collection" diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml index 735075c88..2e7427d15 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/replaced.yaml @@ -10,7 +10,7 @@ # --- REPLACED: FULL REPLACE --- - name: "REPLACED: Replace port-channel501 with a minimal config (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &replace_pc501 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -30,22 +30,7 @@ register: cm_replaced_501 - name: "REPLACED: Replace port-channel501 with a minimal config (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - config_data: - network_os: - policy: - admin_state: true - allowed_vlans: "250" - native_vlan: 50 - ports: - - "{{ test_pc_member_a }}" - description: "Replaced port-channel501" - state: replaced + cisco.nd.nd_interface_port_channel_trunk_host: *replace_pc501 register: nm_replaced_501 - name: "REPLACED: Verify port-channel501 was replaced" @@ -64,22 +49,7 @@ # --- REPLACED: IDEMPOTENCY --- - name: "REPLACED IDEMPOTENT: Re-apply same replaced config" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - config_data: - network_os: - policy: - admin_state: true - allowed_vlans: "250" - native_vlan: 50 - ports: - - "{{ test_pc_member_a }}" - description: "Replaced port-channel501" - state: replaced + cisco.nd.nd_interface_port_channel_trunk_host: *replace_pc501 register: nm_replaced_501_idem - name: "REPLACED IDEMPOTENT: Verify no change on second run" diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml index 7229e180a..f280c05b6 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/vlan_mapping.yaml @@ -12,7 +12,7 @@ # --- SETUP --- - name: "SETUP: Remove test port-channels before vlan_mapping tests" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &delete_pc501 output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -23,7 +23,7 @@ # --- VLAN_MAPPING CREATE --- - name: "VLAN_MAPPING: Create port-channel501 with vlan_mapping enabled (check mode)" - cisco.nd.nd_interface_port_channel_trunk_host: + cisco.nd.nd_interface_port_channel_trunk_host: &merge_pc501_vlan_mapping output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" config: @@ -33,12 +33,7 @@ register: cm_vlan_mapping_create - name: "VLAN_MAPPING: Create port-channel501 with vlan_mapping enabled (normal mode)" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501_with_vlan_mapping }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501_vlan_mapping register: nm_vlan_mapping_create - name: "VLAN_MAPPING: Verify vlan_mapping was applied" @@ -57,12 +52,7 @@ # --- VLAN_MAPPING IDEMPOTENCY --- - name: "VLAN_MAPPING IDEMPOTENT: Re-apply same vlan_mapping config" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - "{{ test_pc_501_with_vlan_mapping }}" - state: merged + cisco.nd.nd_interface_port_channel_trunk_host: *merge_pc501_vlan_mapping register: nm_vlan_mapping_idem - name: "VLAN_MAPPING IDEMPOTENT: Verify no change" @@ -73,10 +63,4 @@ # --- CLEANUP --- - name: "CLEANUP: Remove vlan_mapping test port-channel" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - state: deleted + cisco.nd.nd_interface_port_channel_trunk_host: *delete_pc501 From 22bb6fb0c9421b9e2bd4ca014efb1cee8ced4d54 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 5 May 2026 13:47:03 -1000 Subject: [PATCH 10/14] Extract file-level pre-test cleanup into setup.yaml Move the top-of-file SETUP block from merged.yaml (delete port-channel501/502/503) into a dedicated tasks/setup.yaml and call it from main.yaml before the state-test blocks. Makes the orchestration explicit and keeps merged.yaml focused on its state. Intra-test setup that's coupled to specific tests stays inline: - Reset-to-baseline before the membership tests in merged.yaml - port-channel504 cleanup after the no-deploy test in merged.yaml - vlan_mapping.yaml's local SETUP/CLEANUP for port-channel501 --- .../tasks/main.yaml | 3 +++ .../tasks/merged.yaml | 16 ------------- .../tasks/setup.yaml | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/setup.yaml diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml index a045b1c84..b49e6c114 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/main.yaml @@ -56,6 +56,9 @@ cisco.nd.nd_interface_port_channel_trunk_host: timeout: 300 block: + - name: Pre-test cleanup + ansible.builtin.include_tasks: setup.yaml + - name: Run nd_interface_port_channel_trunk_host merged state tests ansible.builtin.include_tasks: merged.yaml diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml index 80607cb52..4e4cc5e93 100644 --- a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/merged.yaml @@ -10,22 +10,6 @@ # already a member of another. After MEMBER REMOVE, all members except a are # free, so the multi-create that follows succeeds. -# --- CLEANUP --- - -- name: "SETUP: Remove test port-channels before merged tests" - cisco.nd.nd_interface_port_channel_trunk_host: - output_level: "{{ nd_info.output_level }}" - fabric_name: "{{ test_fabric_name }}" - config: - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel501 - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel502 - - switch_ip: "{{ test_switch_ip }}" - interface_name: port-channel503 - state: deleted - tags: always - # --- MERGED CREATE: SINGLE PC --- - name: "MERGED CREATE: Create port-channel501 with two members (check mode)" diff --git a/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/setup.yaml b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/setup.yaml new file mode 100644 index 000000000..19b34d519 --- /dev/null +++ b/tests/integration/targets/nd_interface_port_channel_trunk_host/tasks/setup.yaml @@ -0,0 +1,23 @@ +--- +# Pre-test cleanup for nd_interface_port_channel_trunk_host +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Called from main.yaml before the merged/replaced/overridden/deleted/vlan_mapping +# state blocks. Ensures no test port-channels exist on the target switch before +# the state tests run. + +- name: "SETUP: Remove test port-channels before state tests" + cisco.nd.nd_interface_port_channel_trunk_host: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel501 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel502 + - switch_ip: "{{ test_switch_ip }}" + interface_name: port-channel503 + state: deleted + tags: always From 3ecc8f1926e6fddec533e59aae0e1b5d527eae8f Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 8 May 2026 12:18:45 -1000 Subject: [PATCH 11/14] Drop stale deploymentFreeze fixture from port_channel_trunk_host query_all tests validate_for_mutation reads freeze status from cached fabric_summary (see fabric_context.py:166), so query_all no longer issues a separate GET to /deploymentFreeze. The extra _freeze response was being consumed by the next request in the sequence, breaking the happy-path test (00400) and silently masking the no-match (00410) and switch-404 (00430) tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../orchestrators/test_port_channel_trunk_host_interface.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py b/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py index 18f9c347c..77b84e516 100644 --- a/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py +++ b/tests/unit/module_utils/orchestrators/test_port_channel_trunk_host_interface.py @@ -200,7 +200,6 @@ def test_port_channel_trunk_host_orchestrator_00400() -> None: def responses(): yield responses_pc_trunk_host("test_query_all_happy_path_00400a") - yield responses_pc_trunk_host("test_query_all_happy_path_00400_freeze") yield responses_pc_trunk_host("test_query_all_happy_path_00400b") yield responses_pc_trunk_host("test_query_all_happy_path_00400c") yield responses_pc_trunk_host("test_query_all_happy_path_00400d") @@ -248,7 +247,6 @@ def test_port_channel_trunk_host_orchestrator_00410() -> None: def responses(): yield responses_pc_trunk_host("test_query_all_no_match_00410a") - yield responses_pc_trunk_host("test_query_all_no_match_00410_freeze") yield responses_pc_trunk_host("test_query_all_no_match_00410b") yield responses_pc_trunk_host("test_query_all_no_match_00410c") @@ -307,7 +305,6 @@ def test_port_channel_trunk_host_orchestrator_00430() -> None: def responses(): yield responses_pc_trunk_host("test_query_all_switch_404_00430a") - yield responses_pc_trunk_host("test_query_all_switch_404_00430_freeze") yield responses_pc_trunk_host("test_query_all_switch_404_00430b") yield responses_pc_trunk_host("test_query_all_switch_404_00430c") From 11a76a96a7cc42fc4f2e5e227b3e0b5ad05e2f0f Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 8 May 2026 13:21:40 -1000 Subject: [PATCH 12/14] Hide port_channel_trunk_host scaffolding fields from the user-facing arg spec Same change as ethernet_access (5ecd435): drop interface_type, mode, and network_os_type from get_argument_spec() and DOCUMENTATION; narrow each in the Pydantic model to Literal[] (or keep the existing Enum for policy_type) and add Field(frozen=True) so the values are immutable post-construction. Updates the argspec assertion test, the interface_type/mode existence tests, and the network_os_type test to verify the Literal lock instead of accepting "ios-xe". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../port_channel_trunk_host_interface.py | 11 ++--- .../nd_interface_port_channel_trunk_host.py | 20 +--------- .../test_port_channel_trunk_host_interface.py | 40 +++++++++++++------ 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py index 0d29601f6..5d4059d57 100644 --- a/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py +++ b/plugins/module_utils/models/interfaces/port_channel_trunk_host_interface.py @@ -243,7 +243,7 @@ class PortChannelTrunkHostPolicyModel(NDNestedModel): default=None, alias="orphanPort", description="Configure as a vPC orphan port (suspended by secondary peer on vPC failure)" ) pfc: bool | None = Field(default=None, alias="pfc", description="Enable Priority Flow Control") - policy_type: TrunkPoHostPolicyTypeEnum = Field(default=TrunkPoHostPolicyTypeEnum.TRUNK_PO_HOST, alias="policyType", description="Interface policy type") + policy_type: TrunkPoHostPolicyTypeEnum = Field(default=TrunkPoHostPolicyTypeEnum.TRUNK_PO_HOST, alias="policyType", frozen=True, description="Interface policy type (hardcoded for this module)") port_channel_id: str | None = Field(default=None, alias="portChannelId", description="Port-channel id (response-only echo of interface_name)") port_channel_mode: PortChannelModeEnum | None = Field(default=None, alias="portChannelMode", description="Port-channel mode (on/active/passive)") port_type_edge_trunk: bool | None = Field(default=None, alias="portTypeEdgeTrunk", description="Configure as edge trunk port (PortFast on trunk)") @@ -342,7 +342,7 @@ class PortChannelTrunkHostNetworkOSModel(NDNestedModel): None """ - network_os_type: str = Field(default="nx-os", alias="networkOSType") + network_os_type: Literal["nx-os"] = Field(default="nx-os", alias="networkOSType", frozen=True) policy: PortChannelTrunkHostPolicyModel | None = Field(default=None, alias="policy") @@ -357,7 +357,7 @@ class PortChannelTrunkHostConfigDataModel(NDNestedModel): None """ - mode: str = Field(default="trunk", alias="mode") + mode: Literal["trunk"] = Field(default="trunk", alias="mode", frozen=True) network_os: PortChannelTrunkHostNetworkOSModel = Field(alias="networkOS") @@ -391,7 +391,7 @@ class PortChannelTrunkHostInterfaceModel(NDBaseModel): switch_ip: str = Field(alias="switchIp") interface_name: str = Field(alias="interfaceName") - interface_type: str = Field(default="portChannel", alias="interfaceType") + interface_type: Literal["portChannel"] = Field(default="portChannel", alias="interfaceType", frozen=True) config_data: PortChannelTrunkHostConfigDataModel | None = Field(default=None, alias="configData") @field_validator("interface_name", mode="before") @@ -433,15 +433,12 @@ def get_argument_spec(cls) -> dict: options=dict( switch_ip=dict(type="str", required=True), interface_name=dict(type="str", required=True), - interface_type=dict(type="str", default="portChannel"), config_data=dict( type="dict", options=dict( - mode=dict(type="str", default="trunk"), network_os=dict( type="dict", options=dict( - network_os_type=dict(type="str", default="nx-os"), policy=dict( type="dict", options=dict( diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 13f157ab8..318cb5d54 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -49,34 +49,16 @@ - The port-channel interface name (e.g. C(port-channel501)). type: str required: true - interface_type: - description: - - The type of the interface. - - Defaults to C(portChannel) for this module. - type: str - default: portChannel config_data: description: - The configuration data for the port-channel, following the ND API structure. type: dict suboptions: - mode: - description: - - The interface operational mode. - - Defaults to C(trunk) for this module. The ND API uses this as a discriminator - to select the trunk-mode port-channel configuration schema. - type: str - default: trunk network_os: description: - Network OS specific configuration. type: dict suboptions: - network_os_type: - description: - - The network OS type of the switch. - type: str - default: nx-os policy: description: - The policy configuration for the trunkPoHost port-channel. @@ -324,7 +306,7 @@ - cisco.nd.check_mode notes: - This module is only supported on Nexus Dashboard. -- This module manages NX-OS port-channel trunkPoHost interfaces only. +- This module manages NX-OS port-channel trunkPoHost interfaces only (interface_type C(portChannel), mode C(trunk), network_os_type C(nx-os), policy_type C(trunkPoHost)). These values are hardcoded by the module and are not user-configurable. - The port-channel policy is the source of truth for member interface configuration. """ diff --git a/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py b/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py index a1e9e8f78..a756074e5 100644 --- a/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py +++ b/tests/unit/module_utils/models/test_port_channel_trunk_host_interface.py @@ -998,20 +998,23 @@ def test_port_channel_trunk_host_interface_00420(): """ # Summary - Verify camelCase alias `networkOSType` populates network_os_type. + Verify camelCase alias `networkOSType` populates network_os_type, and that the field is locked to `nx-os`. ## Test - - Construct with camelCase alias - - Python field accessible + - Construct with camelCase alias and the only valid value + - Construct with a non-nx-os value raises ValidationError (Literal lock) ## Classes and Methods - PortChannelTrunkHostNetworkOSModel.__init__() """ with does_not_raise(): - instance = PortChannelTrunkHostNetworkOSModel(networkOSType="ios-xe") - assert instance.network_os_type == "ios-xe" + instance = PortChannelTrunkHostNetworkOSModel(networkOSType="nx-os") + assert instance.network_os_type == "nx-os" + + with pytest.raises(ValidationError, match="Input should be 'nx-os'"): + PortChannelTrunkHostNetworkOSModel(networkOSType="ios-xe") # ============================================================================= @@ -1842,7 +1845,14 @@ def test_port_channel_trunk_host_interface_01100(): assert spec["config"]["elements"] == "dict" assert spec["state"]["choices"] == ["merged", "replaced", "overridden", "deleted"] assert spec["state"]["default"] == "merged" - policy_spec = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + # interface_type, mode, and network_os_type are hardcoded in the Pydantic model + # and intentionally absent from the user-facing argument spec. + config_options = spec["config"]["options"] + assert "interface_type" not in config_options + config_data_spec = config_options["config_data"]["options"] + assert "mode" not in config_data_spec + assert "network_os_type" not in config_data_spec["network_os"]["options"] + policy_spec = config_data_spec["network_os"]["options"]["policy"]["options"] assert "policy_type" not in policy_spec @@ -1850,36 +1860,42 @@ def test_port_channel_trunk_host_interface_01110(): """ # Summary - Verify `interface_type` default is "portChannel" in the argument spec. + Verify `interface_type` is hardcoded on the Pydantic model and absent from the user-facing argument spec. ## Test - - config.options.interface_type.default == "portChannel" + - Model field default is "portChannel" + - argument spec does not expose interface_type as a user option ## Classes and Methods - PortChannelTrunkHostInterfaceModel.get_argument_spec() """ spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() - assert spec["config"]["options"]["interface_type"]["default"] == "portChannel" + assert "interface_type" not in spec["config"]["options"] + instance = PortChannelTrunkHostInterfaceModel(switch_ip="1.2.3.4", interface_name="port-channel501") + assert instance.interface_type == "portChannel" def test_port_channel_trunk_host_interface_01115(): """ # Summary - Verify the `mode` default in the argument spec is "trunk" (distinct from access PC). + Verify the `mode` is hardcoded to "trunk" on the Pydantic model and absent from the user-facing argument spec. ## Test - - config.options.config_data.options.mode.default == "trunk" + - PortChannelTrunkHostConfigDataModel default mode is "trunk" + - argument spec does not expose mode as a user option ## Classes and Methods - PortChannelTrunkHostInterfaceModel.get_argument_spec() """ spec = PortChannelTrunkHostInterfaceModel.get_argument_spec() - assert spec["config"]["options"]["config_data"]["options"]["mode"]["default"] == "trunk" + assert "mode" not in spec["config"]["options"]["config_data"]["options"] + instance = PortChannelTrunkHostConfigDataModel(network_os={}) + assert instance.mode == "trunk" @pytest.mark.parametrize( From 273cf8e1dc48150f659399b44647e92c1b9de1c8 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 21 May 2026 14:51:15 -1000 Subject: [PATCH 13/14] Quote 'on' in port_channel_mode choices to fix validate-modules YAML 1.1 parses an unquoted `on` as the boolean True, so the DOCUMENTATION block's `choices: [ on, active, passive ]` resolved to `[True, 'active', 'passive']` while the argument_spec correctly used the string 'on'. This tripped two ansible-test validate-modules checks: doc-choices-do-not-match-spec and doc-choices-incompatible-type. Quote 'on' so the documented choices parse as strings and match the spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/modules/nd_interface_port_channel_trunk_host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 318cb5d54..21b24c5cd 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -179,7 +179,7 @@ description: - The port-channel mode. type: str - choices: [ on, active, passive ] + choices: [ 'on', active, passive ] port_type_edge_trunk: description: - Configure the port-channel as an edge trunk port (PortFast on trunk). From 98253780d13592676bb411fffca014cb4c1e608b Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 28 May 2026 08:11:09 -1000 Subject: [PATCH 14/14] Bump version_added to 2.0.0 for next ND collection release --- plugins/modules/nd_interface_port_channel_trunk_host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/nd_interface_port_channel_trunk_host.py b/plugins/modules/nd_interface_port_channel_trunk_host.py index 21b24c5cd..91380d067 100644 --- a/plugins/modules/nd_interface_port_channel_trunk_host.py +++ b/plugins/modules/nd_interface_port_channel_trunk_host.py @@ -11,7 +11,7 @@ DOCUMENTATION = r""" --- module: nd_interface_port_channel_trunk_host -version_added: "1.4.0" +version_added: "2.0.0" short_description: Manage port-channel (trunkPoHost) interfaces on Cisco Nexus Dashboard description: - Manage port-channel (trunkPoHost) interfaces on Cisco Nexus Dashboard.