From 560a193a532ff187f5109570ca68e8dcf4d65afc Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 May 2026 10:26:12 -1000 Subject: [PATCH 1/2] Add nd_interface_subinterface_managed module Manages L3 (managed) subinterfaces on Cisco Nexus Dashboard via the Manage Interfaces API. Mirrors the SVI / loopback / ethernet-access patterns: composite identifier (switch_ip, interface_name), bulk POST per switch, partial PUT, bulk remove via interfaceActions/remove followed by interfaceActions/deploy. The subinterface family of policies is split into two modules per the one-policy-per-module convention. This commit covers the managed variant (policyType: subinterface) which carries the L3 field set: admin_state, description, extra_config, mtu, vlan_id, vrf_interface, ip/prefix, ipv6/ipv6_prefix, routing_tag, ip_redirects, pim_sparse, pim_dr_priority, netflow, netflow_monitor, netflow_sampler. The unmanaged variant (policyType: monitorSubinterface) will follow as a separate module stacked on top of this one. Field bounds are aligned with the live intSubifTemplate schema on ND 4.2.1 (prefix 8-31, vlan_id 2-4094, mtu 576-9216). Two wire quirks worked around in the model: - routing_tag: ND accepts string on POST/PUT but returns int on GET. Coerce int -> str so round-trips compare equal. Marked TODO(4.2.1). - interface_name: ND accepts canonical case on POST (Ethernet1/3.2) but returns lowercase on GET (ethernet1/3.2, port-channel10.5). The field validator now accepts any case and normalizes to the canonical form so user input, POST payloads, and GET responses all compare equal. Marked TODO(4.2.1). One handler-layer workaround in the orchestrator: - ND returns HTTP 207 Multi-Status with per-item status: "failed" when the parent interface is not in routed mode (or other policy validation fails). Our RestSend response_handler classifies 207 as success, so without this check the orchestrator would silently report changed=True with nothing actually created. create() and create_bulk() now inspect the body and raise with the ND failure message. To be removed once #295 lands at the RestSend layer. The module's `notes:` documents the parent-must-be-routed prerequisite, including why typical L2 vPC port-channels and peer-links reject subinterface creation in practice. Test notes: integration target covers merged/replaced/overridden/ deleted lifecycles plus check-mode and idempotency assertions, on both Ethernet and Port-channel parents. Live-verified against ND 4.2.1 lab SITE1 with a routed Port-channel parent and a routed Ethernet parent. Co-Authored-By: Claude Opus 4.7 --- .../module_utils/models/interfaces/enums.py | 10 + .../subinterface_managed_interface.py | 304 ++++++++++++++++ .../subinterface_managed_interface.py | 291 +++++++++++++++ .../nd_interface_subinterface_managed.py | 342 ++++++++++++++++++ .../tasks/deleted.yaml | 62 ++++ .../tasks/main.yaml | 49 +++ .../tasks/merged.yaml | 122 +++++++ .../tasks/overridden.yaml | 60 +++ .../tasks/replaced.yaml | 45 +++ .../tasks/setup.yaml | 23 ++ .../vars/main.yaml | 106 ++++++ 11 files changed, 1414 insertions(+) create mode 100644 plugins/module_utils/models/interfaces/subinterface_managed_interface.py create mode 100644 plugins/module_utils/orchestrators/subinterface_managed_interface.py create mode 100644 plugins/modules/nd_interface_subinterface_managed.py create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/tasks/deleted.yaml create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/tasks/main.yaml create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/tasks/merged.yaml create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/tasks/overridden.yaml create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/tasks/replaced.yaml create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/tasks/setup.yaml create mode 100644 tests/integration/targets/nd_interface_subinterface_managed/vars/main.yaml diff --git a/plugins/module_utils/models/interfaces/enums.py b/plugins/module_utils/models/interfaces/enums.py index a8905a9b7..490f7a5ff 100644 --- a/plugins/module_utils/models/interfaces/enums.py +++ b/plugins/module_utils/models/interfaces/enums.py @@ -153,3 +153,13 @@ class SviPolicyTypeEnum(str, Enum): """ SVI = "svi" + + +class SubinterfaceManagedPolicyTypeEnum(str, Enum): + """ + # Summary + + Policy type for managed L3 subinterfaces. + """ + + SUBINTERFACE = "subinterface" diff --git a/plugins/module_utils/models/interfaces/subinterface_managed_interface.py b/plugins/module_utils/models/interfaces/subinterface_managed_interface.py new file mode 100644 index 000000000..eb39ee9eb --- /dev/null +++ b/plugins/module_utils/models/interfaces/subinterface_managed_interface.py @@ -0,0 +1,304 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Managed L3 subinterface Pydantic models for Nexus Dashboard. + +This module defines nested Pydantic models that mirror the ND Manage Interfaces API payload structure for L3 +subinterfaces (`interfaceType: "subInterface"`, `policyType: "subinterface"`, `mode: "managed"`). 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. + +## Parents + +A subinterface is created on a physical Ethernet parent (e.g. `Ethernet1/3.2`) or on a Port-channel parent +(e.g. `Port-channel10.5`). The parent type is encoded only in the `interface_name` string; the ND API does not require +a separate parent reference. The `.` portion encodes the 802.1Q dot1q sub-id. + +## Model Hierarchy + +- `SubinterfaceManagedInterfaceModel` (top-level, `NDBaseModel`) + - `interface_name` (identifier, e.g. `Ethernet1/3.2`) + - `interface_type` (hardcoded: "subInterface") + - `config_data` -> `SubinterfaceManagedConfigDataModel` + - `mode` (hardcoded: "managed") + - `network_os` -> `SubinterfaceManagedNetworkOSModel` + - `network_os_type` (hardcoded: "nx-os") + - `policy` -> `SubinterfaceManagedPolicyModel` + - `policy_type` (hardcoded: SubinterfaceManagedPolicyTypeEnum.SUBINTERFACE), `vlan_id`, L3 fields, PIM, Netflow + +## Field set + +Fields in `SubinterfaceManagedPolicyModel` mirror the `policyType: "subinterface"` schema in the ND Manage API +(createInterfaceSubInterfaceNexusType) as observed on ND 4.2.1, covering vlan_id, VRF binding, IPv4/IPv6 addressing, +routing tag, MTU, PIM, ip-redirects, admin-state, and netflow. The "unmanaged" subinterface variant +(`policyType: "monitorSubinterface"`) is handled by a separate module. +""" + +from __future__ import annotations + +from typing import ClassVar, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import SubinterfaceManagedPolicyTypeEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.types import AsciiDescription + + +class SubinterfaceManagedPolicyModel(NDNestedModel): + """ + # Summary + + Policy fields for a managed L3 subinterface. Maps directly to the `configData.networkOS.policy` object in the + ND API. + + `policy_type` is required by the API as a discriminator on both POST and PUT, so it carries a default of + `SubinterfaceManagedPolicyTypeEnum.SUBINTERFACE` and is always serialized. + + ## Raises + + None + """ + + policy_type: SubinterfaceManagedPolicyTypeEnum = Field( + default=SubinterfaceManagedPolicyTypeEnum.SUBINTERFACE, + alias="policyType", + frozen=True, + description="Interface policy type (hardcoded for this module)", + ) + admin_state: bool | None = Field(default=None, alias="adminState", description="Enable or disable the subinterface") + description: AsciiDescription = Field(default=None, alias="description", max_length=254, description="Subinterface description") + extra_config: str | None = Field(default=None, alias="extraConfig", description="Additional CLI for the subinterface") + mtu: int | None = Field(default=None, alias="mtu", ge=576, le=9216, description="Subinterface MTU") + vlan_id: int | None = Field(default=None, alias="vlanId", ge=2, le=4094, description="802.1Q VLAN tag for the subinterface") + vrf_interface: str | None = Field( + default=None, alias="vrfInterface", min_length=1, max_length=32, description="Interface VRF name; use `default` for default VRF" + ) + ip: str | None = Field(default=None, alias="ip", description="IPv4 address of the subinterface") + prefix: int | None = Field(default=None, alias="prefix", ge=8, le=31, description="IPv4 netmask length used with `ip`") + ipv6: str | None = Field(default=None, alias="ipv6", description="IPv6 address of the subinterface") + ipv6_prefix: int | None = Field(default=None, alias="ipv6Prefix", ge=1, le=127, description="IPv6 netmask length used with `ipv6`") + routing_tag: str | None = Field(default=None, alias="routingTag", description="Routing tag associated with the subinterface IP address") + ip_redirects: bool | None = Field(default=None, alias="ipRedirects", description="Disable both IPv4/IPv6 redirects on the interface") + pim_sparse: bool | None = Field(default=None, alias="pimSparse", description="Enable PIM sparse-mode on the subinterface") + pim_dr_priority: int | None = Field( + default=None, alias="pimDrPriority", ge=1, le=4294967295, description="Priority for PIM DR election on the subinterface" + ) + netflow: bool | None = Field(default=None, alias="netflow", description="Enable Netflow on the subinterface") + netflow_monitor: str | None = Field(default=None, alias="netflowMonitor", description="Layer 3 Netflow monitor name (required when `netflow=true`)") + netflow_sampler: str | None = Field(default=None, alias="netflowSampler", description="Netflow sampler name (applicable to N7K only)") + + # TODO(4.2.1) ND returns routingTag as int on GET despite OpenAPI declaring it string. Coerce so round-trip + # comparisons work. Lab-confirmed against the subinterface HAR; same wire quirk as nd_interface_svi. + @field_validator("routing_tag", mode="before") + @classmethod + def coerce_routing_tag_to_string(cls, value): + """ + # Summary + + Accept `routing_tag` as either string or integer. ND 4.2's API accepts string form on POST/PUT, but GET + responses return the value as an integer. Coerce ints to their decimal string form so round-trips and + idempotency comparisons work uniformly. + + ## Raises + + None + """ + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return value + + +class SubinterfaceManagedNetworkOSModel(NDNestedModel): + """ + # Summary + + Network OS container for a managed subinterface. Maps to `configData.networkOS` in the ND API. + + ## Raises + + None + """ + + network_os_type: Literal["nx-os"] = Field(default="nx-os", alias="networkOSType", frozen=True) + policy: SubinterfaceManagedPolicyModel | None = Field(default=None, alias="policy") + + +class SubinterfaceManagedConfigDataModel(NDNestedModel): + """ + # Summary + + Config data container for a managed subinterface. Maps to `configData` in the ND API. `mode` is always `"managed"` + for this module and is required by the API as a discriminator. + + ## Raises + + None + """ + + mode: Literal["managed"] = Field(default="managed", alias="mode", frozen=True) + network_os: SubinterfaceManagedNetworkOSModel = Field(alias="networkOS") + + +class SubinterfaceManagedOperDataModel(NDNestedModel): + """ + # Summary + + Operational state container returned by GET on a managed subinterface. Server-populated and read-only. Excluded + from payloads via `SubinterfaceManagedInterfaceModel.payload_exclude_fields`. + + ## Raises + + None + """ + + admin_status: str | None = Field(default=None, alias="adminStatus") + operational_description: str | None = Field(default=None, alias="operationalDescription") + operational_status: str | None = Field(default=None, alias="operationalStatus") + switch_name: str | None = Field(default=None, alias="switchName") + + +class SubinterfaceManagedInterfaceModel(NDBaseModel): + """ + # Summary + + Managed L3 subinterface 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. + + `interface_type` is sent on POST but NOT on PUT (the API rejects it on PUT). The orchestrator's `update()` method + is responsible for popping it from the payload before sending. + + ## Raises + + None + """ + + # --- Identifier Configuration --- + + identifiers: ClassVar[list[str] | None] = ["switch_ip", "interface_name"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "composite" + + # --- Serialization Configuration --- + + payload_exclude_fields: ClassVar[set[str]] = {"switch_ip", "oper_data"} + + # --- Fields --- + + switch_ip: str = Field(alias="switchIp") + interface_name: str = Field(alias="interfaceName") + interface_type: Literal["subInterface"] = Field(default="subInterface", alias="interfaceType", frozen=True) + config_data: SubinterfaceManagedConfigDataModel | None = Field(default=None, alias="configData") + oper_data: SubinterfaceManagedOperDataModel | None = Field(default=None, alias="operData") + + @field_validator("interface_name", mode="before") + @classmethod + def normalize_interface_name(cls, value): + """ + # Summary + + Validate that `interface_name` is a dotted subinterface form on either an Ethernet or Port-channel parent + (e.g. `Ethernet1/3.2`, `Port-channel10.5`). The parent kind is inferred from the prefix; no separate + `parent_interface` argument is needed. The sub-id portion (`.`) is required. + + Accepts any case for the parent prefix and normalizes to canonical capitalization (`Ethernet...`, + `Port-channel...`) so user input, POST payloads, and GET responses all compare equal. + + ## Raises + + ### ValueError + + - If `value` is a string without a `.` segment. + - If `value` does not start with `Ethernet` or `Port-channel` (case-insensitive). + """ + if not isinstance(value, str) or not value: + return value + stripped = value.strip() + if "." not in stripped: + raise ValueError(f"interface_name must include a dot-separated subinterface id (e.g. 'Ethernet1/3.2'); got {value!r}") + parent, sub = stripped.rsplit(".", 1) + parent_lower = parent.lower() + # TODO(4.2.1) ND accepts canonical-case parents on POST (`Ethernet1/3.2`) but returns the same name lowercased + # on GET (`ethernet1/3.2`, `port-channel10.5`). Normalize both inputs to canonical case so idempotency + # comparisons work without re-implementing case-insensitive equality everywhere. + if parent_lower.startswith("ethernet"): + canonical_parent = "Ethernet" + parent[len("ethernet") :] + elif parent_lower.startswith("port-channel"): + canonical_parent = "Port-channel" + parent[len("port-channel") :] + else: + raise ValueError(f"interface_name parent must be 'Ethernet...' or 'Port-channel...'; got parent={parent!r}") + return f"{canonical_parent}.{sub}" + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> dict: + """ + # Summary + + Return the Ansible argument spec for the `nd_interface_subinterface_managed` module. + + Each config item targets a single managed L3 subinterface identified by `interface_name` + (e.g. `Ethernet1/3.2`). To configure multiple subinterfaces in one task, list multiple config items. + Per-subinterface L3 settings (vlan_id, ip, vrf_interface, ...) live under `config_data.network_os.policy` + and apply to that one subinterface only. + + ## Raises + + None + """ + return dict( + fabric_name=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + switch_ip=dict(type="str", required=True), + interface_name=dict(type="str", required=True), + config_data=dict( + type="dict", + options=dict( + network_os=dict( + type="dict", + options=dict( + policy=dict( + type="dict", + options=dict( + admin_state=dict(type="bool"), + description=dict(type="str"), + extra_config=dict(type="str"), + mtu=dict(type="int"), + vlan_id=dict(type="int"), + vrf_interface=dict(type="str"), + ip=dict(type="str"), + prefix=dict(type="int"), + ipv6=dict(type="str"), + ipv6_prefix=dict(type="int"), + routing_tag=dict(type="str"), + ip_redirects=dict(type="bool"), + pim_sparse=dict(type="bool"), + pim_dr_priority=dict(type="int"), + netflow=dict(type="bool"), + netflow_monitor=dict(type="str"), + netflow_sampler=dict(type="str"), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/orchestrators/subinterface_managed_interface.py b/plugins/module_utils/orchestrators/subinterface_managed_interface.py new file mode 100644 index 000000000..e4bbea0ba --- /dev/null +++ b/plugins/module_utils/orchestrators/subinterface_managed_interface.py @@ -0,0 +1,291 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Managed L3 subinterface orchestrator for Nexus Dashboard. + +This module provides `SubinterfaceManagedInterfaceOrchestrator`, which implements CRUD operations for managed L3 +subinterfaces (`interfaceType: "subInterface"`, `policyType: "subinterface"`, `mode: "managed"`) via the ND Manage +Interfaces API. Supports configuring subinterfaces across multiple switches in a single task. + +Each mutation operation (create, update, delete) is followed by a deploy call to persist changes to the switch. +Deploy and remove operations are batched per-switch and executed in bulk after all mutations are complete. + +Subinterfaces are logical and support both `interfaceActions/remove` (bulk delete) and `interfaceActions/deploy` +(bulk deploy), so `state: deleted` queues both and lets the standard `remove_pending` + `deploy_pending` flow +handle the work (the same pattern used by `nd_interface_svi`, unlike physical ethernet which must use normalize). +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_interfaces import ( + EpManageInterfacesGet, + EpManageInterfacesListGet, + EpManageInterfacesPost, + EpManageInterfacesPut, + EpManageInterfacesRemove, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.enums import SubinterfaceManagedPolicyTypeEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.subinterface_managed_interface import SubinterfaceManagedInterfaceModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base_interface import NDBaseInterfaceOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType + + +class SubinterfaceManagedInterfaceOrchestrator(NDBaseInterfaceOrchestrator[SubinterfaceManagedInterfaceModel]): + """ + # Summary + + Orchestrator for managed L3 subinterface CRUD operations on Nexus Dashboard. + + Supports configuring subinterfaces 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`) queue deploys instead of executing them immediately. Call `deploy_pending` + after all mutations are complete to deploy all changes in a single API call. `delete` queues subinterfaces for + bulk removal via `remove_pending`. + + For `state: overridden`, `query_all` queries ALL switches in the fabric to enable fabric-wide convergence. + + Uses `FabricContext` for pre-flight validation and switch resolution. + + ## 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 `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. + """ + + model_class: ClassVar[type[NDBaseModel]] = SubinterfaceManagedInterfaceModel + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True + + # TODO ND returns HTTP 207 Multi-Status on subinterface POST with per-item `status: "failed"` when the parent + # interface is not in routed mode (or other policy validation fails). Our RestSend response_handler treats 207 as + # success and returns the body without raising, so without this check the orchestrator would silently report + # "changed" when nothing was actually created. Remove this workaround once CiscoDevNet/ansible-nd#295 lands the + # 207-aware response handling at the RestSend layer. + @staticmethod + def _raise_on_multi_status_failures(response: ResponseType) -> None: + """ + # Summary + + Inspect a 207 Multi-Status body and raise if any item carries `status: "failed"` or `status: "error"`. + + ## Raises + + ### RuntimeError + + - If `response["results"]` contains any item with `status` in `("failed", "error")`. + """ + if not isinstance(response, dict): + return + results = response.get("results") or [] + failed = [r for r in results if isinstance(r, dict) and r.get("status") in ("failed", "error")] + if failed: + summary = "; ".join(f"{r.get('name')}: {r.get('message')}" for r in failed) + raise RuntimeError(f"ND rejected {len(failed)} interface(s): {summary}") + + create_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesPost + update_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesPut + delete_endpoint: type[NDEndpointBaseModel] = NDEndpointBaseModel # unused; delete() uses bulk remove + query_one_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesGet + query_all_endpoint: type[NDEndpointBaseModel] = EpManageInterfacesListGet + create_bulk_endpoint: type[NDEndpointBaseModel] | None = EpManageInterfacesPost + delete_bulk_endpoint: type[NDEndpointBaseModel] | None = EpManageInterfacesRemove + + def create(self, model_instance: SubinterfaceManagedInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Create a managed L3 subinterface. Resolves `switch_ip` from the model instance, injects `switchId`, and wraps + the payload in an `interfaces` array. Queues a deploy for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the create API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.create_endpoint(), switch_sn=switch_id) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + request_body = {"interfaces": [payload]} + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=request_body) + self._raise_on_multi_status_failures(result) + 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: SubinterfaceManagedInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Update a managed L3 subinterface. Resolves `switch_ip` from the model instance, injects `switchId` into the + payload. Queues a deploy for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the update API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.update_endpoint(), switch_sn=switch_id) + api_endpoint.set_identifiers(model_instance.interface_name) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=payload) + self._queue_deploy(model_instance.interface_name, switch_id) + return result + except Exception as e: + raise RuntimeError(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e + + def delete(self, model_instance: SubinterfaceManagedInterfaceModel, **kwargs) -> None: + """ + # Summary + + Queue a managed L3 subinterface for deferred bulk removal via `remove_pending` and bulk deploy via + `deploy_pending`. The remove deletes the subinterface from ND's config; the subsequent deploy pushes that + removal to the switch. + + No API calls are made until `remove_pending` and `deploy_pending` are called after all mutations are complete. + + ## Raises + + None + """ + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._queue_remove(model_instance.interface_name, switch_id) + self._queue_deploy(model_instance.interface_name, switch_id) + + def create_bulk(self, model_instances: list[SubinterfaceManagedInterfaceModel], **kwargs) -> ResponseType: + """ + # Summary + + Create multiple managed L3 subinterfaces in bulk. Groups subinterfaces by switch and sends one POST per + switch with all subinterfaces in the `interfaces` array, reducing API calls from N to one-per-switch. Queues + deploys for all created subinterfaces for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If any create API request fails. + """ + try: + groups: dict[str, list[tuple[str, dict]]] = defaultdict(list) + for model_instance in model_instances: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + groups[switch_id].append((model_instance.interface_name, payload)) + + results = [] + for switch_id, items in groups.items(): + api_endpoint = self._configure_endpoint(self.create_bulk_endpoint(), switch_sn=switch_id) # pyright: ignore[reportOptionalCall] + request_body = {"interfaces": [payload for interface_name, payload in items]} + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=request_body) + self._raise_on_multi_status_failures(result) + results.append(result) + for interface_name, payload in items: + self._queue_deploy(interface_name, switch_id) + return results + except Exception as e: + raise RuntimeError(f"Bulk create failed: {e}") from e + + def delete_bulk(self, model_instances: list[SubinterfaceManagedInterfaceModel], **kwargs) -> None: + """ + # Summary + + Queue multiple managed L3 subinterfaces for deferred bulk removal and deployment. No API calls are made until + `remove_pending` and `deploy_pending` are called after `manage_state` completes. + + ## Raises + + None + """ + for model_instance in model_instances: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._queue_remove(model_instance.interface_name, switch_id) + self._queue_deploy(model_instance.interface_name, switch_id) + + def query_one(self, model_instance: SubinterfaceManagedInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Query a single managed L3 subinterface by name on a specific switch. + + ## Raises + + ### RuntimeError + + - If the query API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.query_one_endpoint(), switch_sn=switch_id) + api_endpoint.set_identifiers(model_instance.interface_name) + return self._request(path=api_endpoint.path, verb=api_endpoint.verb) + except Exception as e: + raise RuntimeError(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e + + def query_all(self, model_instance: NDBaseModel | None = None, **kwargs) -> ResponseType: + """ + # Summary + + Validate the fabric context and query all interfaces across ALL switches in the fabric, filtering for + subinterfaces with `interfaceType: "subInterface"` and `policyType: "subinterface"` (managed variant). The + unmanaged variant (`monitorSubinterface`) is managed by a separate orchestrator and is excluded here. + + 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 + `SubinterfaceManagedInterfaceModel` 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_policy_types = {e.value for e in SubinterfaceManagedPolicyTypeEnum} + try: + self.validate_prerequisites() + all_subifs = [] + for switch_ip, switch_id in self.fabric_context.switch_map.items(): + api_endpoint = self._configure_endpoint(self.query_all_endpoint(), switch_sn=switch_id) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, not_found_ok=True) + if not result: + continue + interfaces = result.get("interfaces", []) or [] + subifs = [iface for iface in interfaces if iface.get("interfaceType") == "subInterface"] + managed = [ + iface for iface in subifs if iface.get("configData", {}).get("networkOS", {}).get("policy", {}).get("policyType") in managed_policy_types + ] + for iface in managed: + iface["switchIp"] = switch_ip + all_subifs.extend(managed) + return all_subifs + except Exception as e: + raise RuntimeError(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_interface_subinterface_managed.py b/plugins/modules/nd_interface_subinterface_managed.py new file mode 100644 index 000000000..0ee081346 --- /dev/null +++ b/plugins/modules/nd_interface_subinterface_managed.py @@ -0,0 +1,342 @@ +#!/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_subinterface_managed +version_added: "1.6.0" +short_description: Manage L3 (managed) subinterfaces on Cisco Nexus Dashboard +description: +- Manage L3 subinterfaces (managed variant) on Cisco Nexus Dashboard. +- A subinterface is created on an Ethernet or Port-channel parent interface; the parent type is encoded in the + O(config[].interface_name) value (e.g. C(Ethernet1/3.2) or C(Port-channel10.5)). +- This module manages the managed variant only (C(policyType) C(subinterface)). + The unmanaged variant (C(policyType) C(monitorSubinterface)) is handled by C(nd_interface_subinterface_unmanaged). +- It supports creating, updating, querying, and deleting subinterface configurations on switches within a fabric. +- Each config item targets a single subinterface identified by O(config[].interface_name). +- Configure multiple subinterfaces in one task by listing multiple config items. +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 L3 subinterfaces to configure. + - Each item specifies the target switch, the subinterface name, and the policy configuration. + - Multiple subinterfaces and multiple switches can be configured in a single task by listing additional items. + - 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 subinterface. + - This is resolved to the switch serial number (switchId) internally. + type: str + required: true + interface_name: + description: + - The full subinterface name, including the dot-separated sub-id (e.g. C(Ethernet1/3.2), C(Port-channel10.5)). + - The parent kind is inferred from the prefix; only Ethernet and Port-channel parents are supported. + type: str + required: true + config_data: + description: + - The configuration data for this subinterface, following the ND API structure. + type: dict + suboptions: + network_os: + description: + - Network OS specific configuration. + type: dict + suboptions: + policy: + description: + - The policy configuration for the subinterface. + type: dict + suboptions: + admin_state: + description: + - The administrative state of the subinterface. + - Defaults to V(true) when unset during creation. + type: bool + description: + description: + - Subinterface description. + - Maximum 254 characters. + type: str + extra_config: + description: + - Additional CLI configuration commands to apply to the subinterface. + type: str + mtu: + description: + - Subinterface MTU. + - Valid range is 576-9216. + type: int + vlan_id: + description: + - 802.1Q VLAN tag for the subinterface. + - Valid range is 2-4094. + type: int + vrf_interface: + description: + - VRF the subinterface is bound to. + - Use V(default) for the default VRF. + type: str + ip: + description: + - IPv4 address of the subinterface. + type: str + prefix: + description: + - IPv4 netmask length used with O(config[].config_data.network_os.policy.ip). + - Valid range is 8-31. + type: int + ipv6: + description: + - IPv6 address of the subinterface. + type: str + ipv6_prefix: + description: + - IPv6 netmask length used with O(config[].config_data.network_os.policy.ipv6). + - Valid range is 1-127. + type: int + routing_tag: + description: + - Routing tag associated with the subinterface IP address. + type: str + ip_redirects: + description: + - Disable both IPv4/IPv6 redirects on the subinterface. + type: bool + pim_sparse: + description: + - Enable PIM sparse-mode on the subinterface. + type: bool + pim_dr_priority: + description: + - Priority for PIM DR election on the subinterface. + - Valid range is 1-4294967295. + type: int + netflow: + description: + - Whether netflow is enabled on the subinterface. + type: bool + netflow_monitor: + description: + - Layer 3 Netflow monitor name. + - Required when O(config[].config_data.network_os.policy.netflow=true). + type: str + netflow_sampler: + description: + - Netflow sampler name (applicable to N7K only). + type: str + 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 subinterfaces 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 managed subinterface managed by this module that exists on ND but is not present in the configuration will be deleted. + Use with extra caution. + - Use O(state=deleted) to remove the specified subinterfaces via the C(interfaceActions/remove) API followed by a deploy. + 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 L3 subinterfaces only (interface_type C(subInterface), mode C(managed), + network_os_type C(nx-os), policy_type C(subinterface)). These values are hardcoded by the module and are not user-configurable. +- The unmanaged variant (C(policyType) C(monitorSubinterface)) is handled by C(nd_interface_subinterface_unmanaged). +- The parent interface must be in routed (L3) mode before a subinterface can be created on it. + ND rejects subinterface POST against L2 access/trunk parents with the message + "Sub-interface can be created only on routed physical or port-channel interfaces (discovered mode is not routed)". + In practice this blocks subinterfaces on typical vPC port-channels and peer-link port-channels, which are L2. + Change the parent's policy to a routed type (e.g. routedHost for Ethernet, l3PortChannel for Port-channel) before + using this module; the module does not auto-normalize the parent. +""" + +EXAMPLES = r""" +- name: Create a managed L3 subinterface on an Ethernet parent + cisco.nd.nd_interface_subinterface_managed: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: Ethernet1/3.2 + config_data: + network_os: + policy: + admin_state: true + description: "Tenant subinterface vlan 2" + vlan_id: 2 + vrf_interface: VRF1 + ip: 192.168.50.50 + prefix: 24 + ipv6: "2001:192:168:50::50" + ipv6_prefix: 64 + routing_tag: "12345" + mtu: 9216 + ip_redirects: true + pim_sparse: true + pim_dr_priority: 1 + netflow: false + state: merged + +- name: Create multiple subinterfaces on different parents in one task + cisco.nd.nd_interface_subinterface_managed: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: Ethernet1/3.10 + config_data: + network_os: + policy: + admin_state: true + vlan_id: 10 + ip: 10.10.10.1 + prefix: 24 + - switch_ip: 192.168.1.1 + interface_name: Port-channel10.20 + config_data: + network_os: + policy: + admin_state: true + vlan_id: 20 + ip: 10.10.20.1 + prefix: 24 + state: merged + +- name: Delete a subinterface + cisco.nd.nd_interface_subinterface_managed: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: Ethernet1/3.2 + state: deleted + +- name: Stage changes without deploying + cisco.nd.nd_interface_subinterface_managed: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: Ethernet1/3.2 + config_data: + network_os: + policy: + admin_state: true + vlan_id: 2 + ip: 192.168.50.50 + prefix: 24 + deploy: false + state: merged +""" + +RETURN = r""" +""" + +import logging +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDStateMachineError +from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.subinterface_managed_interface import SubinterfaceManagedInterfaceModel +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base_interface import NDBaseInterfaceOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.subinterface_managed_interface import SubinterfaceManagedInterfaceOrchestrator + + +def main(): + """ + # Summary + + Entry point for the `nd_interface_subinterface_managed` Ansible module. Initializes the `NDStateMachine` with + `SubinterfaceManagedInterfaceOrchestrator` and executes the requested state operation. + + ## Raises + + None (catches all exceptions and calls `module.fail_json`). + """ + argument_spec = nd_argument_spec() + argument_spec.update(SubinterfaceManagedInterfaceModel.get_argument_spec()) + argument_spec.update( + deploy=dict(type="bool", default=True), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + setup_logging(module) + module_log = logging.getLogger("nd.nd_interface_subinterface_managed") + module_log.debug( + "config items=%d switches=%d", + len(module.params["config"]), + len({item.get("switch_ip") for item in module.params["config"]}), + ) + + nd_state_machine = None + + try: + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=SubinterfaceManagedInterfaceOrchestrator, + ) + if not isinstance(nd_state_machine.model_orchestrator, NDBaseInterfaceOrchestrator): + raise AssertionError(f"Expected NDBaseInterfaceOrchestrator, got {type(nd_state_machine.model_orchestrator)}") + nd_state_machine.model_orchestrator.deploy = module.params["deploy"] + + module_log.debug( + "manage_state begin state=%s check_mode=%s deploy=%s", + module.params.get("state"), + module.check_mode, + module.params["deploy"], + ) + nd_state_machine.manage_state() + module_log.debug("manage_state end") + + if not module.check_mode: + nd_state_machine.model_orchestrator.remove_pending() + nd_state_machine.model_orchestrator.deploy_pending() + + module.exit_json(**nd_state_machine.output.format()) + + except NDStateMachineError as e: + module_log.exception("NDStateMachineError during module execution") + output = nd_state_machine.output.format() if nd_state_machine else {} + error_msg = f"Module execution failed: {str(e)}" + if module.params.get("output_level") == "debug": + error_msg += f"\nTraceback:\n{traceback.format_exc()}" + module.fail_json(msg=error_msg, **output) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_interface_subinterface_managed/tasks/deleted.yaml b/tests/integration/targets/nd_interface_subinterface_managed/tasks/deleted.yaml new file mode 100644 index 000000000..8bafd539e --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/tasks/deleted.yaml @@ -0,0 +1,62 @@ +--- +# Deleted state tests for nd_interface_subinterface_managed +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# At this point Eth.2 and PC.5 exist (from overridden reduce). Delete actually +# removes the subinterfaces from ND via interfaceActions/remove + deploy. + +- name: "DELETED: Delete Eth.2 (check mode)" + cisco.nd.nd_interface_subinterface_managed: &delete_eth_2 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.2" + state: deleted + check_mode: true + register: cm_deleted_eth_2 + +- name: "DELETED: Delete Eth.2 (normal mode)" + cisco.nd.nd_interface_subinterface_managed: *delete_eth_2 + register: nm_deleted_eth_2 + +- name: "DELETED: Verify Eth.2 was removed" + vars: + eth_2_name: "{{ ethernet_parent }}.2" + eth_2_after: "{{ nm_deleted_eth_2.after | selectattr('interface_name', 'equalto', eth_2_name) | list }}" + ansible.builtin.assert: + that: + - cm_deleted_eth_2 is changed + - nm_deleted_eth_2 is changed + - eth_2_after | length == 0 + +- name: "DELETED IDEMPOTENT: Delete Eth.2 again" + cisco.nd.nd_interface_subinterface_managed: *delete_eth_2 + register: nm_deleted_eth_2_idem + +- name: "DELETED IDEMPOTENT: Verify no change when subinterface already gone" + ansible.builtin.assert: + that: + - nm_deleted_eth_2_idem is not changed + +# --- DELETED: BULK CLEANUP (FINAL TEARDOWN) --- + +- name: "DELETED CLEANUP: Remove any remaining test subinterfaces" + cisco.nd.nd_interface_subinterface_managed: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: "{{ cleanup_config }}" + state: deleted + register: nm_deleted_cleanup + +- name: "DELETED CLEANUP: Verify all test subinterfaces are gone" + vars: + remaining: >- + {{ nm_deleted_cleanup.after + | selectattr('interface_name', 'in', cleanup_config | map(attribute='interface_name') | list) + | list }} + ansible.builtin.assert: + that: + - remaining | length == 0 diff --git a/tests/integration/targets/nd_interface_subinterface_managed/tasks/main.yaml b/tests/integration/targets/nd_interface_subinterface_managed/tasks/main.yaml new file mode 100644 index 000000000..9680e5ab0 --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/tasks/main.yaml @@ -0,0 +1,49 @@ +--- +# Test code for the nd_interface_subinterface_managed module +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# +# --- Usage --- +# +# Run the test suite with ansible-test: +# ansible-test network-integration nd_interface_subinterface_managed +# +# --- Optional test variables --- +# +# Set the variables below in the [nd:vars] section of tests/integration/inventory.networking +# +# nd_logging_config (str, default: "") +# Path to a JSON file conforming to logging.config.dictConfig (see +# plugins/module_utils/common/log.py for an example). When set, the path is +# forwarded to the module subprocess via the ND_LOGGING_CONFIG environment +# variable. + +- 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_subinterface_managed state tests with optional logging env + block: + - name: Pre-test cleanup + ansible.builtin.include_tasks: setup.yaml + + - name: Run merged state tests + ansible.builtin.include_tasks: merged.yaml + + - name: Run replaced state tests + ansible.builtin.include_tasks: replaced.yaml + + - name: Run overridden state tests + ansible.builtin.include_tasks: overridden.yaml + + - name: Run deleted state tests + ansible.builtin.include_tasks: deleted.yaml + environment: + ND_LOGGING_CONFIG: "{{ nd_logging_config | default('') }}" diff --git a/tests/integration/targets/nd_interface_subinterface_managed/tasks/merged.yaml b/tests/integration/targets/nd_interface_subinterface_managed/tasks/merged.yaml new file mode 100644 index 000000000..8851806e4 --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/tasks/merged.yaml @@ -0,0 +1,122 @@ +--- +# Merged state tests for nd_interface_subinterface_managed +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- MERGED CREATE: SINGLE SUBINTERFACE (ETHERNET PARENT) --- + +- name: "MERGED CREATE: Create Eth.2 subinterface (check mode)" + cisco.nd.nd_interface_subinterface_managed: &merge_eth_2 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ subif_eth_2 }}" + state: merged + check_mode: true + register: cm_merged_create_eth_2 + +- name: "MERGED CREATE: Create Eth.2 subinterface (normal mode)" + cisco.nd.nd_interface_subinterface_managed: *merge_eth_2 + register: nm_merged_create_eth_2 + +- name: "MERGED CREATE: Verify Eth.2 creation" + vars: + expected_name: "{{ ethernet_parent }}.2" + ansible.builtin.assert: + that: + - cm_merged_create_eth_2 is changed + - nm_merged_create_eth_2 is changed + - nm_merged_create_eth_2.after | selectattr('switch_ip', 'equalto', test_switch_ip) | selectattr('interface_name', 'equalto', expected_name) | list | length == 1 + +# --- MERGED CREATE: MULTIPLE SUBINTERFACES IN ONE TASK --- + +- name: "MERGED CREATE: Create Eth.3 and Eth.4 in one task" + cisco.nd.nd_interface_subinterface_managed: &merge_eth_3_4 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ subif_eth_3 }}" + - "{{ subif_eth_4 }}" + state: merged + register: nm_merged_create_eth_3_4 + +- name: "MERGED CREATE: Verify Eth.3 and Eth.4 creation" + vars: + eth_3_name: "{{ ethernet_parent }}.3" + eth_4_name: "{{ ethernet_parent }}.4" + ansible.builtin.assert: + that: + - nm_merged_create_eth_3_4 is changed + - nm_merged_create_eth_3_4.after | selectattr('interface_name', 'equalto', eth_3_name) | list | length == 1 + - nm_merged_create_eth_3_4.after | selectattr('interface_name', 'equalto', eth_4_name) | list | length == 1 + +# --- MERGED CREATE: SUBINTERFACE ON PORT-CHANNEL PARENT --- + +- name: "MERGED CREATE: Create subinterface on Port-channel parent" + cisco.nd.nd_interface_subinterface_managed: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ subif_pc_5 }}" + state: merged + register: nm_merged_create_pc_5 + +- name: "MERGED CREATE: Verify Port-channel subinterface creation" + vars: + pc_5_name: "{{ port_channel_parent }}.5" + ansible.builtin.assert: + that: + - nm_merged_create_pc_5 is changed + - nm_merged_create_pc_5.after | selectattr('interface_name', 'equalto', pc_5_name) | list | length == 1 + +# --- MERGED IDEMPOTENCY --- + +- name: "MERGED IDEMPOTENT: Re-apply Eth.2 (check mode)" + cisco.nd.nd_interface_subinterface_managed: *merge_eth_2 + check_mode: true + register: cm_merged_idem_eth_2 + +- name: "MERGED IDEMPOTENT: Re-apply Eth.2 (normal mode)" + cisco.nd.nd_interface_subinterface_managed: *merge_eth_2 + register: nm_merged_idem_eth_2 + +- name: "MERGED IDEMPOTENT: Verify no change" + ansible.builtin.assert: + that: + - cm_merged_idem_eth_2 is not changed + - nm_merged_idem_eth_2 is not changed + +# --- MERGED UPDATE --- + +- name: "MERGED UPDATE: Change description, ip, pim_sparse, pim_dr_priority on Eth.2" + cisco.nd.nd_interface_subinterface_managed: &merge_eth_2_updated + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ subif_eth_2_updated }}" + state: merged + register: nm_merged_update_eth_2 + +- name: "MERGED UPDATE: Verify Eth.2 was updated" + vars: + eth_2_name: "{{ ethernet_parent }}.2" + eth_2_after: "{{ nm_merged_update_eth_2.after | selectattr('interface_name', 'equalto', eth_2_name) | first }}" + ansible.builtin.assert: + that: + - nm_merged_update_eth_2 is changed + - eth_2_after.config_data.network_os.policy.description == "Updated subif Eth.2" + - eth_2_after.config_data.network_os.policy.ip == "192.168.50.51" + - eth_2_after.config_data.network_os.policy.ip_redirects == false + - eth_2_after.config_data.network_os.policy.pim_sparse == true + - eth_2_after.config_data.network_os.policy.pim_dr_priority == 50 + - eth_2_after.config_data.network_os.policy.routing_tag == "12345" + +- name: "MERGED UPDATE: Re-apply update for idempotency" + cisco.nd.nd_interface_subinterface_managed: *merge_eth_2_updated + register: nm_merged_update_eth_2_idem + +- name: "MERGED UPDATE: Verify idempotency after update" + ansible.builtin.assert: + that: + - nm_merged_update_eth_2_idem is not changed diff --git a/tests/integration/targets/nd_interface_subinterface_managed/tasks/overridden.yaml b/tests/integration/targets/nd_interface_subinterface_managed/tasks/overridden.yaml new file mode 100644 index 000000000..2555aeafb --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/tasks/overridden.yaml @@ -0,0 +1,60 @@ +--- +# Overridden state tests for nd_interface_subinterface_managed +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# At this point Eth.2, Eth.3, Eth.4, and PC.5 exist on the fabric. Override +# reduces the set to only what is specified; any managed subinterface managed by +# this module that is not in the config is removed. + +- name: "OVERRIDDEN: Reduce to only Eth.2 and PC.5" + cisco.nd.nd_interface_subinterface_managed: &override_eth_2_pc_5 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.2" + config_data: + network_os: + policy: + admin_state: true + description: "Overridden Eth.2" + vlan_id: 2 + vrf_interface: default + ip: 10.99.200.1 + prefix: 24 + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ port_channel_parent }}.5" + config_data: + network_os: + policy: + admin_state: true + description: "Overridden PC.5" + vlan_id: 5 + vrf_interface: default + ip: 10.99.205.1 + prefix: 24 + state: overridden + register: nm_overridden_reduce + +- name: "OVERRIDDEN: Verify reduce changed and removed entries are gone" + vars: + eth_3_name: "{{ ethernet_parent }}.3" + eth_4_name: "{{ ethernet_parent }}.4" + eth_3_after: "{{ nm_overridden_reduce.after | selectattr('interface_name', 'equalto', eth_3_name) | list }}" + eth_4_after: "{{ nm_overridden_reduce.after | selectattr('interface_name', 'equalto', eth_4_name) | list }}" + ansible.builtin.assert: + that: + - nm_overridden_reduce is changed + - eth_3_after | length == 0 + - eth_4_after | length == 0 + +- name: "OVERRIDDEN IDEMPOTENT: Re-apply" + cisco.nd.nd_interface_subinterface_managed: *override_eth_2_pc_5 + register: nm_overridden_idem + +- name: "OVERRIDDEN IDEMPOTENT: Verify no change" + ansible.builtin.assert: + that: + - nm_overridden_idem is not changed diff --git a/tests/integration/targets/nd_interface_subinterface_managed/tasks/replaced.yaml b/tests/integration/targets/nd_interface_subinterface_managed/tasks/replaced.yaml new file mode 100644 index 000000000..4a2687d20 --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/tasks/replaced.yaml @@ -0,0 +1,45 @@ +--- +# Replaced state tests for nd_interface_subinterface_managed +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# At this point Eth.2 (updated), Eth.3, Eth.4, and PC.5 exist from merged.yaml. + +- name: "REPLACED: Replace Eth.3 with a stripped-down config" + cisco.nd.nd_interface_subinterface_managed: &replace_eth_3 + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.3" + config_data: + network_os: + policy: + admin_state: true + description: "Replaced Eth.3" + vlan_id: 3 + vrf_interface: default + ip: 10.99.3.1 + prefix: 24 + state: replaced + register: nm_replaced_eth_3 + +- name: "REPLACED: Verify Eth.3 was replaced" + vars: + eth_3_name: "{{ ethernet_parent }}.3" + eth_3_after: "{{ nm_replaced_eth_3.after | selectattr('interface_name', 'equalto', eth_3_name) | first }}" + ansible.builtin.assert: + that: + - nm_replaced_eth_3 is changed + - eth_3_after.config_data.network_os.policy.description == "Replaced Eth.3" + - eth_3_after.config_data.network_os.policy.ip == "10.99.3.1" + +- name: "REPLACED IDEMPOTENT: Re-apply replace for Eth.3" + cisco.nd.nd_interface_subinterface_managed: *replace_eth_3 + register: nm_replaced_eth_3_idem + +- name: "REPLACED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_replaced_eth_3_idem is not changed diff --git a/tests/integration/targets/nd_interface_subinterface_managed/tasks/setup.yaml b/tests/integration/targets/nd_interface_subinterface_managed/tasks/setup.yaml new file mode 100644 index 000000000..10e28a8ff --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/tasks/setup.yaml @@ -0,0 +1,23 @@ +--- +# Pre-test cleanup for nd_interface_subinterface_managed +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Called from main.yaml before the merged/replaced/overridden/deleted state +# blocks. Ensures no test subinterfaces exist on the target switch before the +# state tests run. + +- name: "SETUP: Remove test subinterfaces before state tests" + cisco.nd.nd_interface_subinterface_managed: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: "{{ cleanup_config }}" + state: deleted + register: setup_cleanup + tags: always + +- name: "DEBUG: Show cleanup result" + ansible.builtin.debug: + var: setup_cleanup + tags: always diff --git a/tests/integration/targets/nd_interface_subinterface_managed/vars/main.yaml b/tests/integration/targets/nd_interface_subinterface_managed/vars/main.yaml new file mode 100644 index 000000000..1ce9511fb --- /dev/null +++ b/tests/integration/targets/nd_interface_subinterface_managed/vars/main.yaml @@ -0,0 +1,106 @@ +--- +# Variables for nd_interface_subinterface_managed integration tests. +# +# Override fabric_name, switch_ip, ethernet_parent and port_channel_parent in your +# inventory or extra-vars to match a real ND 4.2 testbed. +# +# The parent interfaces must exist and (for Ethernet parents) must be free of any +# L2 mode conflicts before subinterfaces can be created on them. + +test_fabric_name: "{{ nd_test_fabric_name | default('test_fabric') }}" +test_switch_ip: "{{ nd_test_switch_ip | default('192.168.1.1') }}" +ethernet_parent: "{{ nd_test_ethernet_parent | default('Ethernet1/3') }}" +port_channel_parent: "{{ nd_test_port_channel_parent | default('Port-channel10') }}" + +# --- Base configs --- + +subif_eth_2: + switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.2" + config_data: + network_os: + policy: + admin_state: true + description: "Ansible integration test subif Eth.2" + vlan_id: 2 + vrf_interface: default + ip: 192.168.50.50 + prefix: 24 + ipv6: "2001:192:168:50::50" + ipv6_prefix: 64 + mtu: 9216 + ip_redirects: true + pim_sparse: false + pim_dr_priority: 1 + netflow: false + +subif_eth_3: + switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.3" + config_data: + network_os: + policy: + admin_state: true + description: "Ansible integration test subif Eth.3" + vlan_id: 3 + vrf_interface: default + ip: 192.168.51.50 + prefix: 24 + +subif_eth_4: + switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.4" + config_data: + network_os: + policy: + admin_state: true + description: "Ansible integration test subif Eth.4" + vlan_id: 4 + vrf_interface: default + ip: 192.168.52.50 + prefix: 24 + +subif_pc_5: + switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ port_channel_parent }}.5" + config_data: + network_os: + policy: + admin_state: true + description: "Ansible integration test subif PC.5" + vlan_id: 5 + vrf_interface: default + ip: 192.168.55.1 + prefix: 24 + +# --- Updated configs for merge/replace tests --- + +subif_eth_2_updated: + switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.2" + config_data: + network_os: + policy: + admin_state: true + description: "Updated subif Eth.2" + vlan_id: 2 + vrf_interface: default + ip: 192.168.50.51 + prefix: 24 + ip_redirects: false + pim_sparse: true + pim_dr_priority: 50 + routing_tag: "12345" + netflow: false + +# --- Cleanup helper: all test subinterfaces to remove before tests run --- + +cleanup_config: + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.2" + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.3" + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ ethernet_parent }}.4" + - switch_ip: "{{ test_switch_ip }}" + interface_name: "{{ port_channel_parent }}.5" From 4e1eafed56d1b7981783ae0373da6f42c972f0e1 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 28 May 2026 08:10:02 -1000 Subject: [PATCH 2/2] Bump version_added to 2.0.0 for next ND collection release --- plugins/modules/nd_interface_subinterface_managed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/nd_interface_subinterface_managed.py b/plugins/modules/nd_interface_subinterface_managed.py index 0ee081346..be86b1bb1 100644 --- a/plugins/modules/nd_interface_subinterface_managed.py +++ b/plugins/modules/nd_interface_subinterface_managed.py @@ -9,7 +9,7 @@ DOCUMENTATION = r""" --- module: nd_interface_subinterface_managed -version_added: "1.6.0" +version_added: "2.0.0" short_description: Manage L3 (managed) subinterfaces on Cisco Nexus Dashboard description: - Manage L3 subinterfaces (managed variant) on Cisco Nexus Dashboard.