diff --git a/plugins/module_utils/models/interfaces/enums.py b/plugins/module_utils/models/interfaces/enums.py index 08b905b1f..e67fe0a20 100644 --- a/plugins/module_utils/models/interfaces/enums.py +++ b/plugins/module_utils/models/interfaces/enums.py @@ -47,6 +47,16 @@ class AccessVpcHostPolicyTypeEnum(str, Enum): ACCESS_VPC_HOST = "accessVpcHost" +class TrunkVpcHostPolicyTypeEnum(str, Enum): + """ + # Summary + + Policy type for vPC trunk host interfaces (`int_vpc_trunk_host` template). + """ + + TRUNK_VPC_HOST = "trunkVpcHost" + + class BpduFilterEnum(str, Enum): """ # Summary diff --git a/plugins/module_utils/models/interfaces/vpc_trunk_host_interface.py b/plugins/module_utils/models/interfaces/vpc_trunk_host_interface.py new file mode 100644 index 000000000..5b412c040 --- /dev/null +++ b/plugins/module_utils/models/interfaces/vpc_trunk_host_interface.py @@ -0,0 +1,601 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +vPC trunk (trunkVpcHost) interface Pydantic models for Nexus Dashboard. + +This module defines nested Pydantic models that mirror the ND Manage Interfaces API payload +structure for vPC trunkVpcHost interfaces (`int_vpc_trunk_host.template`). 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. + +A vPC trunk-host interface spans two switches in a vPC pair. The user supplies one peer's +management IP as `switch_ip`; the orchestrator auto-resolves the peer serial via the +`vpcPair` endpoint and injects it as `peerSwitchId` in the payload. Per-peer policy fields +use the ND-native `peer1_*` / `peer2_*` naming where `peer1` corresponds to `switch_ip` +(the switch in the URL path) and `peer2` corresponds to the auto-resolved peer. + +## Model Hierarchy + +- `TrunkVpcHostInterfaceModel` (top-level, `NDBaseModel`) + - `switch_ip` (routing field; excluded from diff/payload) + - `interface_name` (identifier; e.g. `vpc100`) + - `interface_type` (frozen: "vpc") + - `config_data` -> `TrunkVpcHostConfigDataModel` + - `mode` (frozen: "trunk") + - `network_os` -> `TrunkVpcHostNetworkOSModel` + - `network_os_type` (frozen: "nx-os") + - `policy` -> `TrunkVpcHostPolicyModel` + - `policy_type` (frozen: "trunkVpcHost") + - `peer_switch_id` (orchestrator-injected; not in argspec) + - single user-facing `allowed_vlans`, `native_vlan` (fanned out to per-peer keys on write) + - per-peer fields: `peer1_*` / `peer2_*` (member_ports, port_channel_id, descriptions, configuration) + - `vlan_mapping`, `vlan_mapping_entries` + - shared fields: `admin_state`, `cdp`, `lacp_*`, `mtu`, etc. +""" + +from __future__ import annotations + +import re +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, + model_serializer, +) +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, + TrunkVpcHostPolicyTypeEnum, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.types import AsciiDescription + +# 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. + + ## 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. + + ## 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. + + ## 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 merge to develop, consolidate AllowedVlans, CustomerVlanIdList, +# and _validate_vlan_id_or_range into models/types.py so siblings share a single source of truth. This module +# carries its own copy to keep the vpc stack self-contained. +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 TrunkVpcHostVlanMappingEntryModel(NDNestedModel): + """ + # Summary + + A single VLAN mapping entry for a vPC trunk host. Maps to one element of `policy.vlanMappingEntries` in the + ND API. Entries translate customer VLAN ids to a provider VLAN id, optionally using 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: 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") + + +class TrunkVpcHostPolicyModel(NDNestedModel): + """ + # Summary + + Policy fields for a vPC `trunkVpcHost` interface. Maps directly to the `configData.networkOS.policy` object in the ND API. + + Per-peer fields use the API-native `peer1*` / `peer2*` naming. `peer1` corresponds to the switch in the URL path + (the user-supplied `switch_ip`); `peer2` corresponds to the auto-resolved peer (the value of `peerSwitchId`). + + `peer_switch_id` is set by the orchestrator from the vPC pair record. It is not exposed in the module argument spec. + + ND enforces wire-side consistency between `peer1AllowedVlans`/`peer2AllowedVlans` and between `peer1NativeVlan`/`peer2NativeVlan` + (HTTP 400 on divergent values, lab-verified 2026-05-13). The model exposes single `allowed_vlans` and `native_vlan` user fields + and fans them out to the per-peer keys at payload-serialization time. The GET response collapses the per-peer pair back to a + single key, so the diff matches the wire echo without further normalization. + + ## Raises + + ### ValueError + + - If `allowed_vlans` is set and does not match `none`, `all`, or comma-separated VLAN ranges. + """ + + # --- Policy Discriminator --- + + policy_type: TrunkVpcHostPolicyTypeEnum = Field( + default=TrunkVpcHostPolicyTypeEnum.TRUNK_VPC_HOST, + alias="policyType", + frozen=True, + description="Interface policy type (hardcoded for this module)", + ) + + # --- Orchestrator-Injected (Not In Argspec) --- + + peer_switch_id: str | None = Field( + default=None, + alias="peerSwitchId", + description="Peer switch serial number, auto-resolved by the orchestrator from the vPC pair record", + ) + + # --- Trunk-Specific Single-Valued Fields (collapsed by ND on read; fanned out per-peer on write) --- + # TODO(4.2.1) ND trunkVpcHost wire echoes single `allowedVlans` / `nativeVlan` even though the create schema requires + # per-peer `peer1AllowedVlans` / `peer2AllowedVlans` and `peer1NativeVlan` / `peer2NativeVlan`. ND also rejects + # divergent per-peer values at the API layer (HTTP 400 "should be consistent"). We expose single user-facing fields + # and split them back to per-peer keys via `expand_per_peer_fields` for the write so idempotency matches the wire echo. + + allowed_vlans: AllowedVlans = Field( + default=None, + alias="allowedVlans", + description="Trunk allowed VLANs ('none', 'all', or comma-separated VLAN ids/ranges in 1..4094, e.g. '100-200,300')", + ) + native_vlan: int | None = Field(default=None, alias="nativeVlan", ge=1, le=4094, description="Trunk native VLAN id") + + # --- Per-Peer Fields (peer1 = switch_ip, peer2 = peer_switch_id) --- + + peer1_member_ports: list[str] | None = Field(default=None, alias="peer1MemberPorts", description="Member interface names on Peer-1") + peer1_port_channel_configuration: str | None = Field( + default=None, alias="peer1PortChannelConfiguration", description="Additional CLI for Peer-1's port-channel" + ) + peer1_port_channel_description: AsciiDescription = Field( + default=None, alias="peer1PortChannelDescription", max_length=254, description="Description for Peer-1's port-channel" + ) + peer1_port_channel_id: int | None = Field(default=None, alias="peer1PortChannelId", ge=1, le=4096, description="Peer-1 vPC port-channel number") + peer2_member_ports: list[str] | None = Field(default=None, alias="peer2MemberPorts", description="Member interface names on Peer-2") + peer2_port_channel_configuration: str | None = Field( + default=None, alias="peer2PortChannelConfiguration", description="Additional CLI for Peer-2's port-channel" + ) + peer2_port_channel_description: AsciiDescription = Field( + default=None, alias="peer2PortChannelDescription", max_length=254, description="Description for Peer-2's port-channel" + ) + peer2_port_channel_id: int | None = Field(default=None, alias="peer2PortChannelId", ge=1, le=4096, description="Peer-2 vPC port-channel number") + + # --- VLAN Mapping --- + + vlan_mapping: bool | None = Field(default=None, alias="vlanMapping", description="Enable VLAN mapping on the trunk") + vlan_mapping_entries: list[TrunkVpcHostVlanMappingEntryModel] | None = Field( + default=None, alias="vlanMappingEntries", description="VLAN mapping entries (used when vlan_mapping is enabled)" + ) + + # --- Shared Policy Fields --- + + admin_state: bool | None = Field(default=None, alias="adminState", description="Enable or disable the interface") + bandwidth: int | None = Field(default=None, alias="bandwidth", ge=1, le=100000000, description="Configured bandwidth value for the interface") + 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 (per-peer)", + ) + duplex_mode: DuplexModeEnum | None = Field(default=None, alias="duplexMode", description="Port duplex mode") + inherit_bandwidth: int | None = Field( + default=None, + alias="inheritBandwidth", + ge=1, + le=100000000, + description="Bandwidth value inherited by sub-interfaces", + ) + 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") + lacp_vpc_convergence: bool | None = Field(default=None, alias="lacpVpcConvergence", description="Enable LACP convergence for vPC port-channels") + link_type: LinkTypeEnum | None = Field(default=None, alias="linkType", description="Spanning-tree link type") + mirror_config: bool | None = Field(default=None, alias="mirrorConfig", description="Copy Peer-1 config to Peer-2") + mtu: MtuEnum | None = Field(default=None, alias="mtu", description="Interface MTU") + 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 (N7K only)") + pfc: bool | None = Field(default=None, alias="pfc", description="Enable priority flow control") + 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="Enable spanning-tree edge port (PortFast) behavior on trunk" + ) + 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", + ) + + # --- Serializers --- + + # TODO(4.2.1) trunkVpcHost wire echoes single `allowedVlans` and `nativeVlan` even though the create schema + # requires per-peer `peer1AllowedVlans` / `peer2AllowedVlans` and `peer1NativeVlan` / `peer2NativeVlan`. ND also + # rejects divergent per-peer values at the API layer (HTTP 400 "should be consistent"). We expose single user + # fields and split them back to the per-peer keys on the write side so idempotency works against the actual wire shape. + @model_serializer(mode="wrap") + def expand_per_peer_fields(self, handler, info): + """ + # Summary + + Split single-value user-facing fields into the per-peer keys the ND write API expects, so they round-trip + correctly against ND's read response. Specifically, `allowedVlans` and `nativeVlan` are collapsed by ND on + read but the create schema accepts only per-peer keys. On payload serialization (context `mode == "payload"`), + we expand each to both per-peer keys. In all other modes (config / diff), the fields are left as-is so they + match the wire echo and keep the diff symmetric. + + ## Raises + + None + """ + data = handler(self) + context = getattr(info, "context", None) or {} + if context.get("mode") == "payload": + if "allowedVlans" in data: + vlans = data.pop("allowedVlans") + data["peer1AllowedVlans"] = vlans + data["peer2AllowedVlans"] = vlans + if "nativeVlan" in data: + vlan = data.pop("nativeVlan") + data["peer1NativeVlan"] = vlan + data["peer2NativeVlan"] = vlan + return data + + # --- Validators --- + + @field_validator("peer1_member_ports", "peer2_member_ports", mode="before") + @classmethod + def normalize_member_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 TrunkVpcHostNetworkOSModel(NDNestedModel): + """ + # Summary + + Network OS container for a vPC `trunkVpcHost` interface. Maps to `configData.networkOS` in the ND API. + + ## Raises + + None + """ + + network_os_type: Literal["nx-os"] = Field(default="nx-os", alias="networkOSType", frozen=True) + policy: TrunkVpcHostPolicyModel | None = Field(default=None, alias="policy") + + +class TrunkVpcHostConfigDataModel(NDNestedModel): + """ + # Summary + + Config data container for a vPC `trunkVpcHost` interface. Maps to `configData` in the ND API. + + ## Raises + + None + """ + + mode: Literal["trunk"] = Field(default="trunk", alias="mode", frozen=True) + network_os: TrunkVpcHostNetworkOSModel = Field(alias="networkOS") + + +class TrunkVpcHostInterfaceModel(NDBaseModel): + """ + # Summary + + vPC `trunkVpcHost` interface configuration for Nexus Dashboard. + + 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 vPC's own name (e.g. `vpc100`), not a member + interface. Member interfaces are listed per-peer in `config_data.network_os.policy.peer1_member_ports` and + `peer2_member_ports`. + + ## Raises + + None + """ + + # --- Identifier Configuration --- + # TODO(4.2.1) A vPC interface is a single fabric-level resource, but ND echoes it from BOTH peer switches in + # the per-switch `/interfaces` GET (with identical `configData` and only `switchId` / `peerSwitchId` swapping). + # Using a composite (switch_ip, interface_name) identifier caused `_manage_override_deletions` to delete the + # peer-side duplicate. The identifier is therefore `interface_name` only; `switch_ip` is kept as a field for + # routing (URL-path resolution + peer-resolution) but is excluded from diff and dedup'd in `query_all`. + + identifiers: ClassVar[list[str] | None] = ["interface_name"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "single" + + # --- Serialization Configuration --- + + payload_exclude_fields: ClassVar[set[str]] = {"switch_ip"} + exclude_from_diff: ClassVar[set[str]] = {"switch_ip"} + + # --- Fields --- + + switch_ip: str = Field(alias="switchIp") + interface_name: str = Field(alias="interfaceName") + interface_type: Literal["vpc"] = Field(default="vpc", alias="interfaceType", frozen=True) + config_data: TrunkVpcHostConfigDataModel | None = Field(default=None, alias="configData") + + @field_validator("interface_name", mode="before") + @classmethod + def normalize_interface_name(cls, value): + """ + # Summary + + Normalize the vPC interface name to lowercase to match ND API convention (e.g. `Vpc100` -> `vpc100`). + + ## 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_vpc_trunk_host` module. Frozen scaffolding fields + (`interface_type`, `mode`, `network_os_type`, `policy_type`) are intentionally NOT exposed. `peer_switch_id` + is orchestrator-injected and NOT exposed. + + ## 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), + config_data=dict( + type="dict", + options=dict( + network_os=dict( + type="dict", + options=dict( + 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"), + duplex_mode=dict(type="str", choices=[e.value for e in DuplexModeEnum]), + 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"), + lacp_vpc_convergence=dict(type="bool"), + link_type=dict(type="str", choices=[e.value for e in LinkTypeEnum]), + mirror_config=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"), + peer1_member_ports=dict(type="list", elements="str"), + peer1_port_channel_configuration=dict(type="str"), + peer1_port_channel_description=dict(type="str"), + peer1_port_channel_id=dict(type="int"), + peer2_member_ports=dict(type="list", elements="str"), + peer2_port_channel_configuration=dict(type="str"), + peer2_port_channel_description=dict(type="str"), + peer2_port_channel_id=dict(type="int"), + pfc=dict(type="bool"), + port_channel_mode=dict(type="str", choices=[e.value for e in PortChannelModeEnum]), + port_type_edge_trunk=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/vpc_trunk_host_interface.py b/plugins/module_utils/orchestrators/vpc_trunk_host_interface.py new file mode 100644 index 000000000..67c418d40 --- /dev/null +++ b/plugins/module_utils/orchestrators/vpc_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) + +""" +vPC trunkVpcHost interface orchestrator for Nexus Dashboard. + +This module provides `TrunkVpcHostInterfaceOrchestrator`, which manages CRUD operations for vPC +`trunkVpcHost` interfaces. It inherits all shared vPC logic from `VpcBaseOrchestrator` 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 TrunkVpcHostPolicyTypeEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.vpc_trunk_host_interface import ( + TrunkVpcHostInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_base import VpcBaseOrchestrator + + +class TrunkVpcHostInterfaceOrchestrator(VpcBaseOrchestrator): + """ + # Summary + + Orchestrator for vPC `trunkVpcHost` interface CRUD operations on Nexus Dashboard. + + Inherits all shared vPC logic from `VpcBaseOrchestrator`. Defines `model_class` as + `TrunkVpcHostInterfaceModel` and manages the `trunkVpcHost` policy type. + + ## Raises + + ### RuntimeError + + - Via inherited methods. See `VpcBaseOrchestrator` for full details. + """ + + model_class: ClassVar[Type[NDBaseModel]] = TrunkVpcHostInterfaceModel + + 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 TrunkVpcHostPolicyTypeEnum} diff --git a/plugins/modules/nd_interface_vpc_trunk_host.py b/plugins/modules/nd_interface_vpc_trunk_host.py new file mode 100644 index 000000000..8be605479 --- /dev/null +++ b/plugins/modules/nd_interface_vpc_trunk_host.py @@ -0,0 +1,559 @@ +#!/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 module for managing vPC trunkVpcHost interfaces on Cisco Nexus Dashboard.""" + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_interface_vpc_trunk_host +version_added: "2.0.0" +short_description: Manage vPC trunkVpcHost interfaces on Cisco Nexus Dashboard +description: +- Manage vPC trunkVpcHost interfaces on Cisco Nexus Dashboard. +- It supports creating, updating, and deleting trunkVpcHost vPC configurations on switches within a fabric. +- Each config item represents one vPC interface that spans the two switches in a vPC pair. +- The user supplies one peer's management IP as O(config[].switch_ip); the module reads the vPC pair record to + auto-resolve the second peer's serial and injects it as C(peerSwitchId) in the payload. +- Per-peer policy fields use the API-native C(peer1_*) / C(peer2_*) naming, where C(peer1) corresponds to the + switch supplied in O(config[].switch_ip) and C(peer2) corresponds to the auto-resolved peer. +- The switch supplied in O(config[].switch_ip) must already be in a vPC pair (created via M(cisco.nd.nd_vpc_pair)) + before this module can manage interfaces on it. +- The trunk VLAN fields O(config[].config_data.network_os.policy.allowed_vlans) and + O(config[].config_data.network_os.policy.native_vlan) are single user-facing values. ND requires per-peer + C(peer1AllowedVlans)/C(peer2AllowedVlans) and C(peer1NativeVlan)/C(peer2NativeVlan) on the wire but enforces + consistency between the two peers (divergent values return HTTP 400) and collapses them back to a single value + in the GET response. The module fans out the single values to both per-peer keys on write. +author: +- Allen Robel (@allenrobel) +options: + fabric_name: + description: + - The name of the fabric containing the target vPC pair. + type: str + required: true + config: + description: + - The list of vPC trunkVpcHost interfaces to configure. + - Each item specifies the primary switch, the vPC interface name, and its configuration. + - Multiple vPC pairs 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 one peer in the vPC pair (typically the primary). + - This is resolved to the switch serial number (switchId) internally; the peer's serial is auto-resolved + via the vPC pair record. + type: str + required: true + interface_name: + description: + - The vPC interface name (e.g. C(vpc100)). + type: str + required: true + config_data: + description: + - The configuration data for the vPC interface, following the ND API structure. + type: dict + suboptions: + network_os: + description: + - Network OS specific configuration. + type: dict + suboptions: + policy: + description: + - The policy configuration for the trunkVpcHost vPC interface. + type: dict + suboptions: + admin_state: + description: + - The administrative state of the vPC interface. + type: bool + allowed_vlans: + description: + - Trunk allowed VLANs on both peers. + - One of V(none), V(all), or a comma-separated list of VLAN ids/ranges in the 1-4094 range + (e.g. V(100-200,300)). + - ND requires per-peer C(peer1AllowedVlans)/C(peer2AllowedVlans) on the wire but enforces + consistency between them and collapses them to a single C(allowedVlans) in the GET response. + The module fans out the single value to both per-peer keys on write. + type: str + bandwidth: + description: + - Configured bandwidth value for the interface. + - Valid range is 1-100000000. + type: int + bpdu_filter: + description: + - BPDU filter setting for the vPC interface. + type: str + choices: [ enable, disable, default ] + bpdu_guard: + description: + - BPDU guard setting for the vPC interface. + type: str + choices: [ enable, disable, default ] + cdp: + description: + - Whether Cisco Discovery Protocol is enabled on the vPC interface. + type: bool + copy_description: + description: + - Whether to propagate the per-peer port-channel description to all member interfaces. + type: bool + duplex_mode: + description: + - The duplex mode of the vPC interface. + type: str + choices: [ auto, full, half ] + inherit_bandwidth: + description: + - Bandwidth value inherited by sub-interfaces. + - 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: + - If disabled, LACP puts the port in individual state instead of suspending when LACP BPDUs are + not received. + type: bool + lacp_vpc_convergence: + description: + - Enable LACP convergence for vPC port-channels. + type: bool + link_type: + description: + - Spanning-tree link type. + type: str + choices: [ auto, pointToPoint, shared ] + mirror_config: + description: + - Copy Peer-1 configuration to Peer-2. + type: bool + mtu: + description: + - Interface MTU. + type: str + choices: [ default, jumbo ] + native_vlan: + description: + - Trunk native VLAN id on both peers. + - Valid range is 1-4094. + - ND requires per-peer C(peer1NativeVlan)/C(peer2NativeVlan) on the wire but enforces consistency + and collapses them to a single C(nativeVlan) in the GET response. The module fans out the + single value to both per-peer keys on write. + type: int + negotiate_auto: + description: + - Enable link auto-negotiation. + type: bool + netflow: + description: + - Enable Netflow on the vPC interface. + type: bool + netflow_monitor: + description: + - Layer 2 Netflow monitor name. + type: str + netflow_sampler: + description: + - Netflow sampler name (N7K only). + type: str + peer1_member_ports: + description: + - Member interface names on Peer-1 (e.g. C(Ethernet1/1)). + type: list + elements: str + peer1_port_channel_configuration: + description: + - Additional CLI configuration commands for Peer-1's port-channel. + type: str + peer1_port_channel_description: + description: + - Description for Peer-1's port-channel. + - Maximum 254 characters. + type: str + peer1_port_channel_id: + description: + - Peer-1 vPC port-channel number. + - Valid range is 1-4096. + type: int + peer2_member_ports: + description: + - Member interface names on Peer-2 (e.g. C(Ethernet1/1)). + type: list + elements: str + peer2_port_channel_configuration: + description: + - Additional CLI configuration commands for Peer-2's port-channel. + type: str + peer2_port_channel_description: + description: + - Description for Peer-2's port-channel. + - Maximum 254 characters. + type: str + peer2_port_channel_id: + description: + - Peer-2 vPC port-channel number. + - Valid range is 1-4096. + type: int + pfc: + description: + - Enable priority flow control. + type: bool + port_channel_mode: + description: + - Port-channel mode. + type: str + choices: [ 'on', active, passive ] + port_type_edge_trunk: + description: + - Enable spanning-tree edge port (PortFast) behavior on trunk. + type: bool + qos: + description: + - Enable QoS configuration for the vPC interface. + type: bool + qos_policy: + description: + - Custom QoS policy name. + type: str + queuing_policy: + description: + - Custom queuing policy name. + type: str + speed: + description: + - Interface speed. + type: str + choices: [ auto, 10Mb, 100Mb, 1Gb, 2.5Gb, 5Gb, 10Gb, 25Gb, 40Gb, 50Gb, 100Gb, 200Gb, 400Gb, 800Gb ] + storm_control: + description: + - Enable traffic storm control on the vPC interface. + 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: + - Enable VLAN mapping on the trunk. + 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; each entry is a VLAN id or range string in 1-4094 (e.g. V(['100', '200-300'])). + 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 vPC interface changes after mutations are complete. + - When V(true), all queued vPC interface changes are deployed in a single bulk API call at the end of module + execution via the C(interfaceActions/deploy) API. Only the vPC interfaces 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 vPC interfaces via per-interface DELETE. + Member ethernet interfaces on both peers 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 vPC trunkVpcHost interfaces only (interface_type C(vpc), mode C(trunk), + network_os_type C(nx-os), policy_type C(trunkVpcHost)). These values are hardcoded by the module and + are not user-configurable. +- The primary switch supplied in O(config[].switch_ip) must already be in a vPC pair (managed by + M(cisco.nd.nd_vpc_pair)). The peer serial is auto-resolved from the pair record. +- C(peer1) refers to the switch supplied in O(config[].switch_ip); C(peer2) refers to the auto-resolved peer. +""" + +EXAMPLES = r""" +- name: Create a trunkVpcHost vPC with one member on each peer + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200,300" + native_vlan: 99 + peer1_port_channel_id: 500 + peer1_member_ports: + - Ethernet1/1 + peer2_port_channel_id: 500 + peer2_member_ports: + - Ethernet1/1 + port_channel_mode: active + lacp_rate: fast + peer1_port_channel_description: Server-A on peer1 + peer2_port_channel_description: Server-A on peer2 + state: merged + register: result + +- name: Update allowed VLANs and native VLAN on an existing vPC + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + config_data: + network_os: + policy: + allowed_vlans: all + native_vlan: 1 + state: merged + +- name: Enable VLAN mapping with two entries + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + config_data: + network_os: + policy: + vlan_mapping: true + vlan_mapping_entries: + - customer_vlan_id: + - "100" + - "200-300" + provider_vlan_id: 1000 + - customer_vlan_id: + - "500" + customer_inner_vlan_id: 510 + provider_vlan_id: 1010 + dot1q_tunnel: true + state: merged + +- name: Replace a vPC trunk-host policy (un-set fields revert to ND defaults) + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 100 + peer1_port_channel_id: 500 + peer1_member_ports: + - Ethernet1/1 + peer2_port_channel_id: 500 + peer2_member_ports: + - Ethernet1/1 + port_channel_mode: active + state: replaced + +- name: Override the fabric vPC trunk-host inventory to a single managed vPC + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 100 + peer1_port_channel_id: 500 + peer1_member_ports: + - Ethernet1/1 + peer2_port_channel_id: 500 + peer2_member_ports: + - Ethernet1/1 + port_channel_mode: active + state: overridden + +- name: Delete a vPC interface (cascades to both peers) + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + state: deleted + +- name: Stage vPC interface changes without deploying + cisco.nd.nd_interface_vpc_trunk_host: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 100 + peer1_port_channel_id: 500 + peer2_port_channel_id: 500 + deploy: false + state: merged + +""" + +RETURN = r""" +""" + +# pylint: disable=wrong-import-position +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.vpc_trunk_host_interface import ( + TrunkVpcHostInterfaceModel, +) +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.vpc_trunk_host_interface import ( + TrunkVpcHostInterfaceOrchestrator, +) + + +def main(): + """ + # Summary + + Entry point for the `nd_interface_vpc_trunk_host` Ansible module. Initializes the + `NDStateMachine` with `TrunkVpcHostInterfaceOrchestrator` and executes the requested state operation. + + ## Raises + + None (catches all exceptions and calls `module.fail_json`). + """ + argument_spec = nd_argument_spec() + argument_spec.update(TrunkVpcHostInterfaceModel.get_argument_spec()) + argument_spec.update( + deploy={"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_vpc_trunk_host") + + nd_state_machine = None + + try: + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=TrunkVpcHostInterfaceOrchestrator, + ) + 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_vpc_trunk_host/tasks/deleted.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/deleted.yaml new file mode 100644 index 000000000..2bf90afcc --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/deleted.yaml @@ -0,0 +1,41 @@ +--- +# Deleted state tests for nd_interface_vpc_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) + +# After overridden.yaml, only vpc501 exists. Remove it explicitly and verify idempotency. + +- name: "DELETED: Remove vpc501 (check mode)" + cisco.nd.nd_interface_vpc_trunk_host: &delete_vpc + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip_peer1 }}" + interface_name: vpc501 + state: deleted + check_mode: true + register: cm_deleted + +- name: "DELETED: Remove vpc501 (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *delete_vpc + register: nm_deleted + +- name: "DELETED: Verify delete produced a change" + ansible.builtin.assert: + that: + - cm_deleted is changed + - nm_deleted is changed + +- name: "DELETED IDEMPOTENT: Pause for convergence" + ansible.builtin.pause: + seconds: 30 + +- name: "DELETED IDEMPOTENT: Re-apply delete (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *delete_vpc + register: nm_deleted_idem + +- name: "DELETED IDEMPOTENT: Verify no change after second delete" + ansible.builtin.assert: + that: + - nm_deleted_idem is not changed diff --git a/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/main.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/main.yaml new file mode 100644 index 000000000..971835cd9 --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/main.yaml @@ -0,0 +1,64 @@ +--- +# Test code for the nd_interface_vpc_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 integration nd_interface_vpc_trunk_host +# +# Lab pre-conditions (verified in reference_nd_lab.md): +# - SITE1 fabric with peer1 (192.168.12.151) and peer2 (192.168.12.155) reachable +# - Ethernet1/8, Ethernet1/9, Ethernet1/10 free on both peers for vPC member testing +# - Peer-link cable: peer1 Ethernet1/3 <-> peer2 Ethernet1/2 +# +# Setup creates the vPC pair if missing; teardown unpairs it (which cascades and removes all +# member vPC interfaces, so we don't need an explicit vPC-interface teardown). + +- 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: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + +- name: Run nd_interface_vpc_trunk_host state tests + block: + - name: Pre-test setup (create vPC pair, clean any prior vPC interfaces) + ansible.builtin.include_tasks: setup.yaml + + - name: Run nd_interface_vpc_trunk_host merged state tests + ansible.builtin.include_tasks: merged.yaml + + - name: Run nd_interface_vpc_trunk_host replaced state tests + ansible.builtin.include_tasks: replaced.yaml + + - name: Run nd_interface_vpc_trunk_host overridden state tests + ansible.builtin.include_tasks: overridden.yaml + + - name: Run nd_interface_vpc_trunk_host deleted state tests + ansible.builtin.include_tasks: deleted.yaml + always: + - name: Teardown (unpair vPC, cascades to remove any remaining vPC interfaces) + cisco.nd.nd_vpc_pair: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip_peer1 }}" + peer_switch_ip: "{{ test_switch_ip_peer2 }}" + domain_id: 1 + state: deleted + ignore_errors: true + module_defaults: + cisco.nd.nd_interface_vpc_trunk_host: + timeout: 300 + cisco.nd.nd_vpc_pair: + timeout: 300 + environment: + ND_LOGGING_CONFIG: "{{ nd_logging_config | default('') }}" diff --git a/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/merged.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/merged.yaml new file mode 100644 index 000000000..4d64409e0 --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/merged.yaml @@ -0,0 +1,85 @@ +--- +# Merged state tests for nd_interface_vpc_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) + +# --- MERGED CREATE --- + +- name: "MERGED CREATE: Create vpc500 (check mode)" + cisco.nd.nd_interface_vpc_trunk_host: &merge_vpc + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ vpc_trunk_host_baseline }}" + state: merged + check_mode: true + register: cm_merged_create + +- name: "MERGED CREATE: Create vpc500 (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *merge_vpc + register: nm_merged_create + +- name: "MERGED CREATE: Verify creation produced a change" + ansible.builtin.assert: + that: + - cm_merged_create is changed + - nm_merged_create is changed + +# --- MERGED IDEMPOTENCY --- + +- name: "MERGED IDEMPOTENT: Pause for switch convergence" + ansible.builtin.pause: + seconds: 30 + +- name: "MERGED IDEMPOTENT: Re-apply vpc500 (check mode)" + cisco.nd.nd_interface_vpc_trunk_host: *merge_vpc + check_mode: true + register: cm_merged_idem + +- name: "MERGED IDEMPOTENT: Re-apply vpc500 (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *merge_vpc + register: nm_merged_idem + +- name: "MERGED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - cm_merged_idem is not changed + - nm_merged_idem is not changed + +# --- VLAN MAPPING (gated on supports_vlan_mapping) --- +# Skipped by default because Nexus 9000v virtual switches reject +# `switchport vlan mapping ... dot1q-tunnel ...`. Set ND_SUPPORTS_VLAN_MAPPING=true on +# hardware-backed testbeds to exercise these blocks. + +- name: "MERGED VLAN MAPPING: Apply vlan_mapping_entries (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: &merge_vpc_vlan_map + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ vpc_trunk_host_with_vlan_mapping }}" + state: merged + register: nm_merged_vlan_map + when: supports_vlan_mapping | default(false) | bool + +- name: "MERGED VLAN MAPPING: Verify vlan mapping change applied" + ansible.builtin.assert: + that: + - nm_merged_vlan_map is changed + when: supports_vlan_mapping | default(false) | bool + +- name: "MERGED VLAN MAPPING IDEMPOTENT: Pause for convergence" + ansible.builtin.pause: + seconds: 30 + when: supports_vlan_mapping | default(false) | bool + +- name: "MERGED VLAN MAPPING IDEMPOTENT: Re-apply (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *merge_vpc_vlan_map + register: nm_merged_vlan_map_idem + when: supports_vlan_mapping | default(false) | bool + +- name: "MERGED VLAN MAPPING IDEMPOTENT: Verify no change on re-apply" + ansible.builtin.assert: + that: + - nm_merged_vlan_map_idem is not changed + when: supports_vlan_mapping | default(false) | bool diff --git a/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/overridden.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/overridden.yaml new file mode 100644 index 000000000..8c29ce83c --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/overridden.yaml @@ -0,0 +1,41 @@ +--- +# Overridden state tests for nd_interface_vpc_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) + +# After replaced.yaml, vpc500 exists. State overridden with a single new vpc (vpc501) should +# remove vpc500 and create vpc501 fabric-wide. + +- name: "OVERRIDDEN: Single source of truth = vpc501 only (check mode)" + cisco.nd.nd_interface_vpc_trunk_host: &override_vpc + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ vpc_trunk_host_overridden }}" + state: overridden + check_mode: true + register: cm_overridden + +- name: "OVERRIDDEN: Single source of truth = vpc501 only (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *override_vpc + register: nm_overridden + +- name: "OVERRIDDEN: Verify override produced a change" + ansible.builtin.assert: + that: + - cm_overridden is changed + - nm_overridden is changed + +- name: "OVERRIDDEN IDEMPOTENT: Pause for convergence" + ansible.builtin.pause: + seconds: 30 + +- name: "OVERRIDDEN IDEMPOTENT: Re-apply (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *override_vpc + register: nm_overridden_idem + +- name: "OVERRIDDEN IDEMPOTENT: Verify no change on re-apply" + ansible.builtin.assert: + that: + - nm_overridden_idem is not changed diff --git a/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/replaced.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/replaced.yaml new file mode 100644 index 000000000..9f251066b --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/replaced.yaml @@ -0,0 +1,41 @@ +--- +# Replaced state tests for nd_interface_vpc_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) + +# vpc500 was created in merged.yaml. Replace its config (change allowed_vlans, native_vlan, +# add a peer1 member) and verify the change is reflected. + +- name: "REPLACED: Update vpc500 policy (check mode)" + cisco.nd.nd_interface_vpc_trunk_host: &replace_vpc + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ vpc_trunk_host_updated }}" + state: replaced + check_mode: true + register: cm_replaced + +- name: "REPLACED: Update vpc500 policy (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *replace_vpc + register: nm_replaced + +- name: "REPLACED: Verify replace produced a change" + ansible.builtin.assert: + that: + - cm_replaced is changed + - nm_replaced is changed + +- name: "REPLACED IDEMPOTENT: Pause for convergence" + ansible.builtin.pause: + seconds: 30 + +- name: "REPLACED IDEMPOTENT: Re-apply (normal mode)" + cisco.nd.nd_interface_vpc_trunk_host: *replace_vpc + register: nm_replaced_idem + +- name: "REPLACED IDEMPOTENT: Verify no change on re-apply" + ansible.builtin.assert: + that: + - nm_replaced_idem is not changed diff --git a/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/setup.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/setup.yaml new file mode 100644 index 000000000..d25101c89 --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/tasks/setup.yaml @@ -0,0 +1,41 @@ +--- +# Pre-test setup for nd_interface_vpc_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) + +# Ensures: +# 1. Any pre-existing vPC pair on the test peers is removed (clears any stale vPC interfaces). +# 2. A fresh vPC pair is created so the trunk-host tests have a stable substrate. +# 3. ND switch poller has time to discover the new pair before tests start. + +- name: "SETUP: Tear down any existing vPC pair on test peers" + cisco.nd.nd_vpc_pair: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip_peer1 }}" + peer_switch_ip: "{{ test_switch_ip_peer2 }}" + domain_id: 1 + state: deleted + ignore_errors: true + tags: always + +- name: "SETUP: Pause for unpair convergence" + ansible.builtin.pause: + seconds: 30 + tags: always + +- name: "SETUP: Create vPC pair" + cisco.nd.nd_vpc_pair: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ vpc_pair_setup }}" + state: merged + tags: always + +- name: "SETUP: Pause for pair-establishment convergence" + ansible.builtin.pause: + seconds: 30 + tags: always diff --git a/tests/integration/targets/nd_interface_vpc_trunk_host/vars/main.yaml b/tests/integration/targets/nd_interface_vpc_trunk_host/vars/main.yaml new file mode 100644 index 000000000..67987b33d --- /dev/null +++ b/tests/integration/targets/nd_interface_vpc_trunk_host/vars/main.yaml @@ -0,0 +1,115 @@ +--- +# Variables for nd_interface_vpc_trunk_host integration tests. +# +# Override the defaults in your inventory or extra-vars to match a real ND 4.2 testbed. +# The defaults below match the SITE1 lab documented in reference_nd_lab.md. + +test_fabric_name: "{{ nd_test_vpc_trunk_host_fabric_name | default(nd_test_fabric_name | default('SITE1')) }}" +test_switch_ip_peer1: "{{ nd_test_vpc_peer1_ip | default('192.168.12.151') }}" +test_switch_ip_peer2: "{{ nd_test_vpc_peer2_ip | default('192.168.12.155') }}" + +# vPC pair configuration used by setup. Mirrors the baseline in nd_vpc_pair tests so the +# vPC interface tests share a known-good underlying pair. +vpc_pair_setup: + switch_ip: "{{ test_switch_ip_peer1 }}" + peer_switch_ip: "{{ test_switch_ip_peer2 }}" + domain_id: 1 + keep_alive_vrf: management + switch_po_id: 1 + peer_switch_po_id: 1 + switch_member_interfaces: + - Ethernet1/3 + peer_switch_member_interfaces: + - Ethernet1/2 + +# Baseline vPC trunk-host interface used by merged + idempotency tests. +# ND requires per-peer peer1AllowedVlans/peer2AllowedVlans and peer1NativeVlan/peer2NativeVlan +# but enforces consistency between them and collapses to single values on GET. The model exposes +# single user-facing `allowed_vlans` and `native_vlan` and fans them out on write. +vpc_trunk_host_baseline: + switch_ip: "{{ test_switch_ip_peer1 }}" + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + peer1_port_channel_id: 500 + peer1_member_ports: + - Ethernet1/8 + peer2_port_channel_id: 500 + peer2_member_ports: + - Ethernet1/8 + port_channel_mode: active + lacp_rate: fast + mtu: jumbo + +# Updated config for replaced tests (different allowed_vlans + native_vlan + add a member on peer1). +vpc_trunk_host_updated: + switch_ip: "{{ test_switch_ip_peer1 }}" + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-300" + native_vlan: 100 + peer1_port_channel_id: 500 + peer1_member_ports: + - Ethernet1/8 + - Ethernet1/9 + peer2_port_channel_id: 500 + peer2_member_ports: + - Ethernet1/8 + port_channel_mode: active + lacp_rate: fast + mtu: jumbo + +# Second vPC for overridden test: define only this one, expect vpc500 to be removed. +vpc_trunk_host_overridden: + switch_ip: "{{ test_switch_ip_peer1 }}" + interface_name: vpc501 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: all + native_vlan: 1 + peer1_port_channel_id: 501 + peer1_member_ports: + - Ethernet1/10 + peer2_port_channel_id: 501 + peer2_member_ports: + - Ethernet1/10 + port_channel_mode: active + +# VLAN mapping case — only run when supports_vlan_mapping is true (hardware-backed testbeds). +# Nexus 9000v virtual switches reject `switchport vlan mapping ... dot1q-tunnel ...`. +vpc_trunk_host_with_vlan_mapping: + switch_ip: "{{ test_switch_ip_peer1 }}" + interface_name: vpc500 + config_data: + network_os: + policy: + admin_state: true + allowed_vlans: "100-200" + native_vlan: 99 + peer1_port_channel_id: 500 + peer1_member_ports: + - Ethernet1/8 + peer2_port_channel_id: 500 + peer2_member_ports: + - Ethernet1/8 + port_channel_mode: active + vlan_mapping: true + vlan_mapping_entries: + - customer_vlan_id: + - "100" + - "200-300" + provider_vlan_id: 1000 + - customer_vlan_id: + - "500" + customer_inner_vlan_id: 510 + provider_vlan_id: 1010 + dot1q_tunnel: true diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_vpc_trunk_host_interface_orchestrator.json b/tests/unit/module_utils/fixtures/fixture_data/test_vpc_trunk_host_interface_orchestrator.json new file mode 100644 index 000000000..f5d73b8a5 --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_vpc_trunk_host_interface_orchestrator.json @@ -0,0 +1,360 @@ +{ + "TEST_NOTES": [ + "Fixture data for tests/unit/module_utils/orchestrators/test_vpc_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}/vpcPair (peer-serial resolve)", + " - GET /api/v1/manage/fabrics/{fabric_name}/switches/{sn}/interfaces (EpManageInterfacesListGet per switch)" + ], + "test_peer_resolve_happy_path_00500a": { + "TEST_NOTES": ["vpcPair GET on primary FDO11111AAA returns peer serial FDO22222BBB."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/vpcPair", + "MESSAGE": "OK", + "DATA": { + "switchId": "FDO11111AAA", + "peerSwitchId": "FDO22222BBB", + "vpcPairDetails": { + "domainId": 100, + "keepAliveVrf": "management" + } + } + }, + "test_peer_resolve_not_paired_00510a": { + "TEST_NOTES": ["vpcPair GET returns 404 with structured error body (live lab shape)."], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/vpcPair", + "MESSAGE": "Not Found", + "DATA": { + "code": 404, + "description": "", + "message": "No vPC Pair found for switch serial number: FDO11111AAA" + } + }, + "test_peer_resolve_missing_field_00520a": { + "TEST_NOTES": ["vpcPair GET returns success but the response body omits peerSwitchId."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/vpcPair", + "MESSAGE": "OK", + "DATA": { + "switchId": "FDO11111AAA" + } + }, + "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."], + "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 forming a vPC pair."], + "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 FDO11111AAA: trunkVpcHost vPC, accessVpcHost vPC, ethernet trunkHost.", + "Expect: only the trunkVpcHost vPC 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": "vpc500", + "interfaceType": "vpc", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkVpcHost", + "peerSwitchId": "FDO22222BBB", + "allowedVlans": "100-200", + "nativeVlan": 99, + "peer1PortChannelId": 500, + "peer1MemberPorts": ["Ethernet1/1"] + } + } + } + }, + { + "interfaceName": "vpc100", + "interfaceType": "vpc", + "configData": { + "mode": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "accessVpcHost", + "peerSwitchId": "FDO22222BBB" + } + } + } + }, + { + "interfaceName": "Ethernet1/1", + "interfaceType": "ethernet", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkHost" + } + } + } + } + ] + } + }, + "test_query_all_happy_path_00400d": { + "TEST_NOTES": ["Interfaces for FDO22222BBB: one trunkVpcHost vPC (a different one for cross-switch coverage)."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO22222BBB/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "vpc600", + "interfaceType": "vpc", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkVpcHost", + "peerSwitchId": "FDO11111AAA", + "allowedVlans": "300-400", + "nativeVlan": 100, + "peer1PortChannelId": 600, + "peer1MemberPorts": ["Ethernet1/3"] + } + } + } + } + ] + } + }, + "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": ["Switch interfaces: only ethernet and non-trunkVpcHost interfaces."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO11111AAA/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "Ethernet1/1", + "interfaceType": "ethernet", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkHost" + } + } + } + }, + { + "interfaceName": "vpc999", + "interfaceType": "vpc", + "configData": { + "mode": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "accessVpcHost" + } + } + } + } + ] + } + }, + "test_query_all_dedup_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_dedup_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_dedup_00430b": { + "TEST_NOTES": ["Switch list: two switches in a vPC pair."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + { + "fabricManagementIp": "192.168.1.1", + "switchId": "FDOAAAAAAAA" + }, + { + "fabricManagementIp": "192.168.1.2", + "switchId": "FDOBBBBBBBB" + } + ] + } + }, + "test_query_all_dedup_00430c": { + "TEST_NOTES": ["S1 interfaces: vpc500 returned (peerSwitchId=FDOBBBBBBBB)."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDOAAAAAAAA/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "vpc500", + "interfaceType": "vpc", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkVpcHost", + "peerSwitchId": "FDOBBBBBBBB", + "allowedVlans": "100-200", + "nativeVlan": 99, + "peer1PortChannelId": 500, + "peer1MemberPorts": ["Ethernet1/1"], + "peer2PortChannelId": 500, + "peer2MemberPorts": ["Ethernet1/1"] + } + } + } + } + ] + } + }, + "test_query_all_dedup_00430d": { + "TEST_NOTES": ["S2 interfaces: same vpc500 returned (peerSwitchId=FDOAAAAAAAA)."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDOBBBBBBBB/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "vpc500", + "interfaceType": "vpc", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkVpcHost", + "peerSwitchId": "FDOAAAAAAAA", + "allowedVlans": "100-200", + "nativeVlan": 99, + "peer1PortChannelId": 500, + "peer1MemberPorts": ["Ethernet1/1"], + "peer2PortChannelId": 500, + "peer2MemberPorts": ["Ethernet1/1"] + } + } + } + } + ] + } + }, + "test_query_all_fabric_not_found_00420a": { + "TEST_NOTES": ["Fabric summary 404 -> validate_prerequisites raises."], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/missing_fabric/summary", + "MESSAGE": "Not Found", + "DATA": {} + } +} diff --git a/tests/unit/module_utils/models/test_vpc_trunk_host_interface.py b/tests/unit/module_utils/models/test_vpc_trunk_host_interface.py new file mode 100644 index 000000000..ee5ffd1fa --- /dev/null +++ b/tests/unit/module_utils/models/test_vpc_trunk_host_interface.py @@ -0,0 +1,871 @@ +# -*- 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 vpc_trunk_host_interface.py + +Tests the vPC trunkVpcHost 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.vpc_trunk_host_interface import ( + TrunkVpcHostConfigDataModel, + TrunkVpcHostInterfaceModel, + TrunkVpcHostNetworkOSModel, + TrunkVpcHostPolicyModel, + TrunkVpcHostVlanMappingEntryModel, +) +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": "vpc500", + "interfaceType": "vpc", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkVpcHost", + "peerSwitchId": "FDOPEER0001", + "adminState": True, + "allowedVlans": "100-200", + "nativeVlan": 99, + "peer1PortChannelId": 500, + "peer1MemberPorts": ["Ethernet1/1"], + "peer2PortChannelId": 500, + "peer2MemberPorts": ["Ethernet1/2"], + "portChannelMode": "active", + "lacpRate": "fast", + "mtu": "jumbo", + }, + }, + }, +} + +SAMPLE_ANSIBLE_CONFIG = { + "switch_ip": "192.168.1.1", + "interface_name": "vpc500", + "config_data": { + "network_os": { + "policy": { + "admin_state": True, + "allowed_vlans": "100-200", + "native_vlan": 99, + "peer1_port_channel_id": 500, + "peer1_member_ports": ["Ethernet1/1"], + "peer2_port_channel_id": 500, + "peer2_member_ports": ["Ethernet1/2"], + "port_channel_mode": "active", + "lacp_rate": "fast", + "mtu": "jumbo", + }, + }, + }, +} + + +# ============================================================================= +# Test: TrunkVpcHostPolicyModel — initialization +# ============================================================================= + + +def test_vpc_trunk_host_interface_00100(): + """ + # Summary + + Verify every policy field defaults to None except the frozen policy_type. + + ## Test + + - Instantiate with no arguments + - Every per-peer and shared field is None + - policy_type defaults to "trunkVpcHost" + + ## Classes and Methods + + - TrunkVpcHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = TrunkVpcHostPolicyModel() + assert instance.policy_type == "trunkVpcHost" + assert instance.peer_switch_id is None + # Trunk-specific single-valued fields + assert instance.allowed_vlans is None + assert instance.native_vlan is None + # Per-peer + assert instance.peer1_member_ports is None + assert instance.peer1_port_channel_configuration is None + assert instance.peer1_port_channel_description is None + assert instance.peer1_port_channel_id is None + assert instance.peer2_member_ports is None + assert instance.peer2_port_channel_configuration is None + assert instance.peer2_port_channel_description is None + assert instance.peer2_port_channel_id is None + # VLAN mapping + assert instance.vlan_mapping is None + assert instance.vlan_mapping_entries is None + # Shared + assert instance.admin_state 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.duplex_mode 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.lacp_vpc_convergence is None + assert instance.link_type is None + assert instance.mirror_config is None + assert instance.mtu 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.pfc is None + assert instance.port_channel_mode is None + assert instance.port_type_edge_trunk 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 + + +def test_vpc_trunk_host_interface_00110(): + """ + # Summary + + Verify construction with snake_case field names. + + ## Test + + - Construct with Python field names + - Values accessible via Python attributes + + ## Classes and Methods + + - TrunkVpcHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = TrunkVpcHostPolicyModel( + admin_state=True, + allowed_vlans="100-200,300", + native_vlan=99, + peer1_port_channel_id=500, + peer1_member_ports=["Ethernet1/1"], + peer2_port_channel_id=500, + peer2_member_ports=["Ethernet1/2"], + port_channel_mode="active", + lacp_rate="fast", + ) + assert instance.admin_state is True + assert instance.allowed_vlans == "100-200,300" + assert instance.native_vlan == 99 + assert instance.peer1_port_channel_id == 500 + assert instance.peer1_member_ports == ["Ethernet1/1"] + assert instance.peer2_port_channel_id == 500 + assert instance.peer2_member_ports == ["Ethernet1/2"] + assert instance.port_channel_mode == "active" + assert instance.lacp_rate == "fast" + assert instance.policy_type == "trunkVpcHost" + + +def test_vpc_trunk_host_interface_00120(): + """ + # Summary + + Verify construction with camelCase aliases. + + ## Test + + - Construct with API alias names + - Values accessible by Python names + + ## Classes and Methods + + - TrunkVpcHostPolicyModel.__init__() + """ + with does_not_raise(): + instance = TrunkVpcHostPolicyModel( + adminState=True, + allowedVlans="all", + nativeVlan=1, + peer1PortChannelId=600, + peer1MemberPorts=["Ethernet1/5"], + peer2PortChannelId=600, + peer2MemberPorts=["Ethernet1/6"], + portChannelMode="passive", + policyType="trunkVpcHost", + peerSwitchId="FDOPEER0001", + ) + assert instance.admin_state is True + assert instance.allowed_vlans == "all" + assert instance.native_vlan == 1 + assert instance.peer1_port_channel_id == 600 + assert instance.peer1_member_ports == ["Ethernet1/5"] + assert instance.peer2_port_channel_id == 600 + assert instance.peer2_member_ports == ["Ethernet1/6"] + assert instance.port_channel_mode == "passive" + assert instance.policy_type == "trunkVpcHost" + assert instance.peer_switch_id == "FDOPEER0001" + + +# ============================================================================= +# Test: TrunkVpcHostPolicyModel — allowed_vlans validator +# ============================================================================= + + +@pytest.mark.parametrize( + "value,expected", + [ + ("none", "none"), + ("all", "all"), + ("100", "100"), + ("100-200", "100-200"), + ("100,200,300", "100,200,300"), + ("1-200,500-2000,3000", "1-200,500-2000,3000"), + (250, "250"), # int -> str coercion (per ND wire echo) + (None, None), + ("", ""), + ], + ids=[ + "none_keyword", + "all_keyword", + "single_vlan", + "single_range", + "comma_list", + "comma_with_ranges", + "int_coerced_to_str", + "none_passthrough", + "empty_str_passthrough", + ], +) +def test_vpc_trunk_host_interface_00150_allowed_vlans_accepts(value, expected): + """ + # Summary + + Verify `allowed_vlans` validator accepts valid shapes and coerces ND's int echo to str. + + ## Test + + - "none" / "all" / single id / range / comma-list pass through unchanged + - An int value is coerced to a string for idempotency stability + + ## Classes and Methods + + - TrunkVpcHostPolicyModel._validate_allowed_vlans() + """ + with does_not_raise(): + instance = TrunkVpcHostPolicyModel(allowed_vlans=value) + assert instance.allowed_vlans == expected + + +@pytest.mark.parametrize( + "value", + [ + "abc", + "100-", + "-100", + "100,abc", + "200-100", # reversed range + "100-5000", # out of bounds high + "0-100", # out of bounds low + "0", + "4095", + "100--200", + ], + ids=[ + "non_numeric", + "trailing_dash", + "leading_dash", + "mixed_garbage", + "reversed_range", + "high_out_of_bounds", + "low_out_of_bounds", + "zero", + "above_4094", + "double_dash", + ], +) +def test_vpc_trunk_host_interface_00155_allowed_vlans_rejects(value): + """ + # Summary + + Verify `allowed_vlans` validator rejects invalid shapes and out-of-bounds values. + + ## Test + + - Garbage strings raise ValueError + - Reversed ranges raise ValueError + - VLAN ids outside 1..4094 raise ValueError + + ## Classes and Methods + + - TrunkVpcHostPolicyModel._validate_allowed_vlans() + """ + with pytest.raises(ValidationError): + TrunkVpcHostPolicyModel(allowed_vlans=value) + + +# ============================================================================= +# Test: TrunkVpcHostPolicyModel — member_ports normalizer +# ============================================================================= + + +@pytest.mark.parametrize( + "value,expected", + [ + (["ethernet1/1", "ethernet1/2"], ["Ethernet1/1", "Ethernet1/2"]), + (["Ethernet1/1"], ["Ethernet1/1"]), + (["e1/1"], ["E1/1"]), + ([], []), + (None, None), + (["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_vpc_trunk_host_interface_00180_peer1(value, expected): + """ + # Summary + + Verify `normalize_member_ports` capitalizes the first character of each peer1 member name. + + ## Test + + - Lowercase member names are capitalized + - Already-capitalized values pass through + - Empty list and None pass through + + ## Classes and Methods + + - TrunkVpcHostPolicyModel.normalize_member_ports() + """ + with does_not_raise(): + instance = TrunkVpcHostPolicyModel(peer1_member_ports=value) + assert instance.peer1_member_ports == expected + + +@pytest.mark.parametrize( + "value,expected", + [ + (["ethernet1/1", "ethernet1/2"], ["Ethernet1/1", "Ethernet1/2"]), + (["Ethernet1/1"], ["Ethernet1/1"]), + (None, None), + ], + ids=["lowercase_to_capitalized", "already_capitalized_passthrough", "none_passthrough"], +) +def test_vpc_trunk_host_interface_00185_peer2(value, expected): + """ + # Summary + + Verify `normalize_member_ports` also applies to peer2_member_ports. + + ## Test + + - Same normalization rules as peer1 + + ## Classes and Methods + + - TrunkVpcHostPolicyModel.normalize_member_ports() + """ + with does_not_raise(): + instance = TrunkVpcHostPolicyModel(peer2_member_ports=value) + assert instance.peer2_member_ports == expected + + +# ============================================================================= +# Test: TrunkVpcHostPolicyModel — bounds + frozen +# ============================================================================= + + +@pytest.mark.parametrize( + "field,value", + [ + ("native_vlan", 0), + ("native_vlan", 4095), + ("peer1_port_channel_id", 0), + ("peer1_port_channel_id", 4097), + ("peer2_port_channel_id", 0), + ("peer2_port_channel_id", 4097), + ], + ids=[ + "native_vlan_below_min", + "native_vlan_above_max", + "peer1_pc_id_below_min", + "peer1_pc_id_above_max", + "peer2_pc_id_below_min", + "peer2_pc_id_above_max", + ], +) +def test_vpc_trunk_host_interface_00200_range(field, value): + """ + # Summary + + Verify VLAN (1-4094) and port-channel-id (1-4096) range validation. + + ## Test + + - Values outside the valid range raise ValidationError + + ## Classes and Methods + + - TrunkVpcHostPolicyModel (Pydantic Field constraints) + """ + with pytest.raises(ValidationError): + TrunkVpcHostPolicyModel(**{field: value}) + + +def test_vpc_trunk_host_interface_00210_policy_type_frozen(): + """ + # Summary + + Verify `policy_type` cannot be reassigned to a different value (frozen=True). + + ## Test + + - Construct with default policy_type + - Attempt to assign a different value raises ValidationError + + ## Classes and Methods + + - TrunkVpcHostPolicyModel (frozen Field) + """ + instance = TrunkVpcHostPolicyModel() + with pytest.raises(ValidationError): + instance.policy_type = "accessVpcHost" + + +# ============================================================================= +# Test: TrunkVpcHostVlanMappingEntryModel +# ============================================================================= + + +def test_vpc_trunk_host_interface_00250_vlan_mapping_entry_construction(): + """ + # Summary + + Verify VLAN mapping entry accepts customer/provider ids with valid shape. + + ## Test + + - Construction with mixed scalar / range customer ids + - Optional dot1q_tunnel and customer_inner_vlan_id + + ## Classes and Methods + + - TrunkVpcHostVlanMappingEntryModel.__init__() + """ + entry = TrunkVpcHostVlanMappingEntryModel( + customer_vlan_id=["100", "200-300"], + provider_vlan_id=1000, + dot1q_tunnel=True, + customer_inner_vlan_id=510, + ) + assert entry.customer_vlan_id == ["100", "200-300"] + assert entry.provider_vlan_id == 1000 + assert entry.dot1q_tunnel is True + assert entry.customer_inner_vlan_id == 510 + + +@pytest.mark.parametrize( + "value", + [ + ["abc"], + ["100", ""], + [""], + ["200-100"], + ["0"], + ["5000"], + ], + ids=["non_numeric", "empty_token", "single_empty", "reversed_range", "low_oob", "high_oob"], +) +def test_vpc_trunk_host_interface_00255_customer_vlan_id_rejects(value): + """ + # Summary + + Verify customer_vlan_id list validator rejects invalid entries. + + ## Test + + - Non-string / empty / out-of-bounds / reversed-range entries raise ValueError + + ## Classes and Methods + + - TrunkVpcHostPolicyModel._validate_customer_vlan_id_list() + """ + with pytest.raises(ValidationError): + TrunkVpcHostVlanMappingEntryModel(customer_vlan_id=value) + + +# ============================================================================= +# Test: TrunkVpcHostInterfaceModel — identifier behavior +# ============================================================================= + + +def test_vpc_trunk_host_interface_00300_identifier(): + """ + # Summary + + Verify the identifier is `interface_name` only (single strategy). A vPC interface is one fabric-level resource; + ND echoes it from both peers but it must collapse to one identity for diff/override-deletion correctness. + + ## Test + + - Identifier is `["interface_name"]` + - Identifier strategy is `"single"` + - get_identifier_value returns the interface name string + - `switch_ip` is excluded from the diff dict (routing-only field) + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.get_identifier_value() + - TrunkVpcHostInterfaceModel.to_diff_dict() + """ + instance = TrunkVpcHostInterfaceModel(switch_ip="192.168.1.1", interface_name="vpc500") + assert instance.identifiers == ["interface_name"] + assert instance.identifier_strategy == "single" + assert instance.get_identifier_value() == "vpc500" + # switch_ip is for routing only; it must not appear in the diff dict so two peers' echoes diff-equal. + assert "switchIp" not in instance.to_diff_dict() + + +@pytest.mark.parametrize( + "value,expected", + [ + ("Vpc500", "vpc500"), + ("VPC500", "vpc500"), + ("vpc500", "vpc500"), + ("vPC500", "vpc500"), + ], + ids=["title_case", "upper_case", "lower_case_passthrough", "mixed_case"], +) +def test_vpc_trunk_host_interface_00310_normalize_name(value, expected): + """ + # Summary + + Verify `normalize_interface_name` lowercases interface names. + + ## Test + + - Various casings are normalized to lowercase + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.normalize_interface_name() + """ + instance = TrunkVpcHostInterfaceModel(switch_ip="192.168.1.1", interface_name=value) + assert instance.interface_name == expected + + +# ============================================================================= +# Test: TrunkVpcHostInterfaceModel — round-trip +# ============================================================================= + + +def test_vpc_trunk_host_interface_00400_round_trip_from_api(): + """ + # Summary + + Verify the model accepts the camelCase API response shape and exposes values via Python attributes. + + ## Test + + - Construct from SAMPLE_API_RESPONSE + - Nested values accessible + - peerSwitchId reads through + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.__init__() + """ + instance = TrunkVpcHostInterfaceModel(**SAMPLE_API_RESPONSE) + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "vpc500" + assert instance.interface_type == "vpc" + policy = instance.config_data.network_os.policy + assert policy.policy_type == "trunkVpcHost" + assert policy.peer_switch_id == "FDOPEER0001" + assert policy.allowed_vlans == "100-200" + assert policy.native_vlan == 99 + assert policy.peer1_member_ports == ["Ethernet1/1"] + assert policy.peer2_member_ports == ["Ethernet1/2"] + assert policy.admin_state is True + + +def test_vpc_trunk_host_interface_00410_round_trip_from_ansible(): + """ + # Summary + + Verify the model accepts the snake_case Ansible config shape. + + ## Test + + - Construct from SAMPLE_ANSIBLE_CONFIG + - Nested values accessible + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.__init__() + """ + instance = TrunkVpcHostInterfaceModel(**copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance.switch_ip == "192.168.1.1" + policy = instance.config_data.network_os.policy + assert policy.peer1_port_channel_id == 500 + assert policy.peer2_port_channel_id == 500 + assert policy.allowed_vlans == "100-200" + assert policy.native_vlan == 99 + assert policy.policy_type == "trunkVpcHost" + + +def test_vpc_trunk_host_interface_00420_to_payload(): + """ + # Summary + + Verify `to_payload()` serializes the model back to camelCase, drops the `switch_ip` identifier, and splits the + user-facing single `allowed_vlans` and `native_vlan` into the per-peer write keys that ND's create schema requires. + + ## Test + + - to_payload() returns wire-shaped dict + - switchIp is excluded (Ansible-facing only) + - interfaceType is present + - Nested policy aliases are camelCase + - allowedVlans and nativeVlan are absent from the payload (split into per-peer keys) + - peer1/peer2 AllowedVlans + NativeVlan are present with the same value + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.to_payload() + - TrunkVpcHostPolicyModel.expand_per_peer_fields() + """ + instance = TrunkVpcHostInterfaceModel(**SAMPLE_API_RESPONSE) + payload = instance.to_payload() + assert "switchIp" not in payload + assert payload["interfaceName"] == "vpc500" + assert payload["interfaceType"] == "vpc" + policy = payload["configData"]["networkOS"]["policy"] + assert policy["policyType"] == "trunkVpcHost" + assert policy["peer1MemberPorts"] == ["Ethernet1/1"] + assert policy["peer2MemberPorts"] == ["Ethernet1/2"] + assert policy["peer1PortChannelId"] == 500 + assert policy["peer2PortChannelId"] == 500 + # allowed_vlans split to both per-peer keys; the single-valued allowedVlans key is removed on payload. + assert "allowedVlans" not in policy + assert policy["peer1AllowedVlans"] == "100-200" + assert policy["peer2AllowedVlans"] == "100-200" + # native_vlan split as well. + assert "nativeVlan" not in policy + assert policy["peer1NativeVlan"] == 99 + assert policy["peer2NativeVlan"] == 99 + + +def test_vpc_trunk_host_interface_00425_to_config_keeps_single_vlan(): + """ + # Summary + + Verify `to_config()` keeps `allowed_vlans` and `native_vlan` as single fields (does NOT expand to per-peer). + The expansion only happens for payload mode; config / diff modes use the wire-echo (single) shape so idempotency works. + + ## Test + + - to_config() returns the snake_case fields `allowed_vlans` and `native_vlan` + - `peer1_allowed_vlans` / `peer2_allowed_vlans` / `peer1_native_vlan` / `peer2_native_vlan` are NOT in the config + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.to_config() + - TrunkVpcHostPolicyModel.expand_per_peer_fields() + """ + instance = TrunkVpcHostInterfaceModel(**SAMPLE_API_RESPONSE) + config = instance.to_config() + policy = config["config_data"]["network_os"]["policy"] + assert policy["allowed_vlans"] == "100-200" + assert policy["native_vlan"] == 99 + assert "peer1_allowed_vlans" not in policy + assert "peer2_allowed_vlans" not in policy + assert "peer1_native_vlan" not in policy + assert "peer2_native_vlan" not in policy + + +def test_vpc_trunk_host_interface_00430_payload_with_vlan_mapping(): + """ + # Summary + + Verify VLAN mapping entries serialize through `to_payload()` with their camelCase aliases intact. + + ## Test + + - vlan_mapping=True and a single entry are present in the payload + - Entry keys are camelCase (customerVlanId, providerVlanId, etc.) + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.to_payload() + - TrunkVpcHostVlanMappingEntryModel + """ + cfg = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + cfg["config_data"]["network_os"]["policy"]["vlan_mapping"] = True + cfg["config_data"]["network_os"]["policy"]["vlan_mapping_entries"] = [ + { + "customer_vlan_id": ["100", "200-300"], + "provider_vlan_id": 1000, + "dot1q_tunnel": False, + }, + ] + instance = TrunkVpcHostInterfaceModel(**cfg) + payload = instance.to_payload() + policy = payload["configData"]["networkOS"]["policy"] + assert policy["vlanMapping"] is True + assert len(policy["vlanMappingEntries"]) == 1 + entry = policy["vlanMappingEntries"][0] + assert entry["customerVlanId"] == ["100", "200-300"] + assert entry["providerVlanId"] == 1000 + assert entry["dot1qTunnel"] is False + + +# ============================================================================= +# Test: Argument spec exposure +# ============================================================================= + + +def test_vpc_trunk_host_interface_00500_argument_spec_shape(): + """ + # Summary + + Verify the argument spec exposes the right shape: fabric_name, config list, state choices. + + ## Test + + - top-level keys: fabric_name, config, state + - state choices contain merged/replaced/overridden/deleted + - config has nested policy options including peer1_/peer2_ fields and trunk-specific fields + - Frozen scaffolding (interface_type, mode, network_os_type, policy_type) is NOT exposed + - peer_switch_id (orchestrator-injected) is NOT exposed + - Per-peer AllowedVlans / NativeVlan are NOT exposed (ND collapses to single fields on read) + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.get_argument_spec() + """ + spec = TrunkVpcHostInterfaceModel.get_argument_spec() + assert set(spec) == {"fabric_name", "config", "state"} + assert spec["fabric_name"]["required"] is True + assert set(spec["state"]["choices"]) == {"merged", "replaced", "overridden", "deleted"} + + config_options = spec["config"]["options"] + assert "switch_ip" in config_options + assert "interface_name" in config_options + assert "config_data" in config_options + + policy_options = config_options["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + # Trunk-specific single fields + assert "allowed_vlans" in policy_options + assert "native_vlan" in policy_options + assert "vlan_mapping" in policy_options + assert "vlan_mapping_entries" in policy_options + # Per-peer presence + assert "peer1_port_channel_id" in policy_options + assert "peer1_member_ports" in policy_options + assert "peer2_port_channel_id" in policy_options + assert "peer2_member_ports" in policy_options + # Shared field samples + assert "admin_state" in policy_options + assert "lacp_rate" in policy_options + assert "port_channel_mode" in policy_options + assert "port_type_edge_trunk" in policy_options + # Frozen/injected exclusions + assert "interface_type" not in config_options + assert "policy_type" not in policy_options + assert "mode" not in policy_options + assert "network_os_type" not in policy_options + assert "peer_switch_id" not in policy_options + # Per-peer VLAN/Native explicitly NOT exposed (ND collapses on read; user sets single fields). + assert "peer1_allowed_vlans" not in policy_options + assert "peer2_allowed_vlans" not in policy_options + assert "peer1_native_vlan" not in policy_options + assert "peer2_native_vlan" not in policy_options + + +def test_vpc_trunk_host_interface_00510_argument_spec_vlan_mapping_entries_shape(): + """ + # Summary + + Verify `vlan_mapping_entries` argspec exposes the four nested options. + + ## Test + + - vlan_mapping_entries is a list of dicts with options: customer_vlan_id, customer_inner_vlan_id, dot1q_tunnel, provider_vlan_id + + ## Classes and Methods + + - TrunkVpcHostInterfaceModel.get_argument_spec() + """ + spec = TrunkVpcHostInterfaceModel.get_argument_spec() + policy_options = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + entries = policy_options["vlan_mapping_entries"] + assert entries["type"] == "list" + assert entries["elements"] == "dict" + assert set(entries["options"]) == {"customer_vlan_id", "customer_inner_vlan_id", "dot1q_tunnel", "provider_vlan_id"} + + +# ============================================================================= +# Test: Nested-model sanity (Config / NetworkOS) +# ============================================================================= + + +def test_vpc_trunk_host_interface_00600_nested_defaults(): + """ + # Summary + + Verify ConfigData and NetworkOS containers default their frozen discriminators. + + ## Test + + - TrunkVpcHostConfigDataModel.mode defaults to "trunk" + - TrunkVpcHostNetworkOSModel.network_os_type defaults to "nx-os" + + ## Classes and Methods + + - TrunkVpcHostConfigDataModel + - TrunkVpcHostNetworkOSModel + """ + network_os = TrunkVpcHostNetworkOSModel(policy=TrunkVpcHostPolicyModel()) + config = TrunkVpcHostConfigDataModel(network_os=network_os) + assert config.mode == "trunk" + assert config.network_os.network_os_type == "nx-os" diff --git a/tests/unit/module_utils/orchestrators/test_vpc_trunk_host_interface.py b/tests/unit/module_utils/orchestrators/test_vpc_trunk_host_interface.py new file mode 100644 index 000000000..3258c99ea --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_vpc_trunk_host_interface.py @@ -0,0 +1,492 @@ +# -*- 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 vpc_trunk_host_interface orchestrator. + +Verifies that `TrunkVpcHostInterfaceOrchestrator` correctly: +- declares the right `model_class` and `_managed_policy_types` +- inherits bulk-support flags from `VpcBaseOrchestrator` +- resolves the peer switch's serial via the vPC pair GET endpoint +- raises a clear `RuntimeError` when the primary switch is not in a vPC pair +- caches peer-serial lookups per orchestrator instance +- filters fabric-wide interface results to `interfaceType: "vpc"` plus the managed + policy types (so non-vPC interfaces and other-flavor vPCs are excluded) +- dedupes vPC interfaces that appear on both peers (ND query_all duplicate quirk) +- propagates `RuntimeError` from the inherited `validate_prerequisites` path +- uses per-interface `DELETE` (workaround for ND 4.2.1 bulk-remove silent fail) + +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_vpc_trunk_host_interface_orchestrator.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 pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.vpc_trunk_host_interface import ( + TrunkVpcHostInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_trunk_host_interface import ( + TrunkVpcHostInterfaceOrchestrator, +) +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_vpc_trunk_host(key: str): + """Load fixture data for the orchestrator's test_vpc_trunk_host_interface_orchestrator.json file.""" + return load_fixture("test_vpc_trunk_host_interface_orchestrator")[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") -> TrunkVpcHostInterfaceOrchestrator: + """Construct an orchestrator with the file-based RestSend injected.""" + rest_send = _build_rest_send(gen_responses, fabric_name=fabric_name) + return TrunkVpcHostInterfaceOrchestrator(rest_send=rest_send) + + +# ============================================================================= +# Test: ClassVar / model_class +# ============================================================================= + + +def test_vpc_trunk_host_orchestrator_00010() -> None: + """ + # Summary + + Verify `model_class` points to `TrunkVpcHostInterfaceModel`. + + ## Test + + - model_class is TrunkVpcHostInterfaceModel + + ## Classes and Methods + + - TrunkVpcHostInterfaceOrchestrator.model_class + """ + assert TrunkVpcHostInterfaceOrchestrator.model_class is TrunkVpcHostInterfaceModel + + +def test_vpc_trunk_host_orchestrator_00020() -> None: + """ + # Summary + + Verify bulk-support flags inherited from `VpcBaseOrchestrator`. Bulk-create is enabled (POST /interfaces accepts + an array); bulk-delete is disabled because ND 4.2.1's `interfaceActions/remove` returns `Invalid Interface` for + vPC entries — we use per-interface `DELETE` instead via the state machine's individual delete path. + + ## Test + + - supports_bulk_create is True + - supports_bulk_delete is False + + ## Classes and Methods + + - TrunkVpcHostInterfaceOrchestrator + """ + assert TrunkVpcHostInterfaceOrchestrator.supports_bulk_create is True + assert TrunkVpcHostInterfaceOrchestrator.supports_bulk_delete is False + + +# ============================================================================= +# Test: _managed_policy_types +# ============================================================================= + + +def test_vpc_trunk_host_orchestrator_00100() -> None: + """ + # Summary + + Verify `_managed_policy_types` returns the single `"trunkVpcHost"` API value. + + ## Test + + - Returned set contains exactly "trunkVpcHost" + + ## Classes and Methods + + - TrunkVpcHostInterfaceOrchestrator._managed_policy_types() + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + assert orchestrator._managed_policy_types() == {"trunkVpcHost"} + + +def test_vpc_trunk_host_orchestrator_00110() -> None: + """ + # Summary + + Verify `_managed_policy_types` returns a set. + + ## Test + + - Return type is set + + ## Classes and Methods + + - TrunkVpcHostInterfaceOrchestrator._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 "trunkVpcHost" in result + + +# ============================================================================= +# Test: _resolve_peer_switch_id +# ============================================================================= + + +def test_vpc_trunk_host_orchestrator_00500_peer_resolve_happy() -> None: + """ + # Summary + + Verify `_resolve_peer_switch_id` returns the peer serial when the primary is in a vPC pair. + + ## Test + + - GET /vpcPair returns peerSwitchId + - Helper returns that value + - Result is cached on the orchestrator + + ## Classes and Methods + + - VpcBaseOrchestrator._resolve_peer_switch_id() + """ + + def responses(): + yield responses_vpc_trunk_host("test_peer_resolve_happy_path_00500a") + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + + with does_not_raise(): + peer = orchestrator._resolve_peer_switch_id("192.168.1.1", "FDO11111AAA") + assert peer == "FDO22222BBB" + assert orchestrator._peer_serial_cache == {"FDO11111AAA": "FDO22222BBB"} + + +def test_vpc_trunk_host_orchestrator_00505_peer_resolve_cached() -> None: + """ + # Summary + + Verify `_resolve_peer_switch_id` returns the cached value without issuing a second GET. + + ## Test + + - First call fetches and caches + - Second call returns the cached value (no additional response is yielded) + + ## Classes and Methods + + - VpcBaseOrchestrator._resolve_peer_switch_id() + """ + + def responses(): + yield responses_vpc_trunk_host("test_peer_resolve_happy_path_00500a") + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + + first = orchestrator._resolve_peer_switch_id("192.168.1.1", "FDO11111AAA") + second = orchestrator._resolve_peer_switch_id("192.168.1.1", "FDO11111AAA") + assert first == second == "FDO22222BBB" + + +def test_vpc_trunk_host_orchestrator_00510_peer_resolve_not_paired() -> None: + """ + # Summary + + Verify `_resolve_peer_switch_id` raises a clear RuntimeError when the primary switch is not in a vPC pair. + + ## Test + + - GET /vpcPair returns 404 (live-lab structured error body) + - Helper raises RuntimeError mentioning the switch_ip and pointing the user at nd_vpc_pair + + ## Classes and Methods + + - VpcBaseOrchestrator._resolve_peer_switch_id() + """ + + def responses(): + yield responses_vpc_trunk_host("test_peer_resolve_not_paired_00510a") + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + + with pytest.raises(RuntimeError, match=r"192\.168\.1\.1.*not in a vPC pair.*nd_vpc_pair"): + orchestrator._resolve_peer_switch_id("192.168.1.1", "FDO11111AAA") + + +def test_vpc_trunk_host_orchestrator_00520_peer_resolve_missing_field() -> None: + """ + # Summary + + Verify `_resolve_peer_switch_id` raises RuntimeError when the vPC pair record is missing `peerSwitchId`. + + ## Test + + - GET /vpcPair returns 200 but body omits peerSwitchId + - Helper raises RuntimeError mentioning the missing field + + ## Classes and Methods + + - VpcBaseOrchestrator._resolve_peer_switch_id() + """ + + def responses(): + yield responses_vpc_trunk_host("test_peer_resolve_missing_field_00520a") + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + + with pytest.raises(RuntimeError, match=r"missing 'peerSwitchId'"): + orchestrator._resolve_peer_switch_id("192.168.1.1", "FDO11111AAA") + + +# ============================================================================= +# Test: query_all — happy path with filtering +# ============================================================================= + + +def test_vpc_trunk_host_orchestrator_00400_query_all_happy() -> None: + """ + # Summary + + Verify `query_all` validates the fabric, iterates all switches, filters to interfaceType=="vpc" + and policyType=="trunkVpcHost", and injects `switchIp` onto each kept interface. + + ## Test + + - Fabric summary (validate_prerequisites) returns 200 + - Switches list returns two switches + - Switch 1 returns: configured trunkVpcHost vpc, accessVpcHost vpc, ethernet trunkHost + - Switch 2 returns: one configured trunkVpcHost vpc + - Result contains exactly the two trunkVpcHost vPC interfaces + - Each has switchIp injected with the fabricManagementIp + + ## Classes and Methods + + - TrunkVpcHostInterfaceOrchestrator._managed_policy_types() + - VpcBaseOrchestrator.query_all() + """ + + def responses(): + yield responses_vpc_trunk_host("test_query_all_happy_path_00400a") + yield responses_vpc_trunk_host("test_query_all_happy_path_00400b") + yield responses_vpc_trunk_host("test_query_all_happy_path_00400c") + yield responses_vpc_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) == {"vpc500", "vpc600"} + + assert by_name["vpc500"]["switchIp"] == "192.168.1.1" + assert by_name["vpc600"]["switchIp"] == "192.168.1.2" + + # Filtered out: accessVpcHost (vpc100) and ethernet trunkHost (Ethernet1/1) + assert "vpc100" not in by_name + assert "Ethernet1/1" not in by_name + + +def test_vpc_trunk_host_orchestrator_00410_query_all_no_match() -> None: + """ + # Summary + + Verify `query_all` returns an empty list when no switch reports any trunkVpcHost vPC. + + ## Test + + - Switch returns only non-vPC and non-trunkVpcHost vPC interfaces + - Result is an empty list + + ## Classes and Methods + + - TrunkVpcHostInterfaceOrchestrator._managed_policy_types() + - VpcBaseOrchestrator.query_all() + """ + + def responses(): + yield responses_vpc_trunk_host("test_query_all_no_match_00410a") + yield responses_vpc_trunk_host("test_query_all_no_match_00410b") + yield responses_vpc_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_vpc_trunk_host_orchestrator_00420_query_all_fabric_not_found() -> 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" + + ## Classes and Methods + + - VpcBaseOrchestrator.query_all() + - FabricContext.validate_for_mutation() + """ + + def responses(): + yield responses_vpc_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_vpc_trunk_host_orchestrator_00430_query_all_dedup() -> None: + """ + # Summary + + Verify `query_all` dedupes vPC interfaces that appear on both peers. ND returns each vPC interface twice + (once per peer GET) with identical configData; without dedupe, `_manage_override_deletions` would treat the + peer-side copy as "not in proposed" and queue a spurious delete. + + ## Test + + - Two switches in the fabric, both return the same `vpc500` configuration + - Result contains exactly ONE entry for `vpc500` + - Canonical representative is the entry with the alphabetically-lower `switchId` + (FDOAAAAAAAA = 192.168.1.1 is kept; FDOBBBBBBBB = 192.168.1.2 is dropped) + + ## Classes and Methods + + - VpcBaseOrchestrator.query_all() dedupe logic + """ + + def responses(): + yield responses_vpc_trunk_host("test_query_all_dedup_00430a") + yield responses_vpc_trunk_host("test_query_all_dedup_00430b") + yield responses_vpc_trunk_host("test_query_all_dedup_00430c") + yield responses_vpc_trunk_host("test_query_all_dedup_00430d") + + gen_responses = ResponseGenerator(responses()) + + with does_not_raise(): + orchestrator = _build_orchestrator(gen_responses) + result = orchestrator.query_all() + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["interfaceName"] == "vpc500" + # Canonical representative is the lower-switchId one + assert result[0]["switchIp"] == "192.168.1.1" + assert result[0]["configData"]["networkOS"]["policy"]["peerSwitchId"] == "FDOBBBBBBBB" + + +# ============================================================================= +# Test: delete uses per-interface endpoint +# ============================================================================= + + +def test_vpc_trunk_host_orchestrator_00600_delete_uses_per_interface_endpoint() -> None: + """ + # Summary + + Verify `delete()` calls the per-interface `DELETE /interfaces/{name}` endpoint (not the bulk + `interfaceActions/remove` which silently fails for vPC on ND 4.2.1) and queues a deploy for the same + `(interface_name, switch_id)` pair. + + ## Test + + - Switch-map fixture resolves switch_ip -> switch_id + - DELETE call returns 204 (no body) + - delete() does not raise + - Deploy queue contains exactly one entry: (vpc500, FDOAAAAAAAA) + - Bulk-remove queue stays untouched (per-interface DELETE path) + + ## Classes and Methods + + - VpcBaseOrchestrator.delete() + """ + + def responses(): + yield { + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": {"switches": [{"fabricManagementIp": "192.168.1.1", "switchId": "FDOAAAAAAAA"}]}, + } + yield { + "RETURN_CODE": 204, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDOAAAAAAAA/interfaces/vpc500", + "MESSAGE": "No Content", + "DATA": {}, + } + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + + model = TrunkVpcHostInterfaceModel(switch_ip="192.168.1.1", interface_name="vpc500") + with does_not_raise(): + orchestrator.delete(model) + + assert orchestrator._pending_deploys == [("vpc500", "FDOAAAAAAAA")] + # Bulk-remove queue stays untouched because we use per-interface DELETE. + assert orchestrator._pending_removes == []