diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 37425f4c8..2a7481a96 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -17,8 +17,12 @@ (POST /api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces) - `EpManageInterfacesPut` - Update a specific interface (PUT /api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}) +- `EpManageInterfacesDelete` - Delete a virtual interface (loopback, SVI); not supported for physical ethernet + (DELETE /api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}) - `EpManageInterfacesDeploy` - Deploy interface configurations (POST /api/v1/manage/fabrics/{fabric_name}/interfaceActions/deploy) +- `EpManageInterfacesNormalize` - Reset physical interface configurations to default + (POST /api/v1/manage/fabrics/{fabric_name}/interfaceActions/normalize) - `EpManageInterfacesRemove` - Bulk delete interfaces (POST /api/v1/manage/fabrics/{fabric_name}/interfaceActions/remove) """ @@ -238,6 +242,46 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.PUT +class EpManageInterfacesDelete(_EpManageInterfacesBase): + """ + # Summary + + Delete a specific interface configuration. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}` + - Verb: DELETE + + This endpoint works for virtual interfaces (loopback, SVI) only. For physical ethernet interfaces, the API returns + HTTP 500 ("Interface cannot be deleted!!!"). + + To reset physical interfaces to their default state, see `EpManageInterfacesNormalize` and set the payload to an + appropriate default config (for example `module_utils/models/interfaces/interface_default_config.py`). + + ## Raises + + ### ValueError + + - Via inherited `path` property if `fabric_name`, `switch_sn`, or `interface_name` is not set. + """ + + class_name: Literal["EpManageInterfacesDelete"] = Field( + default="EpManageInterfacesDelete", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.DELETE`. + + ## Raises + + None + """ + return HttpVerbEnum.DELETE + + class EpManageInterfacesDeploy(FabricNameMixin, NDEndpointBaseModel): """ # Summary @@ -290,6 +334,58 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST +class EpManageInterfacesNormalize(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Normalize interface configurations on switches. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/normalize` + - Verb: POST + - Body: `{"interfaceType": "ethernet", "configData": {...}, "switchInterfaces": [{"interfaceName": "...", "switchId": "..."}]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpManageInterfacesNormalize"] = Field( + default="EpManageInterfacesNormalize", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the normalize endpoint path. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + """ + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", self.fabric_name, "interfaceActions", "normalize") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST + + class EpManageInterfacesRemove(FabricNameMixin, NDEndpointBaseModel): """ # Summary diff --git a/plugins/module_utils/models/interfaces/enums.py b/plugins/module_utils/models/interfaces/enums.py new file mode 100644 index 000000000..7847ae9cb --- /dev/null +++ b/plugins/module_utils/models/interfaces/enums.py @@ -0,0 +1,135 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +# Summary + +Shared enum definitions for ethernet interface models. + +These enums are derived from ND config templates (e.g. `int_access_host`, `int_trunk_host`) and constrain policy +fields across multiple interface types. Each enum's member values match the API's expected strings exactly. +""" + +from __future__ import annotations + +from enum import Enum + + +class AccessHostPolicyTypeEnum(str, Enum): + """ + # Summary + + Policy type for access host interfaces. + """ + + ACCESS_HOST = "accessHost" + + +class BpduFilterEnum(str, Enum): + """ + # Summary + + Spanning-tree BPDU filter settings. + """ + + ENABLE = "enable" + DISABLE = "disable" + DEFAULT = "default" + + +class BpduGuardEnum(str, Enum): + """ + # Summary + + Spanning-tree BPDU guard settings. + """ + + ENABLE = "enable" + DISABLE = "disable" + DEFAULT = "default" + + +class DuplexModeEnum(str, Enum): + """ + # Summary + + Port duplex mode settings. + """ + + AUTO = "auto" + FULL = "full" + HALF = "half" + + +class FecEnum(str, Enum): + """ + # Summary + + Forward error correction (FEC) mode. + """ + + AUTO = "auto" + FC_FEC = "fcFec" + OFF = "off" + RS_CONS16 = "rsCons16" + RS_FEC = "rsFec" + RS_IEEE = "rsIEEE" + + +class LinkTypeEnum(str, Enum): + """ + # Summary + + Spanning-tree link type. + """ + + AUTO = "auto" + POINT_TO_POINT = "pointToPoint" + SHARED = "shared" + + +class MtuEnum(str, Enum): + """ + # Summary + + Interface MTU setting. + """ + + DEFAULT = "default" + JUMBO = "jumbo" + + +class SpeedEnum(str, Enum): + """ + # Summary + + Interface speed setting. + """ + + AUTO = "auto" + TEN_MB = "10Mb" + HUNDRED_MB = "100Mb" + ONE_GB = "1Gb" + TWO_POINT_FIVE_GB = "2.5Gb" + FIVE_GB = "5Gb" + TEN_GB = "10Gb" + TWENTY_FIVE_GB = "25Gb" + FORTY_GB = "40Gb" + FIFTY_GB = "50Gb" + HUNDRED_GB = "100Gb" + TWO_HUNDRED_GB = "200Gb" + FOUR_HUNDRED_GB = "400Gb" + EIGHT_HUNDRED_GB = "800Gb" + + +class StormControlActionEnum(str, Enum): + """ + # Summary + + Storm control action on threshold violation. + """ + + SHUTDOWN = "shutdown" + TRAP = "trap" + DEFAULT = "default" diff --git a/plugins/module_utils/models/interfaces/ethernet_access_interface.py b/plugins/module_utils/models/interfaces/ethernet_access_interface.py new file mode 100644 index 000000000..d46c2d8e4 --- /dev/null +++ b/plugins/module_utils/models/interfaces/ethernet_access_interface.py @@ -0,0 +1,301 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Ethernet access (accessHost) interface Pydantic models for Nexus Dashboard. + +This module defines nested Pydantic models that mirror the ND Manage Interfaces API payload +structure for ethernet accessHost interfaces. The playbook config uses the same nesting so that +`to_payload()` and `from_response()` work via standard Pydantic serialization with no custom +wrapping or flattening. + +## Model Hierarchy + +- `EthernetAccessInterfaceModel` (top-level, `NDBaseModel`) + - `interface_name` (identifier) + - `interface_type` (default: "ethernet") + - `config_data` -> `EthernetAccessConfigDataModel` + - `mode` (default: "access") + - `network_os` -> `EthernetAccessNetworkOSModel` + - `network_os_type` (default: "nx-os") + - `policy` -> `EthernetAccessPolicyModel` + - `admin_state`, `access_vlan`, `bpdu_guard`, `speed`, `policy_type`, etc. +""" + +from typing import ClassVar, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import ( + AccessHostPolicyTypeEnum, + BpduFilterEnum, + BpduGuardEnum, + DuplexModeEnum, + FecEnum, + LinkTypeEnum, + MtuEnum, + SpeedEnum, + StormControlActionEnum, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.types import AsciiDescription + + +class EthernetAccessPolicyModel(NDNestedModel): + """ + # Summary + + Policy fields for an ethernet accessHost interface. Maps directly to the `configData.networkOS.policy` object in the ND API. + + ## Raises + + None + """ + + admin_state: bool | None = Field(default=None, alias="adminState", description="Enable or disable the interface") + access_vlan: int | None = Field(default=None, alias="accessVlan", ge=1, le=4094, description="VLAN for this access port") + bandwidth: int | None = Field(default=None, alias="bandwidth", ge=1, le=100000000, description="Bandwidth in kilobits") + 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") + debounce_timer: int | None = Field(default=None, alias="debounceTimer", ge=0, le=20000, description="Link debounce timer in milliseconds") + debounce_linkup_timer: int | None = Field( + default=None, alias="debounceLinkupTimer", ge=1000, le=10000, description="Link debounce link-up timer in milliseconds" + ) + description: AsciiDescription = Field(default=None, alias="description", max_length=254, description="Interface description") + duplex_mode: DuplexModeEnum | None = Field(default=None, alias="duplexMode", description="Port duplex mode") + error_detection_acl: bool | None = Field(default=None, alias="errorDetectionAcl", description="Enable error detection for ACL installation failures") + extra_config: str | None = Field(default=None, alias="extraConfig", description="Additional CLI for the interface") + fec: FecEnum | None = Field(default=None, alias="fec", description="Forward error correction mode") + inherit_bandwidth: int | None = Field( + default=None, alias="inheritBandwidth", ge=1, le=100000000, description="Inherit bandwidth in kilobits for sub-interfaces" + ) + link_type: LinkTypeEnum | None = Field(default=None, alias="linkType", description="Spanning-tree link type") + monitor: bool | None = Field(default=None, alias="monitor", description="Enable switchport monitor for SPAN/ERSPAN") + mtu: MtuEnum | None = Field(default=None, alias="mtu", description="Interface MTU") + negotiate_auto: bool | None = Field(default=None, alias="negotiateAuto", description="Enable link auto-negotiation") + netflow: bool | None = Field(default=None, alias="netflow", description="Enable Netflow on the interface") + netflow_monitor: str | None = Field(default=None, alias="netflowMonitor", description="Layer 2 Netflow monitor name") + netflow_sampler: str | None = Field(default=None, alias="netflowSampler", description="Netflow sampler name") + orphan_port: bool | None = Field(default=None, alias="orphanPort", description="Enable vPC orphan port") + pfc: bool | None = Field(default=None, alias="pfc", description="Enable priority flow control") + policy_type: AccessHostPolicyTypeEnum = Field( + default=AccessHostPolicyTypeEnum.ACCESS_HOST, alias="policyType", description="Interface policy type" + ) + port_type_edge_trunk: bool | None = Field(default=None, alias="portTypeEdgeTrunk", description="Enable spanning-tree edge port behavior") + 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", + ) + + +class EthernetAccessNetworkOSModel(NDNestedModel): + """ + # Summary + + Network OS container for an ethernet accessHost interface. Maps to `configData.networkOS` in the ND API. + + ## Raises + + None + """ + + network_os_type: str = Field(default="nx-os", alias="networkOSType") + policy: EthernetAccessPolicyModel | None = Field(default=None, alias="policy") + + +class EthernetAccessConfigDataModel(NDNestedModel): + """ + # Summary + + Config data container for an ethernet accessHost interface. Maps to `configData` in the ND API. + + ## Raises + + None + """ + + mode: str = Field(default="access", alias="mode") + network_os: EthernetAccessNetworkOSModel = Field(alias="networkOS") + + +class EthernetAccessInterfaceModel(NDBaseModel): + """ + # Summary + + Ethernet accessHost interface configuration for Nexus Dashboard. + + Uses a composite identifier (`switch_ip`, `interface_name`). The nested model structure mirrors the ND Manage + Interfaces API payload, so `to_payload()` and `from_response()` work via standard Pydantic serialization. + + ## Raises + + None + """ + + # --- Identifier Configuration --- + + identifiers: ClassVar[list[str] | None] = ["switch_ip", "interface_name"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "composite" + + # --- Serialization Configuration --- + + payload_exclude_fields: ClassVar[set[str]] = {"switch_ip"} + + # --- Fields --- + + switch_ip: str = Field(alias="switchIp") + interface_name: str = Field(alias="interfaceName") + interface_type: str = Field(default="ethernet", alias="interfaceType") + config_data: EthernetAccessConfigDataModel | None = Field(default=None, alias="configData") + + @field_validator("interface_name", mode="before") + @classmethod + def normalize_interface_name(cls, value): + """ + # Summary + + Normalize interface name to match ND API convention (e.g., ethernet1/1 -> Ethernet1/1). + + ## Raises + + None + """ + if isinstance(value, str) and value: + return value[0].upper() + value[1:] + return value + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> dict: + """ + # Summary + + Return the Ansible argument spec for the `nd_interface_ethernet_access` module. + + ## Raises + + None + """ + return dict( + fabric_name=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + switch_ip=dict(type="str", required=True), + interface_names=dict(type="list", elements="str", required=True), + interface_type=dict(type="str", default="ethernet"), + config_data=dict( + type="dict", + options=dict( + mode=dict(type="str", default="access"), + network_os=dict( + type="dict", + options=dict( + network_os_type=dict(type="str", default="nx-os"), + policy=dict( + type="dict", + options=dict( + admin_state=dict(type="bool"), + access_vlan=dict(type="int"), + 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"), + debounce_timer=dict(type="int"), + debounce_linkup_timer=dict(type="int"), + description=dict(type="str"), + duplex_mode=dict(type="str", choices=[e.value for e in DuplexModeEnum]), + error_detection_acl=dict(type="bool"), + extra_config=dict(type="str"), + fec=dict(type="str", choices=[e.value for e in FecEnum]), + inherit_bandwidth=dict(type="int"), + link_type=dict(type="str", choices=[e.value for e in LinkTypeEnum]), + monitor=dict(type="bool"), + mtu=dict(type="str", choices=[e.value for e in MtuEnum]), + negotiate_auto=dict(type="bool"), + netflow=dict(type="bool"), + netflow_monitor=dict(type="str"), + netflow_sampler=dict(type="str"), + orphan_port=dict(type="bool"), + pfc=dict(type="bool"), + port_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"), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/models/interfaces/interface_default_config.py b/plugins/module_utils/models/interfaces/interface_default_config.py new file mode 100644 index 000000000..e1c960a38 --- /dev/null +++ b/plugins/module_utils/models/interfaces/interface_default_config.py @@ -0,0 +1,133 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Default interface configuration for normalizing physical ethernet interfaces on Nexus Dashboard. + +Physical ethernet interfaces cannot be deleted — neither `interfaceActions/remove`, `interfaceActions/normalize` +with accessHost config, nor the per-interface `DELETE` endpoint works. However, `interfaceActions/normalize` +DOES work when the payload uses the ND `int_trunk_host` config template with `policyType: "trunkHost"` and +`mode: "trunk"`. This resets the interface to the fabric default trunk host configuration. + +`InterfaceDefaultConfig` provides the default `int_trunk_host` template values as a Pydantic model. +The `to_normalize_payload()` class method builds the full `interfaceActions/normalize` request body +from a list of `(interface_name, switch_id)` pairs. +""" + +from __future__ import annotations + +from typing import ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +class InterfaceDefaultPolicyModel(NDNestedModel): + """ + # Summary + + Default policy values from the ND `int_trunk_host` config template. These values represent the fabric default + configuration for a physical ethernet interface. + + ## Raises + + None + """ + + access_vlan: int = Field(default=1, alias="accessVlan") + allowed_vlans: str = Field(default="none", alias="allowedVlans") + admin_state: bool = Field(default=True, alias="adminState") + bpdu_guard: str = Field(default="default", alias="bpduGuard") + bpdu_filter: str = Field(default="default", alias="bpduFilter") + cdp: bool = Field(default=True) + config_template: str = Field(default="int_trunk_host", alias="configTemplate") + debounce_timer: int = Field(default=100, alias="debounceTimer") + duplex_mode: str = Field(default="auto", alias="duplexMode") + error_detection_acl: bool = Field(default=True, alias="errorDetectionAcl") + extra_config: str = Field(default="", alias="extraConfig") + fec: str = Field(default="auto") + link_type: str = Field(default="auto", alias="linkType") + mode: str = Field(default="trunk") + monitor: bool = Field(default=False) + mtu: str = Field(default="jumbo") + negotiate_auto: bool = Field(default=True, alias="negotiateAuto") + netflow: bool = Field(default=False) + orphan_port: bool = Field(default=False, alias="orphanPort") + pfc: bool = Field(default=False) + policy_type: str = Field(default="trunkHost", alias="policyType") + port_type_edge_trunk: bool = Field(default=True, alias="portTypeEdgeTrunk") + qos: bool = Field(default=False) + speed: str = Field(default="auto") + storm_control: bool = Field(default=False, alias="stormControl") + storm_control_action: str = Field(default="default", alias="stormControlAction") + vlan_mapping: bool = Field(default=False, alias="vlanMapping") + + +class InterfaceDefaultNetworkOSModel(NDNestedModel): + """ + # Summary + + Default networkOS wrapper for the `int_trunk_host` config template. + + ## Raises + + None + """ + + network_os_type: str = Field(default="nx-os", alias="networkOSType") + policy: InterfaceDefaultPolicyModel = Field(default_factory=InterfaceDefaultPolicyModel) + + +class InterfaceDefaultConfigDataModel(NDNestedModel): + """ + # Summary + + Default configData wrapper for the `int_trunk_host` config template. + + ## Raises + + None + """ + + mode: str = Field(default="trunk") + network_os: InterfaceDefaultNetworkOSModel = Field(default_factory=InterfaceDefaultNetworkOSModel, alias="networkOS") + + +class InterfaceDefaultConfig(NDNestedModel): + """ + # Summary + + Default interface configuration model for normalizing physical ethernet interfaces to their fabric default state + via the `interfaceActions/normalize` API. + + Uses the ND `int_trunk_host` config template defaults. After normalization, the interface has `policyType: "trunkHost"` + which removes it from the accessHost (and other type-specific) filters in `query_all()`. + + Use `to_normalize_payload()` to build the full request body for `interfaceActions/normalize`. + + ## Raises + + None + """ + + interface_type: str = Field(default="ethernet", alias="interfaceType") + config_data: InterfaceDefaultConfigDataModel = Field(default_factory=InterfaceDefaultConfigDataModel, alias="configData") + + PAYLOAD_FIELDS: ClassVar[list[str]] = [] + + @classmethod + def to_normalize_payload(cls, switch_interfaces: list[tuple[str, str]]) -> dict: + """ + # Summary + + Build the full `interfaceActions/normalize` request body from a list of `(interface_name, switch_id)` pairs. + + ## Raises + + None + """ + instance = cls() + payload = instance.to_payload() + payload["switchInterfaces"] = [{"interfaceName": name, "switchId": switch_id} for name, switch_id in switch_interfaces] + return payload diff --git a/plugins/module_utils/orchestrators/ethernet_access_interface.py b/plugins/module_utils/orchestrators/ethernet_access_interface.py new file mode 100644 index 000000000..da65c40c1 --- /dev/null +++ b/plugins/module_utils/orchestrators/ethernet_access_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) + +""" +Ethernet accessHost interface orchestrator for Nexus Dashboard. + +This module provides `EthernetAccessInterfaceOrchestrator`, which manages CRUD operations +for ethernet accessHost interfaces. It inherits all shared ethernet logic from +`EthernetBaseOrchestrator` 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 AccessHostPolicyTypeEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.ethernet_access_interface import ( + EthernetAccessInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.ethernet_base import EthernetBaseOrchestrator + + +class EthernetAccessInterfaceOrchestrator(EthernetBaseOrchestrator): + """ + # Summary + + Orchestrator for ethernet accessHost interface CRUD operations on Nexus Dashboard. + + Inherits all shared ethernet logic from `EthernetBaseOrchestrator`. Defines `model_class` as + `EthernetAccessInterfaceModel` and manages the `accessHost` policy type. + + ## Raises + + ### RuntimeError + + - Via inherited methods. See `EthernetBaseOrchestrator` for full details. + """ + + model_class: ClassVar[Type[NDBaseModel]] = EthernetAccessInterfaceModel + + 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 AccessHostPolicyTypeEnum} diff --git a/plugins/module_utils/orchestrators/ethernet_base.py b/plugins/module_utils/orchestrators/ethernet_base.py new file mode 100644 index 000000000..59dd374e0 --- /dev/null +++ b/plugins/module_utils/orchestrators/ethernet_base.py @@ -0,0 +1,410 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pyright: reportAttributeAccessIssue=false +# ModelType is NDBaseModel which lacks interface-specific fields (switch_ip, +# interface_name, config_data). Concrete subclasses always bind ModelType to a +# model that provides these fields, so the accesses are safe at runtime. + +""" +Base orchestrator for ethernet interface modules on Nexus Dashboard. + +This module provides `EthernetBaseOrchestrator`, which implements shared CRUD operations +for all ethernet interface types (accessHost, trunkHost, routed, etc.) via the ND Manage +Interfaces API. Type-specific orchestrators inherit from this base and provide their own +`model_class` and `_managed_policy_types()`. + +Inherits shared interface lifecycle operations (deploy queuing, fabric validation, switch +resolution) from `NDBaseInterfaceOrchestrator` and adds ethernet-specific functionality: +- Normalize-based deletion (physical interfaces cannot be deleted via remove/DELETE) +- Port-channel membership enforcement with a whitelisted field set +- Fabric-wide `query_all()` with per-type policy filtering +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_interfaces import ( + EpManageInterfacesGet, + EpManageInterfacesListGet, + EpManageInterfacesNormalize, + EpManageInterfacesPost, + EpManageInterfacesPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.interface_default_config import InterfaceDefaultConfig +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base_interface import NDBaseInterfaceOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType + +ModelType = NDBaseModel + + +class EthernetBaseOrchestrator(NDBaseInterfaceOrchestrator[ModelType]): + """ + # Summary + + Base orchestrator for ethernet interface CRUD operations on Nexus Dashboard. + + Provides shared logic for all ethernet interface types. Subclasses must set `model_class` and implement + `_managed_policy_types()` to define which policy types they manage. + + Supports configuring interfaces across multiple switches in a single task. Each config item + includes a `switch_ip` that is resolved to a `switchId` via `FabricContext`. + + Mutation methods (`create`, `update`) enforce port-channel membership restrictions and queue deploys + for bulk execution. Call `deploy_pending` after all mutations are complete. + + ## Raises + + ### RuntimeError + + - Via `validate_prerequisites` if the fabric does not exist or is in deployment-freeze mode. + - Via `_resolve_switch_id` if no switch matches the given IP in the fabric. + - Via `_check_port_channel_restrictions` if a non-whitelisted field is modified on a port-channel member. + - Via `create` if the create API request fails. + - Via `update` if the update API request fails. + - Via `remove_pending` if the bulk normalize API request fails. + - Via `deploy_pending` if the bulk deploy API request fails. + - Via `query_one` if the query API request fails. + - Via `query_all` if the query API request fails. + """ + + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True + + create_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesPost + update_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesPut + delete_endpoint: type[NDEndpointBaseModel] = NDEndpointBaseModel # unused; delete() uses bulk normalize + query_one_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesGet + query_all_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesListGet + create_bulk_endpoint: type[NDEndpointBaseModel] | None = EpManageInterfacesPost + delete_bulk_endpoint: type[NDEndpointBaseModel] | None = EpManageInterfacesNormalize + + PORT_CHANNEL_MODIFIABLE_FIELDS: ClassVar[set[str]] = {"description", "admin_state", "extra_config"} + + _pending_normalizes: list[tuple[str, str]] = [] + + def _managed_policy_types(self) -> set[str]: + """ + # Summary + + Return the set of API-side policy type values managed by this orchestrator. Subclasses must override this method + to return their specific policy types (e.g., `{"accessHost"}` for the access orchestrator). + + ## Raises + + ### NotImplementedError + + - Always, if not overridden by a subclass. + """ + raise NotImplementedError("Subclasses must implement _managed_policy_types()") + + def _queue_normalize(self, interface_name: str, switch_id: str) -> None: + """ + # Summary + + Queue an `(interface_name, switch_id)` pair for deferred normalization. Call `remove_pending` after all mutations + are complete to normalize in bulk via `interfaceActions/normalize`. + + ## Raises + + None + """ + pair = (interface_name, switch_id) + if pair not in self._pending_normalizes: + self._pending_normalizes.append(pair) + + def _check_port_channel_restrictions(self, model_instance: ModelType, existing_data: dict | None = None) -> None: + """ + # Summary + + Check if the interface is a port-channel member and validate that only whitelisted fields are being modified. + If the interface is a port-channel member and non-whitelisted fields are being changed, raise `RuntimeError`. + + ## Raises + + ### RuntimeError + + - If the interface is a port-channel member and non-whitelisted fields are being modified. + """ + if existing_data is None: + return + + port_channel_id = existing_data.get("configData", {}).get("networkOS", {}).get("policy", {}).get("portChannelId") + if not port_channel_id: + return + + if model_instance.config_data is None: + return + + policy = model_instance.config_data.network_os.policy if model_instance.config_data.network_os else None + if policy is None: + return + + changed_fields = set() + for field_name in policy.model_fields: + value = getattr(policy, field_name) + if value is not None and field_name != "policy_type": + changed_fields.add(field_name) + + non_whitelisted = changed_fields - self.PORT_CHANNEL_MODIFIABLE_FIELDS + if non_whitelisted: + raise RuntimeError( + f"Interface {model_instance.interface_name} is a member of port-channel {port_channel_id}. " + f"The following fields cannot be modified on port-channel members: {sorted(non_whitelisted)}. " + f"Only these fields can be modified: {sorted(self.PORT_CHANNEL_MODIFIABLE_FIELDS)}." + ) + + def remove_pending(self) -> ResponseType | None: + """ + # Summary + + Normalize all queued interface configurations in a single bulk API call via `interfaceActions/normalize`, + resetting them to the fabric default `int_trunk_host` template. This changes the interfaces to + `policyType: "trunkHost"`, which removes them from the type-specific filters in `query_all()`. + + Physical ethernet interfaces cannot be deleted via `interfaceActions/remove` (silently does nothing for + physical interfaces) or `DELETE` (returns 500). The normalize endpoint works when given the full + `int_trunk_host` template defaults with `mode: "trunk"` and `policyType: "trunkHost"`. + + Clears the pending queue after normalization. + + ## Raises + + ### RuntimeError + + - If the normalize API request fails. + """ + if not self._pending_normalizes: + return None + try: + result = self._normalize_interfaces() + self._pending_normalizes = [] + return result + except Exception as e: + raise RuntimeError(f"Bulk normalize failed for interfaces {self._pending_normalizes}: {e}") from e + + def _normalize_interfaces(self) -> ResponseType: + """ + # Summary + + Normalize queued interfaces via `interfaceActions/normalize` using the `InterfaceDefaultConfig` model + which provides the full `int_trunk_host` template defaults. + + ## Raises + + ### Exception + + - If the normalize API request fails (propagated to caller). + """ + api_endpoint = EpManageInterfacesNormalize() + api_endpoint.fabric_name = self.fabric_name + payload = InterfaceDefaultConfig.to_normalize_payload(self._pending_normalizes) + return self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=payload) + + def create(self, model_instance: ModelType, **kwargs) -> ResponseType: + """ + # Summary + + Create an ethernet interface configuration. Resolves `switch_ip` from the model instance, checks port-channel + membership restrictions, injects `switchId`, and wraps the payload in an `interfaces` array. Queues a deploy + for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the interface is a port-channel member and non-whitelisted fields are being modified. + - If the create API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._check_port_channel_restrictions(model_instance, kwargs.get("existing_data")) + api_endpoint = self._configure_endpoint(self.create_endpoint(), switch_sn=switch_id) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + request_body = {"interfaces": [payload]} + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=request_body) + self._queue_deploy(model_instance.interface_name, switch_id) + return result + except Exception as e: + raise RuntimeError(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e + + def update(self, model_instance: ModelType, **kwargs) -> ResponseType: + """ + # Summary + + Update an ethernet interface configuration. Resolves `switch_ip` from the model instance, checks port-channel + membership restrictions, injects `switchId` into the payload. Queues a deploy for later bulk execution + via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the interface is a port-channel member and non-whitelisted fields are being modified. + - If the update API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._check_port_channel_restrictions(model_instance, kwargs.get("existing_data")) + api_endpoint = self._configure_endpoint(self.update_endpoint(), switch_sn=switch_id) + api_endpoint.set_identifiers(model_instance.interface_name) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=payload) + self._queue_deploy(model_instance.interface_name, switch_id) + return result + except Exception as e: + raise RuntimeError(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e + + def delete(self, model_instance: ModelType, **kwargs) -> ResponseType: + """ + # Summary + + Queue an ethernet interface for normalization to the fabric default `int_trunk_host` template. The actual + normalize API call is deferred to `remove_pending()` for bulk execution via `interfaceActions/normalize`. + + After normalization, the interface has `policyType: "trunkHost"` which removes it from the type-specific + filters in `query_all()`, making it invisible to this orchestrator on subsequent runs. + + A deploy is also queued to push the normalized config to the switch. + + ## Raises + + ### RuntimeError + + - If switch IP resolution fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._queue_normalize(model_instance.interface_name, switch_id) + self._queue_deploy(model_instance.interface_name, switch_id) + return {} + except Exception as e: + raise RuntimeError(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e + + def create_bulk(self, model_instances: list[ModelType], **kwargs) -> ResponseType: + """ + # Summary + + Create multiple ethernet interfaces in bulk. Groups interfaces by switch and sends one POST per switch with all + interfaces in the `interfaces` array, reducing API calls from N to one-per-switch. Port-channel membership + restrictions are checked for each interface. Queues deploys for all created interfaces for later bulk execution + via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If any interface is a port-channel member and non-whitelisted fields are being modified. + - If any create API request fails. + """ + try: + groups: dict[str, list[tuple[str, dict]]] = defaultdict(list) + for model_instance in model_instances: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._check_port_channel_restrictions(model_instance, kwargs.get("existing_data")) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + groups[switch_id].append((model_instance.interface_name, payload)) + + results = [] + for switch_id, items in groups.items(): + # Guarded at runtime by @requires_bulk_support("supports_bulk_create") + api_endpoint = self._configure_endpoint(self.create_bulk_endpoint(), switch_sn=switch_id) # pyright: ignore[reportOptionalCall] + request_body = {"interfaces": [payload for interface_name, payload in items]} + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=request_body) + results.append(result) + for interface_name, payload in items: + self._queue_deploy(interface_name, switch_id) + return results + except Exception as e: + raise RuntimeError(f"Bulk create failed: {e}") from e + + def delete_bulk(self, model_instances: list[ModelType], **kwargs) -> None: + """ + # Summary + + Queue multiple ethernet interfaces for deferred bulk normalization and deployment. Each interface is queued + for normalization via `remove_pending` (which resets it to the `int_trunk_host` template) and deployment via + `deploy_pending`. No API calls are made until those methods are called after `manage_state` completes. + + ## Raises + + None + """ + for model_instance in model_instances: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._queue_normalize(model_instance.interface_name, switch_id) + self._queue_deploy(model_instance.interface_name, switch_id) + + def query_one(self, model_instance: ModelType, **kwargs) -> ResponseType: + """ + # Summary + + Query a single ethernet interface by name on a specific switch. + + ## Raises + + ### RuntimeError + + - If the query API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.query_one_endpoint(), switch_sn=switch_id) + api_endpoint.set_identifiers(model_instance.interface_name) + return self._request(path=api_endpoint.path, verb=api_endpoint.verb) + except Exception as e: + raise RuntimeError(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e + + def query_all(self, model_instance: ModelType | None = None, **kwargs) -> ResponseType: + """ + # Summary + + Validate the fabric context and query all interfaces across ALL switches in the fabric, filtering for + ethernet interfaces with policy types managed by this orchestrator (as defined by `_managed_policy_types()`). + + Port-channel member interfaces are included in the results (they exist on the switch and need to be visible + for port-channel restriction checks), but `state: overridden` handling in the state machine should skip them. + + Runs `validate_prerequisites` on first call to ensure the fabric exists and is modifiable before returning any data. + + Each returned interface dict is enriched with a `switch_ip` field so that the model can be constructed + with the composite identifier `(switch_ip, interface_name)`. + + ## Raises + + ### RuntimeError + + - If the fabric does not exist on the target ND node. + - If the fabric is in deployment-freeze mode. + - If the query API request fails. + """ + managed_types = self._managed_policy_types() + try: + self.validate_prerequisites() + all_interfaces = [] + for switch_ip, switch_id in self.fabric_context.switch_map.items(): + api_endpoint = self._configure_endpoint(self.query_all_endpoint(), switch_sn=switch_id) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, not_found_ok=True) + if not result: + continue + interfaces = result.get("interfaces", []) or [] + ethernet_interfaces = [iface for iface in interfaces if iface.get("interfaceType") == "ethernet"] + managed = [ + iface + for iface in ethernet_interfaces + if iface.get("configData", {}).get("networkOS", {}).get("policy", {}).get("policyType") in managed_types + ] + for iface in managed: + iface["switchIp"] = switch_ip + all_interfaces.extend(managed) + return all_interfaces + except Exception as e: + raise RuntimeError(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_interface_ethernet_access.py b/plugins/modules/nd_interface_ethernet_access.py new file mode 100644 index 000000000..b1cd50a78 --- /dev/null +++ b/plugins/modules/nd_interface_ethernet_access.py @@ -0,0 +1,392 @@ +#!/usr/bin/python + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_interface_ethernet_access +version_added: "1.4.0" +short_description: Manage ethernet accessHost interfaces on Cisco Nexus Dashboard +description: +- Manage ethernet accessHost interfaces on Cisco Nexus Dashboard. +- It supports creating, updating, querying, and deleting accessHost interface configurations on switches within a fabric. +- Multiple interfaces can share the same configuration via the O(config[].interface_names) list. +- Interfaces that are port-channel members have restricted mutability; only O(config[].config_data.network_os.policy.description), + O(config[].config_data.network_os.policy.admin_state), and O(config[].config_data.network_os.policy.extra_config) + can be modified on port-channel member interfaces. +author: +- Allen Robel (@allenrobel) +options: + fabric_name: + description: + - The name of the fabric containing the target switches. + type: str + required: true + config: + description: + - The list of ethernet accessHost interface groups to configure. + - Each item specifies the target switch, a list of interface names, and a shared configuration. + - Multiple switches can be configured in a single task. + - The structure mirrors the ND Manage Interfaces API payload. + type: list + elements: dict + required: true + suboptions: + switch_ip: + description: + - The management IP address of the switch on which to manage the ethernet interfaces. + - This is resolved to the switch serial number (switchId) internally. + type: str + required: true + interface_names: + description: + - The list of ethernet interface names to configure with the same settings. + - Each name should be in the format C(Ethernet1/1), C(Ethernet1/2), etc. + type: list + elements: str + required: true + interface_type: + description: + - The type of the interface. + - Defaults to C(ethernet) for this module. + type: str + default: ethernet + config_data: + description: + - The configuration data shared by all interfaces in O(config[].interface_names), following the ND API structure. + type: dict + suboptions: + mode: + description: + - The interface operational mode. + - Defaults to C(access) for this module. The ND API uses this as a discriminator + to select the access interface configuration schema. + type: str + default: access + network_os: + description: + - Network OS specific configuration. + type: dict + suboptions: + network_os_type: + description: + - The network OS type of the switch. + type: str + default: nx-os + policy: + description: + - The policy configuration for the accessHost interface. + type: dict + suboptions: + admin_state: + description: + - The administrative state of the interface. + - It defaults to C(true) when unset during creation. + type: bool + access_vlan: + description: + - The access VLAN for the interface. + - Valid range is 1-4094. + type: int + bpdu_guard: + description: + - BPDU guard setting for the interface. + type: str + choices: [ enable, disable, default ] + cdp: + description: + - Whether Cisco Discovery Protocol is enabled on the interface. + type: bool + description: + description: + - The description of the interface. + - Maximum 254 characters. + type: str + duplex_mode: + description: + - The duplex mode of the interface. + type: str + choices: [ auto, full, half ] + extra_config: + description: + - Additional CLI configuration commands to apply to the interface. + type: str + mtu: + description: + - The MTU setting for the interface. + type: str + choices: [ default, jumbo ] + netflow: + description: + - Whether netflow is enabled on the interface. + type: bool + netflow_monitor: + description: + - The netflow Layer-2 monitor name for the interface. + type: str + netflow_sampler: + description: + - The netflow Layer-2 sampler name for the interface. + - Only applicable for Nexus 7000 platforms. + type: str + orphan_port: + description: + - Whether VPC orphan port suspension is enabled. + type: bool + pfc: + description: + - Whether Priority Flow Control is enabled on the interface. + type: bool + port_type_edge_trunk: + description: + - Whether spanning-tree edge port (PortFast) is enabled. + type: bool + qos: + description: + - Whether a QoS policy is applied to the interface. + type: bool + qos_policy: + description: + - Custom QoS policy name associated with the interface. + - The policy must be defined prior to associating it with the interface. + type: str + queuing_policy: + description: + - Custom queuing policy name associated with the interface. + - The policy must be defined prior to associating it with the interface. + type: str + speed: + description: + - The speed setting for the interface. + type: str + choices: [ auto, 10Mb, 100Mb, 1Gb, 2.5Gb, 5Gb, 10Gb, 25Gb, 40Gb, 50Gb, 100Gb, 200Gb, 400Gb, 800Gb ] + deploy: + description: + - Whether to deploy interface changes after mutations are complete. + - When V(true), all queued interface changes are deployed in a single bulk API call at the end of module execution + via the C(interfaceActions/deploy) API. Only the 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 reset the specified interfaces to their fabric default configuration via the + C(interfaceActions/normalize) API. Physical ethernet interfaces cannot be truly deleted from a switch; + this operation is the API equivalent of the NX-OS C(default interface) CLI command. + 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 ethernet accessHost interfaces only. +- Interfaces that are port-channel members have restricted mutability. +""" + +EXAMPLES = r""" +- name: Create three accessHost interfaces with the same configuration + cisco.nd.nd_interface_ethernet_access: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_names: + - Ethernet1/1 + - Ethernet1/2 + - Ethernet1/3 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 100 + bpdu_guard: enable + cdp: true + description: Access Host Interface + speed: auto + state: merged + register: result + +- name: Create accessHost interfaces across multiple switches + cisco.nd.nd_interface_ethernet_access: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_names: + - Ethernet1/1 + - Ethernet1/2 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 100 + description: Server ports switch 1 + - switch_ip: 192.168.1.2 + interface_names: + - Ethernet1/1 + - Ethernet1/2 + - Ethernet1/3 + - Ethernet1/4 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 200 + description: Server ports switch 2 + state: merged + +- name: Delete accessHost interface configurations + cisco.nd.nd_interface_ethernet_access: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_names: + - Ethernet1/1 + - Ethernet1/2 + state: deleted + +- name: Create accessHost interfaces without deploying (for batching) + cisco.nd.nd_interface_ethernet_access: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_names: + - Ethernet1/1 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 100 + deploy: false + state: merged + +""" + +RETURN = r""" +""" + +import copy +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.ethernet_access_interface import EthernetAccessInterfaceModel +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.ethernet_access_interface import EthernetAccessInterfaceOrchestrator + + +def expand_config(config_list): + """ + # Summary + + Expand grouped config items (with `interface_names` list) into flat config items (with singular `interface_name`). + Each group produces one flat item per interface name, all sharing the same `config_data` and `switch_ip`. + + ## Raises + + None + """ + expanded = [] + for group in config_list: + interface_names = group.get("interface_names", []) + for name in interface_names: + item = copy.deepcopy(group) + item.pop("interface_names", None) + item["interface_name"] = name + expanded.append(item) + return expanded + + +def main(): + """ + # Summary + + Entry point for the `nd_interface_ethernet_access` Ansible module. Expands grouped config items, + initializes the `NDStateMachine` with `EthernetAccessInterfaceOrchestrator`, and executes the + requested state operation. + + ## Raises + + None (catches all exceptions and calls `module.fail_json`). + """ + argument_spec = nd_argument_spec() + argument_spec.update(EthernetAccessInterfaceModel.get_argument_spec()) + argument_spec.update( + deploy=dict(type="bool", default=True), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + setup_logging(module) + module_log = logging.getLogger("nd.nd_interface_ethernet_access") + + # Expand grouped config (interface_names list) into flat config items (interface_name singular) + module.params["config"] = expand_config(module.params["config"]) + module_log.debug( + "expand_config done items=%d switches=%d", + len(module.params["config"]), + len({item.get("switch_ip") for item in module.params["config"]}), + ) + + nd_state_machine = None + + try: + # Initialize StateMachine + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=EthernetAccessInterfaceOrchestrator, + ) + # Narrow type from NDBaseOrchestrator to NDBaseInterfaceOrchestrator so that + # interface-specific attributes (deploy, remove_pending, deploy_pending) are + # visible to Pylance and validated at runtime. + 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") + + # Execute all queued bulk operations + 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_ethernet_access/tasks/deleted.yaml b/tests/integration/targets/nd_interface_ethernet_access/tasks/deleted.yaml new file mode 100644 index 000000000..c39e5e8c1 --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/deleted.yaml @@ -0,0 +1,142 @@ +--- +# Deleted state tests for nd_interface_ethernet_access +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point Ethernet1/45, Ethernet1/46, Ethernet1/47 exist from overridden tests. + +# --- DELETED: SINGLE INTERFACE --- + +- name: "DELETED: Delete Ethernet1/45 (check mode)" + cisco.nd.nd_interface_ethernet_access: &delete_eth45 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/45 + state: deleted + check_mode: true + register: cm_deleted_45 + +- name: "DELETED: Delete Ethernet1/45 (normal mode)" + cisco.nd.nd_interface_ethernet_access: *delete_eth45 + register: nm_deleted_45 + +- name: "DELETED: Verify Ethernet1/45 was deleted" + ansible.builtin.assert: + that: + - cm_deleted_45 is changed + - nm_deleted_45 is changed + - nm_deleted_45.after | selectattr('interface_name', 'equalto', 'Ethernet1/45') | list | length == 0 + +# --- DELETED: IDEMPOTENCY --- + +- name: "DELETED IDEMPOTENT: Delete Ethernet1/45 again" + cisco.nd.nd_interface_ethernet_access: *delete_eth45 + register: nm_deleted_45_idem + +- name: "DELETED IDEMPOTENT: Verify no change when already absent" + ansible.builtin.assert: + that: + - nm_deleted_45_idem is not changed + +# --- DELETED: FAN-OUT DELETE --- + +- name: "DELETED FAN-OUT: Delete Ethernet1/46 and Ethernet1/47 via fan-out (check mode)" + cisco.nd.nd_interface_ethernet_access: &delete_eth46_47 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/46 + - Ethernet1/47 + state: deleted + check_mode: true + register: cm_deleted_fanout + +- name: "DELETED FAN-OUT: Delete Ethernet1/46 and Ethernet1/47 via fan-out (normal mode)" + cisco.nd.nd_interface_ethernet_access: *delete_eth46_47 + register: nm_deleted_fanout + +- name: "DELETED FAN-OUT: Verify both interfaces were deleted" + ansible.builtin.assert: + that: + - cm_deleted_fanout is changed + - nm_deleted_fanout is changed + - nm_deleted_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/46') | list | length == 0 + - nm_deleted_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/47') | list | length == 0 + +# --- DELETED: MULTIPLE CONFIG GROUPS --- + +# Recreate some interfaces for multi-group delete test +- name: "SETUP: Recreate Ethernet1/41 and Ethernet1/42-43 for multi-delete test" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41 }}" + - "{{ ethernet_42_43 }}" + state: merged + +- name: "DELETED MULTI: Delete from multiple config groups (check mode)" + cisco.nd.nd_interface_ethernet_access: &delete_multi + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + - Ethernet1/43 + state: deleted + check_mode: true + register: cm_deleted_multi + +- name: "DELETED MULTI: Delete from multiple config groups (normal mode)" + cisco.nd.nd_interface_ethernet_access: *delete_multi + register: nm_deleted_multi + +- name: "DELETED MULTI: Verify all test interfaces were deleted" + ansible.builtin.assert: + that: + - cm_deleted_multi is changed + - nm_deleted_multi is changed + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | list | length == 0 + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | list | length == 0 + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | list | length == 0 + +# --- DELETED: NON-EXISTENT INTERFACE --- + +- name: "DELETED NON-EXISTENT: Delete interface that does not exist" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/48 + state: deleted + register: nm_deleted_nonexistent + +- name: "DELETED NON-EXISTENT: Verify no change" + ansible.builtin.assert: + that: + - nm_deleted_nonexistent is not changed + +# --- FINAL CLEANUP --- + +- name: "CLEANUP: Remove all test interfaces" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: "{{ cleanup_interface_names }}" + state: deleted + tags: always diff --git a/tests/integration/targets/nd_interface_ethernet_access/tasks/main.yaml b/tests/integration/targets/nd_interface_ethernet_access/tasks/main.yaml new file mode 100644 index 000000000..acbe84229 --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/main.yaml @@ -0,0 +1,56 @@ +--- +# Test code for the nd_interface_ethernet_access 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_ethernet_access +# +# --- Optional test variables --- +# +# Set the variables below in the [nd:vars] section of tests/integration/inventory.networking +# +# TODO: Modify the above (and below) if the team decides to use tests/integration/integration_config.yml instead. +# +# nd_logging_config (str, default: "") +# Path to a JSON file conforming to logging.config.dictConfig (see +# plugins/module_utils/common/log.py for an example). When set, the path is +# forwarded to the module subprocess via the ND_LOGGING_CONFIG environment +# variable, enabling file-based logging from the nd collection. Empty (the +# default) disables logging. ansible-test strips the controller's shell env, +# so the variable cannot be inherited from the user's shell. Add to +# inventory.networking [nd:vars]: +# nd_logging_config=/absolute/path/to/logging_config.json + +- 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_ethernet_access state tests with optional logging env + block: + - name: Pre-test cleanup + ansible.builtin.include_tasks: setup.yaml + + - name: Run nd_interface_ethernet_access merged state tests + ansible.builtin.include_tasks: merged.yaml + + - name: Run nd_interface_ethernet_access replaced state tests + ansible.builtin.include_tasks: replaced.yaml + + - name: Run nd_interface_ethernet_access overridden state tests + ansible.builtin.include_tasks: overridden.yaml + + - name: Run nd_interface_ethernet_access deleted state tests + ansible.builtin.include_tasks: deleted.yaml + environment: + ND_LOGGING_CONFIG: "{{ nd_logging_config | default('') }}" diff --git a/tests/integration/targets/nd_interface_ethernet_access/tasks/merged.yaml b/tests/integration/targets/nd_interface_ethernet_access/tasks/merged.yaml new file mode 100644 index 000000000..d28e55e38 --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/merged.yaml @@ -0,0 +1,256 @@ +--- +# Merged state tests for nd_interface_ethernet_access +# 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: SINGLE INTERFACE --- + +- name: "MERGED CREATE: Create Ethernet1/41 with full config (check mode)" + cisco.nd.nd_interface_ethernet_access: &merge_eth41 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41 }}" + state: merged + check_mode: true + register: cm_merged_create_41 + +- name: "MERGED CREATE: Create Ethernet1/41 with full config (normal mode)" + cisco.nd.nd_interface_ethernet_access: *merge_eth41 + register: nm_merged_create_41 + +- name: "DEBUG: Show merged create result" + ansible.builtin.debug: + var: nm_merged_create_41 + +- name: "MERGED CREATE: Verify Ethernet1/41 creation" + ansible.builtin.assert: + that: + - cm_merged_create_41 is changed + - nm_merged_create_41 is changed + - nm_merged_create_41.after | length >= 1 + - nm_merged_create_41.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | list | length == 1 + +# --- MERGED CREATE: FAN-OUT (MULTIPLE INTERFACES, SINGLE CONFIG) --- + +- name: "MERGED CREATE: Create Ethernet1/42 and Ethernet1/43 via fan-out (check mode)" + cisco.nd.nd_interface_ethernet_access: &merge_eth42_43 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_42_43 }}" + state: merged + check_mode: true + register: cm_merged_create_fanout + +- name: "MERGED CREATE: Create Ethernet1/42 and Ethernet1/43 via fan-out (normal mode)" + cisco.nd.nd_interface_ethernet_access: *merge_eth42_43 + register: nm_merged_create_fanout + +- name: "MERGED CREATE: Verify fan-out creation" + ansible.builtin.assert: + that: + - cm_merged_create_fanout is changed + - nm_merged_create_fanout is changed + - nm_merged_create_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | list | length == 1 + - nm_merged_create_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | list | length == 1 + +# --- MERGED CREATE: MULTIPLE CONFIG GROUPS --- + +- name: "MERGED CREATE: Create Ethernet1/44 as a separate config group (normal mode)" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_44 }}" + state: merged + register: nm_merged_create_44 + +- name: "DEBUG: Show Ethernet1/44 create result" + ansible.builtin.debug: + var: nm_merged_create_44 + +- name: "MERGED CREATE: Verify Ethernet1/44 creation" + ansible.builtin.assert: + that: + - nm_merged_create_44 is changed + - nm_merged_create_44.after | selectattr('interface_name', 'equalto', 'Ethernet1/44') | list | length == 1 + +# --- MERGED IDEMPOTENCY --- + +- name: "MERGED IDEMPOTENT: Re-apply Ethernet1/41 creation (check mode)" + cisco.nd.nd_interface_ethernet_access: *merge_eth41 + check_mode: true + register: cm_merged_idem_41 + +- name: "MERGED IDEMPOTENT: Re-apply Ethernet1/41 creation (normal mode)" + cisco.nd.nd_interface_ethernet_access: *merge_eth41 + register: nm_merged_idem_41 + +- name: "MERGED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - cm_merged_idem_41 is not changed + - nm_merged_idem_41 is not changed + +- name: "MERGED IDEMPOTENT: Re-apply fan-out config for Ethernet1/42 and Ethernet1/43" + cisco.nd.nd_interface_ethernet_access: *merge_eth42_43 + register: nm_merged_idem_fanout + +- name: "MERGED IDEMPOTENT: Verify fan-out idempotency" + ansible.builtin.assert: + that: + - nm_merged_idem_fanout is not changed + +# --- MERGED UPDATE --- + +- name: "MERGED UPDATE: Update Ethernet1/41 access_vlan and description (check mode)" + cisco.nd.nd_interface_ethernet_access: &merge_eth41_updated + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41_updated }}" + state: merged + check_mode: true + register: cm_merged_update_41 + +- name: "MERGED UPDATE: Update Ethernet1/41 access_vlan and description (normal mode)" + cisco.nd.nd_interface_ethernet_access: *merge_eth41_updated + register: nm_merged_update_41 + +- name: "MERGED UPDATE: Verify Ethernet1/41 was updated" + ansible.builtin.assert: + that: + - cm_merged_update_41 is changed + - nm_merged_update_41 is changed + +- name: "MERGED UPDATE: Verify updated values in after state" + vars: + eth41_after: "{{ nm_merged_update_41.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | first }}" + ansible.builtin.assert: + that: + - eth41_after.config_data.network_os.policy.access_vlan == 150 + - eth41_after.config_data.network_os.policy.description == "Updated Ethernet1/41 description" + - eth41_after.config_data.network_os.policy.bpdu_guard == "disable" + - eth41_after.config_data.network_os.policy.cdp == false + +- name: "MERGED UPDATE: Re-apply update for idempotency" + cisco.nd.nd_interface_ethernet_access: *merge_eth41_updated + register: nm_merged_update_41_idem + +- name: "MERGED UPDATE: Verify idempotency after update" + ansible.builtin.assert: + that: + - nm_merged_update_41_idem is not changed + +# --- MERGED UPDATE: FAN-OUT UPDATE --- + +- name: "MERGED UPDATE: Update Ethernet1/42 and Ethernet1/43 via fan-out" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_42_43_updated }}" + state: merged + register: nm_merged_update_fanout + +- name: "MERGED UPDATE: Verify fan-out update" + ansible.builtin.assert: + that: + - nm_merged_update_fanout is changed + +- name: "MERGED UPDATE: Verify fan-out updated values" + vars: + eth42_after: "{{ nm_merged_update_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | first }}" + eth43_after: "{{ nm_merged_update_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | first }}" + ansible.builtin.assert: + that: + - eth42_after.config_data.network_os.policy.access_vlan == 250 + - eth42_after.config_data.network_os.policy.description == "Updated access ports description" + - eth42_after.config_data.network_os.policy.admin_state == false + - eth43_after.config_data.network_os.policy.access_vlan == 250 + - eth43_after.config_data.network_os.policy.description == "Updated access ports description" + - eth43_after.config_data.network_os.policy.admin_state == false + +# --- MERGED WITH deploy: false --- + +- name: "MERGED NO-DEPLOY: Create Ethernet1/48 with deploy disabled" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/48 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 999 + description: "No-deploy test Ethernet1/48" + deploy: false + state: merged + register: nm_merged_no_deploy + +- name: "DEBUG: Show no-deploy result" + ansible.builtin.debug: + var: nm_merged_no_deploy + +- name: "MERGED NO-DEPLOY: Verify change was staged" + ansible.builtin.assert: + that: + - nm_merged_no_deploy is changed + +# Clean up the no-deploy test interface +- name: "CLEANUP: Remove Ethernet1/48" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/48 + state: deleted + +# --- MERGED CREATE: LARGE FAN-OUT --- + +- name: "MERGED CREATE: Create three interfaces via fan-out (normal mode)" + cisco.nd.nd_interface_ethernet_access: &merge_eth45_46_47 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_45_46_47 }}" + state: merged + register: nm_merged_create_large_fanout + +- name: "MERGED CREATE: Verify large fan-out creation" + ansible.builtin.assert: + that: + - nm_merged_create_large_fanout is changed + - nm_merged_create_large_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/45') | list | length == 1 + - nm_merged_create_large_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/46') | list | length == 1 + - nm_merged_create_large_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/47') | list | length == 1 + +- name: "MERGED CREATE: Verify all three fan-out interfaces have the same config" + vars: + eth45_after: "{{ nm_merged_create_large_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/45') | first }}" + eth46_after: "{{ nm_merged_create_large_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/46') | first }}" + eth47_after: "{{ nm_merged_create_large_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/47') | first }}" + ansible.builtin.assert: + that: + - eth45_after.config_data.network_os.policy.access_vlan == 400 + - eth45_after.config_data.network_os.policy.description == "Fan-out test access ports" + - eth46_after.config_data.network_os.policy.access_vlan == 400 + - eth46_after.config_data.network_os.policy.description == "Fan-out test access ports" + - eth47_after.config_data.network_os.policy.access_vlan == 400 + - eth47_after.config_data.network_os.policy.description == "Fan-out test access ports" + +- name: "MERGED IDEMPOTENT: Re-apply large fan-out" + cisco.nd.nd_interface_ethernet_access: *merge_eth45_46_47 + register: nm_merged_idem_large_fanout + +- name: "MERGED IDEMPOTENT: Verify large fan-out idempotency" + ansible.builtin.assert: + that: + - nm_merged_idem_large_fanout is not changed diff --git a/tests/integration/targets/nd_interface_ethernet_access/tasks/overridden.yaml b/tests/integration/targets/nd_interface_ethernet_access/tasks/overridden.yaml new file mode 100644 index 000000000..3f0710e10 --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/overridden.yaml @@ -0,0 +1,145 @@ +--- +# Overridden state tests for nd_interface_ethernet_access +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point Ethernet1/41, Ethernet1/42-43, Ethernet1/44, Ethernet1/45-47 +# exist from prior tests. Override will reduce the set to only what is specified. + +# --- OVERRIDDEN: REDUCE TO A FEW INTERFACES --- + +- name: "OVERRIDDEN: Override to only Ethernet1/41 and Ethernet1/42 (check mode)" + cisco.nd.nd_interface_ethernet_access: &override_eth41_42 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 900 + description: "Overridden Ethernet1/41" + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 901 + description: "Overridden Ethernet1/42" + state: overridden + check_mode: true + register: cm_overridden_reduce + +- name: "OVERRIDDEN: Override to only Ethernet1/41 and Ethernet1/42 (normal mode)" + cisco.nd.nd_interface_ethernet_access: *override_eth41_42 + register: nm_overridden_reduce + +- name: "OVERRIDDEN: Verify override removed extra interfaces" + ansible.builtin.assert: + that: + - cm_overridden_reduce is changed + - nm_overridden_reduce is changed + # After should contain only the overridden interfaces (Ethernet1/41 and Ethernet1/42). + # Ethernet1/43, Ethernet1/44, Ethernet1/45-47 should have been removed. + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | list | length == 1 + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | list | length == 1 + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | list | length == 0 + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/44') | list | length == 0 + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/45') | list | length == 0 + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/46') | list | length == 0 + - nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/47') | list | length == 0 + +- name: "OVERRIDDEN: Verify overridden values" + vars: + eth41_after: "{{ nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | first }}" + eth42_after: "{{ nm_overridden_reduce.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | first }}" + ansible.builtin.assert: + that: + - eth41_after.config_data.network_os.policy.access_vlan == 900 + - eth41_after.config_data.network_os.policy.description == "Overridden Ethernet1/41" + - eth42_after.config_data.network_os.policy.access_vlan == 901 + - eth42_after.config_data.network_os.policy.description == "Overridden Ethernet1/42" + +# --- OVERRIDDEN: IDEMPOTENCY --- + +- name: "OVERRIDDEN IDEMPOTENT: Re-apply same overridden config" + cisco.nd.nd_interface_ethernet_access: *override_eth41_42 + register: nm_overridden_idem + +- name: "DEBUG: Show overridden idem result" + ansible.builtin.debug: + var: nm_overridden_idem + +- name: "OVERRIDDEN IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_overridden_idem is not changed + +# --- OVERRIDDEN: NON-ACCESSHOST FILTERING --- +# Verifies that interfaces with non-accessHost policy types (e.g., trunkHost, routed, +# system-managed) are NOT included in the before collection and therefore are NOT +# targeted for deletion by the overridden state. +# If filtering is broken, this test would attempt to remove non-accessHost interfaces, +# and the changed flag would be incorrectly true. + +- name: "OVERRIDDEN FILTER: Override with same config, verify non-accessHost interfaces are unaffected" + cisco.nd.nd_interface_ethernet_access: *override_eth41_42 + register: nm_overridden_filter + +- name: "OVERRIDDEN FILTER: Verify idempotent (non-accessHost interfaces not in diff)" + ansible.builtin.assert: + that: + # If non-accessHost interfaces were leaking into the before collection, the module + # would see them as "extra" and report changed. Since we just ran the same + # override, this MUST be idempotent. + - nm_overridden_filter is not changed + +# --- OVERRIDDEN: SWAP INTERFACES --- + +- name: "OVERRIDDEN SWAP: Override to Ethernet1/43 and Ethernet1/44, removing Ethernet1/41 and Ethernet1/42" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_42_43 }}" + - "{{ ethernet_44 }}" + state: overridden + register: nm_overridden_swap + +- name: "OVERRIDDEN SWAP: Verify Ethernet1/41 removed and Ethernet1/42-44 present" + ansible.builtin.assert: + that: + - nm_overridden_swap is changed + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | list | length == 0 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | list | length == 1 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | list | length == 1 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'Ethernet1/44') | list | length == 1 + +# --- OVERRIDDEN: FAN-OUT OVERRIDE --- + +- name: "OVERRIDDEN FAN-OUT: Override to three interfaces via single fan-out config" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_45_46_47 }}" + state: overridden + register: nm_overridden_fanout + +- name: "OVERRIDDEN FAN-OUT: Verify only fan-out interfaces remain" + ansible.builtin.assert: + that: + - nm_overridden_fanout is changed + - nm_overridden_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | list | length == 0 + - nm_overridden_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | list | length == 0 + - nm_overridden_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/44') | list | length == 0 + - nm_overridden_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/45') | list | length == 1 + - nm_overridden_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/46') | list | length == 1 + - nm_overridden_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/47') | list | length == 1 diff --git a/tests/integration/targets/nd_interface_ethernet_access/tasks/replaced.yaml b/tests/integration/targets/nd_interface_ethernet_access/tasks/replaced.yaml new file mode 100644 index 000000000..da4bcde7c --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/replaced.yaml @@ -0,0 +1,142 @@ +--- +# Replaced state tests for nd_interface_ethernet_access +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point Ethernet1/41 (updated), Ethernet1/42-43 (updated), +# Ethernet1/44, Ethernet1/45-47 exist from merged tests. + +# --- REPLACED: FULL REPLACE --- + +- name: "REPLACED: Replace Ethernet1/41 config entirely (check mode)" + cisco.nd.nd_interface_ethernet_access: &replace_eth41 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 500 + description: "Replaced Ethernet1/41" + state: replaced + check_mode: true + register: cm_replaced_41 + +- name: "REPLACED: Replace Ethernet1/41 config entirely (normal mode)" + cisco.nd.nd_interface_ethernet_access: *replace_eth41 + register: nm_replaced_41 + +- name: "REPLACED: Verify Ethernet1/41 was replaced" + ansible.builtin.assert: + that: + - cm_replaced_41 is changed + - nm_replaced_41 is changed + +- name: "REPLACED: Verify replaced values" + vars: + eth41_after: "{{ nm_replaced_41.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | first }}" + ansible.builtin.assert: + that: + - eth41_after.config_data.network_os.policy.access_vlan == 500 + - eth41_after.config_data.network_os.policy.description == "Replaced Ethernet1/41" + +# --- REPLACED: IDEMPOTENCY --- + +- name: "REPLACED IDEMPOTENT: Re-apply same replaced config" + cisco.nd.nd_interface_ethernet_access: *replace_eth41 + register: nm_replaced_41_idem + +- name: "REPLACED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_replaced_41_idem is not changed + +# --- REPLACED: FAN-OUT REPLACE --- + +- name: "REPLACED FAN-OUT: Replace Ethernet1/42 and Ethernet1/43 via fan-out (check mode)" + cisco.nd.nd_interface_ethernet_access: &replace_eth42_43 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + - Ethernet1/43 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 600 + description: "Replaced fan-out access ports" + state: replaced + check_mode: true + register: cm_replaced_fanout + +- name: "REPLACED FAN-OUT: Replace Ethernet1/42 and Ethernet1/43 via fan-out (normal mode)" + cisco.nd.nd_interface_ethernet_access: *replace_eth42_43 + register: nm_replaced_fanout + +- name: "REPLACED FAN-OUT: Verify both interfaces were replaced" + ansible.builtin.assert: + that: + - cm_replaced_fanout is changed + - nm_replaced_fanout is changed + +- name: "REPLACED FAN-OUT: Verify replaced values for both interfaces" + vars: + eth42_after: "{{ nm_replaced_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/42') | first }}" + eth43_after: "{{ nm_replaced_fanout.after | selectattr('interface_name', 'equalto', 'Ethernet1/43') | first }}" + ansible.builtin.assert: + that: + - eth42_after.config_data.network_os.policy.access_vlan == 600 + - eth42_after.config_data.network_os.policy.description == "Replaced fan-out access ports" + - eth42_after.config_data.network_os.policy.admin_state == true + - eth43_after.config_data.network_os.policy.access_vlan == 600 + - eth43_after.config_data.network_os.policy.description == "Replaced fan-out access ports" + - eth43_after.config_data.network_os.policy.admin_state == true + +# --- REPLACED: MULTIPLE CONFIG GROUPS --- + +- name: "REPLACED MULTI: Replace Ethernet1/41 and Ethernet1/44 from separate config groups" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 700 + description: "Replaced again Ethernet1/41" + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/44 + config_data: + network_os: + policy: + admin_state: false + access_vlan: 800 + description: "Replaced Ethernet1/44" + state: replaced + register: nm_replaced_multi + +- name: "REPLACED MULTI: Verify both config groups were replaced" + vars: + eth41_after: "{{ nm_replaced_multi.after | selectattr('interface_name', 'equalto', 'Ethernet1/41') | first }}" + eth44_after: "{{ nm_replaced_multi.after | selectattr('interface_name', 'equalto', 'Ethernet1/44') | first }}" + ansible.builtin.assert: + that: + - nm_replaced_multi is changed + - eth41_after.config_data.network_os.policy.access_vlan == 700 + - eth41_after.config_data.network_os.policy.description == "Replaced again Ethernet1/41" + - eth44_after.config_data.network_os.policy.access_vlan == 800 + - eth44_after.config_data.network_os.policy.admin_state == false diff --git a/tests/integration/targets/nd_interface_ethernet_access/tasks/setup.yaml b/tests/integration/targets/nd_interface_ethernet_access/tasks/setup.yaml new file mode 100644 index 000000000..11d3dbff5 --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/setup.yaml @@ -0,0 +1,47 @@ +--- +# Pre-test cleanup for nd_interface_ethernet_access +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Called from main.yaml before the merged/replaced/overridden/deleted state +# blocks. Resets the test ethernet interfaces to fabric defaults so each test +# run starts from a known-clean state. The DEBUG tasks below provide +# observability for the cleanup result and verify (via a check-mode merge probe) +# that the cleanup actually emptied the interfaces' before-state on ND. + +- name: "SETUP: Remove test ethernet interfaces before state tests" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: "{{ cleanup_interface_names }}" + state: deleted + register: setup_cleanup + tags: always + +- name: "DEBUG: Show cleanup result" + ansible.builtin.debug: + var: setup_cleanup + tags: always + +- name: "DEBUG: Re-query to see if interfaces are still present after cleanup" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + config_data: + network_os: + policy: + state: merged + check_mode: true + register: debug_requery + +- name: "DEBUG: Show re-query result (check before list)" + ansible.builtin.debug: + msg: "Before count: {{ debug_requery.before | length }}, Eth1/41 in before: {{ debug_requery.before | selectattr('interface_name', 'equalto', 'Ethernet1/41') | + list | length }}" diff --git a/tests/integration/targets/nd_interface_ethernet_access/vars/main.yaml b/tests/integration/targets/nd_interface_ethernet_access/vars/main.yaml new file mode 100644 index 000000000..aa4e0c04d --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/vars/main.yaml @@ -0,0 +1,113 @@ +--- +# Variables for nd_interface_ethernet_access integration tests. +# +# Override fabric_name and switch_ip in your inventory or extra-vars +# to match a real ND 4.2 testbed. + +test_fabric_name: "{{ nd_test_fabric_name | default('test_fabric') }}" +test_switch_ip: "{{ nd_test_switch_ip | default('192.168.1.1') }}" + +# Ethernet interfaces used across tests. +# Ethernet1/41 through Ethernet1/48 are reserved for these integration tests. +# These interfaces must exist on the test switch and must NOT be port-channel members. + +# --- Base configs --- + +ethernet_41: + switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 100 + bpdu_guard: enable + cdp: true + description: "Ansible integration test Ethernet1/41" + speed: auto + +ethernet_42_43: + switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + - Ethernet1/43 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 200 + bpdu_guard: enable + cdp: true + description: "Ansible integration test access ports" + speed: auto + +ethernet_44: + switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/44 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 300 + description: "Ansible integration test Ethernet1/44" + +# --- Updated configs for merge/replace tests --- + +ethernet_41_updated: + switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/41 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 150 + bpdu_guard: disable + cdp: false + description: "Updated Ethernet1/41 description" + speed: auto + +ethernet_42_43_updated: + switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + - Ethernet1/43 + config_data: + network_os: + policy: + admin_state: false + access_vlan: 250 + description: "Updated access ports description" + +# --- Fan-out test: multiple interfaces sharing one config --- + +ethernet_45_46_47: + switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/45 + - Ethernet1/46 + - Ethernet1/47 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 400 + bpdu_guard: enable + cdp: true + description: "Fan-out test access ports" + mtu: jumbo + speed: auto + +# --- Cleanup helper: all test interfaces --- + +cleanup_interface_names: + - Ethernet1/41 + - Ethernet1/42 + - Ethernet1/43 + - Ethernet1/44 + - Ethernet1/45 + - Ethernet1/46 + - Ethernet1/47 + - Ethernet1/48 diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_ethernet_access_interface.json b/tests/unit/module_utils/fixtures/fixture_data/test_ethernet_access_interface.json new file mode 100644 index 000000000..a24551815 --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_ethernet_access_interface.json @@ -0,0 +1,126 @@ +{ + "TEST_NOTES": [ + "Fixture data for tests/unit/module_utils/orchestrators/test_ethernet_access_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}/switches (switch_map via FabricContext)", + " - GET /api/v1/manage/fabrics/{fabric_name}/switches/{sn}/interfaces (EpManageInterfacesListGet per switch)" + ], + "test_query_all_happy_path_00400a": { + "TEST_NOTES": ["Fabric summary: fabric exists (for validate_prerequisites)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/summary", + "MESSAGE": "OK", + "DATA": { + "name": "fabric_1", + "ownerCluster": "cluster_a" + } + }, + "test_query_all_happy_path_00400_freeze": { + "TEST_NOTES": ["Deployment freeze: disabled (for validate_prerequisites)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/deploymentFreeze", + "MESSAGE": "OK", + "DATA": { + "deploymentFreeze": false + } + }, + "test_query_all_happy_path_00400b": { + "TEST_NOTES": ["Switch list: two switches in fabric_1"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + { + "fabricManagementIp": "192.168.1.1", + "switchId": "FDO11111AAA" + }, + { + "fabricManagementIp": "192.168.1.2", + "switchId": "FDO22222BBB" + } + ] + } + }, + "test_query_all_happy_path_00400c": { + "TEST_NOTES": [ + "Interfaces for switch FDO11111AAA: one accessHost, one trunkHost (should be filtered out).", + "Expect: only the accessHost interface is retained." + ], + "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": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "accessHost", + "accessVlan": 20, + "description": "host-1" + } + } + } + }, + { + "interfaceName": "Ethernet1/2", + "interfaceType": "ethernet", + "configData": { + "mode": "trunk", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "trunkHost", + "allowedVlans": "1-100" + } + } + } + } + ] + } + }, + "test_query_all_happy_path_00400d": { + "TEST_NOTES": ["Interfaces for switch FDO22222BBB: one accessHost."], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches/FDO22222BBB/interfaces", + "MESSAGE": "OK", + "DATA": { + "interfaces": [ + { + "interfaceName": "Ethernet2/1", + "interfaceType": "ethernet", + "configData": { + "mode": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "policyType": "accessHost", + "accessVlan": 30 + } + } + } + } + ] + } + }, + "test_query_all_fabric_not_found_00420a": { + "TEST_NOTES": ["Fabric summary: 404 not found; validate_prerequisites should raise RuntimeError."], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/missing_fabric/summary", + "MESSAGE": "Not Found", + "DATA": {} + } +} diff --git a/tests/unit/module_utils/models/test_ethernet_access_interface.py b/tests/unit/module_utils/models/test_ethernet_access_interface.py new file mode 100644 index 000000000..1dc829265 --- /dev/null +++ b/tests/unit/module_utils/models/test_ethernet_access_interface.py @@ -0,0 +1,1256 @@ +# -*- 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 ethernet_access_interface.py + +Tests the Ethernet accessHost Interface Pydantic model classes. +""" + +# pylint: disable=line-too-long +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-lines + +from __future__ import annotations + +import copy +from contextlib import contextmanager + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import ( + BpduFilterEnum, + BpduGuardEnum, + DuplexModeEnum, + FecEnum, + LinkTypeEnum, + MtuEnum, + SpeedEnum, + StormControlActionEnum, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.ethernet_access_interface import ( + EthernetAccessConfigDataModel, + EthernetAccessInterfaceModel, + EthernetAccessNetworkOSModel, + EthernetAccessPolicyModel, +) +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": "Ethernet1/1", + "interfaceType": "ethernet", + "configData": { + "mode": "access", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "adminState": True, + "accessVlan": 20, + "description": "host port", + "policyType": "accessHost", + "speed": "1Gb", + "duplexMode": "auto", + "mtu": "default", + "bpduGuard": "enable", + "bpduFilter": "disable", + "portTypeEdgeTrunk": False, + }, + }, + }, +} + +SAMPLE_ANSIBLE_CONFIG = { + "switch_ip": "192.168.1.1", + "interface_name": "Ethernet1/1", + "interface_type": "ethernet", + "config_data": { + "mode": "access", + "network_os": { + "network_os_type": "nx-os", + "policy": { + "admin_state": True, + "access_vlan": 20, + "description": "host port", + "policy_type": "accessHost", + "speed": "1Gb", + "duplex_mode": "auto", + "mtu": "default", + "bpdu_guard": "enable", + "bpdu_filter": "disable", + "port_type_edge_trunk": False, + }, + }, + }, +} + + +# ============================================================================= +# Test: EthernetAccessPolicyModel — initialization +# ============================================================================= + + +def test_ethernet_access_interface_00100(): + """ + # Summary + + Verify every policy field defaults to None. + + ## Test + + - Instantiate with no arguments + - Every field is None + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessPolicyModel() + assert instance.admin_state is None + assert instance.access_vlan 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.debounce_timer is None + assert instance.debounce_linkup_timer is None + assert instance.description is None + assert instance.duplex_mode is None + assert instance.error_detection_acl is None + assert instance.extra_config is None + assert instance.fec is None + assert instance.inherit_bandwidth is None + assert instance.link_type is None + assert instance.monitor 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.orphan_port is None + assert instance.pfc is None + assert instance.policy_type == "accessHost" + 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 + assert instance.storm_control_broadcast_level is None + assert instance.storm_control_broadcast_level_pps is None + assert instance.storm_control_multicast_level is None + assert instance.storm_control_multicast_level_pps is None + assert instance.storm_control_unicast_level is None + assert instance.storm_control_unicast_level_pps is None + + +def test_ethernet_access_interface_00110(): + """ + # Summary + + Verify construction with snake_case field names. + + ## Test + + - Construct with Python field names + - Values accessible + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessPolicyModel( + admin_state=True, + access_vlan=20, + description="test", + speed="1Gb", + ) + assert instance.admin_state is True + assert instance.access_vlan == 20 + assert instance.description == "test" + # Hardcoded model default; user no longer supplies this field. + assert instance.policy_type == "accessHost" + assert instance.speed == "1Gb" + + +def test_ethernet_access_interface_00120(): + """ + # Summary + + Verify construction with camelCase aliases. + + ## Test + + - Construct with API alias names + - Values accessible by Python names + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessPolicyModel( + adminState=True, + accessVlan=10, + policyType="accessHost", + bpduGuard="enable", + duplexMode="full", + portTypeEdgeTrunk=True, + ) + assert instance.admin_state is True + assert instance.access_vlan == 10 + assert instance.policy_type == "accessHost" + assert instance.bpdu_guard == "enable" + assert instance.duplex_mode == "full" + assert instance.port_type_edge_trunk is True + + +# ============================================================================= +# Test: EthernetAccessPolicyModel — range and enum validation +# ============================================================================= + + +@pytest.mark.parametrize( + "field,value,should_raise", + [ + ("access_vlan", 1, False), + ("access_vlan", 4094, False), + ("access_vlan", 0, True), + ("access_vlan", 4095, True), + ("bandwidth", 1, False), + ("bandwidth", 100000000, False), + ("bandwidth", 0, True), + ("bandwidth", 100000001, True), + ("debounce_timer", 0, False), + ("debounce_timer", 20000, False), + ("debounce_timer", -1, True), + ("debounce_timer", 20001, True), + ("debounce_linkup_timer", 1000, False), + ("debounce_linkup_timer", 10000, False), + ("debounce_linkup_timer", 999, True), + ("debounce_linkup_timer", 10001, True), + ("inherit_bandwidth", 1, False), + ("inherit_bandwidth", 100000000, False), + ("inherit_bandwidth", 0, True), + ("inherit_bandwidth", 100000001, True), + ("storm_control_broadcast_level_pps", 0, False), + ("storm_control_broadcast_level_pps", 200000000, False), + ("storm_control_broadcast_level_pps", -1, True), + ("storm_control_broadcast_level_pps", 200000001, True), + ("storm_control_multicast_level_pps", 0, False), + ("storm_control_multicast_level_pps", 200000000, False), + ("storm_control_multicast_level_pps", -1, True), + ("storm_control_multicast_level_pps", 200000001, True), + ("storm_control_unicast_level_pps", 0, False), + ("storm_control_unicast_level_pps", 200000000, False), + ("storm_control_unicast_level_pps", -1, True), + ("storm_control_unicast_level_pps", 200000001, True), + ], + ids=lambda v: str(v) if not isinstance(v, bool) else ("raise" if v else "ok"), +) +def test_ethernet_access_interface_00220(field, value, should_raise): + """ + # Summary + + Verify ge/le constraints on every numeric policy field. + + ## Test + + - At-min and at-max values accepted + - Below-min and above-max values rejected with ValidationError + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + """ + if should_raise: + with pytest.raises(ValidationError): + EthernetAccessPolicyModel(**{field: value}) + else: + with does_not_raise(): + instance = EthernetAccessPolicyModel(**{field: value}) + assert getattr(instance, field) == value + + +def test_ethernet_access_interface_00230(): + """ + # Summary + + Verify `description` max_length=254. + + ## Test + + - 254-char description accepted + - 255-char description rejected with ValidationError + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + """ + at_limit = "a" * 254 + over_limit = "a" * 255 + with does_not_raise(): + instance = EthernetAccessPolicyModel(description=at_limit) + assert instance.description == at_limit + + with pytest.raises(ValidationError): + EthernetAccessPolicyModel(description=over_limit) + + +@pytest.mark.parametrize( + "value,should_raise", + [ + ("plain ASCII", False), + ("with-hyphen and 123", False), + ("em — dash", True), + ("smart “quotes”", True), + ("emoji \U0001F600", True), + ("latin-1 \xe9", True), + ], + ids=[ + "ascii_ok", + "ascii_punct_digits", + "em_dash_rejected", + "smart_quotes_rejected", + "emoji_rejected", + "latin1_rejected", + ], +) +def test_ethernet_access_interface_00235(value, should_raise): + """ + # Summary + + Verify `description` (typed `AsciiDescription`) rejects any non-ASCII character. + + Cisco backend pipes interface descriptions through CLI generators that 500 on UTF-8. Catching this client-side + gives users a clear error instead of a generic "unexpected error during policy execution" 500. + + ## Test + + - ASCII strings accepted + - Non-ASCII characters (em-dash, smart quotes, emoji, latin-1) raise + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + - models.types.ascii_only() + """ + if should_raise: + with pytest.raises(ValidationError, match="description must contain only ASCII"): + EthernetAccessPolicyModel(description=value) + else: + instance = EthernetAccessPolicyModel(description=value) + assert instance.description == value + + +@pytest.mark.parametrize( + "field,enum_cls", + [ + ("speed", SpeedEnum), + ("duplex_mode", DuplexModeEnum), + ("fec", FecEnum), + ("bpdu_guard", BpduGuardEnum), + ("bpdu_filter", BpduFilterEnum), + ("link_type", LinkTypeEnum), + ("mtu", MtuEnum), + ("storm_control_action", StormControlActionEnum), + ], + ids=["speed", "duplex_mode", "fec", "bpdu_guard", "bpdu_filter", "link_type", "mtu", "storm_control_action"], +) +def test_ethernet_access_interface_00240(field, enum_cls): + """ + # Summary + + Verify enum-constrained fields accept any valid enum value and reject invalid strings. + + ## Test + + - Valid enum value sets the stored value (enum `.value` due to use_enum_values=True) + - Invalid value raises ValidationError + + ## Classes and Methods + + - EthernetAccessPolicyModel.__init__() + """ + valid_value = next(iter(enum_cls)).value + with does_not_raise(): + instance = EthernetAccessPolicyModel(**{field: valid_value}) + assert getattr(instance, field) == valid_value + + with pytest.raises(ValidationError): + EthernetAccessPolicyModel(**{field: "not_a_real_value"}) + + +# ============================================================================= +# Test: EthernetAccessNetworkOSModel +# ============================================================================= + + +def test_ethernet_access_interface_00400(): + """ + # Summary + + Verify `network_os_type` defaults to "nx-os". + + ## Test + + - Instantiate without args + - network_os_type is "nx-os" + - policy is None + + ## Classes and Methods + + - EthernetAccessNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessNetworkOSModel() + assert instance.network_os_type == "nx-os" + assert instance.policy is None + + +def test_ethernet_access_interface_00410(): + """ + # Summary + + Verify nested `policy` assignment accepts a dict and coerces to EthernetAccessPolicyModel. + + ## Test + + - Construct with policy as dict + - policy is an EthernetAccessPolicyModel instance + + ## Classes and Methods + + - EthernetAccessNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessNetworkOSModel(policy={"admin_state": True, "access_vlan": 10}) + assert isinstance(instance.policy, EthernetAccessPolicyModel) + assert instance.policy.admin_state is True + assert instance.policy.access_vlan == 10 + + +def test_ethernet_access_interface_00420(): + """ + # Summary + + Verify camelCase alias `networkOSType` populates network_os_type. + + ## Test + + - Construct with camelCase alias + - Python field accessible + + ## Classes and Methods + + - EthernetAccessNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessNetworkOSModel(networkOSType="ios-xe") + assert instance.network_os_type == "ios-xe" + + +# ============================================================================= +# Test: EthernetAccessConfigDataModel +# ============================================================================= + + +def test_ethernet_access_interface_00450(): + """ + # Summary + + Verify `mode` defaults to "access". + + ## Test + + - Construct with only network_os + - mode is "access" + + ## Classes and Methods + + - EthernetAccessConfigDataModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessConfigDataModel(network_os=EthernetAccessNetworkOSModel()) + assert instance.mode == "access" + + +def test_ethernet_access_interface_00460(): + """ + # Summary + + Verify camelCase alias `networkOS` populates network_os. + + ## Test + + - Construct with camelCase alias + - Python field accessible + + ## Classes and Methods + + - EthernetAccessConfigDataModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessConfigDataModel(networkOS={"networkOSType": "nx-os"}) + assert isinstance(instance.network_os, EthernetAccessNetworkOSModel) + assert instance.network_os.network_os_type == "nx-os" + + +def test_ethernet_access_interface_00470(): + """ + # Summary + + Verify `network_os` is a required field. + + ## Test + + - Construct without network_os + - ValidationError raised + + ## Classes and Methods + + - EthernetAccessConfigDataModel.__init__() + """ + with pytest.raises(ValidationError, match=r"network_os|networkOS"): + EthernetAccessConfigDataModel() + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — initialization and ClassVars +# ============================================================================= + + +def test_ethernet_access_interface_00500(): + """ + # Summary + + Verify ClassVar `identifiers` and `identifier_strategy`. + + ## Test + + - identifiers == ["switch_ip", "interface_name"] + - identifier_strategy == "composite" + + ## Classes and Methods + + - EthernetAccessInterfaceModel + """ + assert EthernetAccessInterfaceModel.identifiers == ["switch_ip", "interface_name"] + assert EthernetAccessInterfaceModel.identifier_strategy == "composite" + + +def test_ethernet_access_interface_00510(): + """ + # Summary + + Verify `payload_exclude_fields` excludes `switch_ip`. + + ## Test + + - payload_exclude_fields == {"switch_ip"} + + ## Classes and Methods + + - EthernetAccessInterfaceModel + """ + assert EthernetAccessInterfaceModel.payload_exclude_fields == {"switch_ip"} + + +def test_ethernet_access_interface_00520(): + """ + # Summary + + Verify `switch_ip` and `interface_name` are required. + + ## Test + + - Missing switch_ip raises ValidationError + - Missing interface_name raises ValidationError + + ## Classes and Methods + + - EthernetAccessInterfaceModel.__init__() + """ + with pytest.raises(ValidationError, match=r"switch_ip|switchIp"): + EthernetAccessInterfaceModel(interface_name="Ethernet1/1") + + with pytest.raises(ValidationError, match=r"interface_name|interfaceName"): + EthernetAccessInterfaceModel(switch_ip="192.168.1.1") + + +def test_ethernet_access_interface_00530(): + """ + # Summary + + Verify `interface_type` defaults to "ethernet" and `config_data` defaults to None. + + ## Test + + - Minimal construction + - Defaults applied + + ## Classes and Methods + + - EthernetAccessInterfaceModel.__init__() + """ + with does_not_raise(): + instance = EthernetAccessInterfaceModel(switch_ip="192.168.1.1", interface_name="Ethernet1/1") + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "Ethernet1/1" + assert instance.interface_type == "ethernet" + assert instance.config_data is None + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — normalize_interface_name +# ============================================================================= + + +@pytest.mark.parametrize( + "value,expected", + [ + ("ethernet1/1", "Ethernet1/1"), + ("Ethernet1/1", "Ethernet1/1"), + ("e1/1", "E1/1"), + ("eth1/1/1", "Eth1/1/1"), + ("", ""), + ], + ids=["lowercase_full", "already_cap", "single_letter", "breakout", "empty_passthrough"], +) +def test_ethernet_access_interface_00550(value, expected): + """ + # Summary + + Verify `normalize_interface_name` capitalizes the first character. + + ## Test + + - Lowercase input capitalized + - Already-capitalized input unchanged + + ## Classes and Methods + + - EthernetAccessInterfaceModel.normalize_interface_name() + """ + instance = EthernetAccessInterfaceModel(switch_ip="192.168.1.1", interface_name=value) + assert instance.interface_name == expected + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — to_payload +# ============================================================================= + + +def test_ethernet_access_interface_00600(): + """ + # Summary + + Verify `to_payload` emits camelCase keys and excludes `switch_ip`. + + ## Test + + - Top-level keys are camelCase + - switchIp / switch_ip not present + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_payload() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + assert "interfaceName" in result + assert "interfaceType" in result + assert "configData" in result + assert "switchIp" not in result + assert "switch_ip" not in result + + +def test_ethernet_access_interface_00610(): + """ + # Summary + + Verify deeply nested structure preserves camelCase aliases throughout. + + ## Test + + - configData.networkOS.policy has camelCase keys + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_payload() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + policy = result["configData"]["networkOS"]["policy"] + assert "adminState" in policy + assert "accessVlan" in policy + assert "policyType" in policy + assert "bpduGuard" in policy + assert "portTypeEdgeTrunk" in policy + + +def test_ethernet_access_interface_00620(): + """ + # Summary + + Verify `policyType` is the API camelCase value in payload mode. + + ## Test + + - Hardcoded model default for `policy_type` serializes as `"accessHost"` under the `policyType` alias. + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_payload() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + assert result["configData"]["networkOS"]["policy"]["policyType"] == "accessHost" + + +def test_ethernet_access_interface_00630(): + """ + # Summary + + Verify None-valued fields are excluded from payload output. + + ## Test + + - Minimal model with config_data=None + - configData not present in payload + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_payload() + """ + instance = EthernetAccessInterfaceModel(switch_ip="192.168.1.1", interface_name="Ethernet1/1") + result = instance.to_payload() + assert "configData" not in result + assert "interfaceName" in result + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — to_config +# ============================================================================= + + +def test_ethernet_access_interface_00700(): + """ + # Summary + + Verify `to_config` emits snake_case keys throughout. + + ## Test + + - Top-level keys are snake_case + - Nested keys are snake_case + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_config() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_config() + assert "interface_name" in result + assert "interface_type" in result + assert "config_data" in result + policy = result["config_data"]["network_os"]["policy"] + assert "admin_state" in policy + assert "access_vlan" in policy + assert "port_type_edge_trunk" in policy + + +def test_ethernet_access_interface_00710(): + """ + # Summary + + Verify `policy_type` round-trips as the API value in config output. + + ## Test + + - Stored "accessHost" -> output "accessHost" (no Ansible↔API translation; field is hardcoded by the model) + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_config() + """ + instance = EthernetAccessInterfaceModel.from_response(copy.deepcopy(SAMPLE_API_RESPONSE)) + result = instance.to_config() + assert result["config_data"]["network_os"]["policy"]["policy_type"] == "accessHost" + + +def test_ethernet_access_interface_00720(): + """ + # Summary + + Verify `switch_ip` is included in config output (differs from payload). + + ## Test + + - switch_ip present at top level of config + + ## Classes and Methods + + - EthernetAccessInterfaceModel.to_config() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_config() + assert result["switch_ip"] == "192.168.1.1" + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — from_response +# ============================================================================= + + +def test_ethernet_access_interface_00800(): + """ + # Summary + + Verify `from_response` constructs a model from the ND API response. + + ## Test + + - All fields accessible by Python names + - Nested structure populated + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_response() + """ + with does_not_raise(): + instance = EthernetAccessInterfaceModel.from_response(copy.deepcopy(SAMPLE_API_RESPONSE)) + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "Ethernet1/1" + assert instance.interface_type == "ethernet" + assert instance.config_data.mode == "access" + assert instance.config_data.network_os.policy.admin_state is True + assert instance.config_data.network_os.policy.access_vlan == 20 + assert instance.config_data.network_os.policy.policy_type == "accessHost" + + +def test_ethernet_access_interface_00810(): + """ + # Summary + + Verify `from_response` re-serialized via `to_payload` yields an equivalent dict (minus switchIp). + + ## Test + + - API response -> model -> payload matches original (except switchIp which is excluded) + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_response() + - EthernetAccessInterfaceModel.to_payload() + """ + original = copy.deepcopy(SAMPLE_API_RESPONSE) + instance = EthernetAccessInterfaceModel.from_response(original) + result = instance.to_payload() + expected = {k: v for k, v in original.items() if k != "switchIp"} + assert result == expected + + +def test_ethernet_access_interface_00820(): + """ + # Summary + + Verify `from_response` tolerates missing `configData`. + + ## Test + + - Response with only switchIp + interfaceName constructs valid model + - config_data is None + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_response() + """ + with does_not_raise(): + instance = EthernetAccessInterfaceModel.from_response({"switchIp": "192.168.1.1", "interfaceName": "Ethernet1/1"}) + assert instance.config_data is None + + +def test_ethernet_access_interface_00830(): + """ + # Summary + + Verify `from_response` ignores unknown top-level and nested keys (extra="ignore"). + + ## Test + + - Response with extra keys constructs valid model + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_response() + """ + response = copy.deepcopy(SAMPLE_API_RESPONSE) + response["unknownField"] = "ignored" + response["configData"]["somethingExtra"] = "also_ignored" + with does_not_raise(): + instance = EthernetAccessInterfaceModel.from_response(response) + assert instance.interface_name == "Ethernet1/1" + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — from_config +# ============================================================================= + + +def test_ethernet_access_interface_00900(): + """ + # Summary + + Verify `from_config` constructs a model from an Ansible snake_case config. + + ## Test + + - All fields accessible + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_config() + """ + with does_not_raise(): + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance.switch_ip == "192.168.1.1" + assert instance.interface_name == "Ethernet1/1" + assert instance.config_data.network_os.policy.access_vlan == 20 + assert instance.config_data.network_os.policy.description == "host port" + + +def test_ethernet_access_interface_00910(): + """ + # Summary + + Verify model hardcodes the `accessHost` policy type regardless of input. + + ## Test + + - After from_config (no policy_type in input), stored policy_type is the API value "accessHost" + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_config() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance.config_data.network_os.policy.policy_type == "accessHost" + + +def test_ethernet_access_interface_00920(): + """ + # Summary + + Verify `from_config` -> `to_config` round-trip preserves original data. + + ## Test + + - Input config equals to_config() output + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_config() + - EthernetAccessInterfaceModel.to_config() + """ + original = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + instance = EthernetAccessInterfaceModel.from_config(original) + result = instance.to_config() + assert result == original + + +def test_ethernet_access_interface_00930(): + """ + # Summary + + Verify `from_config` accepts a minimal config with just identifiers. + + ## Test + + - Construct with switch_ip + interface_name only + - config_data is None + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_config() + """ + with does_not_raise(): + instance = EthernetAccessInterfaceModel.from_config({"switch_ip": "192.168.1.1", "interface_name": "Ethernet1/1"}) + assert instance.switch_ip == "192.168.1.1" + assert instance.config_data is None + + +def test_ethernet_access_interface_00940(): + """ + # Summary + + Verify full round-trip through all serialization methods. + + ## Test + + - config -> from_config -> to_payload -> from_response (with switchIp injected) -> to_config + matches original config + + ## Classes and Methods + + - EthernetAccessInterfaceModel.from_config() + - EthernetAccessInterfaceModel.to_payload() + - EthernetAccessInterfaceModel.from_response() + - EthernetAccessInterfaceModel.to_config() + """ + original = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + instance = EthernetAccessInterfaceModel.from_config(original) + payload = instance.to_payload() + payload["switchIp"] = original["switch_ip"] + instance2 = EthernetAccessInterfaceModel.from_response(payload) + result = instance2.to_config() + assert result == original + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — identifier, diff, merge +# ============================================================================= + + +def test_ethernet_access_interface_01000(): + """ + # Summary + + Verify `get_identifier_value` returns the composite `(switch_ip, interface_name)` tuple. + + ## Test + + - Composite tuple returned + + ## Classes and Methods + + - EthernetAccessInterfaceModel.get_identifier_value() + """ + instance = EthernetAccessInterfaceModel(switch_ip="192.168.1.1", interface_name="Ethernet1/1") + assert instance.get_identifier_value() == ("192.168.1.1", "Ethernet1/1") + + +def test_ethernet_access_interface_01010(): + """ + # Summary + + Verify `get_diff` returns True when two models are identical. + + ## Test + + - Two identical models + - get_diff returns True + + ## Classes and Methods + + - EthernetAccessInterfaceModel.get_diff() + """ + instance1 = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + instance2 = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance1.get_diff(instance2) is True + + +def test_ethernet_access_interface_01020(): + """ + # Summary + + Verify `get_diff` returns False when a nested field differs. + + ## Test + + - access_vlan differs between two models + - get_diff returns False + + ## Classes and Methods + + - EthernetAccessInterfaceModel.get_diff() + """ + config1 = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config2 = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config2["config_data"]["network_os"]["policy"]["access_vlan"] = 99 + instance1 = EthernetAccessInterfaceModel.from_config(config1) + instance2 = EthernetAccessInterfaceModel.from_config(config2) + assert instance1.get_diff(instance2) is False + + +def test_ethernet_access_interface_01030(): + """ + # Summary + + Verify `merge` applies non-None values from `other` into `self`. + + ## Test + + - Other sets a field self did not have + - After merge, self has the field + + ## Classes and Methods + + - EthernetAccessInterfaceModel.merge() + """ + base = { + "switch_ip": "192.168.1.1", + "interface_name": "Ethernet1/1", + "config_data": { + "network_os": { + "policy": {"admin_state": True}, + }, + }, + } + other = { + "switch_ip": "192.168.1.1", + "interface_name": "Ethernet1/1", + "config_data": { + "network_os": { + "policy": {"access_vlan": 50}, + }, + }, + } + instance = EthernetAccessInterfaceModel.from_config(base) + instance.merge(EthernetAccessInterfaceModel.from_config(other)) + assert instance.config_data.network_os.policy.admin_state is True + assert instance.config_data.network_os.policy.access_vlan == 50 + + +def test_ethernet_access_interface_01040(): + """ + # Summary + + Verify `merge` preserves existing values when `other` has unset fields (model_fields_set semantics). + + ## Test + + - Self has a value, other does not mention that field + - After merge, self still has the original value + + ## Classes and Methods + + - EthernetAccessInterfaceModel.merge() + """ + instance = EthernetAccessInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + other = EthernetAccessInterfaceModel(switch_ip="192.168.1.1", interface_name="Ethernet1/1") + instance.merge(other) + assert instance.config_data.network_os.policy.access_vlan == 20 + + +def test_ethernet_access_interface_01050(): + """ + # Summary + + Verify `merge` raises TypeError when given a model of the wrong type. + + ## Test + + - Passing a policy model to the interface model merge raises TypeError + + ## Classes and Methods + + - EthernetAccessInterfaceModel.merge() + """ + instance = EthernetAccessInterfaceModel(switch_ip="192.168.1.1", interface_name="Ethernet1/1") + with pytest.raises(TypeError, match=r"Cannot merge"): + instance.merge(EthernetAccessPolicyModel()) + + +# ============================================================================= +# Test: EthernetAccessInterfaceModel — get_argument_spec +# ============================================================================= + + +def test_ethernet_access_interface_01100(): + """ + # Summary + + Verify top-level structural layout of the Ansible argument spec. + + ## Test + + - fabric_name, config, state keys present + - switch_ip is under config.options, not top-level + - config.type == "list", elements == "dict" + - state choices and default + - policy_type is not exposed in the argspec (hardcoded by the model) + - mode default is "access" + + ## Classes and Methods + + - EthernetAccessInterfaceModel.get_argument_spec() + """ + spec = EthernetAccessInterfaceModel.get_argument_spec() + assert "fabric_name" in spec + assert "config" in spec + assert "state" in spec + assert "switch_ip" not in spec + assert "switch_ip" in spec["config"]["options"] + assert spec["config"]["type"] == "list" + assert spec["config"]["elements"] == "dict" + assert spec["state"]["choices"] == ["merged", "replaced", "overridden", "deleted"] + assert spec["state"]["default"] == "merged" + + config_data_spec = spec["config"]["options"]["config_data"]["options"] + assert config_data_spec["mode"]["default"] == "access" + policy_spec = config_data_spec["network_os"]["options"]["policy"]["options"] + assert "policy_type" not in policy_spec + + +@pytest.mark.parametrize( + "field,enum_cls,key", + [ + ("bpdu_filter", BpduFilterEnum, "value"), + ("bpdu_guard", BpduGuardEnum, "value"), + ("duplex_mode", DuplexModeEnum, "value"), + ("fec", FecEnum, "value"), + ("link_type", LinkTypeEnum, "value"), + ("mtu", MtuEnum, "value"), + ("speed", SpeedEnum, "value"), + ("storm_control_action", StormControlActionEnum, "value"), + ], + ids=[ + "bpdu_filter", + "bpdu_guard", + "duplex_mode", + "fec", + "link_type", + "mtu", + "speed", + "storm_control_action", + ], +) +def test_ethernet_access_interface_01120(field, enum_cls, key): + """ + # Summary + + Verify enum-constrained policy fields expose correct `choices` in the argument spec. + + ## Test + + - Each enum field's choices list exactly matches the enum values + + ## Classes and Methods + + - EthernetAccessInterfaceModel.get_argument_spec() + """ + spec = EthernetAccessInterfaceModel.get_argument_spec() + policy_spec = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"] + if key == "name": + expected = [e.name.lower() for e in enum_cls] + else: + expected = [e.value for e in enum_cls] + assert policy_spec[field]["choices"] == expected diff --git a/tests/unit/module_utils/orchestrators/__init__.py b/tests/unit/module_utils/orchestrators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/module_utils/orchestrators/test_ethernet_access_interface.py b/tests/unit/module_utils/orchestrators/test_ethernet_access_interface.py new file mode 100644 index 000000000..7109ea76e --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_ethernet_access_interface.py @@ -0,0 +1,247 @@ +# -*- 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 ethernet_access_interface orchestrator. + +Verifies that `EthernetAccessInterfaceOrchestrator` correctly: +- declares the right `model_class` and `_managed_policy_types` +- inherits bulk-support flags from `EthernetBaseOrchestrator` +- filters `query_all` results down to accessHost interfaces across multiple switches +- propagates `RuntimeError` from the inherited `validate_prerequisites` path + +Uses the file-based `Sender` from `tests/unit/module_utils/sender_file.py` as the +`sender` dependency injected into a real `RestSend`. Responses are read from +`tests/unit/module_utils/fixtures/fixture_data/test_ethernet_access_interface.json`. +""" + +# pylint: disable=line-too-long +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-lines + +from __future__ import annotations + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.ethernet_access_interface import ( + EthernetAccessInterfaceModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.ethernet_access_interface import ( + EthernetAccessInterfaceOrchestrator, +) +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_access(key: str): + """Load fixture data for the orchestrator's test_ethernet_access_interface.json file.""" + return load_fixture("test_ethernet_access_interface")[key] + + +def _build_rest_send(gen_responses: ResponseGenerator, fabric_name: str = "fabric_1") -> RestSend: + """Build a RestSend wired to the file-based Sender and the real ResponseHandler.""" + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + + rest_send = RestSend({"check_mode": False, "fabric_name": fabric_name}) + rest_send.sender = sender + rest_send.response_handler = response_handler + rest_send.unit_test = True + rest_send.timeout = 1 + return rest_send + + +def _build_orchestrator(gen_responses: ResponseGenerator, fabric_name: str = "fabric_1") -> EthernetAccessInterfaceOrchestrator: + """Construct an orchestrator with the file-based RestSend injected.""" + rest_send = _build_rest_send(gen_responses, fabric_name=fabric_name) + return EthernetAccessInterfaceOrchestrator(rest_send=rest_send) + + +# ============================================================================= +# Test: ClassVar / model_class +# ============================================================================= + + +def test_ethernet_access_orchestrator_00010() -> None: + """ + # Summary + + Verify `model_class` points to `EthernetAccessInterfaceModel`. + + ## Test + + - model_class is EthernetAccessInterfaceModel + + ## Classes and Methods + + - EthernetAccessInterfaceOrchestrator.model_class + """ + assert EthernetAccessInterfaceOrchestrator.model_class is EthernetAccessInterfaceModel + + +def test_ethernet_access_orchestrator_00020() -> None: + """ + # Summary + + Verify bulk-support flags inherited from `EthernetBaseOrchestrator`. + + ## Test + + - supports_bulk_create is True + - supports_bulk_delete is True + + ## Classes and Methods + + - EthernetAccessInterfaceOrchestrator + """ + assert EthernetAccessInterfaceOrchestrator.supports_bulk_create is True + assert EthernetAccessInterfaceOrchestrator.supports_bulk_delete is True + + +# ============================================================================= +# Test: _managed_policy_types +# ============================================================================= + + +def test_ethernet_access_orchestrator_00100() -> None: + """ + # Summary + + Verify `_managed_policy_types` returns the single `"accessHost"` API value. + + ## Test + + - Returned set contains exactly "accessHost" + + ## Classes and Methods + + - EthernetAccessInterfaceOrchestrator._managed_policy_types() + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + orchestrator = _build_orchestrator(gen_responses) + assert orchestrator._managed_policy_types() == {"accessHost"} + + +def test_ethernet_access_orchestrator_00110() -> None: + """ + # Summary + + Verify `_managed_policy_types` returns a set (supports set membership for `in` checks). + + ## Test + + - Return type is set + + ## Classes and Methods + + - EthernetAccessInterfaceOrchestrator._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 "accessHost" in result + + +# ============================================================================= +# Test: query_all — happy path with filtering +# ============================================================================= + + +def test_ethernet_access_orchestrator_00400() -> None: + """ + # Summary + + Verify `query_all` validates the fabric, iterates all switches, filters to accessHost interfaces only, + and injects `switchIp` onto each kept interface. + + ## Test + + - Fabric summary (validate_prerequisites) returns 200 + - Switches list returns two switches + - Switch 1 returns: accessHost + trunkHost (the trunkHost should be filtered out) + - Switch 2 returns: one accessHost + - Result contains exactly the two accessHost interfaces + - Each has switchIp injected with the fabricManagementIp + + ## Classes and Methods + + - EthernetAccessInterfaceOrchestrator._managed_policy_types() + - EthernetBaseOrchestrator.query_all() + """ + + def responses(): + yield responses_access("test_query_all_happy_path_00400a") + yield responses_access("test_query_all_happy_path_00400_freeze") + yield responses_access("test_query_all_happy_path_00400b") + yield responses_access("test_query_all_happy_path_00400c") + yield responses_access("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) == {"Ethernet1/1", "Ethernet2/1"} + + # switchIp is injected by the base query_all + assert by_name["Ethernet1/1"]["switchIp"] == "192.168.1.1" + assert by_name["Ethernet2/1"]["switchIp"] == "192.168.1.2" + + # Filtered out: the trunkHost interface on switch 1 + assert "Ethernet1/2" not in by_name + + +def test_ethernet_access_orchestrator_00420() -> None: + """ + # Summary + + Verify `query_all` raises `RuntimeError` when the fabric does not exist. + + ## Test + + - Fabric summary returns 404 + - query_all raises RuntimeError with "Query all failed" (wrapping the inner "Fabric ... not found") + + ## Classes and Methods + + - EthernetBaseOrchestrator.query_all() + - FabricContext.validate_for_mutation() + """ + + def responses(): + yield responses_access("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()