From f92454bd71d08933a9262c9f69a3fc7851a1145b Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 31 Mar 2026 07:47:21 -1000 Subject: [PATCH 01/17] Add InterfaceNameMixin --- plugins/module_utils/endpoints/mixins.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index e7f0620c..68e0338c 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -50,6 +50,12 @@ class InclAllMsdSwitchesMixin(BaseModel): incl_all_msd_switches: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Include all MSD switches") +class InterfaceNameMixin(BaseModel): + """Mixin for endpoints that require interface_name parameter.""" + + interface_name: Optional[str] = Field(default=None, min_length=1, description="Interface name") + + class LinkUuidMixin(BaseModel): """Mixin for endpoints that require link_uuid parameter.""" From fc7e8abe98ec5e917ba41bd16c90a696e272a1a8 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 31 Mar 2026 07:50:46 -1000 Subject: [PATCH 02/17] nd_loopback_interface initial commit --- .../endpoints/v1/manage/manage_interfaces.py | 330 +++++++++++++++++ .../endpoints/v1/manage/manage_switches.py | 198 ++++++++++ plugins/module_utils/fabric_context.py | 225 ++++++++++++ plugins/module_utils/models/__init__.py | 0 .../models/interfaces/__init__.py | 0 .../models/interfaces/loopback_interface.py | 239 ++++++++++++ .../module_utils/orchestrators/__init__.py | 0 .../orchestrators/loopback_interface.py | 347 ++++++++++++++++++ plugins/modules/__init__.py | 0 plugins/modules/nd_interface_loopback.py | 314 ++++++++++++++++ 10 files changed, 1653 insertions(+) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_interfaces.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_switches.py create mode 100644 plugins/module_utils/fabric_context.py create mode 100644 plugins/module_utils/models/__init__.py create mode 100644 plugins/module_utils/models/interfaces/__init__.py create mode 100644 plugins/module_utils/models/interfaces/loopback_interface.py create mode 100644 plugins/module_utils/orchestrators/__init__.py create mode 100644 plugins/module_utils/orchestrators/loopback_interface.py create mode 100644 plugins/modules/__init__.py create mode 100644 plugins/modules/nd_interface_loopback.py diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py new file mode 100644 index 00000000..048f49d8 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Interfaces endpoint models. + +This module contains endpoint definitions for interface operations +in the ND Manage API. + +## Endpoints + +- `EpManageInterfacesGet` - Get a specific interface + (GET /api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}) +- `EpManageInterfacesListGet` - List all interfaces on a switch + (GET /api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces) +- `EpManageInterfacesPost` - Create interfaces on a switch + (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 specific interface + (DELETE /api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}) +""" + +from __future__ import annotations + +from typing import ClassVar, Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + SwitchSerialNumberMixin, + InterfaceNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpManageInterfacesBase(FabricNameMixin, SwitchSerialNumberMixin, InterfaceNameMixin, NDEndpointBaseModel): + """ + # Summary + + Base class for ND Manage Interfaces endpoints. + + Provides common functionality for all HTTP methods on the + `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces` endpoint. + Subclasses define the HTTP verb and optionally override `_require_interface_name`. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + - If `switch_sn` is not set before accessing `path`. + - If `_require_interface_name` is True and `interface_name` is not set before accessing `path`. + """ + + _require_interface_name: ClassVar[bool] = True + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path for manage interfaces operations. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + - If `switch_sn` is not set before accessing `path`. + - If `_require_interface_name` is True and `interface_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.") + if self.switch_sn is None: + raise ValueError(f"{type(self).__name__}.path: switch_sn must be set before accessing path.") + if self._require_interface_name and self.interface_name is None: + raise ValueError(f"{type(self).__name__}.path: interface_name must be set before accessing path.") + + segments = ["fabrics", self.fabric_name, "switches", self.switch_sn, "interfaces"] + if self.interface_name is not None: + segments.append(self.interface_name) + return BasePath.path(*segments) + + def set_identifiers(self, identifier: IdentifierKey = None): + """ + # Summary + + Set `interface_name` from `identifier`. `fabric_name` and `switch_sn` must be set separately via `_configure_endpoint`. + + ## Raises + + None + """ + self.interface_name = identifier + + +class EpManageInterfacesGet(_EpManageInterfacesBase): + """ + # Summary + + Retrieve a specific interface by name. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}` + - Verb: GET + + ## Raises + + ### ValueError + + - Via inherited `path` property if `fabric_name`, `switch_sn`, or `interface_name` is not set. + """ + + class_name: Literal["EpManageInterfacesGet"] = Field( + default="EpManageInterfacesGet", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.GET`. + + ## Raises + + None + """ + return HttpVerbEnum.GET + + +class EpManageInterfacesListGet(_EpManageInterfacesBase): + """ + # Summary + + List all interfaces on a switch. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces` + - Verb: GET + + Does not require `interface_name` to be set. + + ## Raises + + ### ValueError + + - Via inherited `path` property if `fabric_name` or `switch_sn` is not set. + """ + + _require_interface_name: ClassVar[bool] = False + + class_name: Literal["EpManageInterfacesListGet"] = Field( + default="EpManageInterfacesListGet", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.GET`. + + ## Raises + + None + """ + return HttpVerbEnum.GET + + +class EpManageInterfacesPost(_EpManageInterfacesBase): + """ + # Summary + + Create one or more interfaces on a switch. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces` + - Verb: POST + + Does not require `interface_name` to be set. + + ## Raises + + ### ValueError + + - Via inherited `path` property if `fabric_name` or `switch_sn` is not set. + """ + + _require_interface_name: ClassVar[bool] = False + + class_name: Literal["EpManageInterfacesPost"] = Field( + default="EpManageInterfacesPost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST + + +class EpManageInterfacesPut(_EpManageInterfacesBase): + """ + # Summary + + Update a specific interface. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}` + - Verb: PUT + + ## Raises + + ### ValueError + + - Via inherited `path` property if `fabric_name`, `switch_sn`, or `interface_name` is not set. + """ + + class_name: Literal["EpManageInterfacesPut"] = Field( + default="EpManageInterfacesPut", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.PUT`. + + ## Raises + + None + """ + return HttpVerbEnum.PUT + + +class EpManageInterfacesDelete(_EpManageInterfacesBase): + """ + # Summary + + Delete a specific interface. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}` + - Verb: DELETE + + ## 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 + + Deploy interface configurations to switches. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/deploy` + - Verb: POST + - Body: `{"interfaces": [{"interfaceName": "...", "switchId": "..."}]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpManageInterfacesDeploy"] = Field( + default="EpManageInterfacesDeploy", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the deploy 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", "deploy") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/manage_switches.py b/plugins/module_utils/endpoints/v1/manage/manage_switches.py new file mode 100644 index 00000000..75e9f339 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_switches.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Switches endpoint models. + +This module contains endpoint definitions for switch-related operations +in the ND Manage API. + +## Endpoints + +- `EpManageSwitchesListGet` - List switches in a fabric with optional Lucene filtering + (GET /api/v1/manage/fabrics/{fabric_name}/switches) +- `EpManageSwitchActionsDeploy` - Deploy all pending switch configuration + (POST /api/v1/manage/fabrics/{fabric_name}/switchActions/deploy) +""" + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import FabricNameMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import LuceneQueryParams +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpManageSwitchesBase(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Base class for ND Manage Switches endpoints. + + Provides common functionality for all HTTP methods on the + `/api/v1/manage/fabrics/{fabric_name}/switches` endpoint. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + """ + + lucene_params: LuceneQueryParams = Field(default_factory=LuceneQueryParams, description="Lucene-style query parameters for filtering") + + def set_identifiers(self, identifier: IdentifierKey = None): + """ + # Summary + + Set `fabric_name` from `identifier`. + + ## Raises + + None + """ + self.fabric_name = identifier + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path for manage switches operations. + + ## 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.") + segments = ["fabrics", self.fabric_name, "switches"] + base_path = BasePath.path(*segments) + query_string = self.lucene_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + +class EpManageSwitchesListGet(_EpManageSwitchesBase): + """ + # Summary + + List switches in a fabric with optional Lucene filtering. + + ## Description + + Endpoint to list switches belonging to a specific fabric. Supports Lucene-style filter queries for narrowing results, e.g. filtering by + `fabricManagementIp` to resolve a switch IP to its serial number / switch ID. + + ## Path + + - `/api/v1/manage/fabrics/{fabric_name}/switches` + - `/api/v1/manage/fabrics/{fabric_name}/switches?filter=fabricManagementIp%3A192.168.12.151` + + ## Verb + + - GET + + ## Raises + + ### ValueError + + - If `fabric_name` is not set when accessing `path`. + + ## Usage + + ```python + # List all switches in a fabric + ep = EpManageSwitchesListGet() + ep.fabric_name = "fabric_1" + path = ep.path + # Path: /api/v1/manage/fabrics/fabric_1/switches + + # Filter switches by management IP + ep = EpManageSwitchesListGet() + ep.fabric_name = "fabric_1" + ep.lucene_params.filter = "fabricManagementIp:192.168.12.151" + path = ep.path + # Path: /api/v1/manage/fabrics/fabric_1/switches?filter=fabricManagementIp%3A192.168.12.151 + ``` + """ + + class_name: Literal["EpManageSwitchesListGet"] = Field(default="EpManageSwitchesListGet", frozen=True, description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.GET`. + + ## Raises + + None + """ + return HttpVerbEnum.GET + + +class EpManageSwitchActionsDeploy(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Deploy all pending switch configuration to one or more switches. + + Deploys the full pending configuration for the given switches, including interface, policy, routing, and any other + staged changes. This is faster than per-interface deploy but has a broader blast radius. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/switchActions/deploy` + - Verb: POST + - Body: `{"switchIds": ["serial1", "serial2"]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpManageSwitchActionsDeploy"] = Field( + default="EpManageSwitchActionsDeploy", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the switch deploy 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, "switchActions", "deploy") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST diff --git a/plugins/module_utils/fabric_context.py b/plugins/module_utils/fabric_context.py new file mode 100644 index 00000000..4b239772 --- /dev/null +++ b/plugins/module_utils/fabric_context.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Reusable fabric context for pre-flight validation and switch resolution. + +Provides `FabricContext`, a lazy-loaded cache of fabric metadata and switch mappings +that orchestrators use to validate preconditions before CRUD operations. + +Uses the `/api/v1/manage/fabrics/{fabric_name}` endpoint to verify fabric existence. + +NOTE: The `fabric_is_local` and `fabric_is_read_only` checks are stubbed to always return +True / False respectively until we identify the correct response fields from the fabric +detail endpoint. The fabric detail response does not include `local` or `meta.allowedActions` +fields that the original implementation assumed. +""" + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics import EpManageFabricsSummaryGet +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_switches import EpManageSwitchesListGet + +# Sentinel to distinguish "not yet fetched" from "fetched but not found" +_NOT_FETCHED = object() + + +class FabricContext: + """ + # Summary + + Cached fabric metadata with pre-flight validation for fabric-level orchestrators. + + Lazily fetches fabric summary and switch inventory on first access. Provides simple + boolean checks and a `validate_for_mutation` method that raises `RuntimeError` with + a clear message when the fabric cannot be modified. + + ## Raises + + ### RuntimeError + + - Via `validate_for_mutation` if the fabric does not exist on any ND node. + - Via `validate_for_mutation` if the fabric is not local to the target ND node. + - Via `validate_for_mutation` if the fabric is in deployment-freeze mode. + - Via `get_switch_id` if no switch matches the given management IP. + """ + + def __init__(self, sender: NDModule, fabric_name: str): + """ + # Summary + + Initialize `FabricContext` with a sender and fabric name. Metadata is not fetched until needed. + + ## Raises + + None + """ + self._sender = sender + self._fabric_name = fabric_name + self._fabric_summary = _NOT_FETCHED + self._switch_map: Optional[dict[str, str]] = None + + @property + def fabric_name(self) -> str: + """ + # Summary + + Return the fabric name this context was created for. + + ## Raises + + None + """ + return self._fabric_name + + @property + def fabric_summary(self) -> Optional[dict]: + """ + # Summary + + Return the cached fabric detail dict, fetching it from the `/fabrics/{fabric_name}` endpoint on first access. + + Returns `None` if the fabric does not exist. + + ## Raises + + None + """ + if self._fabric_summary is _NOT_FETCHED: + ep = EpManageFabricsSummaryGet() + ep.fabric_name = self._fabric_name + result = self._sender.query_obj(ep.path, ignore_not_found_error=True) + # query_obj returns {} for 404 / not found + self._fabric_summary = result if result else None + return self._fabric_summary + + def fabric_exists(self) -> bool: + """ + # Summary + + Check whether the fabric exists (on any ND node in the cluster). + + ## Raises + + None + """ + return self.fabric_summary is not None + + def fabric_is_local(self) -> bool: + """ + # Summary + + Check whether the fabric is local to the target ND node. + + TODO: The `GET /api/v1/manage/fabrics/{fabricName}` response does not include a `local` field. This check needs + to be reimplemented once the correct field or endpoint is identified. Currently returns `True` if the fabric exists. + + ## Raises + + None + """ + if not self.fabric_exists(): + return False + # TODO: Implement local check once the correct response field is identified + return True + + def fabric_is_read_only(self) -> bool: + """ + # Summary + + Check whether the fabric is in a read-only state that prevents mutations. + + TODO: The `GET /api/v1/manage/fabrics/{fabricName}` response does not include `meta.allowedActions`. This check + needs to be reimplemented once the correct field or endpoint is identified. Currently returns `False` (not + read-only) if the fabric exists. + + ## Raises + + None + """ + if not self.fabric_exists(): + return False + # TODO: Implement read-only check once the correct response field is identified + return False + + @property + def switch_map(self) -> dict[str, str]: + """ + # Summary + + Return a cached mapping of `fabricManagementIp` to `switchId` for all switches in the fabric. + + Fetches all switches from the ND Manage Switches API on first access and caches the result. + + ## Raises + + ### RuntimeError + + - If the switches API query fails. + """ + if self._switch_map is None: + ep = EpManageSwitchesListGet() + ep.fabric_name = self._fabric_name + result = self._sender.query_obj(ep.path, ignore_not_found_error=True) + switches = (result.get("switches") or []) if result else [] + self._switch_map = {switch["fabricManagementIp"]: switch["switchId"] for switch in switches if "fabricManagementIp" in switch} + return self._switch_map + + def get_switch_id(self, switch_ip: str) -> str: + """ + # Summary + + Resolve a switch management IP address to its `switchId` via the cached switch map. + + ## Raises + + ### RuntimeError + + - If no switch matches the given IP in the fabric. + """ + try: + return self.switch_map[switch_ip] + except KeyError as e: + raise RuntimeError(f"No switch found with fabricManagementIp '{switch_ip}' in fabric '{self._fabric_name}'.") from e + + def validate_for_mutation(self) -> None: + """ + # Summary + + Run all pre-flight checks required before modifying resources in this fabric. Raises `RuntimeError` with a clear, + actionable message on the first failing check. + + ## Checks + + 1. Fabric exists (on any node in the cluster). + 2. Fabric is local to this ND node (not a remote fabric visible via cluster forwarding). + 3. Fabric is not in a read-only state (deployment-freeze mode or empty `allowedActions`). + + ## Raises + + ### RuntimeError + + - If the fabric does not exist. + - If the fabric is not local to this ND node. + - If the fabric is in a read-only state. + """ + if not self.fabric_exists(): + raise RuntimeError( + f"Fabric '{self._fabric_name}' not found. " + f"Verify the fabric name and ensure you are targeting the correct ND node." + ) + if not self.fabric_is_local(): + raise RuntimeError( + f"Fabric '{self._fabric_name}' is not local to this Nexus Dashboard node. " + f"Target the ND node that owns the fabric (ownerCluster: " + f"'{self.fabric_summary.get('ownerCluster', 'unknown')}')." + ) + if self.fabric_is_read_only(): + raise RuntimeError( + f"Fabric '{self._fabric_name}' is in a read-only state and cannot be modified. " + f"Check that deployment-freeze mode is not enabled." + ) diff --git a/plugins/module_utils/models/__init__.py b/plugins/module_utils/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/models/interfaces/__init__.py b/plugins/module_utils/models/interfaces/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/models/interfaces/loopback_interface.py b/plugins/module_utils/models/interfaces/loopback_interface.py new file mode 100644 index 00000000..8f470620 --- /dev/null +++ b/plugins/module_utils/models/interfaces/loopback_interface.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Loopback interface Pydantic models for Nexus Dashboard. + +This module defines nested Pydantic models that mirror the ND Manage Interfaces API payload +structure. 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 + +- `LoopbackInterfaceModel` (top-level, `NDBaseModel`) + - `interface_name` (identifier) + - `interface_type` (default: "loopback") + - `config_data` -> `LoopbackConfigDataModel` + - `mode` (default: "managed") + - `network_os` -> `LoopbackNetworkOSModel` + - `network_os_type` (default: "nx-os") + - `policy` -> `LoopbackPolicyModel` + - `admin_state`, `ip`, `ipv6`, `vrf`, `policy_type`, etc. +""" + +from typing import Dict, List, Optional, ClassVar, Literal +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, + field_serializer, + FieldSerializationInfo, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping + +LOOPBACK_POLICY_TYPE_MAPPING = NDConstantMapping( + { + "loopback": "loopback", + "ipfm_loopback": "ipfmLoopback", + "user_defined": "userDefined", + } +) + + +class LoopbackPolicyModel(NDNestedModel): + """ + # Summary + + Policy fields for a loopback interface. Maps directly to the `configData.networkOS.policy` object in the ND API. + + ## Raises + + None + """ + + admin_state: Optional[bool] = Field(default=None, alias="adminState") + ip: Optional[str] = Field(default=None, alias="ip") + ipv6: Optional[str] = Field(default=None, alias="ipv6") + vrf: Optional[str] = Field(default=None, alias="vrfInterface") + route_map_tag: Optional[int] = Field(default=None, alias="routeMapTag") + link_state_routing_tag: Optional[str] = Field(default=None, alias="linkStateRoutingTag") + description: Optional[str] = Field(default=None, alias="description") + extra_config: Optional[str] = Field(default=None, alias="extraConfig") + policy_type: Optional[str] = Field(default=None, alias="policyType") + + # --- 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 LOOPBACK_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 (`ipfm_loopback`) or API (`ipfmLoopback`) format, normalizing to Ansible names. + + ## Raises + + None + """ + if v is None: + return v + reverse_mapping = {api: ansible for ansible, api in LOOPBACK_POLICY_TYPE_MAPPING.data.items() if ansible != api} + return reverse_mapping.get(v, v) + + +class LoopbackNetworkOSModel(NDNestedModel): + """ + # Summary + + Network OS container for a loopback interface. Maps to `configData.networkOS` in the ND API. + + ## Raises + + None + """ + + network_os_type: str = Field(default="nx-os", alias="networkOSType") + policy: Optional[LoopbackPolicyModel] = Field(default=None, alias="policy") + + +class LoopbackConfigDataModel(NDNestedModel): + """ + # Summary + + Config data container for a loopback interface. Maps to `configData` in the ND API. + + ## Raises + + None + """ + + mode: str = Field(default="managed", alias="mode") + network_os: LoopbackNetworkOSModel = Field(alias="networkOS") + + +class LoopbackInterfaceModel(NDBaseModel): + """ + # Summary + + Loopback interface configuration for Nexus Dashboard. + + Uses a single identifier (`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]]] = ["interface_name"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + + # --- Fields --- + + interface_name: str = Field(alias="interfaceName") + interface_type: str = Field(default="loopback", alias="interfaceType") + config_data: Optional[LoopbackConfigDataModel] = Field(default=None, alias="configData") + + @field_validator("interface_name", mode="before") + @classmethod + def normalize_interface_name(cls, v): + """ + # Summary + + Normalize interface name to lowercase to match ND API convention (e.g., Loopback0 -> loopback0). + + ## Raises + + None + """ + if isinstance(v, str): + return v.lower() + return v + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + """ + # Summary + + Return the Ansible argument spec for the `nd_interface_loopback` module. + + ## Raises + + None + """ + return dict( + fabric_name=dict(type="str", required=True), + switch_ip=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + interface_name=dict(type="str", required=True), + interface_type=dict(type="str", default="loopback"), + config_data=dict( + type="dict", + options=dict( + mode=dict(type="str", default="managed"), + 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"), + ip=dict(type="str"), + ipv6=dict(type="str"), + vrf=dict(type="str"), + route_map_tag=dict(type="int"), + link_state_routing_tag=dict(type="str"), + description=dict(type="str"), + extra_config=dict(type="str"), + policy_type=dict( + type="str", + choices=LOOPBACK_POLICY_TYPE_MAPPING.get_original_data(), + default="loopback", + ), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/orchestrators/__init__.py b/plugins/module_utils/orchestrators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/orchestrators/loopback_interface.py b/plugins/module_utils/orchestrators/loopback_interface.py new file mode 100644 index 00000000..6c52355e --- /dev/null +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -0,0 +1,347 @@ +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Loopback interface orchestrator for Nexus Dashboard. + +This module provides `LoopbackInterfaceOrchestrator`, which implements CRUD operations +for loopback interfaces via the ND Manage Interfaces API. Each mutation operation +(create, update, delete) is followed by a deploy call to persist changes to the switch. + +Uses `FabricContext` for pre-flight validation (fabric existence, deployment-freeze check) +and switch IP-to-serial resolution. The model structure mirrors the API payload, so the +orchestrator only needs to inject `switchId` and filter `query_all` results by interface type. +""" + +from __future__ import annotations + +from typing import ClassVar, Optional, 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 ( + EpManageInterfacesDelete, + EpManageInterfacesDeploy, + EpManageInterfacesGet, + EpManageInterfacesListGet, + EpManageInterfacesPost, + EpManageInterfacesPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_switches import EpManageSwitchActionsDeploy +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.loopback_interface import LoopbackInterfaceModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType + + +class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): + """ + # Summary + + Orchestrator for loopback interface CRUD operations on Nexus Dashboard. + + Overrides the base orchestrator to handle the ND interfaces API, which requires `fabric_name` and `switch_sn` + on every endpoint, injects `switchId` into payloads, and defers deploy calls for bulk execution. + + Mutation methods (`create`, `update`, `delete`) queue deploys instead of executing them immediately. Call + `deploy_pending` after all mutations are complete to deploy all changes in a single API call. + + Uses `FabricContext` for pre-flight validation and switch resolution. + + ## Raises + + ### RuntimeError + + - Via `validate` if the fabric does not exist or is in deployment-freeze mode. + - Via `validate` 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 `delete` if the delete 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]] = LoopbackInterfaceModel + + create_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPost + update_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPut + delete_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesDelete + query_one_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesListGet + + deploy: bool = True + deploy_type: str = "interface" + + _fabric_context: Optional[FabricContext] = None + _pending_deploys: list[str] = [] + + @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 + + @property + def switch_id(self) -> str: + """ + # Summary + + Return `switchId` resolved from the `switch_ip` module param via `FabricContext`. + + ## Raises + + ### RuntimeError + + - If no switch matches the given IP in the fabric. + """ + switch_ip = self.sender.params.get("switch_ip") + 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, and that + the target switch exists in the fabric. + + ## Raises + + ### RuntimeError + + - If the fabric does not exist on the target ND node. + - If the fabric is in deployment-freeze mode. + - If no switch matches the given `switch_ip` in the fabric. + """ + self.fabric_context.validate_for_mutation() + # Eagerly resolve switch_id to fail fast if the switch IP is invalid + result = self.switch_id # pylint: disable=unused-variable + + def _configure_endpoint(self, api_endpoint): + """ + # 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 = self.switch_id + return api_endpoint + + def _queue_deploy(self, interface_name: str) -> None: + """ + # Summary + + Queue an interface name for deferred deployment. Call `deploy_pending` after all mutations are complete to deploy in bulk. + + ## Raises + + None + """ + if interface_name not in self._pending_deploys: + self._pending_deploys.append(interface_name) + + def deploy_pending(self) -> ResponseType | None: + """ + # Summary + + Deploy all queued interface configurations in a single API call. Clears the pending queue after deployment. + + When `deploy` is `False`, returns `None` without making any API call. When `deploy_type` is `"switch"`, deploys all + pending switch configuration (not just interfaces) via `switchActions/deploy`. When `deploy_type` is `"interface"`, + deploys only the queued interfaces via `interfaceActions/deploy`. + + ## Raises + + ### RuntimeError + + - If the deploy API request fails. + """ + if not self.deploy or not self._pending_deploys: + return None + try: + if self.deploy_type == "switch": + result = self._deploy_switch() + else: + 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": self.switch_id} for name in self._pending_deploys]} + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + + def _deploy_switch(self) -> ResponseType: + """ + # Summary + + Deploy all pending switch configuration via `switchActions/deploy`. Faster than per-interface deploy but deploys all + staged configuration on the switch, not just interfaces. + + ## Raises + + ### Exception + + - If the deploy API request fails (propagated to caller). + """ + api_endpoint = EpManageSwitchActionsDeploy() + api_endpoint.fabric_name = self.fabric_name + payload = {"switchIds": [self.switch_id]} + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + + def create(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Create a loopback interface. 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: + api_endpoint = self._configure_endpoint(self.create_endpoint()) + payload = model_instance.to_payload() + payload["switchId"] = self.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) + 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: LoopbackInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Update a loopback interface. Injects `switchId` into the payload. Queues a deploy for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the update API request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.update_endpoint()) + api_endpoint.set_identifiers(model_instance.interface_name) + payload = model_instance.to_payload() + payload["switchId"] = self.switch_id + result = self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + self._queue_deploy(model_instance.interface_name) + 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: LoopbackInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Delete a loopback interface. Queues a deploy for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the delete API request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.delete_endpoint()) + api_endpoint.set_identifiers(model_instance.interface_name) + result = self.sender.request(path=api_endpoint.path, method=api_endpoint.verb) + self._queue_deploy(model_instance.interface_name) + return result + except Exception as e: + raise RuntimeError(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e + + def query_one(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + Query a single loopback interface by name. + + ## Raises + + ### RuntimeError + + - If the query API request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.query_one_endpoint()) + 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[NDBaseModel] = None, **kwargs) -> ResponseType: + """ + # Summary + + Validate the fabric context and query all interfaces on the switch, filtering for loopback type only. + + Runs `validate` on first call to ensure the fabric exists, is modifiable, and the target switch is reachable + before returning any data. + + ## Raises + + ### RuntimeError + + - If the fabric does not exist on the target ND node. + - If the fabric is in deployment-freeze mode. + - If no switch matches the given `switch_ip` in the fabric. + - If the query API request fails. + """ + try: + self.validate_prerequisites() + api_endpoint = self._configure_endpoint(self.query_all_endpoint()) + result = self.sender.query_obj(api_endpoint.path) + if not result: + return [] + interfaces = result.get("interfaces", []) or [] + return [iface for iface in interfaces if iface.get("interfaceType") == "loopback"] + except Exception as e: + raise RuntimeError(f"Query all failed: {e}") from e diff --git a/plugins/modules/__init__.py b/plugins/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py new file mode 100644 index 00000000..c78ae472 --- /dev/null +++ b/plugins/modules/nd_interface_loopback.py @@ -0,0 +1,314 @@ +#!/usr/bin/python + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# 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_loopback +version_added: "1.4.0" +short_description: Manage loopback interfaces on Cisco Nexus Dashboard +description: +- Manage loopback interfaces on Cisco Nexus Dashboard. +- It supports creating, updating, querying, and deleting loopback interfaces on switches within a fabric. +author: +- Cisco Systems, Inc. +options: + fabric_name: + description: + - The name of the fabric containing the target switch. + type: str + required: true + switch_ip: + description: + - The management IP address of the switch on which to manage loopback interfaces. + - This is resolved to the switch serial number (switchId) internally. + type: str + required: true + config: + description: + - The list of loopback interfaces to configure. + - The structure mirrors the ND Manage Interfaces API payload. + type: list + elements: dict + required: true + suboptions: + interface_name: + description: + - The name of the loopback interface (e.g., C(loopback0), C(Loopback10)). + type: str + required: true + interface_type: + description: + - The type of the interface. + - Defaults to C(loopback) for this module. + type: str + default: loopback + config_data: + description: + - The configuration data for the interface, following the ND API structure. + type: dict + suboptions: + mode: + description: + - The interface management mode. + type: str + default: managed + 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 loopback interface. + type: dict + suboptions: + admin_state: + description: + - The administrative state of the loopback interface. + - It defaults to C(true) when unset during creation. + type: bool + ip: + description: + - The IPv4 address of the loopback interface. + type: str + ipv6: + description: + - The IPv6 address of the loopback interface. + type: str + vrf: + description: + - The VRF to which the loopback interface belongs. + - Maximum 32 characters. + type: str + route_map_tag: + description: + - The route-map tag associated with the interface IP address. + type: int + link_state_routing_tag: + description: + - The link-state routing tag (e.g., C(UNDERLAY)). + type: str + description: + description: + - The description of the loopback interface. + - Maximum 254 characters. + type: str + extra_config: + description: + - Additional CLI configuration commands to apply to the interface. + type: str + policy_type: + description: + - The policy template type for the loopback interface. + - V(loopback) is the standard loopback policy. + - V(ipfm_loopback) is the IP Fabric for Media loopback policy. + - V(user_defined) allows a custom user-defined policy. + type: str + choices: [ loopback, ipfm_loopback, user_defined ] + default: loopback + 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. + - 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 + deploy_type: + description: + - Controls the scope of the deploy operation when O(deploy=true). + - V(interface) deploys only the interfaces modified by this task via the C(interfaceActions/deploy) API. + This is the safe default that ensures only interface configurations are pushed to the switch. + - V(switch) deploys all pending switch configuration (not just interfaces) via the C(switchActions/deploy) API. + This is faster but will also deploy any other staged configuration on the switch (policies, routing, ACLs, etc.). + Use this option only when you understand the implications or have verified there is no other pending configuration. + - Ignored when O(deploy=false). + type: str + default: interface + choices: [ interface, switch ] + state: + description: + - The desired state of the network resources on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new resources and update existing ones as defined in your configuration. + Resources on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the resources specified in the configuration. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + The resources on ND will be modified to exactly match the configuration. + Any resource existing on ND but not present in the configuration will be deleted. Use with extra caution. + - Use O(state=deleted) to remove the resources specified in the configuration from the Cisco Nexus Dashboard. + 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 currently supports NX-OS loopback interfaces only. +""" + +EXAMPLES = r""" +- name: Create a loopback interface + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + switch_ip: 192.168.1.1 + config: + - interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.1 + admin_state: true + description: Management loopback + route_map_tag: 12345 + vrf: default + state: merged + register: result + +- name: Create multiple loopback interfaces + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + switch_ip: 192.168.1.1 + config: + - interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.1 + description: Router ID loopback + - interface_name: loopback1 + config_data: + network_os: + policy: + ip: 10.2.1.1 + description: VTEP loopback + link_state_routing_tag: UNDERLAY + state: merged + +- name: Update a loopback interface + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + switch_ip: 192.168.1.1 + config: + - interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.2 + description: Updated loopback description + state: replaced + +- name: Delete a loopback interface + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + switch_ip: 192.168.1.1 + config: + - interface_name: loopback0 + state: deleted + +- name: Create loopback interfaces without deploying (for batching) + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + switch_ip: 192.168.1.1 + config: + - interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.1 + deploy: false + state: merged + +- name: Create and deploy using switch-level deploy (faster, broader scope) + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + switch_ip: 192.168.1.1 + config: + - interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.1 + deploy: true + deploy_type: switch + state: merged +""" + +RETURN = r""" +""" + +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.loopback_interface import LoopbackInterfaceModel +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.loopback_interface import LoopbackInterfaceOrchestrator + + +def main(): + """ + # Summary + + Entry point for the `nd_interface_loopback` Ansible module. Initializes the `NDStateMachine` with + `LoopbackInterfaceOrchestrator` and executes the requested state operation. + + ## Raises + + None (catches all exceptions and calls `module.fail_json`). + """ + argument_spec = nd_argument_spec() + argument_spec.update(LoopbackInterfaceModel.get_argument_spec()) + argument_spec.update( + deploy=dict(type="bool", default=True), + deploy_type=dict(type="str", default="interface", choices=["interface", "switch"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + nd_state_machine = None + + try: + # Initialize StateMachine + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=LoopbackInterfaceOrchestrator, + ) + nd_state_machine.model_orchestrator.deploy = module.params["deploy"] + nd_state_machine.model_orchestrator.deploy_type = module.params["deploy_type"] + + # Manage state + nd_state_machine.manage_state() + + # Deploy all queued interface changes in a single bulk API call + if not module.check_mode: + 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() From 8b1963e38387a634297e7699589e4bc087e805c1 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 1 Apr 2026 15:01:19 -1000 Subject: [PATCH 03/17] Remove deploy_type param. Use bulk delete endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Team decided not to allow switch level deploy in resource modules. - Removed deploy_type parameter and use only interface-level deploy endpoint. - Updated module documentation 2. Leverage bulk endpoint for interface deletion. POST …interfaceActions/remove 3. Remove single-interface DELETE endpoint DELETE …interfaces/{interface_name} 4. Add initial unit tests for nd_interface_loopback --- .../endpoints/v1/manage/manage_interfaces.py | 80 +- .../orchestrators/loopback_interface.py | 94 +- plugins/modules/nd_interface_loopback.py | 33 +- tests/unit/module_utils/models/__init__.py | 0 .../models/test_loopback_interface.py | 1385 +++++++++++++++++ 5 files changed, 1496 insertions(+), 96 deletions(-) create mode 100644 tests/unit/module_utils/models/__init__.py create mode 100644 tests/unit/module_utils/models/test_loopback_interface.py diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 048f49d8..771acc78 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -19,23 +19,25 @@ (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 specific interface - (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) +- `EpManageInterfacesRemove` - Bulk delete interfaces + (POST /api/v1/manage/fabrics/{fabric_name}/interfaceActions/remove) """ from __future__ import annotations -from typing import ClassVar, Literal, Optional +from typing import ClassVar, Literal -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( FabricNameMixin, - SwitchSerialNumberMixin, InterfaceNameMixin, + SwitchSerialNumberMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey @@ -116,9 +118,7 @@ class EpManageInterfacesGet(_EpManageInterfacesBase): - Via inherited `path` property if `fabric_name`, `switch_sn`, or `interface_name` is not set. """ - class_name: Literal["EpManageInterfacesGet"] = Field( - default="EpManageInterfacesGet", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpManageInterfacesGet"] = Field(default="EpManageInterfacesGet", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -192,9 +192,7 @@ class EpManageInterfacesPost(_EpManageInterfacesBase): _require_interface_name: ClassVar[bool] = False - class_name: Literal["EpManageInterfacesPost"] = Field( - default="EpManageInterfacesPost", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpManageInterfacesPost"] = Field(default="EpManageInterfacesPost", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -226,9 +224,7 @@ class EpManageInterfacesPut(_EpManageInterfacesBase): - Via inherited `path` property if `fabric_name`, `switch_sn`, or `interface_name` is not set. """ - class_name: Literal["EpManageInterfacesPut"] = Field( - default="EpManageInterfacesPut", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpManageInterfacesPut"] = Field(default="EpManageInterfacesPut", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -244,47 +240,65 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.PUT -class EpManageInterfacesDelete(_EpManageInterfacesBase): +class EpManageInterfacesDeploy(FabricNameMixin, NDEndpointBaseModel): """ # Summary - Delete a specific interface. + Deploy interface configurations to switches. - - Path: `/api/v1/manage/fabrics/{fabric_name}/switches/{switch_sn}/interfaces/{interface_name}` - - Verb: DELETE + - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/deploy` + - Verb: POST + - Body: `{"interfaces": [{"interfaceName": "...", "switchId": "..."}]}` ## Raises ### ValueError - - Via inherited `path` property if `fabric_name`, `switch_sn`, or `interface_name` is not set. + - Via `path` property if `fabric_name` is not set. """ - class_name: Literal["EpManageInterfacesDelete"] = Field( - default="EpManageInterfacesDelete", frozen=True, description="Class name for backward compatibility" + class_name: Literal["EpManageInterfacesDeploy"] = Field( + default="EpManageInterfacesDeploy", frozen=True, description="Class name for backward compatibility" ) + @property + def path(self) -> str: + """ + # Summary + + Build the deploy 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", "deploy") + @property def verb(self) -> HttpVerbEnum: """ # Summary - Return `HttpVerbEnum.DELETE`. + Return `HttpVerbEnum.POST`. ## Raises None """ - return HttpVerbEnum.DELETE + return HttpVerbEnum.POST -class EpManageInterfacesDeploy(FabricNameMixin, NDEndpointBaseModel): +class EpManageInterfacesRemove(FabricNameMixin, NDEndpointBaseModel): """ # Summary - Deploy interface configurations to switches. + Bulk delete interfaces across one or more switches. - - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/deploy` + - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/remove` - Verb: POST - Body: `{"interfaces": [{"interfaceName": "...", "switchId": "..."}]}` @@ -295,8 +309,8 @@ class EpManageInterfacesDeploy(FabricNameMixin, NDEndpointBaseModel): - Via `path` property if `fabric_name` is not set. """ - class_name: Literal["EpManageInterfacesDeploy"] = Field( - default="EpManageInterfacesDeploy", frozen=True, description="Class name for backward compatibility" + class_name: Literal["EpManageInterfacesRemove"] = Field( + default="EpManageInterfacesRemove", frozen=True, description="Class name for backward compatibility" ) @property @@ -304,7 +318,7 @@ def path(self) -> str: """ # Summary - Build the deploy endpoint path. + Build the bulk remove endpoint path. ## Raises @@ -314,7 +328,7 @@ def path(self) -> str: """ 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", "deploy") + return BasePath.path("fabrics", self.fabric_name, "interfaceActions", "remove") @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/orchestrators/loopback_interface.py b/plugins/module_utils/orchestrators/loopback_interface.py index 6c52355e..4e5dd656 100644 --- a/plugins/module_utils/orchestrators/loopback_interface.py +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -20,14 +20,13 @@ 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 ( - EpManageInterfacesDelete, EpManageInterfacesDeploy, EpManageInterfacesGet, EpManageInterfacesListGet, EpManageInterfacesPost, EpManageInterfacesPut, + EpManageInterfacesRemove, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_switches import EpManageSwitchActionsDeploy 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.loopback_interface import LoopbackInterfaceModel @@ -44,8 +43,9 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): Overrides the base orchestrator to handle the ND interfaces API, which requires `fabric_name` and `switch_sn` on every endpoint, injects `switchId` into payloads, and defers deploy calls for bulk execution. - Mutation methods (`create`, `update`, `delete`) queue deploys instead of executing them immediately. Call - `deploy_pending` after all mutations are complete to deploy all changes in a single API call. + 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 interfaces for bulk + removal via `remove_pending`. Uses `FabricContext` for pre-flight validation and switch resolution. @@ -57,7 +57,7 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): - Via `validate` 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 `delete` if the delete 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. @@ -67,15 +67,15 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): create_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPost update_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPut - delete_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesDelete + delete_endpoint: Type[NDEndpointBaseModel] = NDEndpointBaseModel # unused; delete() uses bulk remove query_one_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesGet query_all_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesListGet deploy: bool = True - deploy_type: str = "interface" _fabric_context: Optional[FabricContext] = None _pending_deploys: list[str] = [] + _pending_removes: list[str] = [] @property def fabric_name(self) -> str: @@ -167,15 +167,27 @@ def _queue_deploy(self, interface_name: str) -> None: if interface_name not in self._pending_deploys: self._pending_deploys.append(interface_name) + def _queue_remove(self, interface_name: str) -> None: + """ + # Summary + + Queue an interface name for deferred bulk removal. Call `remove_pending` after all mutations are complete to remove in bulk. + + ## Raises + + None + """ + if interface_name not in self._pending_removes: + self._pending_removes.append(interface_name) + def deploy_pending(self) -> ResponseType | None: """ # Summary - Deploy all queued interface configurations in a single API call. Clears the pending queue after deployment. + 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. When `deploy_type` is `"switch"`, deploys all - pending switch configuration (not just interfaces) via `switchActions/deploy`. When `deploy_type` is `"interface"`, - deploys only the queued interfaces via `interfaceActions/deploy`. + When `deploy` is `False`, returns `None` without making any API call. ## Raises @@ -186,10 +198,7 @@ def deploy_pending(self) -> ResponseType | None: if not self.deploy or not self._pending_deploys: return None try: - if self.deploy_type == "switch": - result = self._deploy_switch() - else: - result = self._deploy_interfaces() + result = self._deploy_interfaces() self._pending_deploys = [] return result except Exception as e: @@ -212,22 +221,44 @@ def _deploy_interfaces(self) -> ResponseType: payload = {"interfaces": [{"interfaceName": name, "switchId": self.switch_id} for name in self._pending_deploys]} return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) - def _deploy_switch(self) -> ResponseType: + def remove_pending(self) -> ResponseType | None: + """ + # Summary + + Remove all queued interfaces in a single API call via `interfaceActions/remove`. Clears the pending queue after removal. + + Returns `None` without making any API call if the queue is empty. + + ## Raises + + ### RuntimeError + + - If the remove API request fails. + """ + if not self._pending_removes: + return None + try: + result = self._remove_interfaces() + self._pending_removes = [] + return result + except Exception as e: + raise RuntimeError(f"Bulk remove failed for interfaces {self._pending_removes}: {e}") from e + + def _remove_interfaces(self) -> ResponseType: """ # Summary - Deploy all pending switch configuration via `switchActions/deploy`. Faster than per-interface deploy but deploys all - staged configuration on the switch, not just interfaces. + Remove queued interfaces via `interfaceActions/remove`. Sends the explicit list of `{interfaceName, switchId}` pairs. ## Raises ### Exception - - If the deploy API request fails (propagated to caller). + - If the remove API request fails (propagated to caller). """ - api_endpoint = EpManageSwitchActionsDeploy() + api_endpoint = EpManageInterfacesRemove() api_endpoint.fabric_name = self.fabric_name - payload = {"switchIds": [self.switch_id]} + payload = {"interfaces": [{"interfaceName": name, "switchId": self.switch_id} for name in self._pending_removes]} return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) def create(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: @@ -277,26 +308,21 @@ def update(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseTy except Exception as e: raise RuntimeError(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e - def delete(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: + def delete(self, model_instance: LoopbackInterfaceModel, **kwargs) -> None: """ # Summary - Delete a loopback interface. Queues a deploy for later bulk execution via `deploy_pending`. + Queue a loopback interface for deferred bulk removal via `remove_pending` and bulk deploy via `deploy_pending`. + The remove deletes the interface from ND's config; the deploy pushes that removal to the switch. - ## Raises + No API calls are made until `remove_pending` and `deploy_pending` are called after all mutations are complete. - ### RuntimeError + ## Raises - - If the delete API request fails. + None """ - try: - api_endpoint = self._configure_endpoint(self.delete_endpoint()) - api_endpoint.set_identifiers(model_instance.interface_name) - result = self.sender.request(path=api_endpoint.path, method=api_endpoint.verb) - self._queue_deploy(model_instance.interface_name) - return result - except Exception as e: - raise RuntimeError(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e + self._queue_remove(model_instance.interface_name) + self._queue_deploy(model_instance.interface_name) def query_one(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: """ diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py index c78ae472..c57da904 100644 --- a/plugins/modules/nd_interface_loopback.py +++ b/plugins/modules/nd_interface_loopback.py @@ -119,23 +119,12 @@ 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. + - 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 - deploy_type: - description: - - Controls the scope of the deploy operation when O(deploy=true). - - V(interface) deploys only the interfaces modified by this task via the C(interfaceActions/deploy) API. - This is the safe default that ensures only interface configurations are pushed to the switch. - - V(switch) deploys all pending switch configuration (not just interfaces) via the C(switchActions/deploy) API. - This is faster but will also deploy any other staged configuration on the switch (policies, routing, ACLs, etc.). - Use this option only when you understand the implications or have verified there is no other pending configuration. - - Ignored when O(deploy=false). - type: str - default: interface - choices: [ interface, switch ] state: description: - The desired state of the network resources on the Cisco Nexus Dashboard. @@ -229,19 +218,6 @@ deploy: false state: merged -- name: Create and deploy using switch-level deploy (faster, broader scope) - cisco.nd.nd_interface_loopback: - fabric_name: my_fabric - switch_ip: 192.168.1.1 - config: - - interface_name: loopback0 - config_data: - network_os: - policy: - ip: 10.1.1.1 - deploy: true - deploy_type: switch - state: merged """ RETURN = r""" @@ -273,7 +249,6 @@ def main(): argument_spec.update(LoopbackInterfaceModel.get_argument_spec()) argument_spec.update( deploy=dict(type="bool", default=True), - deploy_type=dict(type="str", default="interface", choices=["interface", "switch"]), ) module = AnsibleModule( @@ -291,13 +266,13 @@ def main(): model_orchestrator=LoopbackInterfaceOrchestrator, ) nd_state_machine.model_orchestrator.deploy = module.params["deploy"] - nd_state_machine.model_orchestrator.deploy_type = module.params["deploy_type"] # Manage state nd_state_machine.manage_state() - # Deploy all queued interface changes in a single bulk API call + # 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()) diff --git a/tests/unit/module_utils/models/__init__.py b/tests/unit/module_utils/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/module_utils/models/test_loopback_interface.py b/tests/unit/module_utils/models/test_loopback_interface.py new file mode 100644 index 00000000..3faeaa4c --- /dev/null +++ b/tests/unit/module_utils/models/test_loopback_interface.py @@ -0,0 +1,1385 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for loopback_interface.py + +Tests the Loopback Interface Pydantic model classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +from contextlib import contextmanager + +import pytest # pylint: disable=unused-import +from pydantic import ValidationError # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.loopback_interface import ( + LOOPBACK_POLICY_TYPE_MAPPING, + LoopbackConfigDataModel, + LoopbackInterfaceModel, + LoopbackNetworkOSModel, + LoopbackPolicyModel, +) + + +@contextmanager +def does_not_raise(): + """A context manager that does not raise an exception.""" + yield + +# ============================================================================= +# Test data constants +# ============================================================================= + +SAMPLE_API_RESPONSE = { + "interfaceName": "loopback0", + "interfaceType": "loopback", + "configData": { + "mode": "managed", + "networkOS": { + "networkOSType": "nx-os", + "policy": { + "adminState": True, + "ip": "10.1.1.1/32", + "vrfInterface": "management", + "policyType": "loopback", + "routeMapTag": 12345, + "description": "mgmt loopback", + }, + }, + }, +} + +SAMPLE_ANSIBLE_CONFIG = { + "interface_name": "loopback0", + "interface_type": "loopback", + "config_data": { + "mode": "managed", + "network_os": { + "network_os_type": "nx-os", + "policy": { + "admin_state": True, + "ip": "10.1.1.1/32", + "vrf": "management", + "policy_type": "loopback", + "route_map_tag": 12345, + "description": "mgmt loopback", + }, + }, + }, +} + + +# ============================================================================= +# Test: LOOPBACK_POLICY_TYPE_MAPPING +# ============================================================================= + + +def test_loopback_interface_00000(): + """ + # Summary + + Verify get_original_data() returns the expected list. + + ## Test + + - get_original_data() returns ["loopback", "ipfm_loopback", "user_defined"] + + ## Classes and Methods + + - NDConstantMapping.get_original_data() + """ + result = LOOPBACK_POLICY_TYPE_MAPPING.get_original_data() + assert result == ["loopback", "ipfm_loopback", "user_defined"] + + +def test_loopback_interface_00001(): + """ + # Summary + + Verify get_dict() maps correctly. + + ## Test + + - ipfm_loopback -> ipfmLoopback + - user_defined -> userDefined + - Reverse mapping also present + + ## Classes and Methods + + - NDConstantMapping.get_dict() + """ + mapping = LOOPBACK_POLICY_TYPE_MAPPING.get_dict() + assert mapping["ipfm_loopback"] == "ipfmLoopback" + assert mapping["user_defined"] == "userDefined" + assert mapping["loopback"] == "loopback" + assert mapping["ipfmLoopback"] == "ipfm_loopback" + assert mapping["userDefined"] == "user_defined" + + +# ============================================================================= +# Test: LoopbackPolicyModel +# ============================================================================= + + +def test_loopback_interface_00010(): + """ + # Summary + + Verify all fields default to None. + + ## Test + + - Instantiate with no arguments + - All fields are None + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with does_not_raise(): + instance = LoopbackPolicyModel() + assert instance.admin_state is None + assert instance.ip is None + assert instance.ipv6 is None + assert instance.vrf is None + assert instance.route_map_tag is None + assert instance.link_state_routing_tag is None + assert instance.description is None + assert instance.extra_config is None + assert instance.policy_type is None + + +def test_loopback_interface_00020(): + """ + # Summary + + Verify construction with snake_case field names. + + ## Test + + - Construct with Python field names + - All values accessible + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with does_not_raise(): + instance = LoopbackPolicyModel( + admin_state=True, + ip="10.1.1.1/32", + vrf="management", + policy_type="loopback", + route_map_tag=100, + description="test", + ) + assert instance.admin_state is True + assert instance.ip == "10.1.1.1/32" + assert instance.vrf == "management" + assert instance.policy_type == "loopback" + assert instance.route_map_tag == 100 + assert instance.description == "test" + + +def test_loopback_interface_00030(): + """ + # Summary + + Verify construction with camelCase aliases (populate_by_name=True). + + ## Test + + - Construct with API alias names + - All values accessible by Python names + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with does_not_raise(): + instance = LoopbackPolicyModel( + adminState=False, + ip="10.2.2.2/32", + vrfInterface="default", + policyType="loopback", + routeMapTag=200, + ) + assert instance.admin_state is False + assert instance.ip == "10.2.2.2/32" + assert instance.vrf == "default" + assert instance.policy_type == "loopback" + assert instance.route_map_tag == 200 + + +def test_loopback_interface_00040(): + """ + # Summary + + Verify normalize_policy_type converts API "ipfmLoopback" to "ipfm_loopback". + + ## Test + + - Construct with policyType="ipfmLoopback" + - policy_type is normalized to "ipfm_loopback" + + ## Classes and Methods + + - LoopbackPolicyModel.normalize_policy_type() + """ + instance = LoopbackPolicyModel(policyType="ipfmLoopback") + assert instance.policy_type == "ipfm_loopback" + + +def test_loopback_interface_00041(): + """ + # Summary + + Verify normalize_policy_type passes through "ipfm_loopback" unchanged. + + ## Test + + - Construct with policy_type="ipfm_loopback" + - Value passes through unchanged (already in Ansible format) + + ## Classes and Methods + + - LoopbackPolicyModel.normalize_policy_type() + """ + instance = LoopbackPolicyModel(policy_type="ipfm_loopback") + assert instance.policy_type == "ipfm_loopback" + + +def test_loopback_interface_00042(): + """ + # Summary + + Verify normalize_policy_type converts "userDefined" to "user_defined". + + ## Test + + - Construct with policyType="userDefined" + - policy_type is normalized to "user_defined" + + ## Classes and Methods + + - LoopbackPolicyModel.normalize_policy_type() + """ + instance = LoopbackPolicyModel(policyType="userDefined") + assert instance.policy_type == "user_defined" + + +def test_loopback_interface_00043(): + """ + # Summary + + Verify normalize_policy_type passes through None. + + ## Test + + - Construct with policy_type=None (default) + - Value is None + + ## Classes and Methods + + - LoopbackPolicyModel.normalize_policy_type() + """ + instance = LoopbackPolicyModel() + assert instance.policy_type is None + + +def test_loopback_interface_00044(): + """ + # Summary + + Verify normalize_policy_type passes through unknown values unchanged. + + ## Test + + - Construct with policy_type="custom_unknown" + - Value passes through unchanged + + ## Classes and Methods + + - LoopbackPolicyModel.normalize_policy_type() + """ + instance = LoopbackPolicyModel(policy_type="custom_unknown") + assert instance.policy_type == "custom_unknown" + + +def test_loopback_interface_00050(): + """ + # Summary + + Verify serialize_policy_type in payload mode produces camelCase. + + ## Test + + - model_dump with payload context + - policy_type serialized to camelCase + + ## Classes and Methods + + - LoopbackPolicyModel.serialize_policy_type() + """ + instance = LoopbackPolicyModel(policy_type="ipfm_loopback") + result = instance.model_dump(by_alias=True, exclude_none=True, context={"mode": "payload"}) + assert result["policyType"] == "ipfmLoopback" + + +def test_loopback_interface_00051(): + """ + # Summary + + Verify serialize_policy_type in config mode keeps ansible name. + + ## Test + + - model_dump with config context + - policy_type stays as ansible name + + ## Classes and Methods + + - LoopbackPolicyModel.serialize_policy_type() + """ + instance = LoopbackPolicyModel(policy_type="ipfm_loopback") + result = instance.model_dump(by_alias=False, exclude_none=True, context={"mode": "config"}) + assert result["policy_type"] == "ipfm_loopback" + + +def test_loopback_interface_00052(): + """ + # Summary + + Verify serialize_policy_type with None returns None in both modes. + + ## Test + + - policy_type is None + - Serialization in both modes returns None (excluded by exclude_none) + + ## Classes and Methods + + - LoopbackPolicyModel.serialize_policy_type() + """ + instance = LoopbackPolicyModel() + payload_result = instance.model_dump(by_alias=True, context={"mode": "payload"}) + config_result = instance.model_dump(by_alias=False, context={"mode": "config"}) + assert payload_result["policyType"] is None + assert config_result["policy_type"] is None + + +def test_loopback_interface_00053(): + """ + # Summary + + Verify default serialization (no context) uses payload mode (camelCase). + + ## Test + + - model_dump with no context + - policy_type serialized to camelCase (default is payload) + + ## Classes and Methods + + - LoopbackPolicyModel.serialize_policy_type() + """ + instance = LoopbackPolicyModel(policy_type="user_defined") + result = instance.model_dump(by_alias=True, exclude_none=True) + assert result["policyType"] == "userDefined" + + +def test_loopback_interface_00060(): + """ + # Summary + + Verify model_dump(exclude_none=True) excludes None fields. + + ## Test + + - Only ip is set + - exclude_none=True omits all None fields + + ## Classes and Methods + + - LoopbackPolicyModel.model_dump() + """ + instance = LoopbackPolicyModel(ip="10.1.1.1/32") + result = instance.model_dump(exclude_none=True) + assert "ip" in result + assert "admin_state" not in result + assert "vrf" not in result + assert "policy_type" not in result + + +# ============================================================================= +# Test: LoopbackNetworkOSModel +# ============================================================================= + + +def test_loopback_interface_00100(): + """ + # Summary + + Verify network_os_type defaults to "nx-os". + + ## Test + + - Construct with only required policy field + - network_os_type defaults to "nx-os" + + ## Classes and Methods + + - LoopbackNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = LoopbackNetworkOSModel(policy=LoopbackPolicyModel()) + assert instance.network_os_type == "nx-os" + + +def test_loopback_interface_00110(): + """ + # Summary + + Verify policy field defaults to None when not provided. + + ## Test + + - Construct without policy field + - policy is None + - network_os_type has default value + + ## Classes and Methods + + - LoopbackNetworkOSModel.__init__() + """ + with does_not_raise(): + instance = LoopbackNetworkOSModel() + assert instance.policy is None + assert instance.network_os_type == "nx-os" + + +def test_loopback_interface_00120(): + """ + # Summary + + Verify construction from camelCase dict with nested policy normalization. + + ## Test + + - Construct from API-style camelCase dict + - Nested policy_type is normalized + + ## Classes and Methods + + - LoopbackNetworkOSModel.__init__() + - LoopbackPolicyModel.normalize_policy_type() + """ + data = { + "networkOSType": "nx-os", + "policy": { + "adminState": True, + "policyType": "ipfmLoopback", + }, + } + with does_not_raise(): + instance = LoopbackNetworkOSModel(**data) + assert instance.network_os_type == "nx-os" + assert instance.policy.admin_state is True + assert instance.policy.policy_type == "ipfm_loopback" + + +# ============================================================================= +# Test: LoopbackConfigDataModel +# ============================================================================= + + +def test_loopback_interface_00150(): + """ + # Summary + + Verify mode defaults to "managed". + + ## Test + + - Construct with only required network_os field + - mode defaults to "managed" + + ## Classes and Methods + + - LoopbackConfigDataModel.__init__() + """ + with does_not_raise(): + instance = LoopbackConfigDataModel(network_os=LoopbackNetworkOSModel(policy=LoopbackPolicyModel())) + assert instance.mode == "managed" + + +def test_loopback_interface_00160(): + """ + # Summary + + Verify deeply nested construction and field access. + + ## Test + + - Construct with full nesting + - All nested fields accessible + + ## Classes and Methods + + - LoopbackConfigDataModel.__init__() + """ + with does_not_raise(): + instance = LoopbackConfigDataModel( + mode="managed", + network_os=LoopbackNetworkOSModel( + policy=LoopbackPolicyModel(ip="10.1.1.1/32", admin_state=True), + ), + ) + assert instance.mode == "managed" + assert instance.network_os.policy.ip == "10.1.1.1/32" + assert instance.network_os.policy.admin_state is True + + +# ============================================================================= +# Test: LoopbackInterfaceModel — Initialization +# ============================================================================= + + +def test_loopback_interface_00200(): + """ + # Summary + + Verify ClassVars: identifiers and identifier_strategy. + + ## Test + + - identifiers == ["interface_name"] + - identifier_strategy == "single" + + ## Classes and Methods + + - LoopbackInterfaceModel class attributes + """ + assert LoopbackInterfaceModel.identifiers == ["interface_name"] + assert LoopbackInterfaceModel.identifier_strategy == "single" + + +def test_loopback_interface_00210(): + """ + # Summary + + Verify interface_type defaults to "loopback". + + ## Test + + - Construct with only interface_name + - interface_type defaults to "loopback" + + ## Classes and Methods + + - LoopbackInterfaceModel.__init__() + """ + with does_not_raise(): + instance = LoopbackInterfaceModel(interface_name="loopback0") + assert instance.interface_type == "loopback" + + +def test_loopback_interface_00220(): + """ + # Summary + + Verify config_data defaults to None. + + ## Test + + - Construct with only interface_name + - config_data defaults to None + + ## Classes and Methods + + - LoopbackInterfaceModel.__init__() + """ + instance = LoopbackInterfaceModel(interface_name="loopback0") + assert instance.config_data is None + + +def test_loopback_interface_00230(): + """ + # Summary + + Verify interface_name is required — ValidationError without it. + + ## Test + + - Construct without interface_name + - Raises ValidationError + + ## Classes and Methods + + - LoopbackInterfaceModel.__init__() + """ + with pytest.raises(ValidationError): + LoopbackInterfaceModel() + + +# ============================================================================= +# Test: LoopbackInterfaceModel — Validators +# ============================================================================= + + +def test_loopback_interface_00250(): + """ + # Summary + + Verify normalize_interface_name lowercases "Loopback0" to "loopback0". + + ## Test + + - Construct with interface_name="Loopback0" + - Value normalized to "loopback0" + + ## Classes and Methods + + - LoopbackInterfaceModel.normalize_interface_name() + """ + instance = LoopbackInterfaceModel(interface_name="Loopback0") + assert instance.interface_name == "loopback0" + + +def test_loopback_interface_00251(): + """ + # Summary + + Verify normalize_interface_name lowercases "LOOPBACK1" to "loopback1". + + ## Test + + - Construct with interface_name="LOOPBACK1" + - Value normalized to "loopback1" + + ## Classes and Methods + + - LoopbackInterfaceModel.normalize_interface_name() + """ + instance = LoopbackInterfaceModel(interface_name="LOOPBACK1") + assert instance.interface_name == "loopback1" + + +def test_loopback_interface_00252(): + """ + # Summary + + Verify already-lowercase passes through unchanged. + + ## Test + + - Construct with interface_name="loopback0" + - Value unchanged + + ## Classes and Methods + + - LoopbackInterfaceModel.normalize_interface_name() + """ + instance = LoopbackInterfaceModel(interface_name="loopback0") + assert instance.interface_name == "loopback0" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — to_payload +# ============================================================================= + + +def test_loopback_interface_00300(): + """ + # Summary + + Verify top-level keys are camelCase in payload. + + ## Test + + - to_payload() returns camelCase keys + + ## Classes and Methods + + - LoopbackInterfaceModel.to_payload() + """ + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + assert "interfaceName" in result + assert "interfaceType" in result + assert "configData" in result + + +def test_loopback_interface_00310(): + """ + # Summary + + Verify config_data=None excluded from payload output. + + ## Test + + - Construct with only interface_name + - to_payload() does not include configData + + ## Classes and Methods + + - LoopbackInterfaceModel.to_payload() + """ + instance = LoopbackInterfaceModel(interface_name="loopback0") + result = instance.to_payload() + assert "configData" not in result + assert "interfaceName" in result + + +def test_loopback_interface_00320(): + """ + # Summary + + Verify nested aliases in payload: configData.networkOS.policy keys. + + ## Test + + - Nested keys use camelCase aliases + + ## Classes and Methods + + - LoopbackInterfaceModel.to_payload() + """ + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_payload() + policy = result["configData"]["networkOS"]["policy"] + assert "adminState" in policy + assert "vrfInterface" in policy + assert "policyType" in policy + assert "routeMapTag" in policy + + +def test_loopback_interface_00330(): + """ + # Summary + + Verify policy_type serialized to camelCase in payload: ipfm_loopback -> ipfmLoopback. + + ## Test + + - policy_type="ipfm_loopback" becomes "ipfmLoopback" in payload + + ## Classes and Methods + + - LoopbackInterfaceModel.to_payload() + - LoopbackPolicyModel.serialize_policy_type() + """ + config = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config["config_data"]["network_os"]["policy"]["policy_type"] = "ipfm_loopback" + instance = LoopbackInterfaceModel.from_config(config) + result = instance.to_payload() + assert result["configData"]["networkOS"]["policy"]["policyType"] == "ipfmLoopback" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — to_config +# ============================================================================= + + +def test_loopback_interface_00350(): + """ + # Summary + + Verify top-level keys are snake_case in config. + + ## Test + + - to_config() returns snake_case keys + + ## Classes and Methods + + - LoopbackInterfaceModel.to_config() + """ + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_config() + assert "interface_name" in result + assert "interface_type" in result + assert "config_data" in result + + +def test_loopback_interface_00360(): + """ + # Summary + + Verify nested keys in config: config_data.network_os.policy keys. + + ## Test + + - Nested keys use snake_case Python names + + ## Classes and Methods + + - LoopbackInterfaceModel.to_config() + """ + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + result = instance.to_config() + policy = result["config_data"]["network_os"]["policy"] + assert "admin_state" in policy + assert "vrf" in policy + assert "route_map_tag" in policy + + +def test_loopback_interface_00370(): + """ + # Summary + + Verify policy_type stays as ansible name in config mode. + + ## Test + + - policy_type="ipfm_loopback" stays as "ipfm_loopback" in config output + + ## Classes and Methods + + - LoopbackInterfaceModel.to_config() + - LoopbackPolicyModel.serialize_policy_type() + """ + config = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config["config_data"]["network_os"]["policy"]["policy_type"] = "ipfm_loopback" + instance = LoopbackInterfaceModel.from_config(config) + result = instance.to_config() + assert result["config_data"]["network_os"]["policy"]["policy_type"] == "ipfm_loopback" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — from_response +# ============================================================================= + + +def test_loopback_interface_00400(): + """ + # Summary + + Verify from_response constructs from SAMPLE_API_RESPONSE. + + ## Test + + - All fields accessible by Python names + + ## Classes and Methods + + - LoopbackInterfaceModel.from_response() + """ + with does_not_raise(): + instance = LoopbackInterfaceModel.from_response(copy.deepcopy(SAMPLE_API_RESPONSE)) + assert instance.interface_name == "loopback0" + assert instance.interface_type == "loopback" + assert instance.config_data.mode == "managed" + assert instance.config_data.network_os.policy.ip == "10.1.1.1/32" + assert instance.config_data.network_os.policy.vrf == "management" + assert instance.config_data.network_os.policy.admin_state is True + + +def test_loopback_interface_00410(): + """ + # Summary + + Verify from_response normalizes "Loopback0" to "loopback0". + + ## Test + + - API response with "Loopback0" is lowercased + + ## Classes and Methods + + - LoopbackInterfaceModel.from_response() + - LoopbackInterfaceModel.normalize_interface_name() + """ + response = copy.deepcopy(SAMPLE_API_RESPONSE) + response["interfaceName"] = "Loopback0" + instance = LoopbackInterfaceModel.from_response(response) + assert instance.interface_name == "loopback0" + + +def test_loopback_interface_00420(): + """ + # Summary + + Verify from_response normalizes "ipfmLoopback" to "ipfm_loopback". + + ## Test + + - API response with policyType="ipfmLoopback" is normalized + + ## Classes and Methods + + - LoopbackInterfaceModel.from_response() + - LoopbackPolicyModel.normalize_policy_type() + """ + response = copy.deepcopy(SAMPLE_API_RESPONSE) + response["configData"]["networkOS"]["policy"]["policyType"] = "ipfmLoopback" + instance = LoopbackInterfaceModel.from_response(response) + assert instance.config_data.network_os.policy.policy_type == "ipfm_loopback" + + +def test_loopback_interface_00430(): + """ + # Summary + + Verify from_response ignores unknown keys (extra="ignore" in model config). + + ## Test + + - API response with extra keys does not raise + + ## Classes and Methods + + - LoopbackInterfaceModel.from_response() + """ + response = copy.deepcopy(SAMPLE_API_RESPONSE) + response["unknownField"] = "should be ignored" + response["configData"]["unknownNested"] = "also ignored" + with does_not_raise(): + instance = LoopbackInterfaceModel.from_response(response) + assert instance.interface_name == "loopback0" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — from_config +# ============================================================================= + + +def test_loopback_interface_00450(): + """ + # Summary + + Verify from_config constructs from SAMPLE_ANSIBLE_CONFIG. + + ## Test + + - All fields correct + + ## Classes and Methods + + - LoopbackInterfaceModel.from_config() + """ + with does_not_raise(): + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance.interface_name == "loopback0" + assert instance.interface_type == "loopback" + assert instance.config_data.mode == "managed" + assert instance.config_data.network_os.policy.ip == "10.1.1.1/32" + assert instance.config_data.network_os.policy.vrf == "management" + + +def test_loopback_interface_00460(): + """ + # Summary + + Verify minimal config: only interface_name produces config_data=None. + + ## Test + + - Minimal config with just interface_name + - config_data is None + + ## Classes and Methods + + - LoopbackInterfaceModel.from_config() + """ + instance = LoopbackInterfaceModel.from_config({"interface_name": "loopback0"}) + assert instance.interface_name == "loopback0" + assert instance.config_data is None + + +# ============================================================================= +# Test: LoopbackInterfaceModel — Round-trip +# ============================================================================= + + +def test_loopback_interface_00500(): + """ + # Summary + + Verify config -> from_config -> to_payload -> from_response -> to_config == original. + + ## Test + + - Round-trip through all serialization methods preserves data + + ## Classes and Methods + + - LoopbackInterfaceModel.from_config() + - LoopbackInterfaceModel.to_payload() + - LoopbackInterfaceModel.from_response() + - LoopbackInterfaceModel.to_config() + """ + original = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + instance = LoopbackInterfaceModel.from_config(original) + payload = instance.to_payload() + instance2 = LoopbackInterfaceModel.from_response(payload) + result = instance2.to_config() + assert result == original + + +def test_loopback_interface_00510(): + """ + # Summary + + Verify response -> from_response -> to_config -> from_config -> to_payload == original. + + ## Test + + - Round-trip starting from API response preserves data + + ## Classes and Methods + + - LoopbackInterfaceModel.from_response() + - LoopbackInterfaceModel.to_config() + - LoopbackInterfaceModel.from_config() + - LoopbackInterfaceModel.to_payload() + """ + original = copy.deepcopy(SAMPLE_API_RESPONSE) + instance = LoopbackInterfaceModel.from_response(original) + config = instance.to_config() + instance2 = LoopbackInterfaceModel.from_config(config) + result = instance2.to_payload() + assert result == original + + +def test_loopback_interface_00520(): + """ + # Summary + + Verify policy_type round-trip: ipfm_loopback (config) -> ipfmLoopback (payload) -> ipfm_loopback (config). + + ## Test + + - policy_type correctly converts between formats in round-trip + + ## Classes and Methods + + - LoopbackPolicyModel.normalize_policy_type() + - LoopbackPolicyModel.serialize_policy_type() + """ + config = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config["config_data"]["network_os"]["policy"]["policy_type"] = "ipfm_loopback" + + instance = LoopbackInterfaceModel.from_config(config) + payload = instance.to_payload() + assert payload["configData"]["networkOS"]["policy"]["policyType"] == "ipfmLoopback" + + instance2 = LoopbackInterfaceModel.from_response(payload) + result_config = instance2.to_config() + assert result_config["config_data"]["network_os"]["policy"]["policy_type"] == "ipfm_loopback" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — Identifier +# ============================================================================= + + +def test_loopback_interface_00550(): + """ + # Summary + + Verify get_identifier_value() returns "loopback0". + + ## Test + + - get_identifier_value() returns the interface_name + + ## Classes and Methods + + - LoopbackInterfaceModel.get_identifier_value() + """ + instance = LoopbackInterfaceModel(interface_name="loopback0") + assert instance.get_identifier_value() == "loopback0" + + +def test_loopback_interface_00560(): + """ + # Summary + + Verify get_identifier_value returns lowercased value when constructed with "Loopback1". + + ## Test + + - Constructed with "Loopback1" + - get_identifier_value() returns "loopback1" + + ## Classes and Methods + + - LoopbackInterfaceModel.get_identifier_value() + - LoopbackInterfaceModel.normalize_interface_name() + """ + instance = LoopbackInterfaceModel(interface_name="Loopback1") + assert instance.get_identifier_value() == "loopback1" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — get_diff +# ============================================================================= + + +def test_loopback_interface_00600(): + """ + # Summary + + Verify identical models -> True (other is subset of self). + + ## Test + + - Two identical models + - get_diff returns True + + ## Classes and Methods + + - LoopbackInterfaceModel.get_diff() + """ + instance1 = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + instance2 = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + assert instance1.get_diff(instance2) is True + + +def test_loopback_interface_00610(): + """ + # Summary + + Verify different ip -> False. + + ## Test + + - Two models with different ip + - get_diff returns False + + ## Classes and Methods + + - LoopbackInterfaceModel.get_diff() + """ + config1 = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config2 = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) + config2["config_data"]["network_os"]["policy"]["ip"] = "10.2.2.2/32" + instance1 = LoopbackInterfaceModel.from_config(config1) + instance2 = LoopbackInterfaceModel.from_config(config2) + assert instance1.get_diff(instance2) is False + + +def test_loopback_interface_00620(): + """ + # Summary + + Verify other with fewer fields (None excluded) -> True (subset). + + ## Test + + - other has fewer fields than self + - get_diff returns True (other is subset of self) + + ## Classes and Methods + + - LoopbackInterfaceModel.get_diff() + """ + instance_full = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + instance_minimal = LoopbackInterfaceModel(interface_name="loopback0") + assert instance_full.get_diff(instance_minimal) is True + + +# ============================================================================= +# Test: LoopbackInterfaceModel — merge +# ============================================================================= + + +def test_loopback_interface_00650(): + """ + # Summary + + Verify non-None fields from other update self. + + ## Test + + - Merge other with ip set into self without ip + - Self now has ip from other + + ## Classes and Methods + + - LoopbackInterfaceModel.merge() + """ + config_base = { + "interface_name": "loopback0", + "config_data": { + "network_os": { + "policy": { + "admin_state": True, + }, + }, + }, + } + config_other = { + "interface_name": "loopback0", + "config_data": { + "network_os": { + "policy": { + "ip": "10.1.1.1/32", + }, + }, + }, + } + instance = LoopbackInterfaceModel.from_config(config_base) + other = LoopbackInterfaceModel.from_config(config_other) + instance.merge(other) + assert instance.config_data.network_os.policy.ip == "10.1.1.1/32" + + +def test_loopback_interface_00660(): + """ + # Summary + + Verify None fields in other do not overwrite existing values. + + ## Test + + - Self has ip set, other has ip=None (config_data=None) + - After merge, self still has ip + + ## Classes and Methods + + - LoopbackInterfaceModel.merge() + """ + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + other = LoopbackInterfaceModel(interface_name="loopback0") + instance.merge(other) + assert instance.config_data.network_os.policy.ip == "10.1.1.1/32" + + +def test_loopback_interface_00670(): + """ + # Summary + + Verify mismatched types -> TypeError. + + ## Test + + - Merge with wrong type raises TypeError + + ## Classes and Methods + + - LoopbackInterfaceModel.merge() + """ + instance = LoopbackInterfaceModel(interface_name="loopback0") + with pytest.raises(TypeError, match="Cannot merge"): + instance.merge(LoopbackPolicyModel()) + + +def test_loopback_interface_00680(): + """ + # Summary + + Verify merge returns self for chaining. + + ## Test + + - merge() returns self + + ## Classes and Methods + + - LoopbackInterfaceModel.merge() + """ + instance = LoopbackInterfaceModel(interface_name="loopback0") + other = LoopbackInterfaceModel(interface_name="loopback0") + result = instance.merge(other) + assert result is instance + + +# ============================================================================= +# Test: LoopbackInterfaceModel — get_argument_spec +# ============================================================================= + + +def test_loopback_interface_00700(): + """ + # Summary + + Verify top-level keys in argument spec. + + ## Test + + - get_argument_spec() returns fabric_name, switch_ip, config, state + + ## Classes and Methods + + - LoopbackInterfaceModel.get_argument_spec() + """ + spec = LoopbackInterfaceModel.get_argument_spec() + assert "fabric_name" in spec + assert "switch_ip" in spec + assert "config" in spec + assert "state" in spec + + +def test_loopback_interface_00710(): + """ + # Summary + + Verify config is type="list", elements="dict", has nested options. + + ## Test + + - config spec has correct type, elements, and options + + ## Classes and Methods + + - LoopbackInterfaceModel.get_argument_spec() + """ + spec = LoopbackInterfaceModel.get_argument_spec() + config_spec = spec["config"] + assert config_spec["type"] == "list" + assert config_spec["elements"] == "dict" + assert "options" in config_spec + assert "interface_name" in config_spec["options"] + + +def test_loopback_interface_00720(): + """ + # Summary + + Verify state choices and default. + + ## Test + + - state choices: ["merged", "replaced", "overridden", "deleted"] + - state default: "merged" + + ## Classes and Methods + + - LoopbackInterfaceModel.get_argument_spec() + """ + spec = LoopbackInterfaceModel.get_argument_spec() + state_spec = spec["state"] + assert state_spec["choices"] == ["merged", "replaced", "overridden", "deleted"] + assert state_spec["default"] == "merged" + + +def test_loopback_interface_00730(): + """ + # Summary + + Verify policy_type choices from mapping and default. + + ## Test + + - policy_type choices: ["loopback", "ipfm_loopback", "user_defined"] + - policy_type default: "loopback" + + ## Classes and Methods + + - LoopbackInterfaceModel.get_argument_spec() + """ + spec = LoopbackInterfaceModel.get_argument_spec() + policy_spec = spec["config"]["options"]["config_data"]["options"]["network_os"]["options"]["policy"]["options"]["policy_type"] + assert policy_spec["choices"] == ["loopback", "ipfm_loopback", "user_defined"] + assert policy_spec["default"] == "loopback" From 2bcc08373af44ea679e55cd9dafbf771fee17ff7 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 10:15:50 -1000 Subject: [PATCH 04/17] Fix state:overridden attempting to delete system-provisioned loopback interfaces query_all() in LoopbackInterfaceOrchestrator previously filtered interfaces only by interfaceType == "loopback", which included system-provisioned loopbacks (Loopback0 routing, Loopback1 VTEP) that ND creates during initial switch role configuration. These have policyType "underlayLoopback" and are not deletable. When state:overridden was used, the module saw these system loopbacks in the before collection, identified them as "not in proposed", and queued them for removal. ND silently rejected the delete requests (the interfaces have deletable: false), but the module incorrectly reported changed=true and made unnecessary API calls. The fix adds a second filter stage to query_all() that checks each loopback's configData.networkOS.policy.policyType against the set of policy types this module manages (loopback, ipfmLoopback, userDefined). System loopbacks with policyType "underlayLoopback" are now excluded from the before collection. This ensures: - Accurate changed flag (no false positives from undeletable interfaces) - No unnecessary remove API calls for system-managed interfaces - The module correctly understands its own management scope Co-Authored-By: Claude Opus 4.6 --- .../orchestrators/loopback_interface.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/module_utils/orchestrators/loopback_interface.py b/plugins/module_utils/orchestrators/loopback_interface.py index 4e5dd656..cf96218e 100644 --- a/plugins/module_utils/orchestrators/loopback_interface.py +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -29,7 +29,10 @@ ) 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.loopback_interface import LoopbackInterfaceModel +from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.loopback_interface import ( + LOOPBACK_POLICY_TYPE_MAPPING, + LoopbackInterfaceModel, +) from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType @@ -347,7 +350,10 @@ def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> R """ # Summary - Validate the fabric context and query all interfaces on the switch, filtering for loopback type only. + Validate the fabric context and query all interfaces on the switch, filtering for user-managed loopback interfaces only. + + System-provisioned loopbacks (e.g. Loopback0 routing, Loopback1 VTEP with `policyType: "underlayLoopback"`) are excluded because they are managed by ND + during initial switch role configuration and cannot be deleted or modified by this module. Runs `validate` on first call to ensure the fabric exists, is modifiable, and the target switch is reachable before returning any data. @@ -361,6 +367,7 @@ def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> R - If no switch matches the given `switch_ip` in the fabric. - If the query API request fails. """ + managed_policy_types = set(LOOPBACK_POLICY_TYPE_MAPPING.data.values()) try: self.validate_prerequisites() api_endpoint = self._configure_endpoint(self.query_all_endpoint()) @@ -368,6 +375,7 @@ def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> R if not result: return [] interfaces = result.get("interfaces", []) or [] - return [iface for iface in interfaces if iface.get("interfaceType") == "loopback"] + loopbacks = [iface for iface in interfaces if iface.get("interfaceType") == "loopback"] + return [lb for lb in loopbacks if lb.get("configData", {}).get("networkOS", {}).get("policy", {}).get("policyType") in managed_policy_types] except Exception as e: raise RuntimeError(f"Query all failed: {e}") from e From 16d36cc899fa95953cba0829b564b384b2ca00c1 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 10:18:24 -1000 Subject: [PATCH 05/17] Add integration tests for nd_interface_loopback module Integration test suite covering all four states (merged, replaced, overridden, deleted) for the nd_interface_loopback module. Tests are organized into separate task files per state for easier debugging and selective execution. Test coverage includes: - Merged: create single/multiple, idempotency, update, deploy=false - Replaced: full replace, idempotency, multi-interface replace - Overridden: reduce to single, idempotency, system loopback filtering, swap interfaces in one pass - Deleted: single/multi delete, idempotency, non-existent interface The overridden tests include a dedicated system loopback filtering test that verifies Loopback0/Loopback1 (policyType "underlayLoopback") do not appear in the before collection and are not targeted for deletion. Co-Authored-By: Claude Opus 4.6 --- .../nd_interface_loopback/tasks/deleted.yaml | 119 +++++++++++ .../nd_interface_loopback/tasks/main.yaml | 27 +++ .../nd_interface_loopback/tasks/merged.yaml | 198 ++++++++++++++++++ .../tasks/overridden.yaml | 146 +++++++++++++ .../nd_interface_loopback/tasks/replaced.yaml | 139 ++++++++++++ .../nd_interface_loopback/vars/main.yaml | 65 ++++++ 6 files changed, 694 insertions(+) create mode 100644 tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml create mode 100644 tests/integration/targets/nd_interface_loopback/tasks/main.yaml create mode 100644 tests/integration/targets/nd_interface_loopback/tasks/merged.yaml create mode 100644 tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml create mode 100644 tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml create mode 100644 tests/integration/targets/nd_interface_loopback/vars/main.yaml diff --git a/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml new file mode 100644 index 00000000..89d3c5df --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml @@ -0,0 +1,119 @@ +--- +# Deleted state tests for nd_interface_loopback +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point loopback101 and loopback102 exist from overridden tests. + +# --- DELETED: SINGLE INTERFACE --- + +- name: "DELETED: Delete loopback101 (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback101 + state: deleted + check_mode: true + register: cm_deleted_101 + +- name: "DELETED: Delete loopback101 (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback101 + state: deleted + register: nm_deleted_101 + +- name: "DELETED: Verify loopback101 was deleted" + ansible.builtin.assert: + that: + - cm_deleted_101 is changed + - nm_deleted_101 is changed + - nm_deleted_101.after | selectattr('interface_name', 'equalto', 'loopback101') | list | length == 0 + +# --- DELETED: IDEMPOTENCY --- + +- name: "DELETED IDEMPOTENT: Delete loopback101 again" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback101 + state: deleted + register: nm_deleted_101_idem + +- name: "DELETED IDEMPOTENT: Verify no change when already absent" + ansible.builtin.assert: + that: + - nm_deleted_101_idem is not changed + +# --- DELETED: MULTIPLE INTERFACES --- + +# First recreate loopback100 and loopback101 so we can test multi-delete +- name: "SETUP: Recreate loopback100 and loopback101 for multi-delete test" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100 }}" + - "{{ loopback_101 }}" + state: merged + +- name: "DELETED MULTI: Delete loopback100, loopback101, and loopback102 (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + - interface_name: loopback101 + - interface_name: loopback102 + state: deleted + check_mode: true + register: cm_deleted_multi + +- name: "DELETED MULTI: Delete loopback100, loopback101, and loopback102 (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + - interface_name: loopback101 + - interface_name: loopback102 + state: deleted + register: nm_deleted_multi + +- name: "DELETED MULTI: Verify all test loopbacks were deleted" + ansible.builtin.assert: + that: + - cm_deleted_multi is changed + - nm_deleted_multi is changed + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'loopback100') | list | length == 0 + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'loopback101') | list | length == 0 + - nm_deleted_multi.after | selectattr('interface_name', 'equalto', 'loopback102') | list | length == 0 + +# --- DELETED: NON-EXISTENT INTERFACE --- + +- name: "DELETED NON-EXISTENT: Delete interface that does not exist" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback199 + state: deleted + register: nm_deleted_nonexistent + +- name: "DELETED NON-EXISTENT: Verify no change" + ansible.builtin.assert: + that: + - nm_deleted_nonexistent is not changed diff --git a/tests/integration/targets/nd_interface_loopback/tasks/main.yaml b/tests/integration/targets/nd_interface_loopback/tasks/main.yaml new file mode 100644 index 00000000..77572069 --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/main.yaml @@ -0,0 +1,27 @@ +--- +# Test code for the nd_interface_loopback module +# Copyright: (c) 2026, Cisco Systems, Inc. + +# 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_loopback merged state tests + ansible.builtin.include_tasks: merged.yaml + +- name: Run nd_interface_loopback replaced state tests + ansible.builtin.include_tasks: replaced.yaml + +- name: Run nd_interface_loopback overridden state tests + ansible.builtin.include_tasks: overridden.yaml + +- name: Run nd_interface_loopback deleted state tests + ansible.builtin.include_tasks: deleted.yaml diff --git a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml new file mode 100644 index 00000000..f360c3be --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml @@ -0,0 +1,198 @@ +--- +# Merged state tests for nd_interface_loopback +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- CLEANUP --- + +- name: "SETUP: Remove test loopback interfaces before merged tests" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + - interface_name: loopback101 + - interface_name: loopback102 + state: deleted + tags: always + +# --- MERGED CREATE --- + +- name: "MERGED CREATE: Create loopback100 with full config (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100 }}" + state: merged + check_mode: true + register: cm_merged_create_100 + +- name: "MERGED CREATE: Create loopback100 with full config (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100 }}" + state: merged + register: nm_merged_create_100 + +- name: "MERGED CREATE: Verify loopback100 creation" + ansible.builtin.assert: + that: + - cm_merged_create_100 is changed + - nm_merged_create_100 is changed + - nm_merged_create_100.after | length >= 1 + - nm_merged_create_100.after | selectattr('interface_name', 'equalto', 'loopback100') | list | length == 1 + +- name: "MERGED CREATE: Create multiple loopbacks in a single task (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_101 }}" + - "{{ loopback_102 }}" + state: merged + check_mode: true + register: cm_merged_create_multi + +- name: "MERGED CREATE: Create multiple loopbacks in a single task (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_101 }}" + - "{{ loopback_102 }}" + state: merged + register: nm_merged_create_multi + +- name: "MERGED CREATE: Verify multiple loopback creation" + ansible.builtin.assert: + that: + - cm_merged_create_multi is changed + - nm_merged_create_multi is changed + - nm_merged_create_multi.after | selectattr('interface_name', 'equalto', 'loopback101') | list | length == 1 + - nm_merged_create_multi.after | selectattr('interface_name', 'equalto', 'loopback102') | list | length == 1 + +# --- MERGED IDEMPOTENCY --- + +- name: "MERGED IDEMPOTENT: Re-apply loopback100 creation (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100 }}" + state: merged + check_mode: true + register: cm_merged_idem_100 + +- name: "MERGED IDEMPOTENT: Re-apply loopback100 creation (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100 }}" + state: merged + register: nm_merged_idem_100 + +- name: "MERGED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - cm_merged_idem_100 is not changed + - nm_merged_idem_100 is not changed + +# --- MERGED UPDATE --- + +- name: "MERGED UPDATE: Update loopback100 IP and description (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100_updated }}" + state: merged + check_mode: true + register: cm_merged_update_100 + +- name: "MERGED UPDATE: Update loopback100 IP and description (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100_updated }}" + state: merged + register: nm_merged_update_100 + +- name: "MERGED UPDATE: Verify loopback100 was updated" + ansible.builtin.assert: + that: + - cm_merged_update_100 is changed + - nm_merged_update_100 is changed + +- name: "MERGED UPDATE: Verify updated values in after state" + vars: + lb100_after: "{{ nm_merged_update_100.after | selectattr('interface_name', 'equalto', 'loopback100') | first }}" + ansible.builtin.assert: + that: + - lb100_after.config_data.network_os.policy.ip == "10.100.100.2" + - lb100_after.config_data.network_os.policy.description == "Updated loopback100 description" + - lb100_after.config_data.network_os.policy.route_map_tag == 54321 + +- name: "MERGED UPDATE: Re-apply update for idempotency" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_100_updated }}" + state: merged + register: nm_merged_update_100_idem + +- name: "MERGED UPDATE: Verify idempotency after update" + ansible.builtin.assert: + that: + - nm_merged_update_100_idem is not changed + +# --- MERGED WITH deploy: false --- + +- name: "MERGED NO-DEPLOY: Create with deploy disabled" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback103 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.103.1" + description: "No-deploy test loopback103" + policy_type: loopback + deploy: false + state: merged + register: 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 loopback103" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback103 + state: deleted diff --git a/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml new file mode 100644 index 00000000..444bf815 --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml @@ -0,0 +1,146 @@ +--- +# Overridden state tests for nd_interface_loopback +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point loopback100, loopback101, loopback102 exist from prior tests. +# Override will reduce the set to only what is specified in config. + +# --- OVERRIDDEN: REDUCE TO SINGLE INTERFACE --- + +- name: "OVERRIDDEN: Override to only loopback100 (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.5" + description: "Overridden loopback100" + policy_type: loopback + state: overridden + check_mode: true + register: cm_overridden_single + +- name: "OVERRIDDEN: Override to only loopback100 (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.5" + description: "Overridden loopback100" + policy_type: loopback + state: overridden + register: nm_overridden_single + +- name: "OVERRIDDEN: Verify override removed extra interfaces" + ansible.builtin.assert: + that: + - cm_overridden_single is changed + - nm_overridden_single is changed + # After should contain only the overridden interface (loopback100). + # loopback101 and loopback102 should have been removed. + - nm_overridden_single.after | selectattr('interface_name', 'equalto', 'loopback100') | list | length == 1 + - nm_overridden_single.after | selectattr('interface_name', 'equalto', 'loopback101') | list | length == 0 + - nm_overridden_single.after | selectattr('interface_name', 'equalto', 'loopback102') | list | length == 0 + +- name: "OVERRIDDEN: Verify overridden values" + vars: + lb100_after: "{{ nm_overridden_single.after | selectattr('interface_name', 'equalto', 'loopback100') | first }}" + ansible.builtin.assert: + that: + - lb100_after.config_data.network_os.policy.ip == "10.100.100.5" + - lb100_after.config_data.network_os.policy.description == "Overridden loopback100" + +# --- OVERRIDDEN: IDEMPOTENCY --- + +- name: "OVERRIDDEN IDEMPOTENT: Re-apply same overridden config" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.5" + description: "Overridden loopback100" + policy_type: loopback + state: overridden + register: nm_overridden_idem + +- name: "OVERRIDDEN IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_overridden_idem is not changed + +# --- OVERRIDDEN: SYSTEM LOOPBACK FILTERING --- +# Verifies that system-provisioned loopbacks (e.g. Loopback0, Loopback1 with +# policyType "underlayLoopback") 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 system loopbacks, +# which ND silently rejects, and the changed flag would be incorrectly true. + +- name: "OVERRIDDEN SYSTEM-FILTER: Override with loopback100 only, verify system loopbacks are unaffected" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.5" + description: "Overridden loopback100" + policy_type: loopback + state: overridden + register: nm_overridden_system_filter + +- name: "OVERRIDDEN SYSTEM-FILTER: Verify idempotent (system loopbacks not in diff)" + ansible.builtin.assert: + that: + # If system loopbacks 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_system_filter is not changed + # Verify no system loopback interfaces appear in the before collection. + # System loopbacks use policyType "underlayLoopback" which should be filtered out. + - nm_overridden_system_filter.before | selectattr('interface_name', 'equalto', 'loopback0') | list | length == 0 + - nm_overridden_system_filter.before | selectattr('interface_name', 'equalto', 'loopback1') | list | length == 0 + +# --- OVERRIDDEN: REPLACE AND ADD IN ONE PASS --- + +- name: "OVERRIDDEN SWAP: Override to loopback101 and loopback102, removing loopback100" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - "{{ loopback_101 }}" + - "{{ loopback_102 }}" + state: overridden + register: nm_overridden_swap + +- name: "OVERRIDDEN SWAP: Verify loopback100 removed and loopback101/102 present" + ansible.builtin.assert: + that: + - nm_overridden_swap is changed + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'loopback100') | list | length == 0 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'loopback101') | list | length == 1 + - nm_overridden_swap.after | selectattr('interface_name', 'equalto', 'loopback102') | list | length == 1 diff --git a/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml b/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml new file mode 100644 index 00000000..3c252c77 --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml @@ -0,0 +1,139 @@ +--- +# Replaced state tests for nd_interface_loopback +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point loopback100 (updated), loopback101, loopback102 exist from merged tests. + +# --- REPLACED: FULL REPLACE --- + +- name: "REPLACED: Replace loopback100 config entirely (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.3" + description: "Replaced loopback100" + policy_type: loopback + state: replaced + check_mode: true + register: cm_replaced_100 + +- name: "REPLACED: Replace loopback100 config entirely (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.3" + description: "Replaced loopback100" + policy_type: loopback + state: replaced + register: nm_replaced_100 + +- name: "REPLACED: Verify loopback100 was replaced" + ansible.builtin.assert: + that: + - cm_replaced_100 is changed + - nm_replaced_100 is changed + +- name: "REPLACED: Verify replaced values" + vars: + lb100_after: "{{ nm_replaced_100.after | selectattr('interface_name', 'equalto', 'loopback100') | first }}" + ansible.builtin.assert: + that: + - lb100_after.config_data.network_os.policy.ip == "10.100.100.3" + - lb100_after.config_data.network_os.policy.description == "Replaced loopback100" + +# --- REPLACED: IDEMPOTENCY --- + +- name: "REPLACED IDEMPOTENT: Re-apply same replaced config" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.3" + description: "Replaced loopback100" + policy_type: loopback + state: replaced + register: nm_replaced_100_idem + +- name: "REPLACED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_replaced_100_idem is not changed + +# --- REPLACED: MULTIPLE INTERFACES --- + +- name: "REPLACED MULTI: Replace loopback100 and loopback101 (check mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.4" + description: "Replaced again loopback100" + policy_type: loopback + - "{{ loopback_101_updated }}" + state: replaced + check_mode: true + register: cm_replaced_multi + +- name: "REPLACED MULTI: Replace loopback100 and loopback101 (normal mode)" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + switch_ip: "{{ test_switch_ip }}" + config: + - interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.4" + description: "Replaced again loopback100" + policy_type: loopback + - "{{ loopback_101_updated }}" + state: replaced + register: nm_replaced_multi + +- name: "REPLACED MULTI: Verify both interfaces were replaced" + ansible.builtin.assert: + that: + - cm_replaced_multi is changed + - nm_replaced_multi is changed + +- name: "REPLACED MULTI: Verify replaced values for both interfaces" + vars: + lb100_after: "{{ nm_replaced_multi.after | selectattr('interface_name', 'equalto', 'loopback100') | first }}" + lb101_after: "{{ nm_replaced_multi.after | selectattr('interface_name', 'equalto', 'loopback101') | first }}" + ansible.builtin.assert: + that: + - lb100_after.config_data.network_os.policy.ip == "10.100.100.4" + - lb100_after.config_data.network_os.policy.description == "Replaced again loopback100" + - lb101_after.config_data.network_os.policy.ip == "10.100.101.2" + - lb101_after.config_data.network_os.policy.admin_state == false diff --git a/tests/integration/targets/nd_interface_loopback/vars/main.yaml b/tests/integration/targets/nd_interface_loopback/vars/main.yaml new file mode 100644 index 00000000..90784a59 --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/vars/main.yaml @@ -0,0 +1,65 @@ +--- +# Variables for nd_interface_loopback 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') }}" + +# Loopback interface configs used across tests. +# Loopback IDs 100-109 are reserved for these integration tests. +loopback_100: + interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.1" + description: "Ansible integration test loopback100" + vrf: default + route_map_tag: 12345 + policy_type: loopback + +loopback_101: + interface_name: loopback101 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.101.1" + description: "Ansible integration test loopback101" + policy_type: loopback + +loopback_102: + interface_name: loopback102 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.102.1" + description: "Ansible integration test loopback102" + policy_type: loopback + +# Updated versions for merge/replace tests +loopback_100_updated: + interface_name: loopback100 + config_data: + network_os: + policy: + admin_state: true + ip: "10.100.100.2" + description: "Updated loopback100 description" + vrf: default + route_map_tag: 54321 + policy_type: loopback + +loopback_101_updated: + interface_name: loopback101 + config_data: + network_os: + policy: + admin_state: false + ip: "10.100.101.2" + description: "Updated loopback101 description" + policy_type: loopback From c4de4010edffbc0ab7f56da95eb9571401cd10d2 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 10:19:50 -1000 Subject: [PATCH 06/17] Add dynamic inventory generator for integration tests Python script that serves dual purpose: - Generator mode (no args): reads ND connection and testbed topology from environment variables and writes a static inventory.networking file for use with ansible-test network-integration - Dynamic inventory mode (--list): outputs JSON inventory to stdout for direct use with ansible-playbook -i inventory.py Required env vars: ND_IP4, ND_USERNAME, ND_PASSWORD, ND_DOMAIN Optional env vars: ND_FABRIC_NAME, ND_SWITCH_IP (with defaults) The generator approach is needed because ansible-test sanitizes the subprocess environment, stripping arbitrary env vars at runtime. Running the generator beforehand bakes values into a static INI file that ansible-test picks up automatically. Co-Authored-By: Claude Opus 4.6 --- tests/integration/inventory.py | 230 +++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100755 tests/integration/inventory.py diff --git a/tests/integration/inventory.py b/tests/integration/inventory.py new file mode 100755 index 00000000..d97672b2 --- /dev/null +++ b/tests/integration/inventory.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +# Summary + +Generate `inventory.networking` for cisco.nd integration tests from environment variables. + +`ansible-test network-integration` runs in a sandboxed environment that strips env vars, so a +dynamic inventory script cannot read them at runtime. Instead, run this generator *before* +`ansible-test` to produce a static `inventory.networking` file that `ansible-test` picks up +automatically. + +The generated `inventory.networking` contains credentials and MUST NOT be committed. +It is already covered by `.gitignore`. + +## Required Environment Variables + +```bash +export ND_IP4=10.1.1.1 # ND controller management IP +export ND_USERNAME=admin # ND login username +export ND_PASSWORD=secret # ND login password +export ND_DOMAIN=local # ND login domain (local, radius, etc.) +``` + +## Optional Environment Variables + +These provide testbed-specific topology details. Each integration target can also +define its own defaults in `vars/main.yaml`; these env vars take precedence. + +```bash +export ND_FABRIC_NAME=my_fabric # Fabric name for interface tests +export ND_SWITCH_IP=192.168.1.1 # Switch management IP within the fabric +``` + +## macOS: Python Fork Safety + +On macOS, `ansible-test` may crash with `crashed on child side of fork pre-exec` due to the +ObjC runtime's fork-safety check in Python 3.11. Set the following before running `ansible-test`: + +```bash +export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES +``` + +This is not needed on Linux or with Python 3.12+. + +## Usage + +```bash +# 1. Generate inventory.networking from your environment +python tests/integration/inventory.py + +# 2. Run tests via ansible-test +ansible-test network-integration nd_interface_loopback + +# Or run directly with ansible-playbook (no generation step needed) +ansible-playbook \ + -i tests/integration/inventory.py \ + tests/integration/targets/nd_interface_loopback/tasks/main.yaml +``` +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from os import environ +from pathlib import Path + + +def _required(var_name: str, description: str) -> str: + """ + # Summary + + Return the value of a required environment variable, or exit with an error message. + + ## Raises + + None + """ + value = environ.get(var_name) + if not value: + print(f"ERROR: {var_name} must be set ({description})", file=sys.stderr) + sys.exit(1) + return value + + +@dataclass +class NdConnection: + """ + # Summary + + ND controller connection parameters sourced from environment variables. + + ## Raises + + None + """ + + host: str = _required("ND_IP4", "ND controller management IP") + username: str = _required("ND_USERNAME", "ND login username") + password: str = _required("ND_PASSWORD", "ND login password") + domain: str = _required("ND_DOMAIN", "ND login domain e.g. 'local', 'radius'") + use_ssl: bool = True + validate_certs: bool = False + + +@dataclass +class TestbedTopology: + """ + # Summary + + Testbed topology variables. Defaults are provided for convenience; override via + environment variables to match your testbed. Integration targets can further + override these in their own `vars/main.yaml` using the Jinja default filter. + + ## Raises + + None + """ + + fabric_name: str = environ.get("ND_FABRIC_NAME", "test_fabric") + switch_ip: str = environ.get("ND_SWITCH_IP", "192.168.1.1") + + +def build_inventory() -> dict: + """ + # Summary + + Build and return the Ansible dynamic inventory dict. + + ## Raises + + None + """ + conn = NdConnection() + topo = TestbedTopology() + + return { + "_meta": {"hostvars": {}}, + "all": { + "children": ["nd"], + }, + "nd": { + "hosts": [conn.host], + "vars": { + "ansible_connection": "ansible.netcommon.httpapi", + "ansible_network_os": "cisco.nd.nd", + "ansible_httpapi_login_domain": conn.domain, + "ansible_httpapi_use_ssl": conn.use_ssl, + "ansible_httpapi_validate_certs": conn.validate_certs, + "ansible_user": conn.username, + "ansible_password": conn.password, + "ansible_python_interpreter": "python", + # Testbed topology — targets reference these via Jinja defaults + # e.g. {{ nd_test_fabric_name | default('test_fabric') }} + "nd_test_fabric_name": topo.fabric_name, + "nd_test_switch_ip": topo.switch_ip, + }, + }, + } + + +def build_ini_inventory() -> str: + """ + # Summary + + Build an INI-format inventory string suitable for `ansible-test network-integration`. + + ## Raises + + None + """ + conn = NdConnection() + topo = TestbedTopology() + + lines = [ + "[nd]", + f"nd ansible_host={conn.host}", + "", + "[nd:vars]", + "ansible_connection=ansible.netcommon.httpapi", + "ansible_network_os=cisco.nd.nd", + f"ansible_httpapi_login_domain={conn.domain}", + f"ansible_httpapi_use_ssl={conn.use_ssl}", + f"ansible_httpapi_validate_certs={conn.validate_certs}", + f"ansible_user={conn.username}", + f"ansible_password={conn.password}", + "ansible_python_interpreter=python", + f"nd_test_fabric_name={topo.fabric_name}", + f"nd_test_switch_ip={topo.switch_ip}", + ] + return "\n".join(lines) + "\n" + + +def main() -> None: + """ + # Summary + + When called with `--list`, output JSON inventory to stdout (dynamic inventory mode for + `ansible-playbook -i inventory.py`). When called with no arguments, generate a static + `inventory.networking` file in the same directory for use with `ansible-test`. + + ## Raises + + None + """ + if "--list" in sys.argv: + print(json.dumps(build_inventory(), indent=4, sort_keys=True)) + return + + if "--host" in sys.argv: + print(json.dumps({})) + return + + # Generator mode: write static inventory.networking + inventory_dir = Path(__file__).resolve().parent + inventory_path = inventory_dir / "inventory.networking" + + ini_content = build_ini_inventory() + inventory_path.write_text(ini_content) + print(f"Generated {inventory_path}") + + +if __name__ == "__main__": + main() From a8de39a8bdb9850ae9106b4c96fe7eb09aa33f30 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 10:22:11 -1000 Subject: [PATCH 07/17] Add unit tests for manage_interfaces endpoint classes Unit tests for the EpManageInterfaces endpoint classes used by nd_interface_loopback: Get, ListGet, Post, Put, Deploy, and Remove. Covers path construction, HTTP verb assignment, and fabric/switch identifier injection. Co-Authored-By: Claude Opus 4.6 --- ...test_endpoints_api_v1_manage_interfaces.py | 713 ++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_interfaces.py diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_interfaces.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_interfaces.py new file mode 100644 index 00000000..aa3e59af --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_interfaces.py @@ -0,0 +1,713 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage_interfaces.py + +Tests the ND Manage Interfaces endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from contextlib import contextmanager + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_interfaces import ( + EpManageInterfacesDeploy, + EpManageInterfacesGet, + EpManageInterfacesListGet, + EpManageInterfacesPost, + EpManageInterfacesPut, + EpManageInterfacesRemove, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@contextmanager +def does_not_raise(): + """A context manager that does not raise an exception.""" + yield + + +# ============================================================================= +# Test: EpManageInterfacesGet +# ============================================================================= + + +def test_ep_manage_interfaces_00010(): + """ + # Summary + + Verify EpManageInterfacesGet basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + - All mixin params default to None + + ## Classes and Methods + + - EpManageInterfacesGet.__init__() + - EpManageInterfacesGet.verb + - EpManageInterfacesGet.class_name + """ + with does_not_raise(): + instance = EpManageInterfacesGet() + assert instance.class_name == "EpManageInterfacesGet" + assert instance.verb == HttpVerbEnum.GET + assert instance.fabric_name is None + assert instance.switch_sn is None + assert instance.interface_name is None + + +def test_ep_manage_interfaces_00020(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name is not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesGet.path + """ + instance = EpManageInterfacesGet() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_manage_interfaces_00030(): + """ + # Summary + + Verify path raises ValueError when switch_sn is None (fabric_name set). + + ## Test + + - fabric_name is set, switch_sn is not + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesGet.path + """ + instance = EpManageInterfacesGet() + instance.fabric_name = "fab1" + with pytest.raises(ValueError, match="switch_sn must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_manage_interfaces_00040(): + """ + # Summary + + Verify path raises ValueError when interface_name is None (fabric_name + switch_sn set). + + ## Test + + - fabric_name and switch_sn are set, interface_name is not + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesGet.path + """ + instance = EpManageInterfacesGet() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + with pytest.raises(ValueError, match="interface_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_manage_interfaces_00050(): + """ + # Summary + + Verify path returns correct URL with all params set. + + ## Test + + - All params set + - path returns expected URL + + ## Classes and Methods + + - EpManageInterfacesGet.path + """ + with does_not_raise(): + instance = EpManageInterfacesGet() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + instance.interface_name = "loopback0" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN123/interfaces/loopback0" + + +def test_ep_manage_interfaces_00060(): + """ + # Summary + + Verify set_identifiers sets interface_name. + + ## Test + + - set_identifiers("loopback0") sets interface_name + + ## Classes and Methods + + - EpManageInterfacesGet.set_identifiers() + """ + with does_not_raise(): + instance = EpManageInterfacesGet() + instance.set_identifiers("loopback0") + assert instance.interface_name == "loopback0" + + +def test_ep_manage_interfaces_00070(): + """ + # Summary + + Verify set_identifiers(None) sets interface_name to None. + + ## Test + + - set_identifiers(None) sets interface_name to None + + ## Classes and Methods + + - EpManageInterfacesGet.set_identifiers() + """ + with does_not_raise(): + instance = EpManageInterfacesGet() + instance.interface_name = "loopback0" + instance.set_identifiers(None) + assert instance.interface_name is None + + +# ============================================================================= +# Test: EpManageInterfacesListGet +# ============================================================================= + + +def test_ep_manage_interfaces_00100(): + """ + # Summary + + Verify EpManageInterfacesListGet basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageInterfacesListGet.__init__() + - EpManageInterfacesListGet.verb + - EpManageInterfacesListGet.class_name + """ + with does_not_raise(): + instance = EpManageInterfacesListGet() + assert instance.class_name == "EpManageInterfacesListGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_ep_manage_interfaces_00110(): + """ + # Summary + + Verify path succeeds without interface_name (_require_interface_name=False). + + ## Test + + - fabric_name and switch_sn set, interface_name not set + - path returns URL ending at /interfaces + + ## Classes and Methods + + - EpManageInterfacesListGet.path + """ + with does_not_raise(): + instance = EpManageInterfacesListGet() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN123/interfaces" + + +def test_ep_manage_interfaces_00120(): + """ + # Summary + + Verify path appends interface_name when optionally set. + + ## Test + + - All params set including optional interface_name + - path includes interface_name segment + + ## Classes and Methods + + - EpManageInterfacesListGet.path + """ + with does_not_raise(): + instance = EpManageInterfacesListGet() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + instance.interface_name = "loopback0" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN123/interfaces/loopback0" + + +def test_ep_manage_interfaces_00130(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesListGet.path + """ + instance = EpManageInterfacesListGet() + instance.switch_sn = "SN123" + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +# ============================================================================= +# Test: EpManageInterfacesPost +# ============================================================================= + + +def test_ep_manage_interfaces_00200(): + """ + # Summary + + Verify EpManageInterfacesPost basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageInterfacesPost.__init__() + - EpManageInterfacesPost.verb + - EpManageInterfacesPost.class_name + """ + with does_not_raise(): + instance = EpManageInterfacesPost() + assert instance.class_name == "EpManageInterfacesPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_ep_manage_interfaces_00210(): + """ + # Summary + + Verify path succeeds without interface_name. + + ## Test + + - fabric_name and switch_sn set + - path returns URL ending at /interfaces + + ## Classes and Methods + + - EpManageInterfacesPost.path + """ + with does_not_raise(): + instance = EpManageInterfacesPost() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN123/interfaces" + + +def test_ep_manage_interfaces_00220(): + """ + # Summary + + Verify path raises ValueError when switch_sn is None. + + ## Test + + - fabric_name set, switch_sn not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesPost.path + """ + instance = EpManageInterfacesPost() + instance.fabric_name = "fab1" + with pytest.raises(ValueError, match="switch_sn must be set"): + result = instance.path # pylint: disable=unused-variable + + +# ============================================================================= +# Test: EpManageInterfacesPut +# ============================================================================= + + +def test_ep_manage_interfaces_00300(): + """ + # Summary + + Verify EpManageInterfacesPut basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is PUT + + ## Classes and Methods + + - EpManageInterfacesPut.__init__() + - EpManageInterfacesPut.verb + - EpManageInterfacesPut.class_name + """ + with does_not_raise(): + instance = EpManageInterfacesPut() + assert instance.class_name == "EpManageInterfacesPut" + assert instance.verb == HttpVerbEnum.PUT + + +def test_ep_manage_interfaces_00310(): + """ + # Summary + + Verify path requires interface_name — ValueError when missing. + + ## Test + + - fabric_name and switch_sn set, interface_name not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesPut.path + """ + instance = EpManageInterfacesPut() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + with pytest.raises(ValueError, match="interface_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_manage_interfaces_00320(): + """ + # Summary + + Verify path correct with all params. + + ## Test + + - All params set + - path returns expected URL + + ## Classes and Methods + + - EpManageInterfacesPut.path + """ + with does_not_raise(): + instance = EpManageInterfacesPut() + instance.fabric_name = "fab1" + instance.switch_sn = "SN123" + instance.interface_name = "loopback0" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN123/interfaces/loopback0" + + +# ============================================================================= +# Test: EpManageInterfacesDeploy +# ============================================================================= + + +def test_ep_manage_interfaces_00500(): + """ + # Summary + + Verify EpManageInterfacesDeploy basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageInterfacesDeploy.__init__() + - EpManageInterfacesDeploy.verb + - EpManageInterfacesDeploy.class_name + """ + with does_not_raise(): + instance = EpManageInterfacesDeploy() + assert instance.class_name == "EpManageInterfacesDeploy" + assert instance.verb == HttpVerbEnum.POST + + +def test_ep_manage_interfaces_00510(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesDeploy.path + """ + instance = EpManageInterfacesDeploy() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_manage_interfaces_00520(): + """ + # Summary + + Verify path returns correct deploy URL. + + ## Test + + - fabric_name set + - path returns /api/v1/manage/fabrics/fab1/interfaceActions/deploy + + ## Classes and Methods + + - EpManageInterfacesDeploy.path + """ + with does_not_raise(): + instance = EpManageInterfacesDeploy() + instance.fabric_name = "fab1" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/interfaceActions/deploy" + + +def test_ep_manage_interfaces_00530(): + """ + # Summary + + Verify Deploy does NOT have switch_sn or interface_name attributes. + + ## Test + + - EpManageInterfacesDeploy only has FabricNameMixin + - Accessing switch_sn or interface_name raises AttributeError + + ## Classes and Methods + + - EpManageInterfacesDeploy.__init__() + """ + instance = EpManageInterfacesDeploy() + assert not hasattr(instance, "switch_sn") + assert not hasattr(instance, "interface_name") + + +# ============================================================================= +# Test: EpManageInterfacesRemove +# ============================================================================= + + +def test_ep_manage_interfaces_00540(): + """ + # Summary + + Verify EpManageInterfacesRemove basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageInterfacesRemove.__init__() + - EpManageInterfacesRemove.verb + - EpManageInterfacesRemove.class_name + """ + with does_not_raise(): + instance = EpManageInterfacesRemove() + assert instance.class_name == "EpManageInterfacesRemove" + assert instance.verb == HttpVerbEnum.POST + + +def test_ep_manage_interfaces_00550(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpManageInterfacesRemove.path + """ + instance = EpManageInterfacesRemove() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_manage_interfaces_00560(): + """ + # Summary + + Verify path returns correct remove URL. + + ## Test + + - fabric_name set + - path returns /api/v1/manage/fabrics/fab1/interfaceActions/remove + + ## Classes and Methods + + - EpManageInterfacesRemove.path + """ + with does_not_raise(): + instance = EpManageInterfacesRemove() + instance.fabric_name = "fab1" + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/interfaceActions/remove" + + +def test_ep_manage_interfaces_00570(): + """ + # Summary + + Verify Remove does NOT have switch_sn or interface_name attributes. + + ## Test + + - EpManageInterfacesRemove only has FabricNameMixin + - Accessing switch_sn or interface_name raises AttributeError + + ## Classes and Methods + + - EpManageInterfacesRemove.__init__() + """ + instance = EpManageInterfacesRemove() + assert not hasattr(instance, "switch_sn") + assert not hasattr(instance, "interface_name") + + +# ============================================================================= +# Test: Cross-class +# ============================================================================= + + +def test_ep_manage_interfaces_00600(): + """ + # Summary + + Verify Get/Put produce same path for identical params; different verbs. + + ## Test + + - Both classes with same params produce identical path + - Each has a distinct verb + + ## Classes and Methods + + - EpManageInterfacesGet.path + - EpManageInterfacesPut.path + """ + params = {"fabric_name": "fab1", "switch_sn": "SN123", "interface_name": "loopback0"} + expected_path = "/api/v1/manage/fabrics/fab1/switches/SN123/interfaces/loopback0" + + with does_not_raise(): + get_ep = EpManageInterfacesGet(**params) + put_ep = EpManageInterfacesPut(**params) + + assert get_ep.path == expected_path + assert put_ep.path == expected_path + + assert get_ep.verb == HttpVerbEnum.GET + assert put_ep.verb == HttpVerbEnum.PUT + + +def test_ep_manage_interfaces_00610(): + """ + # Summary + + Verify fabric_name="" raises ValueError (Pydantic min_length=1). + + ## Test + + - Setting fabric_name to empty string raises ValueError + + ## Classes and Methods + + - EpManageInterfacesGet.__init__() + """ + with pytest.raises(ValueError): + EpManageInterfacesGet(fabric_name="") + + +def test_ep_manage_interfaces_00620(): + """ + # Summary + + Verify switch_sn="" raises ValueError (Pydantic min_length=1). + + ## Test + + - Setting switch_sn to empty string raises ValueError + + ## Classes and Methods + + - EpManageInterfacesGet.__init__() + """ + with pytest.raises(ValueError): + EpManageInterfacesGet(switch_sn="") + + +def test_ep_manage_interfaces_00630(): + """ + # Summary + + Verify interface_name="" raises ValueError (Pydantic min_length=1). + + ## Test + + - Setting interface_name to empty string raises ValueError + + ## Classes and Methods + + - EpManageInterfacesGet.__init__() + """ + with pytest.raises(ValueError): + EpManageInterfacesGet(interface_name="") From bc2a3d80d03f4ffad2e3f1b6b360822e93790e9c Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 10:39:26 -1000 Subject: [PATCH 08/17] Fix black formatting in fabric_context.py and test_loopback_interface.py Co-Authored-By: Claude Opus 4.6 --- plugins/module_utils/fabric_context.py | 8 ++------ tests/unit/module_utils/models/test_loopback_interface.py | 1 + 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/plugins/module_utils/fabric_context.py b/plugins/module_utils/fabric_context.py index 4b239772..75e3a826 100644 --- a/plugins/module_utils/fabric_context.py +++ b/plugins/module_utils/fabric_context.py @@ -208,10 +208,7 @@ def validate_for_mutation(self) -> None: - If the fabric is in a read-only state. """ if not self.fabric_exists(): - raise RuntimeError( - f"Fabric '{self._fabric_name}' not found. " - f"Verify the fabric name and ensure you are targeting the correct ND node." - ) + raise RuntimeError(f"Fabric '{self._fabric_name}' not found. " f"Verify the fabric name and ensure you are targeting the correct ND node.") if not self.fabric_is_local(): raise RuntimeError( f"Fabric '{self._fabric_name}' is not local to this Nexus Dashboard node. " @@ -220,6 +217,5 @@ def validate_for_mutation(self) -> None: ) if self.fabric_is_read_only(): raise RuntimeError( - f"Fabric '{self._fabric_name}' is in a read-only state and cannot be modified. " - f"Check that deployment-freeze mode is not enabled." + f"Fabric '{self._fabric_name}' is in a read-only state and cannot be modified. " f"Check that deployment-freeze mode is not enabled." ) diff --git a/tests/unit/module_utils/models/test_loopback_interface.py b/tests/unit/module_utils/models/test_loopback_interface.py index 3faeaa4c..7a6755ed 100644 --- a/tests/unit/module_utils/models/test_loopback_interface.py +++ b/tests/unit/module_utils/models/test_loopback_interface.py @@ -35,6 +35,7 @@ def does_not_raise(): """A context manager that does not raise an exception.""" yield + # ============================================================================= # Test data constants # ============================================================================= From 4213db246602438184ca60cdae0da117a3e17298 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 11:02:30 -1000 Subject: [PATCH 09/17] Fix ansible-test sanity failures for shebang and validate-modules - Remove shebang from tests/integration/inventory.py (non-module files must not have a shebang line per ansible-test sanity rules) - Fix author format in nd_interface_loopback.py DOCUMENTATION block from 'Cisco Systems, Inc.' to 'Allen Robel (@allenrobel)' to match the required 'Name (@github_handle)' format Co-Authored-By: Claude Opus 4.6 --- plugins/modules/nd_interface_loopback.py | 2 +- tests/integration/inventory.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py index c57da904..ce79a02f 100644 --- a/plugins/modules/nd_interface_loopback.py +++ b/plugins/modules/nd_interface_loopback.py @@ -15,7 +15,7 @@ - Manage loopback interfaces on Cisco Nexus Dashboard. - It supports creating, updating, querying, and deleting loopback interfaces on switches within a fabric. author: -- Cisco Systems, Inc. +- Allen Robel (@allenrobel) options: fabric_name: description: diff --git a/tests/integration/inventory.py b/tests/integration/inventory.py index d97672b2..ff7c3f6e 100755 --- a/tests/integration/inventory.py +++ b/tests/integration/inventory.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # Copyright: (c) 2026, Cisco Systems, Inc. From 6a663c44054ab1e10b91bca94f3563360f1e4859 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 11:05:26 -1000 Subject: [PATCH 10/17] Fix black formatting in inventory.py (remove leading blank line) Co-Authored-By: Claude Opus 4.6 --- tests/integration/inventory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/inventory.py b/tests/integration/inventory.py index ff7c3f6e..d0aed93d 100755 --- a/tests/integration/inventory.py +++ b/tests/integration/inventory.py @@ -1,4 +1,3 @@ - # Copyright: (c) 2026, Cisco Systems, Inc. # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) From 141cb2cb00aeebfda02d3995b9c03a86680f6629 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 11:36:18 -1000 Subject: [PATCH 11/17] Remove executable bit from inventory.py to fix shebang sanity test Co-Authored-By: Claude Opus 4.6 --- tests/integration/inventory.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 tests/integration/inventory.py diff --git a/tests/integration/inventory.py b/tests/integration/inventory.py old mode 100755 new mode 100644 From 1b6de6b728a645bc80d549b1921fe074403f6dd8 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 3 Apr 2026 12:54:02 -1000 Subject: [PATCH 12/17] Fix copyright formatting, remove coding: utf-8 1. Ansible sanity requires author to be Name (@github_handle) Changed all files in this PR to: 2. Removed the following legacy Python 2.x remnant: --- plugins/module_utils/endpoints/v1/manage/manage_interfaces.py | 4 +--- plugins/module_utils/endpoints/v1/manage/manage_switches.py | 4 +--- plugins/module_utils/fabric_context.py | 4 +--- plugins/module_utils/models/interfaces/loopback_interface.py | 4 +--- plugins/module_utils/orchestrators/loopback_interface.py | 2 +- plugins/modules/nd_interface_loopback.py | 2 +- tests/integration/inventory.py | 2 +- .../targets/nd_interface_loopback/tasks/deleted.yaml | 2 +- .../integration/targets/nd_interface_loopback/tasks/main.yaml | 2 +- .../targets/nd_interface_loopback/tasks/merged.yaml | 2 +- .../targets/nd_interface_loopback/tasks/overridden.yaml | 2 +- .../targets/nd_interface_loopback/tasks/replaced.yaml | 2 +- 12 files changed, 12 insertions(+), 20 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py index 771acc78..37425f4c 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ diff --git a/plugins/module_utils/endpoints/v1/manage/manage_switches.py b/plugins/module_utils/endpoints/v1/manage/manage_switches.py index 75e9f339..6b5890a1 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_switches.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_switches.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ diff --git a/plugins/module_utils/fabric_context.py b/plugins/module_utils/fabric_context.py index 75e3a826..5f817008 100644 --- a/plugins/module_utils/fabric_context.py +++ b/plugins/module_utils/fabric_context.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/models/interfaces/loopback_interface.py b/plugins/module_utils/models/interfaces/loopback_interface.py index 8f470620..28cebb26 100644 --- a/plugins/module_utils/models/interfaces/loopback_interface.py +++ b/plugins/module_utils/models/interfaces/loopback_interface.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/orchestrators/loopback_interface.py b/plugins/module_utils/orchestrators/loopback_interface.py index cf96218e..22a5b2ed 100644 --- a/plugins/module_utils/orchestrators/loopback_interface.py +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -1,4 +1,4 @@ -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py index ce79a02f..af812244 100644 --- a/plugins/modules/nd_interface_loopback.py +++ b/plugins/modules/nd_interface_loopback.py @@ -1,6 +1,6 @@ #!/usr/bin/python -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/inventory.py b/tests/integration/inventory.py index d0aed93d..ba90171c 100644 --- a/tests/integration/inventory.py +++ b/tests/integration/inventory.py @@ -1,4 +1,4 @@ -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml index 89d3c5df..8eb73cfa 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml @@ -1,6 +1,6 @@ --- # Deleted state tests for nd_interface_loopback -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/targets/nd_interface_loopback/tasks/main.yaml b/tests/integration/targets/nd_interface_loopback/tasks/main.yaml index 77572069..59a17cb0 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/main.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/main.yaml @@ -1,6 +1,6 @@ --- # Test code for the nd_interface_loopback module -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml index f360c3be..794f8f06 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml @@ -1,6 +1,6 @@ --- # Merged state tests for nd_interface_loopback -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml index 444bf815..24a9775c 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml @@ -1,6 +1,6 @@ --- # Overridden state tests for nd_interface_loopback -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml b/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml index 3c252c77..2120a379 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml @@ -1,6 +1,6 @@ --- # Replaced state tests for nd_interface_loopback -# Copyright: (c) 2026, Cisco Systems, Inc. +# Copyright: (c) 2026, Allen Robel (@allenrobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) From c82ace95694cf131b610f7a12b71df88d2d29953 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 6 Apr 2026 09:55:45 -1000 Subject: [PATCH 13/17] Move switch_ip into config items to support multi-switch tasks Each config item now includes switch_ip, enabling interfaces across multiple switches in a single task. Uses composite identifier (switch_ip, interface_name) and fabric-wide query_all for overridden state convergence. Co-Authored-By: Claude Opus 4.6 --- .../models/interfaces/loopback_interface.py | 20 ++- .../orchestrators/loopback_interface.py | 122 ++++++++++-------- plugins/modules/nd_interface_loopback.py | 52 +++++--- .../nd_interface_loopback/tasks/deleted.yaml | 37 +++--- .../nd_interface_loopback/tasks/merged.yaml | 27 ++-- .../tasks/overridden.yaml | 17 ++- .../nd_interface_loopback/tasks/replaced.yaml | 20 +-- .../nd_interface_loopback/vars/main.yaml | 5 + .../models/test_loopback_interface.py | 117 ++++++++++++----- 9 files changed, 251 insertions(+), 166 deletions(-) diff --git a/plugins/module_utils/models/interfaces/loopback_interface.py b/plugins/module_utils/models/interfaces/loopback_interface.py index 28cebb26..b6f2b143 100644 --- a/plugins/module_utils/models/interfaces/loopback_interface.py +++ b/plugins/module_utils/models/interfaces/loopback_interface.py @@ -22,16 +22,17 @@ - `admin_state`, `ip`, `ipv6`, `vrf`, `policy_type`, etc. """ -from typing import Dict, List, Optional, ClassVar, Literal +from typing import ClassVar, Dict, List, Literal, Optional, Set + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, - field_validator, - field_serializer, 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 -from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping LOOPBACK_POLICY_TYPE_MAPPING = NDConstantMapping( { @@ -149,11 +150,16 @@ class LoopbackInterfaceModel(NDBaseModel): # --- Identifier Configuration --- - identifiers: ClassVar[Optional[List[str]]] = ["interface_name"] - identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + 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="loopback", alias="interfaceType") config_data: Optional[LoopbackConfigDataModel] = Field(default=None, alias="configData") @@ -189,12 +195,12 @@ def get_argument_spec(cls) -> Dict: """ return dict( fabric_name=dict(type="str", required=True), - switch_ip=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), interface_type=dict(type="str", default="loopback"), config_data=dict( diff --git a/plugins/module_utils/orchestrators/loopback_interface.py b/plugins/module_utils/orchestrators/loopback_interface.py index 22a5b2ed..c62dc808 100644 --- a/plugins/module_utils/orchestrators/loopback_interface.py +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -6,8 +6,12 @@ Loopback interface orchestrator for Nexus Dashboard. This module provides `LoopbackInterfaceOrchestrator`, which implements CRUD operations -for loopback interfaces via the ND Manage Interfaces API. Each mutation operation -(create, update, delete) is followed by a deploy call to persist changes to the switch. +for loopback interfaces via the ND Manage Interfaces API. Supports configuring interfaces +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. Uses `FabricContext` for pre-flight validation (fabric existence, deployment-freeze check) and switch IP-to-serial resolution. The model structure mirrors the API payload, so the @@ -43,6 +47,9 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): Orchestrator for loopback interface CRUD operations on Nexus Dashboard. + 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`. + Overrides the base orchestrator to handle the ND interfaces API, which requires `fabric_name` and `switch_sn` on every endpoint, injects `switchId` into payloads, and defers deploy calls for bulk execution. @@ -50,14 +57,16 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): after all mutations are complete to deploy all changes in a single API call. `delete` queues interfaces 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` if the fabric does not exist or is in deployment-freeze mode. - - Via `validate` if no switch matches the given IP in the fabric. + - 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. @@ -77,8 +86,8 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): deploy: bool = True _fabric_context: Optional[FabricContext] = None - _pending_deploys: list[str] = [] - _pending_removes: list[str] = [] + _pending_deploys: list[tuple[str, str]] = [] + _pending_removes: list[tuple[str, str]] = [] @property def fabric_name(self) -> str: @@ -108,12 +117,11 @@ def fabric_context(self) -> FabricContext: self._fabric_context = FabricContext(sender=self.sender, fabric_name=self.fabric_name) return self._fabric_context - @property - def switch_id(self) -> str: + def _resolve_switch_id(self, switch_ip: str) -> str: """ # Summary - Return `switchId` resolved from the `switch_ip` module param via `FabricContext`. + Resolve a `switch_ip` to its `switchId` via `FabricContext`. ## Raises @@ -121,15 +129,13 @@ def switch_id(self) -> str: - If no switch matches the given IP in the fabric. """ - switch_ip = self.sender.params.get("switch_ip") 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, and that - the target switch exists in the fabric. + Run pre-flight validation before any CRUD operations. Checks that the fabric exists and is modifiable. ## Raises @@ -137,13 +143,10 @@ def validate_prerequisites(self) -> None: - If the fabric does not exist on the target ND node. - If the fabric is in deployment-freeze mode. - - If no switch matches the given `switch_ip` in the fabric. """ self.fabric_context.validate_for_mutation() - # Eagerly resolve switch_id to fail fast if the switch IP is invalid - result = self.switch_id # pylint: disable=unused-variable - def _configure_endpoint(self, api_endpoint): + def _configure_endpoint(self, api_endpoint, switch_sn: str): """ # Summary @@ -154,34 +157,38 @@ def _configure_endpoint(self, api_endpoint): None """ api_endpoint.fabric_name = self.fabric_name - api_endpoint.switch_sn = self.switch_id + api_endpoint.switch_sn = switch_sn return api_endpoint - def _queue_deploy(self, interface_name: str) -> None: + def _queue_deploy(self, interface_name: str, switch_id: str) -> None: """ # Summary - Queue an interface name for deferred deployment. Call `deploy_pending` after all mutations are complete to deploy in bulk. + Queue an `(interface_name, switch_id)` pair for deferred deployment. Call `deploy_pending` after all mutations + are complete to deploy in bulk. ## Raises None """ - if interface_name not in self._pending_deploys: - self._pending_deploys.append(interface_name) + pair = (interface_name, switch_id) + if pair not in self._pending_deploys: + self._pending_deploys.append(pair) - def _queue_remove(self, interface_name: str) -> None: + def _queue_remove(self, interface_name: str, switch_id: str) -> None: """ # Summary - Queue an interface name for deferred bulk removal. Call `remove_pending` after all mutations are complete to remove in bulk. + Queue an `(interface_name, switch_id)` pair for deferred bulk removal. Call `remove_pending` after all mutations + are complete to remove in bulk. ## Raises None """ - if interface_name not in self._pending_removes: - self._pending_removes.append(interface_name) + pair = (interface_name, switch_id) + if pair not in self._pending_removes: + self._pending_removes.append(pair) def deploy_pending(self) -> ResponseType | None: """ @@ -221,7 +228,7 @@ def _deploy_interfaces(self) -> ResponseType: """ api_endpoint = EpManageInterfacesDeploy() api_endpoint.fabric_name = self.fabric_name - payload = {"interfaces": [{"interfaceName": name, "switchId": self.switch_id} for name in self._pending_deploys]} + 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: @@ -261,15 +268,15 @@ def _remove_interfaces(self) -> ResponseType: """ api_endpoint = EpManageInterfacesRemove() api_endpoint.fabric_name = self.fabric_name - payload = {"interfaces": [{"interfaceName": name, "switchId": self.switch_id} for name in self._pending_removes]} + payload = {"interfaces": [{"interfaceName": name, "switchId": switch_id} for name, switch_id in self._pending_removes]} return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) def create(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: """ # Summary - Create a loopback interface. Injects `switchId` and wraps the payload in an `interfaces` array. Queues a deploy for later - bulk execution via `deploy_pending`. + Create a loopback interface. 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 @@ -278,12 +285,13 @@ def create(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseTy - If the create API request fails. """ try: - api_endpoint = self._configure_endpoint(self.create_endpoint()) + 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"] = self.switch_id + 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) + 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 @@ -292,7 +300,8 @@ def update(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseTy """ # Summary - Update a loopback interface. Injects `switchId` into the payload. Queues a deploy for later bulk execution via `deploy_pending`. + Update a loopback interface. Resolves `switch_ip` from the model instance, injects `switchId` into the payload. + Queues a deploy for later bulk execution via `deploy_pending`. ## Raises @@ -301,12 +310,13 @@ def update(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseTy - If the update API request fails. """ try: - api_endpoint = self._configure_endpoint(self.update_endpoint()) + 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"] = self.switch_id + 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) + 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 @@ -324,14 +334,15 @@ def delete(self, model_instance: LoopbackInterfaceModel, **kwargs) -> None: None """ - self._queue_remove(model_instance.interface_name) - self._queue_deploy(model_instance.interface_name) + 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: LoopbackInterfaceModel, **kwargs) -> ResponseType: """ # Summary - Query a single loopback interface by name. + Query a single loopback interface by name on a specific switch. ## Raises @@ -340,7 +351,8 @@ def query_one(self, model_instance: LoopbackInterfaceModel, **kwargs) -> Respons - If the query API request fails. """ try: - api_endpoint = self._configure_endpoint(self.query_one_endpoint()) + 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: @@ -350,13 +362,16 @@ def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> R """ # Summary - Validate the fabric context and query all interfaces on the switch, filtering for user-managed loopback interfaces only. + Validate the fabric context and query all interfaces across ALL switches in the fabric, filtering for user-managed + loopback interfaces only. System-provisioned loopbacks (e.g. Loopback0 routing, Loopback1 VTEP with `policyType: "underlayLoopback"`) are excluded because they are managed by ND during initial switch role configuration and cannot be deleted or modified by this module. - Runs `validate` on first call to ensure the fabric exists, is modifiable, and the target switch is reachable - before returning any data. + 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 `LoopbackInterfaceModel` can be constructed + with the composite identifier `(switch_ip, interface_name)`. ## Raises @@ -364,18 +379,23 @@ def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> R - If the fabric does not exist on the target ND node. - If the fabric is in deployment-freeze mode. - - If no switch matches the given `switch_ip` in the fabric. - If the query API request fails. """ managed_policy_types = set(LOOPBACK_POLICY_TYPE_MAPPING.data.values()) try: self.validate_prerequisites() - api_endpoint = self._configure_endpoint(self.query_all_endpoint()) - result = self.sender.query_obj(api_endpoint.path) - if not result: - return [] - interfaces = result.get("interfaces", []) or [] - loopbacks = [iface for iface in interfaces if iface.get("interfaceType") == "loopback"] - return [lb for lb in loopbacks if lb.get("configData", {}).get("networkOS", {}).get("policy", {}).get("policyType") in managed_policy_types] + all_loopbacks = [] + 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 [] + loopbacks = [iface for iface in interfaces if iface.get("interfaceType") == "loopback"] + managed = [lb for lb in loopbacks if lb.get("configData", {}).get("networkOS", {}).get("policy", {}).get("policyType") in managed_policy_types] + for iface in managed: + iface["switchIp"] = switch_ip + all_loopbacks.extend(managed) + return all_loopbacks except Exception as e: raise RuntimeError(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py index af812244..347c1c71 100644 --- a/plugins/modules/nd_interface_loopback.py +++ b/plugins/modules/nd_interface_loopback.py @@ -19,23 +19,25 @@ options: fabric_name: description: - - The name of the fabric containing the target switch. - type: str - required: true - switch_ip: - description: - - The management IP address of the switch on which to manage loopback interfaces. - - This is resolved to the switch serial number (switchId) internally. + - The name of the fabric containing the target switches. type: str required: true config: description: - The list of loopback interfaces to configure. + - Each item specifies the target switch and interface 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 this loopback interface. + - This is resolved to the switch serial number (switchId) internally. + type: str + required: true interface_name: description: - The name of the loopback interface (e.g., C(loopback0), C(Loopback10)). @@ -147,12 +149,12 @@ """ EXAMPLES = r""" -- name: Create a loopback interface +- name: Create a loopback interface on a single switch cisco.nd.nd_interface_loopback: fabric_name: my_fabric - switch_ip: 192.168.1.1 config: - - interface_name: loopback0 + - switch_ip: 192.168.1.1 + interface_name: loopback0 config_data: network_os: policy: @@ -164,32 +166,40 @@ state: merged register: result -- name: Create multiple loopback interfaces +- name: Create loopback interfaces across multiple switches cisco.nd.nd_interface_loopback: fabric_name: my_fabric - switch_ip: 192.168.1.1 config: - - interface_name: loopback0 + - switch_ip: 192.168.1.1 + interface_name: loopback0 config_data: network_os: policy: ip: 10.1.1.1 description: Router ID loopback - - interface_name: loopback1 + - switch_ip: 192.168.1.1 + interface_name: loopback1 config_data: network_os: policy: ip: 10.2.1.1 description: VTEP loopback link_state_routing_tag: UNDERLAY + - switch_ip: 192.168.1.2 + interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.2 + description: Router ID loopback on switch 2 state: merged -- name: Update a loopback interface +- name: Replace a loopback interface cisco.nd.nd_interface_loopback: fabric_name: my_fabric - switch_ip: 192.168.1.1 config: - - interface_name: loopback0 + - switch_ip: 192.168.1.1 + interface_name: loopback0 config_data: network_os: policy: @@ -200,17 +210,17 @@ - name: Delete a loopback interface cisco.nd.nd_interface_loopback: fabric_name: my_fabric - switch_ip: 192.168.1.1 config: - - interface_name: loopback0 + - switch_ip: 192.168.1.1 + interface_name: loopback0 state: deleted - name: Create loopback interfaces without deploying (for batching) cisco.nd.nd_interface_loopback: fabric_name: my_fabric - switch_ip: 192.168.1.1 config: - - interface_name: loopback0 + - switch_ip: 192.168.1.1 + interface_name: loopback0 config_data: network_os: policy: diff --git a/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml index 8eb73cfa..91f653cc 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml @@ -13,9 +13,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback101 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 state: deleted check_mode: true register: cm_deleted_101 @@ -24,9 +24,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback101 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 state: deleted register: nm_deleted_101 @@ -43,9 +43,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback101 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 state: deleted register: nm_deleted_101_idem @@ -61,7 +61,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100 }}" - "{{ loopback_101 }}" @@ -71,11 +70,13 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 - - interface_name: loopback101 - - interface_name: loopback102 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback102 state: deleted check_mode: true register: cm_deleted_multi @@ -84,11 +85,13 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 - - interface_name: loopback101 - - interface_name: loopback102 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback102 state: deleted register: nm_deleted_multi @@ -107,9 +110,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback199 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback199 state: deleted register: nm_deleted_nonexistent diff --git a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml index 794f8f06..a501aedf 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml @@ -10,11 +10,13 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 - - interface_name: loopback101 - - interface_name: loopback102 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback102 state: deleted tags: always @@ -24,7 +26,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100 }}" state: merged @@ -35,7 +36,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100 }}" state: merged @@ -53,7 +53,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_101 }}" - "{{ loopback_102 }}" @@ -65,7 +64,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_101 }}" - "{{ loopback_102 }}" @@ -86,7 +84,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100 }}" state: merged @@ -97,7 +94,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100 }}" state: merged @@ -115,7 +111,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100_updated }}" state: merged @@ -126,7 +121,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100_updated }}" state: merged @@ -151,7 +145,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_100_updated }}" state: merged @@ -168,9 +161,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback103 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback103 config_data: network_os: policy: @@ -192,7 +185,7 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback103 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback103 state: deleted diff --git a/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml index 24a9775c..28697e1d 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml @@ -14,9 +14,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -32,9 +32,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -70,9 +70,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -99,9 +99,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -130,7 +130,6 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - "{{ loopback_101 }}" - "{{ loopback_102 }}" diff --git a/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml b/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml index 2120a379..1b1c64dd 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/replaced.yaml @@ -13,9 +13,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -31,9 +31,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -64,9 +64,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -88,9 +88,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: @@ -107,9 +107,9 @@ cisco.nd.nd_interface_loopback: output_level: "{{ nd_info.output_level }}" fabric_name: "{{ test_fabric_name }}" - switch_ip: "{{ test_switch_ip }}" config: - - interface_name: loopback100 + - switch_ip: "{{ test_switch_ip }}" + interface_name: loopback100 config_data: network_os: policy: diff --git a/tests/integration/targets/nd_interface_loopback/vars/main.yaml b/tests/integration/targets/nd_interface_loopback/vars/main.yaml index 90784a59..4b484cfa 100644 --- a/tests/integration/targets/nd_interface_loopback/vars/main.yaml +++ b/tests/integration/targets/nd_interface_loopback/vars/main.yaml @@ -10,6 +10,7 @@ test_switch_ip: "{{ nd_test_switch_ip | default('192.168.1.1') }}" # Loopback interface configs used across tests. # Loopback IDs 100-109 are reserved for these integration tests. loopback_100: + switch_ip: "{{ test_switch_ip }}" interface_name: loopback100 config_data: network_os: @@ -22,6 +23,7 @@ loopback_100: policy_type: loopback loopback_101: + switch_ip: "{{ test_switch_ip }}" interface_name: loopback101 config_data: network_os: @@ -32,6 +34,7 @@ loopback_101: policy_type: loopback loopback_102: + switch_ip: "{{ test_switch_ip }}" interface_name: loopback102 config_data: network_os: @@ -43,6 +46,7 @@ loopback_102: # Updated versions for merge/replace tests loopback_100_updated: + switch_ip: "{{ test_switch_ip }}" interface_name: loopback100 config_data: network_os: @@ -55,6 +59,7 @@ loopback_100_updated: policy_type: loopback loopback_101_updated: + switch_ip: "{{ test_switch_ip }}" interface_name: loopback101 config_data: network_os: diff --git a/tests/unit/module_utils/models/test_loopback_interface.py b/tests/unit/module_utils/models/test_loopback_interface.py index 7a6755ed..3b23390d 100644 --- a/tests/unit/module_utils/models/test_loopback_interface.py +++ b/tests/unit/module_utils/models/test_loopback_interface.py @@ -20,7 +20,6 @@ from contextlib import contextmanager import pytest # pylint: disable=unused-import -from pydantic import ValidationError # pylint: disable=unused-import from ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.loopback_interface import ( LOOPBACK_POLICY_TYPE_MAPPING, LoopbackConfigDataModel, @@ -28,6 +27,7 @@ LoopbackNetworkOSModel, LoopbackPolicyModel, ) +from pydantic import ValidationError # pylint: disable=unused-import @contextmanager @@ -41,6 +41,7 @@ def does_not_raise(): # ============================================================================= SAMPLE_API_RESPONSE = { + "switchIp": "192.168.1.1", "interfaceName": "loopback0", "interfaceType": "loopback", "configData": { @@ -60,6 +61,7 @@ def does_not_raise(): } SAMPLE_ANSIBLE_CONFIG = { + "switch_ip": "192.168.1.1", "interface_name": "loopback0", "interface_type": "loopback", "config_data": { @@ -563,15 +565,15 @@ def test_loopback_interface_00200(): ## Test - - identifiers == ["interface_name"] - - identifier_strategy == "single" + - identifiers == ["switch_ip", "interface_name"] + - identifier_strategy == "composite" ## Classes and Methods - LoopbackInterfaceModel class attributes """ - assert LoopbackInterfaceModel.identifiers == ["interface_name"] - assert LoopbackInterfaceModel.identifier_strategy == "single" + assert LoopbackInterfaceModel.identifiers == ["switch_ip", "interface_name"] + assert LoopbackInterfaceModel.identifier_strategy == "composite" def test_loopback_interface_00210(): @@ -582,7 +584,7 @@ def test_loopback_interface_00210(): ## Test - - Construct with only interface_name + - Construct with switch_ip and interface_name - interface_type defaults to "loopback" ## Classes and Methods @@ -590,7 +592,7 @@ def test_loopback_interface_00210(): - LoopbackInterfaceModel.__init__() """ with does_not_raise(): - instance = LoopbackInterfaceModel(interface_name="loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") assert instance.interface_type == "loopback" @@ -602,14 +604,14 @@ def test_loopback_interface_00220(): ## Test - - Construct with only interface_name + - Construct with switch_ip and interface_name - config_data defaults to None ## Classes and Methods - LoopbackInterfaceModel.__init__() """ - instance = LoopbackInterfaceModel(interface_name="loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") assert instance.config_data is None @@ -617,11 +619,11 @@ def test_loopback_interface_00230(): """ # Summary - Verify interface_name is required — ValidationError without it. + Verify switch_ip and interface_name are required — ValidationError without them. ## Test - - Construct without interface_name + - Construct without required fields - Raises ValidationError ## Classes and Methods @@ -630,6 +632,10 @@ def test_loopback_interface_00230(): """ with pytest.raises(ValidationError): LoopbackInterfaceModel() + with pytest.raises(ValidationError): + LoopbackInterfaceModel(interface_name="loopback0") + with pytest.raises(ValidationError): + LoopbackInterfaceModel(switch_ip="192.168.1.1") # ============================================================================= @@ -652,7 +658,7 @@ def test_loopback_interface_00250(): - LoopbackInterfaceModel.normalize_interface_name() """ - instance = LoopbackInterfaceModel(interface_name="Loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="Loopback0") assert instance.interface_name == "loopback0" @@ -671,7 +677,7 @@ def test_loopback_interface_00251(): - LoopbackInterfaceModel.normalize_interface_name() """ - instance = LoopbackInterfaceModel(interface_name="LOOPBACK1") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="LOOPBACK1") assert instance.interface_name == "loopback1" @@ -690,7 +696,7 @@ def test_loopback_interface_00252(): - LoopbackInterfaceModel.normalize_interface_name() """ - instance = LoopbackInterfaceModel(interface_name="loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") assert instance.interface_name == "loopback0" @@ -735,10 +741,11 @@ def test_loopback_interface_00310(): - LoopbackInterfaceModel.to_payload() """ - instance = LoopbackInterfaceModel(interface_name="loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") result = instance.to_payload() assert "configData" not in result assert "interfaceName" in result + assert "switchIp" not in result # switch_ip excluded from payload def test_loopback_interface_00320(): @@ -791,6 +798,31 @@ def test_loopback_interface_00330(): # ============================================================================= +def test_loopback_interface_00340(): + """ + # Summary + + Verify switch_ip is excluded from payload output but present in config output. + + ## Test + + - to_payload() does not include switchIp or switch_ip + - to_config() includes switch_ip + + ## Classes and Methods + + - LoopbackInterfaceModel.to_payload() + - LoopbackInterfaceModel.to_config() + """ + instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) + payload = instance.to_payload() + assert "switchIp" not in payload + assert "switch_ip" not in payload + config = instance.to_config() + assert "switch_ip" in config + assert config["switch_ip"] == "192.168.1.1" + + def test_loopback_interface_00350(): """ # Summary @@ -992,7 +1024,8 @@ def test_loopback_interface_00460(): - LoopbackInterfaceModel.from_config() """ - instance = LoopbackInterfaceModel.from_config({"interface_name": "loopback0"}) + instance = LoopbackInterfaceModel.from_config({"switch_ip": "192.168.1.1", "interface_name": "loopback0"}) + assert instance.switch_ip == "192.168.1.1" assert instance.interface_name == "loopback0" assert instance.config_data is None @@ -1008,6 +1041,10 @@ def test_loopback_interface_00500(): Verify config -> from_config -> to_payload -> from_response -> to_config == original. + `switch_ip` is excluded from payload (it's a routing concern, not an API field). + The orchestrator re-injects `switchIp` when building models from API responses. + This test simulates that injection. + ## Test - Round-trip through all serialization methods preserves data @@ -1022,6 +1059,8 @@ def test_loopback_interface_00500(): original = copy.deepcopy(SAMPLE_ANSIBLE_CONFIG) instance = LoopbackInterfaceModel.from_config(original) payload = instance.to_payload() + # Simulate orchestrator injecting switchIp back into API response + payload["switchIp"] = original["switch_ip"] instance2 = LoopbackInterfaceModel.from_response(payload) result = instance2.to_config() assert result == original @@ -1031,11 +1070,13 @@ def test_loopback_interface_00510(): """ # Summary - Verify response -> from_response -> to_config -> from_config -> to_payload == original. + Verify response -> from_response -> to_config -> from_config -> to_payload round-trip. + + `switch_ip` is excluded from payload output, so the round-trip comparison excludes it. ## Test - - Round-trip starting from API response preserves data + - Round-trip starting from API response preserves data (except switchIp in payload) ## Classes and Methods @@ -1049,7 +1090,9 @@ def test_loopback_interface_00510(): config = instance.to_config() instance2 = LoopbackInterfaceModel.from_config(config) result = instance2.to_payload() - assert result == original + # switchIp is excluded from payload, so compare without it + expected = {k: v for k, v in original.items() if k != "switchIp"} + assert result == expected def test_loopback_interface_00520(): @@ -1074,6 +1117,8 @@ def test_loopback_interface_00520(): payload = instance.to_payload() assert payload["configData"]["networkOS"]["policy"]["policyType"] == "ipfmLoopback" + # Simulate orchestrator injecting switchIp back into API response + payload["switchIp"] = config["switch_ip"] instance2 = LoopbackInterfaceModel.from_response(payload) result_config = instance2.to_config() assert result_config["config_data"]["network_os"]["policy"]["policy_type"] == "ipfm_loopback" @@ -1088,38 +1133,38 @@ def test_loopback_interface_00550(): """ # Summary - Verify get_identifier_value() returns "loopback0". + Verify get_identifier_value() returns composite tuple (switch_ip, interface_name). ## Test - - get_identifier_value() returns the interface_name + - get_identifier_value() returns a tuple of (switch_ip, interface_name) ## Classes and Methods - LoopbackInterfaceModel.get_identifier_value() """ - instance = LoopbackInterfaceModel(interface_name="loopback0") - assert instance.get_identifier_value() == "loopback0" + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") + assert instance.get_identifier_value() == ("192.168.1.1", "loopback0") def test_loopback_interface_00560(): """ # Summary - Verify get_identifier_value returns lowercased value when constructed with "Loopback1". + Verify get_identifier_value returns lowercased interface_name in composite tuple. ## Test - Constructed with "Loopback1" - - get_identifier_value() returns "loopback1" + - get_identifier_value() returns ("192.168.1.1", "loopback1") ## Classes and Methods - LoopbackInterfaceModel.get_identifier_value() - LoopbackInterfaceModel.normalize_interface_name() """ - instance = LoopbackInterfaceModel(interface_name="Loopback1") - assert instance.get_identifier_value() == "loopback1" + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="Loopback1") + assert instance.get_identifier_value() == ("192.168.1.1", "loopback1") # ============================================================================= @@ -1186,7 +1231,7 @@ def test_loopback_interface_00620(): - LoopbackInterfaceModel.get_diff() """ instance_full = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) - instance_minimal = LoopbackInterfaceModel(interface_name="loopback0") + instance_minimal = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") assert instance_full.get_diff(instance_minimal) is True @@ -1211,6 +1256,7 @@ def test_loopback_interface_00650(): - LoopbackInterfaceModel.merge() """ config_base = { + "switch_ip": "192.168.1.1", "interface_name": "loopback0", "config_data": { "network_os": { @@ -1221,6 +1267,7 @@ def test_loopback_interface_00650(): }, } config_other = { + "switch_ip": "192.168.1.1", "interface_name": "loopback0", "config_data": { "network_os": { @@ -1252,7 +1299,7 @@ def test_loopback_interface_00660(): - LoopbackInterfaceModel.merge() """ instance = LoopbackInterfaceModel.from_config(copy.deepcopy(SAMPLE_ANSIBLE_CONFIG)) - other = LoopbackInterfaceModel(interface_name="loopback0") + other = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") instance.merge(other) assert instance.config_data.network_os.policy.ip == "10.1.1.1/32" @@ -1271,7 +1318,7 @@ def test_loopback_interface_00670(): - LoopbackInterfaceModel.merge() """ - instance = LoopbackInterfaceModel(interface_name="loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") with pytest.raises(TypeError, match="Cannot merge"): instance.merge(LoopbackPolicyModel()) @@ -1290,8 +1337,8 @@ def test_loopback_interface_00680(): - LoopbackInterfaceModel.merge() """ - instance = LoopbackInterfaceModel(interface_name="loopback0") - other = LoopbackInterfaceModel(interface_name="loopback0") + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") + other = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") result = instance.merge(other) assert result is instance @@ -1309,7 +1356,8 @@ def test_loopback_interface_00700(): ## Test - - get_argument_spec() returns fabric_name, switch_ip, config, state + - get_argument_spec() returns fabric_name, config, state + - switch_ip is inside config options, not top-level ## Classes and Methods @@ -1317,9 +1365,10 @@ def test_loopback_interface_00700(): """ spec = LoopbackInterfaceModel.get_argument_spec() assert "fabric_name" in spec - assert "switch_ip" in spec + assert "switch_ip" not in spec assert "config" in spec assert "state" in spec + assert "switch_ip" in spec["config"]["options"] def test_loopback_interface_00710(): From c8a8c7ded70f3830c6bcf6600ade656b904d2f96 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 9 Apr 2026 07:41:28 -1000 Subject: [PATCH 14/17] Rename validator parameter v to value for consistency with serializers Co-Authored-By: Claude Opus 4.6 --- .../models/interfaces/loopback_interface.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/module_utils/models/interfaces/loopback_interface.py b/plugins/module_utils/models/interfaces/loopback_interface.py index b6f2b143..ab83269f 100644 --- a/plugins/module_utils/models/interfaces/loopback_interface.py +++ b/plugins/module_utils/models/interfaces/loopback_interface.py @@ -88,7 +88,7 @@ def serialize_policy_type(self, value: Optional[str], info: FieldSerializationIn @field_validator("policy_type", mode="before") @classmethod - def normalize_policy_type(cls, v): + def normalize_policy_type(cls, value): """ # Summary @@ -98,10 +98,10 @@ def normalize_policy_type(cls, v): None """ - if v is None: - return v + if value is None: + return value reverse_mapping = {api: ansible for ansible, api in LOOPBACK_POLICY_TYPE_MAPPING.data.items() if ansible != api} - return reverse_mapping.get(v, v) + return reverse_mapping.get(value, value) class LoopbackNetworkOSModel(NDNestedModel): @@ -166,7 +166,7 @@ class LoopbackInterfaceModel(NDBaseModel): @field_validator("interface_name", mode="before") @classmethod - def normalize_interface_name(cls, v): + def normalize_interface_name(cls, value): """ # Summary @@ -176,9 +176,9 @@ def normalize_interface_name(cls, v): None """ - if isinstance(v, str): - return v.lower() - return v + if isinstance(value, str): + return value.lower() + return value # --- Argument Spec --- From c15a24e085d40b6317e34e5e5266f5e50be353b4 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 9 Apr 2026 10:50:50 -1000 Subject: [PATCH 15/17] Harden LoopbackPolicyModel fields based on int_loopback config template Constrain LoopbackPolicyModel fields to match the ND int_loopback config template parameters: - Add min_length=1, max_length=32 constraint to vrf (vrfInterface) - Add min_length=1, max_length=254 constraint to description - Add IPv4 validator using ipaddress.IPv4Interface for ip field - Add IPv6 validator using ipaddress.IPv6Interface for ipv6 field - Change route_map_tag from Optional[int] to Optional[str] to match the template parameterType, with a coercion validator that accepts both int and str inputs for compatibility with the ND API - Remove link_state_routing_tag (not in int_loopback template; it belongs to the fabric iBGP model) - Add field descriptions to all LoopbackPolicyModel fields - Update argument spec, module DOCUMENTATION, and integration test vars/assertions to reflect the str type for route_map_tag Co-Authored-By: Claude Opus 4.6 --- .../models/interfaces/loopback_interface.py | 81 +++- plugins/modules/nd_interface_loopback.py | 6 +- .../nd_interface_loopback/tasks/merged.yaml | 2 +- .../nd_interface_loopback/vars/main.yaml | 4 +- .../models/test_loopback_interface.py | 426 +++++++++++++++++- 5 files changed, 493 insertions(+), 26 deletions(-) diff --git a/plugins/module_utils/models/interfaces/loopback_interface.py b/plugins/module_utils/models/interfaces/loopback_interface.py index ab83269f..f2d0fb47 100644 --- a/plugins/module_utils/models/interfaces/loopback_interface.py +++ b/plugins/module_utils/models/interfaces/loopback_interface.py @@ -22,6 +22,7 @@ - `admin_state`, `ip`, `ipv6`, `vrf`, `policy_type`, etc. """ +import ipaddress from typing import ClassVar, Dict, List, Literal, Optional, Set from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( @@ -54,15 +55,14 @@ class LoopbackPolicyModel(NDNestedModel): None """ - admin_state: Optional[bool] = Field(default=None, alias="adminState") - ip: Optional[str] = Field(default=None, alias="ip") - ipv6: Optional[str] = Field(default=None, alias="ipv6") - vrf: Optional[str] = Field(default=None, alias="vrfInterface") - route_map_tag: Optional[int] = Field(default=None, alias="routeMapTag") - link_state_routing_tag: Optional[str] = Field(default=None, alias="linkStateRoutingTag") - description: Optional[str] = Field(default=None, alias="description") - extra_config: Optional[str] = Field(default=None, alias="extraConfig") - policy_type: Optional[str] = Field(default=None, alias="policyType") + admin_state: Optional[bool] = Field(default=None, alias="adminState", description="Enable or disable the interface") + ip: Optional[str] = Field(default=None, alias="ip", description="Loopback IPv4 address in CIDR notation (e.g. 10.1.1.1/32)") + ipv6: Optional[str] = Field(default=None, alias="ipv6", description="Loopback IPv6 address in CIDR notation") + vrf: Optional[str] = Field(default=None, alias="vrfInterface", min_length=1, max_length=32, description="Interface VRF name") + route_map_tag: Optional[str] = Field(default=None, alias="routeMapTag", description="Route-Map tag associated with interface IP") + description: Optional[str] = Field(default=None, alias="description", min_length=1, max_length=254, description="Interface description") + extra_config: Optional[str] = Field(default=None, alias="extraConfig", description="Additional CLI for the interface") + policy_type: Optional[str] = Field(default=None, alias="policyType", description="Interface policy type") # --- Serializers --- @@ -103,6 +103,66 @@ def normalize_policy_type(cls, value): reverse_mapping = {api: ansible for ansible, api in LOOPBACK_POLICY_TYPE_MAPPING.data.items() if ansible != api} return reverse_mapping.get(value, value) + @field_validator("route_map_tag", mode="before") + @classmethod + def coerce_route_map_tag(cls, value): + """ + # Summary + + Coerce `route_map_tag` to a string. The ND API returns this field as an integer, but the template defines it as a string. + + ## Raises + + None + """ + if value is None: + return value + return str(value) + + @field_validator("ip", mode="before") + @classmethod + def validate_ipv4(cls, value): + """ + # Summary + + Validate that `ip` is a valid IPv4 interface address in CIDR notation. + + ## Raises + + ### ValueError + + - If `value` is not a valid IPv4 interface address in CIDR notation + """ + if value is None: + return value + try: + ipaddress.IPv4Interface(value) + except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as err: + raise ValueError(f"Invalid IPv4 address: {value!r}. Expected CIDR notation (e.g. '10.1.1.1/32').") from err + return value + + @field_validator("ipv6", mode="before") + @classmethod + def validate_ipv6(cls, value): + """ + # Summary + + Validate that `ipv6` is a valid IPv6 interface address in CIDR notation. + + ## Raises + + ### ValueError + + - If `value` is not a valid IPv6 interface address in CIDR notation + """ + if value is None: + return value + try: + ipaddress.IPv6Interface(value) + except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as err: + raise ValueError(f"Invalid IPv6 address: {value!r}. Expected CIDR notation (e.g. '2001:db8::1/128').") from err + return value + class LoopbackNetworkOSModel(NDNestedModel): """ @@ -218,8 +278,7 @@ def get_argument_spec(cls) -> Dict: ip=dict(type="str"), ipv6=dict(type="str"), vrf=dict(type="str"), - route_map_tag=dict(type="int"), - link_state_routing_tag=dict(type="str"), + route_map_tag=dict(type="str"), description=dict(type="str"), extra_config=dict(type="str"), policy_type=dict( diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py index 347c1c71..4aed572a 100644 --- a/plugins/modules/nd_interface_loopback.py +++ b/plugins/modules/nd_interface_loopback.py @@ -95,10 +95,6 @@ route_map_tag: description: - The route-map tag associated with the interface IP address. - type: int - link_state_routing_tag: - description: - - The link-state routing tag (e.g., C(UNDERLAY)). type: str description: description: @@ -184,7 +180,7 @@ policy: ip: 10.2.1.1 description: VTEP loopback - link_state_routing_tag: UNDERLAY + route_map_tag: "12345" - switch_ip: 192.168.1.2 interface_name: loopback0 config_data: diff --git a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml index a501aedf..361529cd 100644 --- a/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml +++ b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml @@ -139,7 +139,7 @@ that: - lb100_after.config_data.network_os.policy.ip == "10.100.100.2" - lb100_after.config_data.network_os.policy.description == "Updated loopback100 description" - - lb100_after.config_data.network_os.policy.route_map_tag == 54321 + - lb100_after.config_data.network_os.policy.route_map_tag == "54321" - name: "MERGED UPDATE: Re-apply update for idempotency" cisco.nd.nd_interface_loopback: diff --git a/tests/integration/targets/nd_interface_loopback/vars/main.yaml b/tests/integration/targets/nd_interface_loopback/vars/main.yaml index 4b484cfa..725ac252 100644 --- a/tests/integration/targets/nd_interface_loopback/vars/main.yaml +++ b/tests/integration/targets/nd_interface_loopback/vars/main.yaml @@ -19,7 +19,7 @@ loopback_100: ip: "10.100.100.1" description: "Ansible integration test loopback100" vrf: default - route_map_tag: 12345 + route_map_tag: "12345" policy_type: loopback loopback_101: @@ -55,7 +55,7 @@ loopback_100_updated: ip: "10.100.100.2" description: "Updated loopback100 description" vrf: default - route_map_tag: 54321 + route_map_tag: "54321" policy_type: loopback loopback_101_updated: diff --git a/tests/unit/module_utils/models/test_loopback_interface.py b/tests/unit/module_utils/models/test_loopback_interface.py index 3b23390d..ae0f9b71 100644 --- a/tests/unit/module_utils/models/test_loopback_interface.py +++ b/tests/unit/module_utils/models/test_loopback_interface.py @@ -53,7 +53,7 @@ def does_not_raise(): "ip": "10.1.1.1/32", "vrfInterface": "management", "policyType": "loopback", - "routeMapTag": 12345, + "routeMapTag": "12345", "description": "mgmt loopback", }, }, @@ -73,7 +73,7 @@ def does_not_raise(): "ip": "10.1.1.1/32", "vrf": "management", "policy_type": "loopback", - "route_map_tag": 12345, + "route_map_tag": "12345", "description": "mgmt loopback", }, }, @@ -155,7 +155,6 @@ def test_loopback_interface_00010(): assert instance.ipv6 is None assert instance.vrf is None assert instance.route_map_tag is None - assert instance.link_state_routing_tag is None assert instance.description is None assert instance.extra_config is None assert instance.policy_type is None @@ -182,14 +181,14 @@ def test_loopback_interface_00020(): ip="10.1.1.1/32", vrf="management", policy_type="loopback", - route_map_tag=100, + route_map_tag="100", description="test", ) assert instance.admin_state is True assert instance.ip == "10.1.1.1/32" assert instance.vrf == "management" assert instance.policy_type == "loopback" - assert instance.route_map_tag == 100 + assert instance.route_map_tag == "100" assert instance.description == "test" @@ -214,13 +213,13 @@ def test_loopback_interface_00030(): ip="10.2.2.2/32", vrfInterface="default", policyType="loopback", - routeMapTag=200, + routeMapTag="200", ) assert instance.admin_state is False assert instance.ip == "10.2.2.2/32" assert instance.vrf == "default" assert instance.policy_type == "loopback" - assert instance.route_map_tag == 200 + assert instance.route_map_tag == "200" def test_loopback_interface_00040(): @@ -423,6 +422,419 @@ def test_loopback_interface_00060(): assert "policy_type" not in result +# ============================================================================= +# Test: LoopbackPolicyModel — route_map_tag coercion +# ============================================================================= + + +def test_loopback_interface_00065(): + """ + # Summary + + Verify `route_map_tag` coerces an integer to a string. + + ## Test + + - Construct with route_map_tag=12345 (int) + - Value is coerced to "12345" (str) + + ## Classes and Methods + + - LoopbackPolicyModel.coerce_route_map_tag() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(route_map_tag=12345) + assert instance.route_map_tag == "12345" + assert isinstance(instance.route_map_tag, str) + + +def test_loopback_interface_00066(): + """ + # Summary + + Verify `route_map_tag` accepts a string unchanged. + + ## Test + + - Construct with route_map_tag="12345" (str) + - Value remains "12345" + + ## Classes and Methods + + - LoopbackPolicyModel.coerce_route_map_tag() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(route_map_tag="12345") + assert instance.route_map_tag == "12345" + + +def test_loopback_interface_00067(): + """ + # Summary + + Verify `route_map_tag` coerces an integer via the camelCase alias (API response path). + + ## Test + + - Construct with routeMapTag=12345 (int, camelCase alias) + - Value is coerced to "12345" (str) + + ## Classes and Methods + + - LoopbackPolicyModel.coerce_route_map_tag() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(routeMapTag=12345) + assert instance.route_map_tag == "12345" + assert isinstance(instance.route_map_tag, str) + + +# ============================================================================= +# Test: LoopbackPolicyModel — Field Constraints +# ============================================================================= + + +def test_loopback_interface_00070(): + """ + # Summary + + Verify `vrf` rejects empty string (min_length=1). + + ## Test + + - Construct with vrf="" + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with pytest.raises(ValidationError, match="vrf"): + LoopbackPolicyModel(vrf="") + + +def test_loopback_interface_00071(): + """ + # Summary + + Verify `vrf` rejects strings exceeding 32 characters (max_length=32). + + ## Test + + - Construct with vrf of 33 characters + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with pytest.raises(ValidationError, match="vrf"): + LoopbackPolicyModel(vrf="a" * 33) + + +def test_loopback_interface_00072(): + """ + # Summary + + Verify `vrf` accepts a string at the maximum length (32 characters). + + ## Test + + - Construct with vrf of exactly 32 characters + - Value is accepted + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(vrf="a" * 32) + assert instance.vrf == "a" * 32 + + +def test_loopback_interface_00073(): + """ + # Summary + + Verify `description` rejects empty string (min_length=1). + + ## Test + + - Construct with description="" + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with pytest.raises(ValidationError, match="description"): + LoopbackPolicyModel(description="") + + +def test_loopback_interface_00074(): + """ + # Summary + + Verify `description` rejects strings exceeding 254 characters (max_length=254). + + ## Test + + - Construct with description of 255 characters + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with pytest.raises(ValidationError, match="description"): + LoopbackPolicyModel(description="a" * 255) + + +def test_loopback_interface_00075(): + """ + # Summary + + Verify `description` accepts a string at the maximum length (254 characters). + + ## Test + + - Construct with description of exactly 254 characters + - Value is accepted + + ## Classes and Methods + + - LoopbackPolicyModel.__init__() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(description="a" * 254) + assert instance.description == "a" * 254 + + +# ============================================================================= +# Test: LoopbackPolicyModel — IPv4 Validation +# ============================================================================= + + +def test_loopback_interface_00080(): + """ + # Summary + + Verify `ip` accepts a valid IPv4 address in CIDR notation. + + ## Test + + - Construct with ip="10.1.1.1/32" + - Value is accepted + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv4() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(ip="10.1.1.1/32") + assert instance.ip == "10.1.1.1/32" + + +def test_loopback_interface_00081(): + """ + # Summary + + Verify `ip` accepts a valid IPv4 address with a non-/32 prefix. + + ## Test + + - Construct with ip="10.1.1.0/24" + - Value is accepted + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv4() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(ip="10.1.1.0/24") + assert instance.ip == "10.1.1.0/24" + + +def test_loopback_interface_00082(): + """ + # Summary + + Verify `ip` rejects an invalid IPv4 address. + + ## Test + + - Construct with ip="999.999.999.999/32" + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv4() + """ + with pytest.raises(ValidationError, match="Invalid IPv4 address"): + LoopbackPolicyModel(ip="999.999.999.999/32") + + +def test_loopback_interface_00083(): + """ + # Summary + + Verify `ip` rejects a bare IPv4 address without prefix length. + + ## Test + + - Construct with ip="10.1.1.1" (no CIDR prefix) + - Value is accepted (ipaddress.IPv4Interface accepts bare addresses, defaulting to /32) + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv4() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(ip="10.1.1.1") + assert instance.ip == "10.1.1.1" + + +def test_loopback_interface_00084(): + """ + # Summary + + Verify `ip` rejects non-IPv4 garbage strings. + + ## Test + + - Construct with ip="not-an-ip" + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv4() + """ + with pytest.raises(ValidationError, match="Invalid IPv4 address"): + LoopbackPolicyModel(ip="not-an-ip") + + +def test_loopback_interface_00085(): + """ + # Summary + + Verify `ip` rejects an IPv6 address. + + ## Test + + - Construct with ip="2001:db8::1/128" + - Raises ValidationError (IPv6 address passed to IPv4 field) + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv4() + """ + with pytest.raises(ValidationError, match="Invalid IPv4 address"): + LoopbackPolicyModel(ip="2001:db8::1/128") + + +# ============================================================================= +# Test: LoopbackPolicyModel — IPv6 Validation +# ============================================================================= + + +def test_loopback_interface_00086(): + """ + # Summary + + Verify `ipv6` accepts a valid IPv6 address in CIDR notation. + + ## Test + + - Construct with ipv6="2001:db8::1/128" + - Value is accepted + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv6() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(ipv6="2001:db8::1/128") + assert instance.ipv6 == "2001:db8::1/128" + + +def test_loopback_interface_00087(): + """ + # Summary + + Verify `ipv6` accepts a valid IPv6 address with a non-/128 prefix. + + ## Test + + - Construct with ipv6="2001:db8::/64" + - Value is accepted + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv6() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(ipv6="2001:db8::/64") + assert instance.ipv6 == "2001:db8::/64" + + +def test_loopback_interface_00088(): + """ + # Summary + + Verify `ipv6` rejects an invalid IPv6 address. + + ## Test + + - Construct with ipv6="not-an-ipv6" + - Raises ValidationError + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv6() + """ + with pytest.raises(ValidationError, match="Invalid IPv6 address"): + LoopbackPolicyModel(ipv6="not-an-ipv6") + + +def test_loopback_interface_00089(): + """ + # Summary + + Verify `ipv6` rejects an IPv4 address. + + ## Test + + - Construct with ipv6="10.1.1.1/32" + - Raises ValidationError (IPv4 address passed to IPv6 field) + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv6() + """ + with pytest.raises(ValidationError, match="Invalid IPv6 address"): + LoopbackPolicyModel(ipv6="10.1.1.1/32") + + +def test_loopback_interface_00090(): + """ + # Summary + + Verify `ipv6` accepts a bare IPv6 address without prefix length. + + ## Test + + - Construct with ipv6="2001:db8::1" (no CIDR prefix) + - Value is accepted (ipaddress.IPv6Interface accepts bare addresses, defaulting to /128) + + ## Classes and Methods + + - LoopbackPolicyModel.validate_ipv6() + """ + with does_not_raise(): + instance = LoopbackPolicyModel(ipv6="2001:db8::1") + assert instance.ipv6 == "2001:db8::1" + + # ============================================================================= # Test: LoopbackNetworkOSModel # ============================================================================= From 5cf894f0eed910da132aeaaa090b0b48ede13a47 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 9 Apr 2026 11:00:32 -1000 Subject: [PATCH 16/17] Fix ansible-lint error Fix below ansible-lint error: Failed: 2 failure(s), 0 warning(s) on 1109 files. Profile 'production' was required, but 'min' profile passed. yaml[empty-lines]: Too many blank lines (1 > 0) .ansible/collections/ansible_collections/cisco/nd/plugins/modules/nd_interface_loopback.py:226 yaml[empty-lines]: Too many blank lines (1 > 0) plugins/modules/nd_interface_loopback.py:226 --- plugins/modules/nd_interface_loopback.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/modules/nd_interface_loopback.py b/plugins/modules/nd_interface_loopback.py index 4aed572a..a485ccdb 100644 --- a/plugins/modules/nd_interface_loopback.py +++ b/plugins/modules/nd_interface_loopback.py @@ -223,7 +223,6 @@ ip: 10.1.1.1 deploy: false state: merged - """ RETURN = r""" From f5ee201d65d0abff62fbfd5269ab0837cb2c1cda Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 10 Apr 2026 11:33:28 -1000 Subject: [PATCH 17/17] Add bulk create and delete support to LoopbackInterfaceOrchestrator Leverage NDBaseOrchestrator bulk infrastructure from PR #223 to reduce API calls. create_bulk groups interfaces by switch and sends one POST per switch instead of one per interface. delete_bulk queues all removals for deferred execution via remove_pending/deploy_pending. Co-Authored-By: Claude Opus 4.6 --- .../orchestrators/loopback_interface.py | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/plugins/module_utils/orchestrators/loopback_interface.py b/plugins/module_utils/orchestrators/loopback_interface.py index c62dc808..d739b42f 100644 --- a/plugins/module_utils/orchestrators/loopback_interface.py +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -20,7 +20,8 @@ from __future__ import annotations -from typing import ClassVar, Optional, Type +from collections import defaultdict +from typing import ClassVar, List, Optional, 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 ( @@ -76,12 +77,16 @@ class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): """ model_class: ClassVar[Type[NDBaseModel]] = LoopbackInterfaceModel + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True create_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPost update_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPut delete_endpoint: Type[NDEndpointBaseModel] = NDEndpointBaseModel # unused; delete() uses bulk remove query_one_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesGet query_all_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesListGet + create_bulk_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesPost + delete_bulk_endpoint: Type[NDEndpointBaseModel] = EpManageInterfacesRemove deploy: bool = True @@ -338,6 +343,57 @@ def delete(self, model_instance: LoopbackInterfaceModel, **kwargs) -> None: 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[LoopbackInterfaceModel], **kwargs) -> ResponseType: + """ + # Summary + + Create multiple loopback interfaces in bulk. Groups interfaces by switch and sends one POST per switch with all + interfaces in the `interfaces` array, reducing API calls from N to one-per-switch. Queues deploys for all created + interfaces 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) + request_body = {"interfaces": [payload for _, payload in items]} + result = self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=request_body) + results.append(result) + for interface_name, _ 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[LoopbackInterfaceModel], **kwargs) -> None: + """ + # Summary + + Queue multiple loopback interfaces for deferred bulk removal and deployment. Each interface is queued for removal + via `remove_pending` and deployment via `deploy_pending`. No API calls are made until those methods are called + after `manage_state` completes. + + ## Raises + + None + """ + for model_instance in model_instances: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._queue_remove(model_instance.interface_name, switch_id) + self._queue_deploy(model_instance.interface_name, switch_id) + def query_one(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: """ # Summary