From 5ad9ea48c765f6536362f2576eb85fd99393b3d8 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 7 Apr 2026 14:04:21 -1000 Subject: [PATCH 1/6] Add nd_interface_ethernet_access module with bulk normalize delete Add Ansible module for managing ethernet accessHost interfaces on Nexus Dashboard, with fan-out playbook shape, port-channel membership enforcement, and bulk normalize-based delete for physical interfaces. New files: - Module: nd_interface_ethernet_access with expand_config() fan-out - Model: EthernetAccessInterfaceModel with 19 accessHost policy fields - Model: InterfaceDefaultConfig for int_trunk_host normalize payload - Orchestrator: EthernetBaseOrchestrator (shared ethernet CRUD logic) - Orchestrator: EthernetAccessInterfaceOrchestrator (thin subclass) - Endpoints: EpManageInterfacesDelete, EpManageInterfacesNormalize - Integration tests: merged, replaced, overridden, deleted states Physical ethernet interfaces cannot be deleted via interfaceActions/remove (silently does nothing) or DELETE (returns 500). The delete mechanism uses interfaceActions/normalize with the int_trunk_host template defaults, which resets interfaces to policyType: trunkHost, removing them from the accessHost query filter and enabling idempotent state: overridden. Co-Authored-By: Claude Opus 4.6 --- .../endpoints/v1/manage/manage_interfaces.py | 93 ++++ .../interfaces/ethernet_access_interface.py | 280 ++++++++++ .../interfaces/interface_default_config.py | 133 +++++ .../ethernet_access_interface.py | 53 ++ .../orchestrators/ethernet_base.py | 478 ++++++++++++++++++ .../modules/nd_interface_ethernet_access.py | 377 ++++++++++++++ .../tasks/deleted.yaml | 175 +++++++ .../tasks/main.yaml | 27 + .../tasks/merged.yaml | 336 ++++++++++++ .../tasks/overridden.yaml | 219 ++++++++ .../tasks/replaced.yaml | 189 +++++++ .../vars/main.yaml | 119 +++++ 12 files changed, 2479 insertions(+) create mode 100644 plugins/module_utils/models/interfaces/ethernet_access_interface.py create mode 100644 plugins/module_utils/models/interfaces/interface_default_config.py create mode 100644 plugins/module_utils/orchestrators/ethernet_access_interface.py create mode 100644 plugins/module_utils/orchestrators/ethernet_base.py create mode 100644 plugins/modules/nd_interface_ethernet_access.py create mode 100644 tests/integration/targets/nd_interface_ethernet_access/tasks/deleted.yaml create mode 100644 tests/integration/targets/nd_interface_ethernet_access/tasks/main.yaml create mode 100644 tests/integration/targets/nd_interface_ethernet_access/tasks/merged.yaml create mode 100644 tests/integration/targets/nd_interface_ethernet_access/tasks/overridden.yaml create mode 100644 tests/integration/targets/nd_interface_ethernet_access/tasks/replaced.yaml create mode 100644 tests/integration/targets/nd_interface_ethernet_access/vars/main.yaml diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 37425f4c..b0500b2c 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -238,6 +238,43 @@ 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 + + For physical interfaces (ethernet), this removes the managed configuration and returns the interface + to its default/unmanaged state. For virtual interfaces (loopback, SVI), this fully removes the interface. + + ## 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 +327,62 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST +class EpManageInterfacesNormalize(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Normalize (reset) interface configurations on switches. + + For physical ethernet interfaces, this is the API equivalent of the NX-OS `default interface` CLI command. + Unlike `interfaceActions/remove` (which silently does nothing for ethernet) and `DELETE` (which returns 500), + normalize actually resets the interface configuration. + + - 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/ethernet_access_interface.py b/plugins/module_utils/models/interfaces/ethernet_access_interface.py new file mode 100644 index 00000000..4e5a2a98 --- /dev/null +++ b/plugins/module_utils/models/interfaces/ethernet_access_interface.py @@ -0,0 +1,280 @@ +# 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: "managed") + - `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.constants import NDConstantMapping +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + +ACCESS_HOST_POLICY_TYPE_MAPPING = NDConstantMapping( + { + "access_host": "accessHost", + } +) + + +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") + access_vlan: Optional[int] = Field(default=None, alias="accessVlan") + bpdu_guard: Optional[str] = Field(default=None, alias="bpduGuard") + cdp: Optional[bool] = Field(default=None, alias="cdp") + description: Optional[str] = Field(default=None, alias="description") + duplex_mode: Optional[str] = Field(default=None, alias="duplexMode") + extra_config: Optional[str] = Field(default=None, alias="extraConfig") + mtu: Optional[str] = Field(default=None, alias="mtu") + netflow: Optional[bool] = Field(default=None, alias="netflow") + netflow_monitor: Optional[str] = Field(default=None, alias="netflowMonitor") + netflow_sampler: Optional[str] = Field(default=None, alias="netflowSampler") + orphan_port: Optional[bool] = Field(default=None, alias="orphanPort") + pfc: Optional[bool] = Field(default=None, alias="pfc") + policy_type: Optional[str] = Field(default=None, alias="policyType") + port_type_edge_trunk: Optional[bool] = Field(default=None, alias="portTypeEdgeTrunk") + qos: Optional[bool] = Field(default=None, alias="qos") + qos_policy: Optional[str] = Field(default=None, alias="qosPolicy") + queuing_policy: Optional[str] = Field(default=None, alias="queuingPolicy") + speed: Optional[str] = Field(default=None, alias="speed") + + # --- 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 keep the Ansible name in config mode. + + ## Raises + + None + """ + if value is None: + return None + mode = (info.context or {}).get("mode", "payload") + if mode == "config": + return value + return ACCESS_HOST_POLICY_TYPE_MAPPING.get_dict().get(value, value) + + # --- Validators --- + + @field_validator("policy_type", mode="before") + @classmethod + def normalize_policy_type(cls, v): + """ + # Summary + + Accept `policy_type` in either Ansible (`access_host`) or API (`accessHost`) format, normalizing to Ansible names. + + ## Raises + + None + """ + if v is None: + return v + reverse_mapping = {api: ansible for ansible, api in ACCESS_HOST_POLICY_TYPE_MAPPING.data.items() if ansible != api} + return reverse_mapping.get(v, v) + + +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, v): + """ + # Summary + + Normalize interface name to match ND API convention (e.g., ethernet1/1 -> Ethernet1/1). + + ## Raises + + None + """ + if isinstance(v, str) and v: + return v[0].upper() + v[1:] + return v + + # --- 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"), + bpdu_guard=dict(type="str", choices=["enable", "disable", "default"]), + cdp=dict(type="bool"), + description=dict(type="str"), + duplex_mode=dict(type="str", choices=["auto", "full", "half"]), + extra_config=dict(type="str"), + mtu=dict(type="str", choices=["default", "jumbo"]), + 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=ACCESS_HOST_POLICY_TYPE_MAPPING.get_original_data(), + 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=[ + "auto", + "10Mb", + "100Mb", + "1Gb", + "2.5Gb", + "5Gb", + "10Gb", + "25Gb", + "40Gb", + "50Gb", + "100Gb", + "200Gb", + "400Gb", + "800Gb", + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + 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..cf4a040b --- /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.ethernet_access_interface import ( + ACCESS_HOST_POLICY_TYPE_MAPPING, + 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 set(ACCESS_HOST_POLICY_TYPE_MAPPING.data.values()) 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 From 13fb4705817cc9930b266f458fceb35b0e90a0e9 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 8 Apr 2026 06:41:15 -1000 Subject: [PATCH 2/6] Fix EpManageInterfacesDelete docstring and add missing module-level entries The DELETE endpoint does not work for physical ethernet interfaces (returns HTTP 500). Updated docstring to reflect this and point to EpManageInterfacesNormalize. Added missing EpManageInterfacesDelete and EpManageInterfacesNormalize to the module-level endpoint list. Co-Authored-By: Claude Opus 4.6 --- .../endpoints/v1/manage/manage_interfaces.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index b0500b2c..653705bb 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) """ @@ -247,8 +251,9 @@ class EpManageInterfacesDelete(_EpManageInterfacesBase): - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}` - Verb: DELETE - For physical interfaces (ethernet), this removes the managed configuration and returns the interface - to its default/unmanaged state. For virtual interfaces (loopback, SVI), this fully removes the interface. + This endpoint works for virtual interfaces (loopback, SVI) only. For physical ethernet interfaces, + the API returns HTTP 500 ("Interface cannot be deleted!!!"). Use `EpManageInterfacesNormalize` instead + to reset physical interfaces to their default state. ## Raises From a79d9e673970fc4a1394bb5da141a773f775b081 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 8 Apr 2026 15:11:28 -1000 Subject: [PATCH 3/6] EpManageInterfacesNormalize : clarify docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed mention of “reset” and “default interface” CLI. While this endpoint can serve as the equivalent to “default interface” this is only so if the payload is constructured to do so. --- .../module_utils/endpoints/v1/manage/manage_interfaces.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 653705bb..89e56168 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -336,11 +336,7 @@ class EpManageInterfacesNormalize(FabricNameMixin, NDEndpointBaseModel): """ # Summary - Normalize (reset) interface configurations on switches. - - For physical ethernet interfaces, this is the API equivalent of the NX-OS `default interface` CLI command. - Unlike `interfaceActions/remove` (which silently does nothing for ethernet) and `DELETE` (which returns 500), - normalize actually resets the interface configuration. + Normalize interface configurations on switches. - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/normalize` - Verb: POST From eb6cea4ad94f35bc1daf30706f2322bfb6cc7766 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 8 Apr 2026 15:19:43 -1000 Subject: [PATCH 4/6] EpManageInterfacesDelete: update docstring Update docstring to clarify that EpManageInterfacesNormalize does not reset physical interfaces without an appropriate payload. --- .../module_utils/endpoints/v1/manage/manage_interfaces.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 89e56168..2a7481a9 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -251,9 +251,11 @@ class EpManageInterfacesDelete(_EpManageInterfacesBase): - 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!!!"). Use `EpManageInterfacesNormalize` instead - to reset physical interfaces to their default state. + 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 From c4e66919b5d8623aa212053d7e4b0c031dc57ad2 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 8 Apr 2026 16:21:46 -1000 Subject: [PATCH 5/6] Add shared interface enums and constrain EthernetAccessPolicyModel fields Add plugins/module_utils/models/interfaces/enums.py with reusable enums derived from the int_access_host config template. Replace unconstrained Optional[str]/Optional[int] fields with enum types, Field(ge=/le=) for integer ranges, and Field(max_length=) for strings. Add 16 missing policy fields from the template (storm control, fec, debounce, etc.). Replace NDConstantMapping with AccessHostPolicyTypeEnum for policy_type handling. Co-Authored-By: Claude Opus 4.6 --- .../module_utils/models/interfaces/enums.py | 135 ++++++++++++ .../interfaces/ethernet_access_interface.py | 193 +++++++++++------- .../ethernet_access_interface.py | 4 +- 3 files changed, 261 insertions(+), 71 deletions(-) create mode 100644 plugins/module_utils/models/interfaces/enums.py 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 index 4e5a2a98..1c426ecd 100644 --- a/plugins/module_utils/models/interfaces/ethernet_access_interface.py +++ b/plugins/module_utils/models/interfaces/ethernet_access_interface.py @@ -16,7 +16,7 @@ - `interface_name` (identifier) - `interface_type` (default: "ethernet") - `config_data` -> `EthernetAccessConfigDataModel` - - `mode` (default: "managed") + - `mode` (default: "access") - `network_os` -> `EthernetAccessNetworkOSModel` - `network_os_type` (default: "nx-os") - `policy` -> `EthernetAccessPolicyModel` @@ -31,15 +31,19 @@ field_serializer, field_validator, ) -from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel - -ACCESS_HOST_POLICY_TYPE_MAPPING = NDConstantMapping( - { - "access_host": "accessHost", - } +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): @@ -53,64 +57,115 @@ class EthernetAccessPolicyModel(NDNestedModel): None """ - admin_state: Optional[bool] = Field(default=None, alias="adminState") - access_vlan: Optional[int] = Field(default=None, alias="accessVlan") - bpdu_guard: Optional[str] = Field(default=None, alias="bpduGuard") - cdp: Optional[bool] = Field(default=None, alias="cdp") - description: Optional[str] = Field(default=None, alias="description") - duplex_mode: Optional[str] = Field(default=None, alias="duplexMode") - extra_config: Optional[str] = Field(default=None, alias="extraConfig") - mtu: Optional[str] = Field(default=None, alias="mtu") - netflow: Optional[bool] = Field(default=None, alias="netflow") - netflow_monitor: Optional[str] = Field(default=None, alias="netflowMonitor") - netflow_sampler: Optional[str] = Field(default=None, alias="netflowSampler") - orphan_port: Optional[bool] = Field(default=None, alias="orphanPort") - pfc: Optional[bool] = Field(default=None, alias="pfc") - policy_type: Optional[str] = Field(default=None, alias="policyType") - port_type_edge_trunk: Optional[bool] = Field(default=None, alias="portTypeEdgeTrunk") - qos: Optional[bool] = Field(default=None, alias="qos") - qos_policy: Optional[str] = Field(default=None, alias="qosPolicy") - queuing_policy: Optional[str] = Field(default=None, alias="queuingPolicy") - speed: Optional[str] = Field(default=None, alias="speed") + 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", + ) - # --- Serializers --- + # --- Validators --- - @field_serializer("policy_type") - def serialize_policy_type(self, value: Optional[str], info: FieldSerializationInfo) -> Optional[str]: + @field_validator("policy_type", mode="before") + @classmethod + def normalize_policy_type(cls, v): """ # Summary - Serialize `policy_type` to the API's camelCase value in payload mode, or keep the Ansible name in config mode. + 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 None - mode = (info.context or {}).get("mode", "payload") - if mode == "config": - return value - return ACCESS_HOST_POLICY_TYPE_MAPPING.get_dict().get(value, value) + if v is None: + return v + ansible_to_api = {e.name.lower(): e.value for e in AccessHostPolicyTypeEnum} + return ansible_to_api.get(v, v) - # --- Validators --- + # --- Serializers --- - @field_validator("policy_type", mode="before") - @classmethod - def normalize_policy_type(cls, v): + @field_serializer("policy_type") + def serialize_policy_type(self, value: Optional[str], info: FieldSerializationInfo) -> Optional[str]: """ # Summary - Accept `policy_type` in either Ansible (`access_host`) or API (`accessHost`) format, normalizing to Ansible names. + 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 v is None: - return v - reverse_mapping = {api: ansible for ansible, api in ACCESS_HOST_POLICY_TYPE_MAPPING.data.items() if ansible != api} - return reverse_mapping.get(v, v) + 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): @@ -225,12 +280,22 @@ def get_argument_spec(cls) -> Dict: options=dict( admin_state=dict(type="bool"), access_vlan=dict(type="int"), - bpdu_guard=dict(type="str", choices=["enable", "disable", "default"]), + 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=["auto", "full", "half"]), + duplex_mode=dict(type="str", choices=[e.value for e in DuplexModeEnum]), + error_detection_acl=dict(type="bool"), extra_config=dict(type="str"), - mtu=dict(type="str", choices=["default", "jumbo"]), + 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"), @@ -238,32 +303,22 @@ def get_argument_spec(cls) -> Dict: pfc=dict(type="bool"), policy_type=dict( type="str", - choices=ACCESS_HOST_POLICY_TYPE_MAPPING.get_original_data(), + 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=[ - "auto", - "10Mb", - "100Mb", - "1Gb", - "2.5Gb", - "5Gb", - "10Gb", - "25Gb", - "40Gb", - "50Gb", - "100Gb", - "200Gb", - "400Gb", - "800Gb", - ], - ), + 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"), ), ), ), diff --git a/plugins/module_utils/orchestrators/ethernet_access_interface.py b/plugins/module_utils/orchestrators/ethernet_access_interface.py index cf4a040b..da65c40c 100644 --- a/plugins/module_utils/orchestrators/ethernet_access_interface.py +++ b/plugins/module_utils/orchestrators/ethernet_access_interface.py @@ -15,8 +15,8 @@ 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 ( - ACCESS_HOST_POLICY_TYPE_MAPPING, EthernetAccessInterfaceModel, ) from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.ethernet_base import EthernetBaseOrchestrator @@ -50,4 +50,4 @@ def _managed_policy_types(self) -> set[str]: None """ - return set(ACCESS_HOST_POLICY_TYPE_MAPPING.data.values()) + return {e.value for e in AccessHostPolicyTypeEnum} From 9912dd3cdb6f5a1a6a13b1ca9145e06a75d8193a Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 9 Apr 2026 07:43:19 -1000 Subject: [PATCH 6/6] Rename validator parameter v to value for consistency with serializers Co-Authored-By: Claude Opus 4.6 --- .../interfaces/ethernet_access_interface.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/module_utils/models/interfaces/ethernet_access_interface.py b/plugins/module_utils/models/interfaces/ethernet_access_interface.py index 1c426ecd..aff8354d 100644 --- a/plugins/module_utils/models/interfaces/ethernet_access_interface.py +++ b/plugins/module_utils/models/interfaces/ethernet_access_interface.py @@ -129,7 +129,7 @@ class EthernetAccessPolicyModel(NDNestedModel): @field_validator("policy_type", mode="before") @classmethod - def normalize_policy_type(cls, v): + def normalize_policy_type(cls, value): """ # Summary @@ -139,10 +139,10 @@ def normalize_policy_type(cls, v): None """ - if v is None: - return v + if value is None: + return value ansible_to_api = {e.name.lower(): e.value for e in AccessHostPolicyTypeEnum} - return ansible_to_api.get(v, v) + return ansible_to_api.get(value, value) # --- Serializers --- @@ -230,7 +230,7 @@ class EthernetAccessInterfaceModel(NDBaseModel): @field_validator("interface_name", mode="before") @classmethod - def normalize_interface_name(cls, v): + def normalize_interface_name(cls, value): """ # Summary @@ -240,9 +240,9 @@ def normalize_interface_name(cls, v): None """ - if isinstance(v, str) and v: - return v[0].upper() + v[1:] - return v + if isinstance(value, str) and value: + return value[0].upper() + value[1:] + return value # --- Argument Spec ---