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.""" 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..37425f4c --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_interfaces.py @@ -0,0 +1,342 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# 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}) +- `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 + +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, + InterfaceNameMixin, + SwitchSerialNumberMixin, +) +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 _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 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 + + +class EpManageInterfacesRemove(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Bulk delete interfaces across one or more switches. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/interfaceActions/remove` + - Verb: POST + - Body: `{"interfaces": [{"interfaceName": "...", "switchId": "..."}]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpManageInterfacesRemove"] = Field( + default="EpManageInterfacesRemove", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the bulk remove 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", "remove") + + @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..6b5890a1 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_switches.py @@ -0,0 +1,196 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# 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..5f817008 --- /dev/null +++ b/plugins/module_utils/fabric_context.py @@ -0,0 +1,219 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# 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..f2d0fb47 --- /dev/null +++ b/plugins/module_utils/models/interfaces/loopback_interface.py @@ -0,0 +1,302 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# 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. +""" + +import ipaddress +from typing import ClassVar, Dict, List, Literal, Optional, Set + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + FieldSerializationInfo, + field_serializer, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + +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", 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 --- + + @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, value): + """ + # Summary + + Accept `policy_type` in either Ansible (`ipfm_loopback`) or API (`ipfmLoopback`) format, normalizing to Ansible names. + + ## Raises + + None + """ + 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(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): + """ + # 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]]] = ["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") + + @field_validator("interface_name", mode="before") + @classmethod + def normalize_interface_name(cls, value): + """ + # Summary + + Normalize interface name to lowercase to match ND API convention (e.g., Loopback0 -> loopback0). + + ## Raises + + None + """ + if isinstance(value, str): + return value.lower() + return value + + # --- 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), + 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( + 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="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..d739b42f --- /dev/null +++ b/plugins/module_utils/orchestrators/loopback_interface.py @@ -0,0 +1,457 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# 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. 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 +orchestrator only needs to inject `switchId` and filter `query_all` results by interface type. +""" + +from __future__ import annotations + +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 ( + EpManageInterfacesDeploy, + EpManageInterfacesGet, + EpManageInterfacesListGet, + EpManageInterfacesPost, + EpManageInterfacesPut, + EpManageInterfacesRemove, +) +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 ( + 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 + + +class LoopbackInterfaceOrchestrator(NDBaseOrchestrator[LoopbackInterfaceModel]): + """ + # Summary + + 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. + + 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`. + + For `state: overridden`, `query_all` queries ALL switches in the fabric to enable fabric-wide convergence. + + Uses `FabricContext` for pre-flight validation and switch resolution. + + ## Raises + + ### RuntimeError + + - Via `validate_prerequisites` if the fabric does not exist or is in deployment-freeze mode. + - Via `_resolve_switch_id` if no switch matches the given IP in the fabric. + - Via `create` if the create API request fails. + - Via `update` if the update API request fails. + - Via `remove_pending` if the bulk remove API request fails. + - Via `deploy_pending` if the bulk deploy API request fails. + - Via `query_one` if the query API request fails. + - Via `query_all` if the query API request fails. + """ + + model_class: ClassVar[Type[NDBaseModel]] = 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 + + _fabric_context: Optional[FabricContext] = None + _pending_deploys: list[tuple[str, str]] = [] + _pending_removes: list[tuple[str, 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 + + def _resolve_switch_id(self, switch_ip: str) -> str: + """ + # Summary + + Resolve a `switch_ip` to its `switchId` via `FabricContext`. + + ## Raises + + ### RuntimeError + + - If no switch matches the given IP in the fabric. + """ + return self.fabric_context.get_switch_id(switch_ip) + + def validate_prerequisites(self) -> None: + """ + # Summary + + Run pre-flight validation before any CRUD operations. Checks that the fabric exists and is modifiable. + + ## Raises + + ### RuntimeError + + - If the fabric does not exist on the target ND node. + - If the fabric is in deployment-freeze mode. + """ + self.fabric_context.validate_for_mutation() + + def _configure_endpoint(self, api_endpoint, switch_sn: str): + """ + # Summary + + Set `fabric_name` and `switch_sn` on an endpoint instance before path generation. + + ## Raises + + None + """ + api_endpoint.fabric_name = self.fabric_name + api_endpoint.switch_sn = switch_sn + return api_endpoint + + def _queue_deploy(self, interface_name: str, switch_id: str) -> None: + """ + # Summary + + Queue an `(interface_name, switch_id)` pair for deferred deployment. Call `deploy_pending` after all mutations + are complete to deploy in bulk. + + ## Raises + + None + """ + pair = (interface_name, switch_id) + if pair not in self._pending_deploys: + self._pending_deploys.append(pair) + + def _queue_remove(self, interface_name: str, switch_id: str) -> None: + """ + # Summary + + 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 + """ + pair = (interface_name, switch_id) + if pair not in self._pending_removes: + self._pending_removes.append(pair) + + def deploy_pending(self) -> ResponseType | None: + """ + # Summary + + Deploy all queued interface configurations in a single API call via `interfaceActions/deploy`. Clears the pending + queue after deployment. + + When `deploy` is `False`, returns `None` without making any API call. + + ## Raises + + ### RuntimeError + + - If the deploy API request fails. + """ + if not self.deploy or not self._pending_deploys: + return None + try: + result = self._deploy_interfaces() + self._pending_deploys = [] + return result + except Exception as e: + raise RuntimeError(f"Bulk deploy failed for interfaces {self._pending_deploys}: {e}") from e + + def _deploy_interfaces(self) -> ResponseType: + """ + # Summary + + Deploy queued interfaces via `interfaceActions/deploy`. Sends the explicit list of `{interfaceName, switchId}` pairs. + + ## Raises + + ### Exception + + - If the deploy API request fails (propagated to caller). + """ + api_endpoint = EpManageInterfacesDeploy() + api_endpoint.fabric_name = self.fabric_name + payload = {"interfaces": [{"interfaceName": name, "switchId": switch_id} for name, switch_id in self._pending_deploys]} + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + + def remove_pending(self) -> ResponseType | None: + """ + # Summary + + 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 + + Remove queued interfaces via `interfaceActions/remove`. Sends the explicit list of `{interfaceName, switchId}` pairs. + + ## Raises + + ### Exception + + - If the remove API request fails (propagated to caller). + """ + api_endpoint = EpManageInterfacesRemove() + api_endpoint.fabric_name = self.fabric_name + 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. Resolves `switch_ip` from the model instance, injects `switchId`, and wraps the payload + in an `interfaces` array. Queues a deploy for later bulk execution via `deploy_pending`. + + ## Raises + + ### RuntimeError + + - If the create API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.create_endpoint(), switch_sn=switch_id) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + request_body = {"interfaces": [payload]} + result = self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=request_body) + self._queue_deploy(model_instance.interface_name, switch_id) + return result + except Exception as e: + raise RuntimeError(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e + + def update(self, model_instance: LoopbackInterfaceModel, **kwargs) -> ResponseType: + """ + # Summary + + 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 + + ### RuntimeError + + - If the update API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.update_endpoint(), switch_sn=switch_id) + api_endpoint.set_identifiers(model_instance.interface_name) + payload = model_instance.to_payload() + payload["switchId"] = switch_id + result = self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + self._queue_deploy(model_instance.interface_name, switch_id) + return result + except Exception as e: + raise RuntimeError(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e + + def delete(self, model_instance: LoopbackInterfaceModel, **kwargs) -> None: + """ + # Summary + + 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. + + No API calls are made until `remove_pending` and `deploy_pending` are called after all mutations are complete. + + ## Raises + + None + """ + switch_id = self._resolve_switch_id(model_instance.switch_ip) + self._queue_remove(model_instance.interface_name, switch_id) + self._queue_deploy(model_instance.interface_name, switch_id) + + def create_bulk(self, model_instances: List[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 + + Query a single loopback interface by name on a specific switch. + + ## Raises + + ### RuntimeError + + - If the query API request fails. + """ + try: + switch_id = self._resolve_switch_id(model_instance.switch_ip) + api_endpoint = self._configure_endpoint(self.query_one_endpoint(), switch_sn=switch_id) + api_endpoint.set_identifiers(model_instance.interface_name) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb) + except Exception as e: + raise RuntimeError(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e + + def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> ResponseType: + """ + # Summary + + 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_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 + + ### RuntimeError + + - If the fabric does not exist on the target ND node. + - If the fabric is in deployment-freeze mode. + - If the query API request fails. + """ + managed_policy_types = set(LOOPBACK_POLICY_TYPE_MAPPING.data.values()) + try: + self.validate_prerequisites() + 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/__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..a485ccdb --- /dev/null +++ b/plugins/modules/nd_interface_loopback.py @@ -0,0 +1,294 @@ +#!/usr/bin/python + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_interface_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: +- Allen Robel (@allenrobel) +options: + fabric_name: + description: + - The name of the fabric containing the target switches. + type: str + required: true + config: + description: + - The list of 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)). + 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: 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 + via the C(interfaceActions/deploy) API. Only the interfaces modified by this task are deployed. + - When V(false), changes are staged but not deployed. Use a separate deploy module or task to deploy later. + - Setting O(deploy=false) is useful when batching changes across multiple interface tasks before a single deploy. + type: bool + default: true + state: + description: + - The desired state of the network resources on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new resources and update existing ones as defined in your configuration. + Resources on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the resources specified in the configuration. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + The resources on ND will be modified to exactly match the configuration. + Any resource existing on ND but not present in the configuration will be deleted. Use with extra caution. + - Use O(state=deleted) to 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 on a single switch + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + 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 loopback interfaces across multiple switches + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.1 + description: Router ID loopback + - switch_ip: 192.168.1.1 + interface_name: loopback1 + config_data: + network_os: + policy: + ip: 10.2.1.1 + description: VTEP loopback + route_map_tag: "12345" + - 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: Replace a loopback interface + cisco.nd.nd_interface_loopback: + fabric_name: my_fabric + config: + - switch_ip: 192.168.1.1 + 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 + config: + - 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 + config: + - switch_ip: 192.168.1.1 + interface_name: loopback0 + config_data: + network_os: + policy: + ip: 10.1.1.1 + deploy: false + 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), + ) + + 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"] + + # Manage state + nd_state_machine.manage_state() + + # Execute all queued bulk operations + if not module.check_mode: + nd_state_machine.model_orchestrator.remove_pending() + nd_state_machine.model_orchestrator.deploy_pending() + + module.exit_json(**nd_state_machine.output.format()) + + except NDStateMachineError as e: + output = nd_state_machine.output.format() if nd_state_machine else {} + error_msg = f"Module execution failed: {str(e)}" + if module.params.get("output_level") == "debug": + error_msg += f"\nTraceback:\n{traceback.format_exc()}" + module.fail_json(msg=error_msg, **output) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/inventory.py b/tests/integration/inventory.py new file mode 100644 index 00000000..ba90171c --- /dev/null +++ b/tests/integration/inventory.py @@ -0,0 +1,228 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +# Summary + +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() 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..91f653cc --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/deleted.yaml @@ -0,0 +1,122 @@ +--- +# Deleted state tests for nd_interface_loopback +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + 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 }}" + config: + - 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 + +- 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 }}" + config: + - 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 + +- 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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..59a17cb0 --- /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, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Test that we have a Nexus Dashboard host, username and password + ansible.builtin.fail: + msg: 'Please define the following variables: ansible_host, ansible_user and ansible_password.' + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + +- name: Set vars + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + +- name: Run nd_interface_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..361529cd --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/merged.yaml @@ -0,0 +1,191 @@ +--- +# Merged state tests for nd_interface_loopback +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- CLEANUP --- + +- name: "SETUP: Remove test loopback interfaces before merged tests" + cisco.nd.nd_interface_loopback: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - 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 + +# --- 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 }}" + 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 }}" + 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 }}" + 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 }}" + 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 }}" + 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 }}" + 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 }}" + 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 }}" + 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 }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - 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 new file mode 100644 index 00000000..28697e1d --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/tasks/overridden.yaml @@ -0,0 +1,145 @@ +--- +# Overridden state tests for nd_interface_loopback +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + 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..1b1c64dd --- /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, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# --- SETUP --- +# At this point 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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 }}" + config: + - switch_ip: "{{ test_switch_ip }}" + 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..725ac252 --- /dev/null +++ b/tests/integration/targets/nd_interface_loopback/vars/main.yaml @@ -0,0 +1,70 @@ +--- +# 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: + switch_ip: "{{ test_switch_ip }}" + 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: + switch_ip: "{{ test_switch_ip }}" + 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: + switch_ip: "{{ test_switch_ip }}" + 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: + switch_ip: "{{ test_switch_ip }}" + 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: + switch_ip: "{{ test_switch_ip }}" + interface_name: loopback101 + config_data: + network_os: + policy: + admin_state: false + ip: "10.100.101.2" + description: "Updated loopback101 description" + policy_type: loopback 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="") 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..ae0f9b71 --- /dev/null +++ b/tests/unit/module_utils/models/test_loopback_interface.py @@ -0,0 +1,1847 @@ +# -*- 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 ansible_collections.cisco.nd.plugins.module_utils.models.interfaces.loopback_interface import ( + LOOPBACK_POLICY_TYPE_MAPPING, + LoopbackConfigDataModel, + LoopbackInterfaceModel, + LoopbackNetworkOSModel, + LoopbackPolicyModel, +) +from pydantic import ValidationError # pylint: disable=unused-import + + +@contextmanager +def does_not_raise(): + """A context manager that does not raise an exception.""" + yield + + +# ============================================================================= +# Test data constants +# ============================================================================= + +SAMPLE_API_RESPONSE = { + "switchIp": "192.168.1.1", + "interfaceName": "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 = { + "switch_ip": "192.168.1.1", + "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.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: 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 +# ============================================================================= + + +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 == ["switch_ip", "interface_name"] + - identifier_strategy == "composite" + + ## Classes and Methods + + - LoopbackInterfaceModel class attributes + """ + assert LoopbackInterfaceModel.identifiers == ["switch_ip", "interface_name"] + assert LoopbackInterfaceModel.identifier_strategy == "composite" + + +def test_loopback_interface_00210(): + """ + # Summary + + Verify interface_type defaults to "loopback". + + ## Test + + - Construct with switch_ip and interface_name + - interface_type defaults to "loopback" + + ## Classes and Methods + + - LoopbackInterfaceModel.__init__() + """ + with does_not_raise(): + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") + assert instance.interface_type == "loopback" + + +def test_loopback_interface_00220(): + """ + # Summary + + Verify config_data defaults to None. + + ## Test + + - Construct with switch_ip and interface_name + - config_data defaults to None + + ## Classes and Methods + + - LoopbackInterfaceModel.__init__() + """ + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="loopback0") + assert instance.config_data is None + + +def test_loopback_interface_00230(): + """ + # Summary + + Verify switch_ip and interface_name are required — ValidationError without them. + + ## Test + + - Construct without required fields + - Raises ValidationError + + ## Classes and Methods + + - LoopbackInterfaceModel.__init__() + """ + with pytest.raises(ValidationError): + LoopbackInterfaceModel() + with pytest.raises(ValidationError): + LoopbackInterfaceModel(interface_name="loopback0") + with pytest.raises(ValidationError): + LoopbackInterfaceModel(switch_ip="192.168.1.1") + + +# ============================================================================= +# 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(switch_ip="192.168.1.1", 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(switch_ip="192.168.1.1", 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(switch_ip="192.168.1.1", 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(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(): + """ + # 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_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 + + 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({"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 + + +# ============================================================================= +# Test: LoopbackInterfaceModel — Round-trip +# ============================================================================= + + +def test_loopback_interface_00500(): + """ + # Summary + + 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 + + ## 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() + # 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 + + +def test_loopback_interface_00510(): + """ + # Summary + + 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 (except switchIp in payload) + + ## 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() + # 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(): + """ + # 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" + + # 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" + + +# ============================================================================= +# Test: LoopbackInterfaceModel — Identifier +# ============================================================================= + + +def test_loopback_interface_00550(): + """ + # Summary + + Verify get_identifier_value() returns composite tuple (switch_ip, interface_name). + + ## Test + + - get_identifier_value() returns a tuple of (switch_ip, interface_name) + + ## Classes and Methods + + - LoopbackInterfaceModel.get_identifier_value() + """ + 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 interface_name in composite tuple. + + ## Test + + - Constructed with "Loopback1" + - get_identifier_value() returns ("192.168.1.1", "loopback1") + + ## Classes and Methods + + - LoopbackInterfaceModel.get_identifier_value() + - LoopbackInterfaceModel.normalize_interface_name() + """ + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", interface_name="Loopback1") + assert instance.get_identifier_value() == ("192.168.1.1", "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(switch_ip="192.168.1.1", 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 = { + "switch_ip": "192.168.1.1", + "interface_name": "loopback0", + "config_data": { + "network_os": { + "policy": { + "admin_state": True, + }, + }, + }, + } + config_other = { + "switch_ip": "192.168.1.1", + "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(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" + + +def test_loopback_interface_00670(): + """ + # Summary + + Verify mismatched types -> TypeError. + + ## Test + + - Merge with wrong type raises TypeError + + ## Classes and Methods + + - LoopbackInterfaceModel.merge() + """ + instance = LoopbackInterfaceModel(switch_ip="192.168.1.1", 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(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 + + +# ============================================================================= +# 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, config, state + - switch_ip is inside config options, not top-level + + ## Classes and Methods + + - LoopbackInterfaceModel.get_argument_spec() + """ + spec = LoopbackInterfaceModel.get_argument_spec() + assert "fabric_name" 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(): + """ + # 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"