diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 37425f4c..2a7481a9 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 00000000..7847ae9c --- /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 00000000..aff8354d --- /dev/null +++ b/plugins/module_utils/models/interfaces/ethernet_access_interface.py @@ -0,0 +1,335 @@ +# 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, Dict, List, Literal, Optional, Set + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + FieldSerializationInfo, + field_serializer, + 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 + + +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: Optional[bool] = Field(default=None, alias="adminState", description="Enable or disable the interface") + access_vlan: Optional[int] = Field(default=None, alias="accessVlan", ge=1, le=4094, description="VLAN for this access port") + bandwidth: Optional[int] = Field(default=None, alias="bandwidth", ge=1, le=100000000, description="Bandwidth in kilobits") + bpdu_filter: Optional[BpduFilterEnum] = Field(default=None, alias="bpduFilter", description="Configure spanning-tree BPDU filter") + bpdu_guard: Optional[BpduGuardEnum] = Field(default=None, alias="bpduGuard", description="Enable spanning-tree BPDU guard") + cdp: Optional[bool] = Field(default=None, alias="cdp", description="Enable CDP on the interface") + debounce_timer: Optional[int] = Field(default=None, alias="debounceTimer", ge=0, le=20000, description="Link debounce timer in milliseconds") + debounce_linkup_timer: Optional[int] = Field( + default=None, alias="debounceLinkupTimer", ge=1000, le=10000, description="Link debounce link-up timer in milliseconds" + ) + description: Optional[str] = Field(default=None, alias="description", max_length=254, description="Interface description") + duplex_mode: Optional[DuplexModeEnum] = Field(default=None, alias="duplexMode", description="Port duplex mode") + error_detection_acl: Optional[bool] = Field(default=None, alias="errorDetectionAcl", description="Enable error detection for ACL installation failures") + extra_config: Optional[str] = Field(default=None, alias="extraConfig", description="Additional CLI for the interface") + fec: Optional[FecEnum] = Field(default=None, alias="fec", description="Forward error correction mode") + inherit_bandwidth: Optional[int] = Field( + default=None, alias="inheritBandwidth", ge=1, le=100000000, description="Inherit bandwidth in kilobits for sub-interfaces" + ) + link_type: Optional[LinkTypeEnum] = Field(default=None, alias="linkType", description="Spanning-tree link type") + monitor: Optional[bool] = Field(default=None, alias="monitor", description="Enable switchport monitor for SPAN/ERSPAN") + mtu: Optional[MtuEnum] = Field(default=None, alias="mtu", description="Interface MTU") + negotiate_auto: Optional[bool] = Field(default=None, alias="negotiateAuto", description="Enable link auto-negotiation") + netflow: Optional[bool] = Field(default=None, alias="netflow", description="Enable Netflow on the interface") + netflow_monitor: Optional[str] = Field(default=None, alias="netflowMonitor", description="Layer 2 Netflow monitor name") + netflow_sampler: Optional[str] = Field(default=None, alias="netflowSampler", description="Netflow sampler name") + orphan_port: Optional[bool] = Field(default=None, alias="orphanPort", description="Enable vPC orphan port") + pfc: Optional[bool] = Field(default=None, alias="pfc", description="Enable priority flow control") + policy_type: Optional[AccessHostPolicyTypeEnum] = Field(default=None, alias="policyType", description="Interface policy type") + port_type_edge_trunk: Optional[bool] = Field(default=None, alias="portTypeEdgeTrunk", description="Enable spanning-tree edge port behavior") + qos: Optional[bool] = Field(default=None, alias="qos", description="Enable QoS configuration for this interface") + qos_policy: Optional[str] = Field(default=None, alias="qosPolicy", description="Custom QoS policy name") + queuing_policy: Optional[str] = Field(default=None, alias="queuingPolicy", description="Custom queuing policy name") + speed: Optional[SpeedEnum] = Field(default=None, alias="speed", description="Interface speed") + storm_control: Optional[bool] = Field(default=None, alias="stormControl", description="Enable traffic storm control") + storm_control_action: Optional[StormControlActionEnum] = Field( + default=None, alias="stormControlAction", description="Storm control action on threshold violation" + ) + storm_control_broadcast_level: Optional[str] = Field( + default=None, alias="stormControlBroadcastLevel", description="Broadcast storm control level in percentage (0.00-100.00)" + ) + storm_control_broadcast_level_pps: Optional[int] = Field( + default=None, + alias="stormControlBroadcastLevelPps", + ge=0, + le=200000000, + description="Broadcast storm control level in packets per second", + ) + storm_control_multicast_level: Optional[str] = Field( + default=None, alias="stormControlMulticastLevel", description="Multicast storm control level in percentage (0.00-100.00)" + ) + storm_control_multicast_level_pps: Optional[int] = Field( + default=None, + alias="stormControlMulticastLevelPps", + ge=0, + le=200000000, + description="Multicast storm control level in packets per second", + ) + storm_control_unicast_level: Optional[str] = Field( + default=None, alias="stormControlUnicastLevel", description="Unicast storm control level in percentage (0.00-100.00)" + ) + storm_control_unicast_level_pps: Optional[int] = Field( + default=None, + alias="stormControlUnicastLevelPps", + ge=0, + le=200000000, + description="Unicast storm control level in packets per second", + ) + + # --- Validators --- + + @field_validator("policy_type", mode="before") + @classmethod + def normalize_policy_type(cls, value): + """ + # Summary + + Accept `policy_type` in either Ansible (`access_host`) or API (`accessHost`) format, normalizing to the API value for enum validation. + + ## Raises + + None + """ + if value is None: + return value + ansible_to_api = {e.name.lower(): e.value for e in AccessHostPolicyTypeEnum} + return ansible_to_api.get(value, value) + + # --- Serializers --- + + @field_serializer("policy_type") + def serialize_policy_type(self, value: Optional[str], info: FieldSerializationInfo) -> Optional[str]: + """ + # Summary + + Serialize `policy_type` to the API's camelCase value in payload mode, or the Ansible-friendly name in config mode. + + With `use_enum_values=True`, the stored value is the enum's `.value` string (e.g. `"accessHost"`). + + ## Raises + + None + """ + if value is None: + return None + mode = (info.context or {}).get("mode", "payload") + if mode == "config": + reverse = {e.value: e.name.lower() for e in AccessHostPolicyTypeEnum} + return reverse.get(value, value) + return value + + +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: Optional[EthernetAccessPolicyModel] = 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[Optional[List[str]]] = ["switch_ip", "interface_name"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "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: Optional[EthernetAccessConfigDataModel] = 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"), + policy_type=dict( + type="str", + choices=[e.name.lower() for e in AccessHostPolicyTypeEnum], + default="access_host", + ), + 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="str"), + storm_control_broadcast_level_pps=dict(type="int"), + storm_control_multicast_level=dict(type="str"), + storm_control_multicast_level_pps=dict(type="int"), + storm_control_unicast_level=dict(type="str"), + 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 00000000..dfa75a7e --- /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, List + +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 00000000..da65c40c --- /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 00000000..3acb3078 --- /dev/null +++ b/plugins/module_utils/orchestrators/ethernet_base.py @@ -0,0 +1,478 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +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()`. + +Shared functionality includes: +- Switch IP-to-serial resolution via `FabricContext` +- Pre-flight fabric validation +- Deferred bulk deploy and remove operations +- Port-channel membership enforcement with a whitelisted field set +- Fabric-wide `query_all()` with per-type policy filtering +""" + +from __future__ import annotations + +from typing import ClassVar, Optional, Set, Type + +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 ( + EpManageInterfacesDeploy, + EpManageInterfacesGet, + EpManageInterfacesListGet, + EpManageInterfacesNormalize, + EpManageInterfacesPost, + EpManageInterfacesPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.fabric_context import FabricContext +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 import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType + +ModelType = NDBaseModel + + +class EthernetBaseOrchestrator(NDBaseOrchestrator[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 remove 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. + """ + + 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 + + deploy: bool = True + + PORT_CHANNEL_MODIFIABLE_FIELDS: ClassVar[Set[str]] = {"description", "admin_state", "extra_config"} + + _fabric_context: Optional[FabricContext] = None + _pending_deploys: list[tuple[str, str]] = [] + _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()") + + @property + def fabric_name(self) -> str: + """ + # Summary + + Return `fabric_name` from module params. + + ## Raises + + None + """ + return self.sender.params.get("fabric_name") + + @property + def fabric_context(self) -> FabricContext: + """ + # Summary + + Return a lazily-initialized `FabricContext` for this orchestrator's fabric. + + ## Raises + + None + """ + if self._fabric_context is None: + self._fabric_context = FabricContext(sender=self.sender, fabric_name=self.fabric_name) + return self._fabric_context + + def _resolve_switch_id(self, switch_ip: str) -> str: + """ + # Summary + + Resolve a `switch_ip` to its `switchId` via `FabricContext`. + + ## Raises + + ### RuntimeError + + - If no switch matches the given IP in the fabric. + """ + return self.fabric_context.get_switch_id(switch_ip) + + def validate_prerequisites(self) -> None: + """ + # Summary + + Run pre-flight validation before any CRUD operations. Checks that the fabric exists and is modifiable. + + ## Raises + + ### RuntimeError + + - If the fabric does not exist on the target ND node. + - If the fabric is in deployment-freeze mode. + """ + self.fabric_context.validate_for_mutation() + + def _configure_endpoint(self, api_endpoint, switch_sn: str): + """ + # Summary + + Set `fabric_name` and `switch_sn` on an endpoint instance before path generation. + + ## Raises + + None + """ + api_endpoint.fabric_name = self.fabric_name + api_endpoint.switch_sn = switch_sn + return api_endpoint + + def _queue_deploy(self, interface_name: str, switch_id: str) -> None: + """ + # Summary + + Queue an `(interface_name, switch_id)` pair for deferred deployment. Call `deploy_pending` after all mutations + are complete to deploy in bulk. + + ## Raises + + None + """ + pair = (interface_name, switch_id) + if pair not in self._pending_deploys: + self._pending_deploys.append(pair) + + 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: Optional[dict] = 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 deploy_pending(self) -> ResponseType | None: + """ + # Summary + + Deploy all queued interface configurations in a single API call via `interfaceActions/deploy`. Clears the pending + queue after deployment. + + When `deploy` is `False`, returns `None` without making any API call. + + ## Raises + + ### RuntimeError + + - If the deploy API request fails. + """ + if not self.deploy or not self._pending_deploys: + return None + try: + result = self._deploy_interfaces() + self._pending_deploys = [] + return result + except Exception as e: + raise RuntimeError(f"Bulk deploy failed for interfaces {self._pending_deploys}: {e}") from e + + def _deploy_interfaces(self) -> ResponseType: + """ + # Summary + + Deploy queued interfaces via `interfaceActions/deploy`. Sends the explicit list of `{interfaceName, switchId}` pairs. + + ## Raises + + ### Exception + + - If the deploy API request fails (propagated to caller). + """ + api_endpoint = EpManageInterfacesDeploy() + api_endpoint.fabric_name = self.fabric_name + payload = {"interfaces": [{"interfaceName": name, "switchId": switch_id} for name, switch_id in self._pending_deploys]} + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + + 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.sender.request(path=api_endpoint.path, method=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.sender.request(path=api_endpoint.path, method=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.sender.request(path=api_endpoint.path, method=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 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.sender.request(path=api_endpoint.path, method=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: Optional[ModelType] = 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.sender.query_obj(api_endpoint.path) + 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 00000000..c372b537 --- /dev/null +++ b/plugins/modules/nd_interface_ethernet_access.py @@ -0,0 +1,377 @@ +#!/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 + policy_type: + description: + - The policy template type for the interface. + - V(access_host) is the standard accessHost policy. + type: str + choices: [ access_host ] + default: access_host + 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 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.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.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) + + # Expand grouped config (interface_names list) into flat config items (interface_name singular) + module.params["config"] = expand_config(module.params["config"]) + + nd_state_machine = None + + try: + # Initialize StateMachine + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=EthernetAccessInterfaceOrchestrator, + ) + nd_state_machine.model_orchestrator.deploy = module.params["deploy"] + + # Manage state + nd_state_machine.manage_state() + + # 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: + 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 00000000..f8352f0a --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/deleted.yaml @@ -0,0 +1,175 @@ +--- +# 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: + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/45 + state: deleted + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/45 + state: deleted + 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: + 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: + 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 + 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: + 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: + 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 + 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 00000000..99e4b58d --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/main.yaml @@ -0,0 +1,27 @@ +--- +# 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) + +- 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 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 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 00000000..5398e715 --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/merged.yaml @@ -0,0 +1,336 @@ +--- +# 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) + +# --- CLEANUP --- + +- name: "SETUP: Remove test ethernet interfaces before merged 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: + policy_type: access_host + 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 }}" + +# --- MERGED CREATE: SINGLE INTERFACE --- + +- name: "MERGED CREATE: Create Ethernet1/41 with full config (check mode)" + cisco.nd.nd_interface_ethernet_access: + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41 }}" + state: merged + 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: + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_42_43 }}" + state: merged + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41 }}" + state: merged + check_mode: true + register: cm_merged_idem_41 + +- name: "MERGED IDEMPOTENT: Re-apply Ethernet1/41 creation (normal mode)" + cisco.nd.nd_interface_ethernet_access: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41 }}" + state: merged + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_42_43 }}" + state: merged + 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: + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41_updated }}" + state: merged + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_41_updated }}" + state: merged + 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" + policy_type: access_host + 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: + 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: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ ethernet_45_46_47 }}" + state: merged + 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 00000000..27aeacff --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/overridden.yaml @@ -0,0 +1,219 @@ +--- +# 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: + 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" + policy_type: access_host + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 901 + description: "Overridden Ethernet1/42" + policy_type: access_host + 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: + 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" + policy_type: access_host + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 901 + description: "Overridden Ethernet1/42" + policy_type: access_host + state: overridden + 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: + 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" + policy_type: access_host + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 901 + description: "Overridden Ethernet1/42" + policy_type: access_host + state: overridden + 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: + 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" + policy_type: access_host + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/42 + config_data: + network_os: + policy: + admin_state: true + access_vlan: 901 + description: "Overridden Ethernet1/42" + policy_type: access_host + state: overridden + 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 00000000..ef4cb7ed --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/tasks/replaced.yaml @@ -0,0 +1,189 @@ +--- +# 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: + 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" + policy_type: access_host + state: replaced + check_mode: true + register: cm_replaced_41 + +- name: "REPLACED: Replace Ethernet1/41 config entirely (normal mode)" + 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: 500 + description: "Replaced Ethernet1/41" + policy_type: access_host + state: replaced + 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: + 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" + policy_type: access_host + state: replaced + 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: + 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" + policy_type: access_host + 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: + 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" + policy_type: access_host + state: replaced + 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" + policy_type: access_host + - switch_ip: "{{ test_switch_ip }}" + interface_names: + - Ethernet1/44 + config_data: + network_os: + policy: + admin_state: false + access_vlan: 800 + description: "Replaced Ethernet1/44" + policy_type: access_host + 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/vars/main.yaml b/tests/integration/targets/nd_interface_ethernet_access/vars/main.yaml new file mode 100644 index 00000000..6066fbec --- /dev/null +++ b/tests/integration/targets/nd_interface_ethernet_access/vars/main.yaml @@ -0,0 +1,119 @@ +--- +# 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 + policy_type: access_host + +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 + policy_type: access_host + +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" + policy_type: access_host + +# --- 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 + policy_type: access_host + +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" + policy_type: access_host + +# --- 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 + policy_type: access_host + +# --- Cleanup helper: all test interfaces --- + +cleanup_interface_names: + - Ethernet1/41 + - Ethernet1/42 + - Ethernet1/43 + - Ethernet1/44 + - Ethernet1/45 + - Ethernet1/46 + - Ethernet1/47 + - Ethernet1/48