From d02b5d982337342260a62be109d375af07a32308 Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Mon, 13 Apr 2026 20:24:30 -0400 Subject: [PATCH 1/3] initial add for tenancy --- .../endpoints/v1/infra/tenant_domains.py | 170 +++++++ .../endpoints/v1/infra/tenants.py | 170 +++++++ .../v1/manage/tenant_fabric_associations.py | 110 +++++ .../models/infra_tenant/infra_tenant.py | 148 +++++++ .../infra_tenant_domain.py | 63 +++ .../orchestrators/infra_tenant.py | 247 +++++++++++ .../orchestrators/infra_tenant_domain.py | 39 ++ plugins/modules/nd_infra_tenant.py | 177 ++++++++ plugins/modules/nd_infra_tenant_domain.py | 128 ++++++ .../targets/nd_infra_tenant/tasks/main.yml | 300 +++++++++++++ .../nd_infra_tenant_domain/tasks/main.yml | 222 ++++++++++ ...t_endpoints_api_v1_infra_tenant_domains.py | 415 ++++++++++++++++++ .../test_endpoints_api_v1_infra_tenants.py | 415 ++++++++++++++++++ ...pi_v1_manage_tenant_fabric_associations.py | 175 ++++++++ 14 files changed, 2779 insertions(+) create mode 100644 plugins/module_utils/endpoints/v1/infra/tenant_domains.py create mode 100644 plugins/module_utils/endpoints/v1/infra/tenants.py create mode 100644 plugins/module_utils/endpoints/v1/manage/tenant_fabric_associations.py create mode 100644 plugins/module_utils/models/infra_tenant/infra_tenant.py create mode 100644 plugins/module_utils/models/infra_tenant_domain/infra_tenant_domain.py create mode 100644 plugins/module_utils/orchestrators/infra_tenant.py create mode 100644 plugins/module_utils/orchestrators/infra_tenant_domain.py create mode 100644 plugins/modules/nd_infra_tenant.py create mode 100644 plugins/modules/nd_infra_tenant_domain.py create mode 100644 tests/integration/targets/nd_infra_tenant/tasks/main.yml create mode 100644 tests/integration/targets/nd_infra_tenant_domain/tasks/main.yml create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenant_domains.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenants.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py diff --git a/plugins/module_utils/endpoints/v1/infra/tenant_domains.py b/plugins/module_utils/endpoints/v1/infra/tenant_domains.py new file mode 100644 index 00000000..32a9c33a --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/tenant_domains.py @@ -0,0 +1,170 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra Tenant Domains endpoint models. + +This module contains endpoint definitions for Tenant Domain operations in the ND Infra API. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal, Optional +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.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 TenantDomainNameMixin(BaseModel): + """Mixin for endpoints that require tenant_domain_name parameter.""" + + tenant_domain_name: Optional[str] = Field(default=None, min_length=1, max_length=63, description="Tenant domain name") + + +class _EpInfraTenantDomainsBase(TenantDomainNameMixin, NDEndpointBaseModel): + """ + Base class for ND Infra Tenant Domains endpoints. + + Provides common functionality for all HTTP methods on the /api/v1/infra/tenantDomains endpoint. + """ + + @property + def path(self) -> str: + """ + # Summary + + Build the /api/v1/infra/tenantDomains endpoint path. + + ## Returns + + - Complete endpoint path string, optionally including tenant_domain_name + """ + if self.tenant_domain_name is not None: + return BasePath.path("tenantDomains", self.tenant_domain_name) + return BasePath.path("tenantDomains") + + def set_identifiers(self, identifier: IdentifierKey = None): + self.tenant_domain_name = identifier + + +class EpInfraTenantDomainsGet(_EpInfraTenantDomainsBase): + """ + # Summary + + ND Infra Tenant Domains GET Endpoint + + ## Description + + Endpoint to retrieve tenant domains from the ND Infra service. + Optionally retrieve a specific tenant domain by name. + + ## Path + + - /api/v1/infra/tenantDomains + - /api/v1/infra/tenantDomains/{tenantDomainName} + + ## Verb + + - GET + """ + + class_name: Literal["EpInfraTenantDomainsGet"] = Field( + default="EpInfraTenantDomainsGet", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpInfraTenantDomainsPost(_EpInfraTenantDomainsBase): + """ + # Summary + + ND Infra Tenant Domains POST Endpoint + + ## Description + + Endpoint to create a tenant domain in the ND Infra service. + + ## Path + + - /api/v1/infra/tenantDomains + + ## Verb + + - POST + """ + + class_name: Literal["EpInfraTenantDomainsPost"] = Field( + default="EpInfraTenantDomainsPost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpInfraTenantDomainsPut(_EpInfraTenantDomainsBase): + """ + # Summary + + ND Infra Tenant Domains PUT Endpoint + + ## Description + + Endpoint to update a tenant domain in the ND Infra service. + + ## Path + + - /api/v1/infra/tenantDomains/{tenantDomainName} + + ## Verb + + - PUT + """ + + class_name: Literal["EpInfraTenantDomainsPut"] = Field( + default="EpInfraTenantDomainsPut", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +class EpInfraTenantDomainsDelete(_EpInfraTenantDomainsBase): + """ + # Summary + + ND Infra Tenant Domains DELETE Endpoint + + ## Description + + Endpoint to delete a tenant domain from the ND Infra service. + + ## Path + + - /api/v1/infra/tenantDomains/{tenantDomainName} + + ## Verb + + - DELETE + """ + + class_name: Literal["EpInfraTenantDomainsDelete"] = Field( + default="EpInfraTenantDomainsDelete", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.DELETE diff --git a/plugins/module_utils/endpoints/v1/infra/tenants.py b/plugins/module_utils/endpoints/v1/infra/tenants.py new file mode 100644 index 00000000..59f0cbef --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/tenants.py @@ -0,0 +1,170 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra Tenants endpoint models. + +This module contains endpoint definitions for Tenant operations in the ND Infra API. +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import Literal, Optional +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.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 TenantNameMixin(BaseModel): + """Mixin for endpoints that require tenant_name parameter.""" + + tenant_name: Optional[str] = Field(default=None, min_length=1, max_length=63, description="Tenant name") + + +class _EpInfraTenantsBase(TenantNameMixin, NDEndpointBaseModel): + """ + Base class for ND Infra Tenants endpoints. + + Provides common functionality for all HTTP methods on the /api/v1/infra/tenants endpoint. + """ + + @property + def path(self) -> str: + """ + # Summary + + Build the /api/v1/infra/tenants endpoint path. + + ## Returns + + - Complete endpoint path string, optionally including tenant_name + """ + if self.tenant_name is not None: + return BasePath.path("tenants", self.tenant_name) + return BasePath.path("tenants") + + def set_identifiers(self, identifier: IdentifierKey = None): + self.tenant_name = identifier + + +class EpInfraTenantsGet(_EpInfraTenantsBase): + """ + # Summary + + ND Infra Tenants GET Endpoint + + ## Description + + Endpoint to retrieve tenants from the ND Infra service. + Optionally retrieve a specific tenant by name. + + ## Path + + - /api/v1/infra/tenants + - /api/v1/infra/tenants/{tenantName} + + ## Verb + + - GET + """ + + class_name: Literal["EpInfraTenantsGet"] = Field( + default="EpInfraTenantsGet", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpInfraTenantsPost(_EpInfraTenantsBase): + """ + # Summary + + ND Infra Tenants POST Endpoint + + ## Description + + Endpoint to create a tenant in the ND Infra service. + + ## Path + + - /api/v1/infra/tenants + + ## Verb + + - POST + """ + + class_name: Literal["EpInfraTenantsPost"] = Field( + default="EpInfraTenantsPost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpInfraTenantsPut(_EpInfraTenantsBase): + """ + # Summary + + ND Infra Tenants PUT Endpoint + + ## Description + + Endpoint to update a tenant in the ND Infra service. + + ## Path + + - /api/v1/infra/tenants/{tenantName} + + ## Verb + + - PUT + """ + + class_name: Literal["EpInfraTenantsPut"] = Field( + default="EpInfraTenantsPut", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +class EpInfraTenantsDelete(_EpInfraTenantsBase): + """ + # Summary + + ND Infra Tenants DELETE Endpoint + + ## Description + + Endpoint to delete a tenant from the ND Infra service. + + ## Path + + - /api/v1/infra/tenants/{tenantName} + + ## Verb + + - DELETE + """ + + class_name: Literal["EpInfraTenantsDelete"] = Field( + default="EpInfraTenantsDelete", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.DELETE diff --git a/plugins/module_utils/endpoints/v1/manage/tenant_fabric_associations.py b/plugins/module_utils/endpoints/v1/manage/tenant_fabric_associations.py new file mode 100644 index 00000000..58108055 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/tenant_fabric_associations.py @@ -0,0 +1,110 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Tenant Fabric Associations endpoint models. + +This module contains endpoint definitions for Tenant Fabric Association +operations in the ND Manage API. + +## Endpoints + +- `EpManageTenantFabricAssociationsGet` - List all tenant fabric associations + (GET /api/v1/manage/tenantFabricAssociations) +- `EpManageTenantFabricAssociationsPost` - Create or delete tenant fabric associations + (POST /api/v1/manage/tenantFabricAssociations) +""" + +from __future__ import absolute_import, annotations, division, print_function + +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.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 _EpManageTenantFabricAssociationsBase(NDEndpointBaseModel): + """ + Base class for ND Manage Tenant Fabric Associations endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/tenantFabricAssociations endpoint. + """ + + @property + def path(self) -> str: + """ + # Summary + + Build the /api/v1/manage/tenantFabricAssociations endpoint path. + + ## Returns + + - Complete endpoint path string + """ + return BasePath.path("tenantFabricAssociations") + + def set_identifiers(self, identifier: IdentifierKey = None): + pass + + +class EpManageTenantFabricAssociationsGet(_EpManageTenantFabricAssociationsBase): + """ + # Summary + + ND Manage Tenant Fabric Associations GET Endpoint + + ## Description + + Endpoint to retrieve all tenant fabric associations from the ND Manage service. + + ## Path + + - /api/v1/manage/tenantFabricAssociations + + ## Verb + + - GET + """ + + class_name: Literal["EpManageTenantFabricAssociationsGet"] = Field( + default="EpManageTenantFabricAssociationsGet", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpManageTenantFabricAssociationsPost(_EpManageTenantFabricAssociationsBase): + """ + # Summary + + ND Manage Tenant Fabric Associations POST Endpoint + + ## Description + + Endpoint to create or delete tenant fabric associations in the ND Manage service. + The request body contains an 'items' array where each item includes an 'associate' + boolean flag (true = create, false = delete). + + ## Path + + - /api/v1/manage/tenantFabricAssociations + + ## Verb + + - POST + """ + + class_name: Literal["EpManageTenantFabricAssociationsPost"] = Field( + default="EpManageTenantFabricAssociationsPost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/infra_tenant/infra_tenant.py b/plugins/module_utils/models/infra_tenant/infra_tenant.py new file mode 100644 index 00000000..d450bfc8 --- /dev/null +++ b/plugins/module_utils/models/infra_tenant/infra_tenant.py @@ -0,0 +1,148 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Any, List, Dict, Optional, ClassVar, Literal, Set +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_serializer, + field_validator, + FieldSerializationInfo, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +class InfraTenantFabricAssociationModel(NDNestedModel): + """ + Fabric association for a tenant. + + Canonical form (config): + {"fabric_name": "fab1", "allowed_vlans": ["10-20"], "local_name": "loc1", "tenant_prefix": "pfx"} + API payload form (manage API): + {"fabricName": "fab1", "tenantName": "t1", "allowedVlans": ["10-20"], "localName": "loc1", "tenantPrefix": "pfx"} + """ + + fabric_name: str = Field(alias="fabricName") + allowed_vlans: Optional[List[str]] = Field(default=None, alias="allowedVlans") + local_name: Optional[str] = Field(default=None, alias="localName") + tenant_prefix: Optional[str] = Field(default=None, alias="tenantPrefix") + + +class InfraTenantModel(NDBaseModel): + """ + Tenant configuration for Nexus Dashboard Multi Tenancy. + + Identifier: name (single) + + API endpoints (tenant CRUD — infra API): + - GET /api/v1/infra/tenants + - GET /api/v1/infra/tenants/{tenantName} + - POST /api/v1/infra/tenants + - PUT /api/v1/infra/tenants/{tenantName} + - DELETE /api/v1/infra/tenants/{tenantName} + + API endpoints (fabric associations — manage API): + - GET /api/v1/manage/tenantFabricAssociations + - POST /api/v1/manage/tenantFabricAssociations + + Serialization notes: + - ``fabric_associations`` is excluded from the infra API payload + (handled by ``payload_exclude_fields``). The orchestrator sends + association data to the manage API separately. + - In config mode, ``fabric_associations`` appears as a flat list + of dicts with snake_case keys. + """ + + # --- Identifier Configuration --- + + identifiers: ClassVar[Optional[List[str]]] = ["name"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + + # --- Serialization Configuration --- + + exclude_from_diff: ClassVar[set] = set() + payload_exclude_fields: ClassVar[Set[str]] = {"fabric_associations"} + + # --- Fields --- + + name: str = Field(alias="name") + description: Optional[str] = Field(default=None, alias="description") + fabric_associations: Optional[List[InfraTenantFabricAssociationModel]] = Field( + default=None, alias="fabricAssociations" + ) + + # --- Serializers --- + + @field_serializer("fabric_associations") + def serialize_fabric_associations( + self, + value: Optional[List[InfraTenantFabricAssociationModel]], + info: FieldSerializationInfo, + ) -> Any: + if not value: + return None + + mode = (info.context or {}).get("mode", "payload") + + if mode == "config": + return [ + assoc.model_dump(by_alias=False, exclude_none=True) + for assoc in value + ] + + # Payload mode — not used directly (excluded via payload_exclude_fields), + # but provided for completeness. + return [ + assoc.model_dump(by_alias=True, exclude_none=True) + for assoc in value + ] + + # --- Validators --- + + @field_validator("fabric_associations", mode="before") + @classmethod + def normalize_fabric_associations(cls, value: Any) -> Optional[List[Dict]]: + """ + Accept fabric_associations in either format: + - List of dicts (Ansible config or merged orchestrator data) + - None + """ + if value is None: + return None + if isinstance(value, list): + return value + return value + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + return dict( + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + fabric_associations=dict( + type="list", + elements="dict", + options=dict( + fabric_name=dict(type="str", required=True), + allowed_vlans=dict(type="list", elements="str"), + local_name=dict(type="str"), + tenant_prefix=dict(type="str"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/models/infra_tenant_domain/infra_tenant_domain.py b/plugins/module_utils/models/infra_tenant_domain/infra_tenant_domain.py new file mode 100644 index 00000000..dc649e17 --- /dev/null +++ b/plugins/module_utils/models/infra_tenant_domain/infra_tenant_domain.py @@ -0,0 +1,63 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import List, Dict, Optional, ClassVar, Literal +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + + +class InfraTenantDomainModel(NDBaseModel): + """ + Tenant Domain configuration for Nexus Dashboard Multi Tenancy. + + Identifier: name (single) + + API endpoints: + - GET /api/v1/infra/tenantDomains + - GET /api/v1/infra/tenantDomains/{tenantDomainName} + - POST /api/v1/infra/tenantDomains + - PUT /api/v1/infra/tenantDomains/{tenantDomainName} + - DELETE /api/v1/infra/tenantDomains/{tenantDomainName} + """ + + # --- Identifier Configuration --- + + identifiers: ClassVar[Optional[List[str]]] = ["name"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + + # --- Serialization Configuration --- + + exclude_from_diff: ClassVar[set] = set() + + # --- Fields --- + + name: str = Field(alias="name") + tenant_names: Optional[List[str]] = Field(default=None, alias="tenantNames") + description: Optional[str] = Field(default=None, alias="description") + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + return dict( + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + name=dict(type="str", required=True), + tenant_names=dict(type="list", elements="str"), + description=dict(type="str"), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/orchestrators/infra_tenant.py b/plugins/module_utils/orchestrators/infra_tenant.py new file mode 100644 index 00000000..db4c1308 --- /dev/null +++ b/plugins/module_utils/orchestrators/infra_tenant.py @@ -0,0 +1,247 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Type, ClassVar, List, Dict, Any, Optional +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.infra_tenant.infra_tenant import InfraTenantModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.tenants import ( + EpInfraTenantsPost, + EpInfraTenantsPut, + EpInfraTenantsDelete, + EpInfraTenantsGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.tenant_fabric_associations import ( + EpManageTenantFabricAssociationsGet, + EpManageTenantFabricAssociationsPost, +) + + +class InfraTenantOrchestrator(NDBaseOrchestrator[InfraTenantModel]): + model_class: ClassVar[Type[NDBaseModel]] = InfraTenantModel + + create_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantsPost + update_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantsPut + delete_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantsDelete + query_one_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantsGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantsGet + + # --- Helpers for fabric associations (manage API) --- + + def _query_all_fabric_associations(self) -> List[Dict[str, Any]]: + """Fetch all tenant-fabric associations from the manage API.""" + try: + ep = EpManageTenantFabricAssociationsGet() + result = self.sender.query_obj(ep.path) + return result.get("tenantFabricAssociations", []) or [] + except Exception: + return [] + + @staticmethod + def _group_associations_by_tenant(associations: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: + """Group fabric associations by tenant name, stripping tenantName and syncStatus.""" + grouped: Dict[str, List[Dict[str, Any]]] = {} + for assoc in associations: + tenant_name = assoc.get("tenantName") + if not tenant_name: + continue + entry = {k: v for k, v in assoc.items() if k not in ("tenantName", "syncStatus")} + grouped.setdefault(tenant_name, []).append(entry) + return grouped + + def _sync_fabric_associations( + self, + tenant_name: str, + proposed_associations: Optional[List[Any]], + existing_associations: Optional[List[Dict[str, Any]]] = None, + ) -> None: + """ + Reconcile fabric associations for a tenant. + + Compares proposed vs existing associations and issues create/delete + calls to the manage API as needed. + """ + if proposed_associations is None: + return + + ep = EpManageTenantFabricAssociationsPost() + + # Build lookup of existing associations by fabric_name + existing_by_fabric: Dict[str, Dict[str, Any]] = {} + if existing_associations: + for assoc in existing_associations: + fname = assoc.get("fabricName") or assoc.get("fabric_name") + if fname: + existing_by_fabric[fname] = assoc + + # Build lookup of proposed associations by fabric_name + proposed_by_fabric: Dict[str, Dict[str, Any]] = {} + for assoc in proposed_associations: + if hasattr(assoc, "model_dump"): + d = assoc.model_dump(by_alias=True, exclude_none=True) + elif isinstance(assoc, dict): + d = dict(assoc) + else: + continue + fname = d.get("fabricName") or d.get("fabric_name") + if fname: + proposed_by_fabric[fname] = d + + # Delete associations no longer proposed + to_delete = [] + for fname in existing_by_fabric: + if fname not in proposed_by_fabric: + to_delete.append({ + "fabricName": fname, + "tenantName": tenant_name, + "associate": False, + }) + + # Create or update associations + to_create = [] + for fname, proposed_data in proposed_by_fabric.items(): + payload = { + "fabricName": fname, + "tenantName": tenant_name, + "associate": True, + } + if "allowedVlans" in proposed_data: + payload["allowedVlans"] = proposed_data["allowedVlans"] + elif "allowed_vlans" in proposed_data: + payload["allowedVlans"] = proposed_data["allowed_vlans"] + if "localName" in proposed_data: + payload["localName"] = proposed_data["localName"] + elif "local_name" in proposed_data: + payload["localName"] = proposed_data["local_name"] + if "tenantPrefix" in proposed_data: + payload["tenantPrefix"] = proposed_data["tenantPrefix"] + elif "tenant_prefix" in proposed_data: + payload["tenantPrefix"] = proposed_data["tenant_prefix"] + + existing = existing_by_fabric.get(fname) + if existing: + # Check if association changed + changed = False + for key in ("allowedVlans", "localName", "tenantPrefix"): + if payload.get(key) != existing.get(key): + changed = True + break + if changed: + to_create.append(payload) + else: + to_create.append(payload) + + items = to_delete + to_create + if items: + self.sender.request(path=ep.path, method=ep.verb, data={"items": items}) + + def _delete_fabric_associations(self, tenant_name: str) -> None: + """Delete all fabric associations for a tenant.""" + all_assocs = self._query_all_fabric_associations() + to_delete = [ + { + "fabricName": a.get("fabricName"), + "tenantName": tenant_name, + "associate": False, + } + for a in all_assocs + if a.get("tenantName") == tenant_name + ] + if to_delete: + ep = EpManageTenantFabricAssociationsPost() + self.sender.request(path=ep.path, method=ep.verb, data={"items": to_delete}) + + # --- Overridden CRUD operations --- + + def query_all(self) -> ResponseType: + """ + Fetch tenants from infra API and merge fabric associations from manage API. + """ + try: + # Fetch tenants + tenant_ep = self.query_all_endpoint() + tenant_result = self.sender.query_obj(tenant_ep.path) + tenants = tenant_result.get("tenants", []) or [] + + # Fetch fabric associations + all_assocs = self._query_all_fabric_associations() + grouped = self._group_associations_by_tenant(all_assocs) + + # Merge associations into tenant data + for tenant in tenants: + tenant_name = tenant.get("name") + if tenant_name and tenant_name in grouped: + tenant["fabricAssociations"] = grouped[tenant_name] + + return tenants + except Exception as e: + raise Exception(f"Query all failed: {e}") from e + + def create(self, model_instance: InfraTenantModel, **kwargs) -> ResponseType: + """Create tenant via infra API, then create fabric associations via manage API.""" + try: + # Create tenant in infra API + api_endpoint = self.create_endpoint() + result = self.sender.request( + path=api_endpoint.path, + method=api_endpoint.verb, + data=model_instance.to_payload(), + ) + + # Create fabric associations if specified + if model_instance.fabric_associations: + self._sync_fabric_associations( + tenant_name=model_instance.name, + proposed_associations=model_instance.fabric_associations, + ) + + return result + except Exception as e: + raise Exception(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e + + def update(self, model_instance: InfraTenantModel, **kwargs) -> ResponseType: + """Update tenant via infra API, then reconcile fabric associations via manage API.""" + try: + # Update tenant in infra API + api_endpoint = self.update_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + result = self.sender.request( + path=api_endpoint.path, + method=api_endpoint.verb, + data=model_instance.to_payload(), + ) + + # Reconcile fabric associations if specified + if model_instance.fabric_associations is not None: + all_assocs = self._query_all_fabric_associations() + existing_for_tenant = [ + a for a in all_assocs + if a.get("tenantName") == model_instance.name + ] + self._sync_fabric_associations( + tenant_name=model_instance.name, + proposed_associations=model_instance.fabric_associations, + existing_associations=existing_for_tenant, + ) + + return result + except Exception as e: + raise Exception(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e + + def delete(self, model_instance: InfraTenantModel, **kwargs) -> ResponseType: + """Delete fabric associations first, then delete tenant via infra API.""" + try: + # Delete all fabric associations for this tenant + self._delete_fabric_associations(model_instance.name) + + # Delete tenant from infra API + api_endpoint = self.delete_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb) + except Exception as e: + raise Exception(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e diff --git a/plugins/module_utils/orchestrators/infra_tenant_domain.py b/plugins/module_utils/orchestrators/infra_tenant_domain.py new file mode 100644 index 00000000..b93189f2 --- /dev/null +++ b/plugins/module_utils/orchestrators/infra_tenant_domain.py @@ -0,0 +1,39 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Type, ClassVar +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.infra_tenant_domain.infra_tenant_domain import InfraTenantDomainModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.tenant_domains import ( + EpInfraTenantDomainsPost, + EpInfraTenantDomainsPut, + EpInfraTenantDomainsDelete, + EpInfraTenantDomainsGet, +) + + +class InfraTenantDomainOrchestrator(NDBaseOrchestrator[InfraTenantDomainModel]): + model_class: ClassVar[Type[NDBaseModel]] = InfraTenantDomainModel + + create_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantDomainsPost + update_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantDomainsPut + delete_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantDomainsDelete + query_one_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantDomainsGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpInfraTenantDomainsGet + + def query_all(self) -> ResponseType: + """ + Custom query_all action to extract 'tenantDomains' from response. + """ + try: + api_endpoint = self.query_all_endpoint() + result = self.sender.query_obj(api_endpoint.path) + return result.get("tenantDomains", []) or [] + except Exception as e: + raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_infra_tenant.py b/plugins/modules/nd_infra_tenant.py new file mode 100644 index 00000000..bba2330e --- /dev/null +++ b/plugins/modules/nd_infra_tenant.py @@ -0,0 +1,177 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_infra_tenant +version_added: "2.0.0" +short_description: Manage tenants on Cisco Nexus Dashboard +description: +- Manage tenants on Cisco Nexus Dashboard (ND). +- It supports creating, updating, and deleting tenants. +- Optionally manage fabric associations for each tenant, linking tenants to NDFC-managed fabrics + with VLAN allocation. +author: +- Matt Tarkington (@mtarking) +options: + config: + description: + - The list of the tenants to configure. + type: list + elements: dict + required: True + suboptions: + name: + description: + - The name of the tenant. + - The name must be between 1 and 63 characters and can contain alphanumeric characters, dots, dashes, and underscores. + type: str + required: true + description: + description: + - The description of the tenant. + - The description can be up to 128 characters. + type: str + fabric_associations: + description: + - The list of fabric associations for the tenant. + - Each entry associates the tenant with a fabric managed by NDFC and defines the allowed VLANs. + - When specified, the module will reconcile the associations on the ND Manage API + (C(/api/v1/manage/tenantFabricAssociations)). + - When not specified, existing fabric associations are left unchanged. + type: list + elements: dict + suboptions: + fabric_name: + description: + - The name of the fabric to associate with the tenant. + type: str + required: true + allowed_vlans: + description: + - The list of allowed VLAN ranges for the tenant on this fabric. + - Each element can be a single VLAN ID or a range (e.g., C(10-20), C(30-40)). + type: list + elements: str + local_name: + description: + - The local name for the tenant in the cluster. + type: str + tenant_prefix: + description: + - The tenant prefix for ACI fabrics. + type: str + state: + description: + - The desired state of the tenant resources on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new tenants and update existing ones as defined in your configuration. + Tenants on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the tenants specified in the configuration. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + The tenants on ND will be modified to exactly match the configuration. + Any tenant existing on ND but not present in the configuration will be deleted. Use with extra caution. + - Use O(state=deleted) to remove the tenants 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 having version 4.2.1 or higher. +""" + +EXAMPLES = r""" +- name: Create a new tenant + cisco.nd.nd_infra_tenant: + config: + - name: ansible_tenant + description: Tenant managed by Ansible + state: merged + register: result + +- name: Create multiple tenants + cisco.nd.nd_infra_tenant: + config: + - name: ansible_tenant_1 + description: First tenant + - name: ansible_tenant_2 + description: Second tenant + state: merged + +- name: Create a tenant with fabric associations + cisco.nd.nd_infra_tenant: + config: + - name: ansible_tenant + description: Tenant with fabric access + fabric_associations: + - fabric_name: my_fabric + allowed_vlans: + - "10-20" + - "30-40" + - fabric_name: my_other_fabric + allowed_vlans: + - "100-200" + local_name: tenant_local + state: merged + +- name: Update tenant description and fabric associations + cisco.nd.nd_infra_tenant: + config: + - name: ansible_tenant + description: Updated description + fabric_associations: + - fabric_name: my_fabric + allowed_vlans: + - "10-50" + state: replaced + +- name: Delete a tenant (also removes its fabric associations) + cisco.nd.nd_infra_tenant: + config: + - name: ansible_tenant + state: deleted +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +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.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.infra_tenant.infra_tenant import InfraTenantModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.infra_tenant import InfraTenantOrchestrator + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update(InfraTenantModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + try: + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=InfraTenantOrchestrator, + ) + + nd_state_machine.manage_state() + + module.exit_json(**nd_state_machine.output.format()) + + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}", **nd_state_machine.output.format()) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/nd_infra_tenant_domain.py b/plugins/modules/nd_infra_tenant_domain.py new file mode 100644 index 00000000..cbcbf13d --- /dev/null +++ b/plugins/modules/nd_infra_tenant_domain.py @@ -0,0 +1,128 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_infra_tenant_domain +version_added: "2.0.0" +short_description: Manage tenant domains on Cisco Nexus Dashboard +description: +- Manage tenant domains on Cisco Nexus Dashboard (ND). +- It supports creating, updating, and deleting tenant domains. +- Tenant domains group tenants together by referencing their names. +author: +- Matt Tarkington (@mtarking) +options: + config: + description: + - The list of the tenant domains to configure. + type: list + elements: dict + required: True + suboptions: + name: + description: + - The name of the tenant domain. + - The name must be between 1 and 63 characters and can contain alphanumeric characters, dashes, and underscores. + type: str + required: true + tenant_names: + description: + - The list of tenant names that belong to this tenant domain. + type: list + elements: str + description: + description: + - The description of the tenant domain. + type: str + state: + description: + - The desired state of the tenant domain resources on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new tenant domains and update existing ones as defined in your configuration. + Tenant domains on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the tenant domains specified in the configuration. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + The tenant domains on ND will be modified to exactly match the configuration. + Any tenant domain existing on ND but not present in the configuration will be deleted. Use with extra caution. + - Use O(state=deleted) to remove the tenant domains 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 having version 4.2.1 or higher. +""" + +EXAMPLES = r""" +- name: Create a new tenant domain + cisco.nd.nd_infra_tenant_domain: + config: + - name: ansible_tenant_domain + tenant_names: + - tenant_1 + - tenant_2 + description: Tenant domain managed by Ansible + state: merged + register: result + +- name: Update tenant domain membership + cisco.nd.nd_infra_tenant_domain: + config: + - name: ansible_tenant_domain + tenant_names: + - tenant_1 + - tenant_2 + - tenant_3 + state: replaced + +- name: Delete a tenant domain + cisco.nd.nd_infra_tenant_domain: + config: + - name: ansible_tenant_domain + state: deleted +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +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.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.infra_tenant_domain.infra_tenant_domain import InfraTenantDomainModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.infra_tenant_domain import InfraTenantDomainOrchestrator + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update(InfraTenantDomainModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + try: + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=InfraTenantDomainOrchestrator, + ) + + nd_state_machine.manage_state() + + module.exit_json(**nd_state_machine.output.format()) + + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}", **nd_state_machine.output.format()) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_infra_tenant/tasks/main.yml b/tests/integration/targets/nd_infra_tenant/tasks/main.yml new file mode 100644 index 00000000..9c8baef0 --- /dev/null +++ b/tests/integration/targets/nd_infra_tenant/tasks/main.yml @@ -0,0 +1,300 @@ +# Test code for the ND nd_infra_tenant module +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# 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") }}' + test_fabric: "{{ test_fabric_name | default('test_fabric') }}" + +- name: Ensure tenants do not exist before test starts + cisco.nd.nd_infra_tenant: &clean_all_tenants + <<: *nd_info + config: + - name: ansible_tenant + - name: ansible_tenant_2 + - name: ansible_tenant_3 + state: deleted + + +# --- MERGED STATE TESTS --- + +# MERGED STATE TESTS: CREATE (basic tenants) +- name: Create tenants with full and minimum configuration (merged state - check mode) + cisco.nd.nd_infra_tenant: &create_tenant_merged_state + <<: *nd_info + config: + - name: ansible_tenant + description: Tenant managed by Ansible + - name: ansible_tenant_2 + state: merged + check_mode: true + register: cm_merged_create_tenants + +- name: Create tenants with full and minimum configuration (merged state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *create_tenant_merged_state + register: nm_merged_create_tenants + +- name: Create tenants again (merged state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *create_tenant_merged_state + register: nm_merged_create_tenants_again + +- name: Asserts for tenants merged state creation tasks + ansible.builtin.assert: + that: + - cm_merged_create_tenants is changed + - nm_merged_create_tenants is changed + - nm_merged_create_tenants_again is not changed + - nm_merged_create_tenants.after | length >= 2 + - nm_merged_create_tenants.proposed.0.name == "ansible_tenant" + - nm_merged_create_tenants.proposed.0.description == "Tenant managed by Ansible" + - nm_merged_create_tenants.proposed.1.name == "ansible_tenant_2" + +# MERGED STATE TESTS: UPDATE (description only) +- name: Update ansible_tenant description (merged state - check mode) + cisco.nd.nd_infra_tenant: &update_tenant_merged_state + <<: *nd_info + config: + - name: ansible_tenant + description: Updated tenant description + state: merged + check_mode: true + register: cm_merged_update_tenant + +- name: Update ansible_tenant description (merged state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *update_tenant_merged_state + register: nm_merged_update_tenant + +- name: Update ansible_tenant description again (merged state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *update_tenant_merged_state + register: nm_merged_update_tenant_again + +- name: Asserts for tenants merged state update tasks + ansible.builtin.assert: + that: + - cm_merged_update_tenant is changed + - nm_merged_update_tenant is changed + - nm_merged_update_tenant_again is not changed + - nm_merged_update_tenant.proposed.0.name == "ansible_tenant" + - nm_merged_update_tenant.proposed.0.description == "Updated tenant description" + +# MERGED STATE TESTS: CREATE WITH FABRIC ASSOCIATIONS +- name: Create tenant with fabric associations (merged state - check mode) + cisco.nd.nd_infra_tenant: &create_tenant_with_assoc_merged + <<: *nd_info + config: + - name: ansible_tenant_3 + description: Tenant with fabric associations + fabric_associations: + - fabric_name: "{{ test_fabric }}" + allowed_vlans: + - "10-20" + - "30-40" + state: merged + check_mode: true + register: cm_merged_create_tenant_with_assoc + +- name: Create tenant with fabric associations (merged state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *create_tenant_with_assoc_merged + register: nm_merged_create_tenant_with_assoc + +- name: Create tenant with fabric associations again (merged state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *create_tenant_with_assoc_merged + register: nm_merged_create_tenant_with_assoc_again + +- name: Asserts for tenant with fabric associations merged state creation tasks + ansible.builtin.assert: + that: + - cm_merged_create_tenant_with_assoc is changed + - nm_merged_create_tenant_with_assoc is changed + - nm_merged_create_tenant_with_assoc_again is not changed + - nm_merged_create_tenant_with_assoc.proposed.0.name == "ansible_tenant_3" + - nm_merged_create_tenant_with_assoc.proposed.0.fabric_associations | length == 1 + - nm_merged_create_tenant_with_assoc.proposed.0.fabric_associations.0.fabric_name == test_fabric + - nm_merged_create_tenant_with_assoc.proposed.0.fabric_associations.0.allowed_vlans | length == 2 + +# MERGED STATE TESTS: UPDATE FABRIC ASSOCIATIONS +- name: Update tenant fabric associations (merged state - check mode) + cisco.nd.nd_infra_tenant: &update_tenant_assoc_merged + <<: *nd_info + config: + - name: ansible_tenant_3 + fabric_associations: + - fabric_name: "{{ test_fabric }}" + allowed_vlans: + - "10-50" + - "60-80" + local_name: tenant3_local + state: merged + check_mode: true + register: cm_merged_update_tenant_assoc + +- name: Update tenant fabric associations (merged state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *update_tenant_assoc_merged + register: nm_merged_update_tenant_assoc + +- name: Update tenant fabric associations again (merged state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *update_tenant_assoc_merged + register: nm_merged_update_tenant_assoc_again + +- name: Asserts for tenant fabric associations merged state update tasks + ansible.builtin.assert: + that: + - cm_merged_update_tenant_assoc is changed + - nm_merged_update_tenant_assoc is changed + - nm_merged_update_tenant_assoc_again is not changed + - nm_merged_update_tenant_assoc.proposed.0.fabric_associations.0.allowed_vlans | length == 2 + - nm_merged_update_tenant_assoc.proposed.0.fabric_associations.0.local_name == "tenant3_local" + + +# --- REPLACED STATE TESTS --- + +- name: Replace tenant configuration (replaced state - check mode) + cisco.nd.nd_infra_tenant: &replace_tenant_state + <<: *nd_info + config: + - name: ansible_tenant + description: Replaced tenant description + state: replaced + check_mode: true + register: cm_replaced_tenant + +- name: Replace tenant configuration (replaced state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *replace_tenant_state + register: nm_replaced_tenant + +- name: Replace tenant configuration again (replaced state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *replace_tenant_state + register: nm_replaced_tenant_again + +- name: Asserts for tenants replaced state tasks + ansible.builtin.assert: + that: + - cm_replaced_tenant is changed + - nm_replaced_tenant is changed + - nm_replaced_tenant_again is not changed + - nm_replaced_tenant.proposed.0.name == "ansible_tenant" + - nm_replaced_tenant.proposed.0.description == "Replaced tenant description" + +# REPLACED STATE TESTS: WITH FABRIC ASSOCIATIONS +- name: Replace tenant with fabric associations (replaced state - check mode) + cisco.nd.nd_infra_tenant: &replace_tenant_with_assoc + <<: *nd_info + config: + - name: ansible_tenant_3 + description: Replaced tenant with new associations + fabric_associations: + - fabric_name: "{{ test_fabric }}" + allowed_vlans: + - "500-600" + state: replaced + check_mode: true + register: cm_replaced_tenant_with_assoc + +- name: Replace tenant with fabric associations (replaced state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *replace_tenant_with_assoc + register: nm_replaced_tenant_with_assoc + +- name: Replace tenant with fabric associations again (replaced state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *replace_tenant_with_assoc + register: nm_replaced_tenant_with_assoc_again + +- name: Asserts for tenant with fabric associations replaced state tasks + ansible.builtin.assert: + that: + - cm_replaced_tenant_with_assoc is changed + - nm_replaced_tenant_with_assoc is changed + - nm_replaced_tenant_with_assoc_again is not changed + - nm_replaced_tenant_with_assoc.proposed.0.fabric_associations.0.allowed_vlans.0 == "500-600" + + +# --- OVERRIDDEN STATE TESTS --- + +- name: Override all tenants (overridden state - check mode) + cisco.nd.nd_infra_tenant: &override_tenant_state + <<: *nd_info + config: + - name: ansible_tenant_3 + description: Only tenant after override + fabric_associations: + - fabric_name: "{{ test_fabric }}" + allowed_vlans: + - "1-100" + state: overridden + check_mode: true + register: cm_overridden_tenant + +- name: Override all tenants (overridden state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *override_tenant_state + register: nm_overridden_tenant + +- name: Override all tenants again (overridden state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *override_tenant_state + register: nm_overridden_tenant_again + +- name: Asserts for tenants overridden state tasks + ansible.builtin.assert: + that: + - cm_overridden_tenant is changed + - nm_overridden_tenant is changed + - nm_overridden_tenant_again is not changed + - nm_overridden_tenant.proposed.0.name == "ansible_tenant_3" + - nm_overridden_tenant.proposed.0.description == "Only tenant after override" + - nm_overridden_tenant.proposed.0.fabric_associations | length == 1 + + +# --- DELETED STATE TESTS --- + +- name: Delete tenants (deleted state - check mode) + cisco.nd.nd_infra_tenant: &delete_tenant_state + <<: *nd_info + config: + - name: ansible_tenant_3 + state: deleted + check_mode: true + register: cm_deleted_tenant + +- name: Delete tenants (deleted state - normal mode) + cisco.nd.nd_infra_tenant: + <<: *delete_tenant_state + register: nm_deleted_tenant + +- name: Delete tenants again (deleted state - idempotency) + cisco.nd.nd_infra_tenant: + <<: *delete_tenant_state + register: nm_deleted_tenant_again + +- name: Asserts for tenants deleted state tasks + ansible.builtin.assert: + that: + - cm_deleted_tenant is changed + - nm_deleted_tenant is changed + - nm_deleted_tenant_again is not changed + + +# --- CLEANUP --- + +- name: Ensure all test tenants are cleaned up + cisco.nd.nd_infra_tenant: + <<: *clean_all_tenants diff --git a/tests/integration/targets/nd_infra_tenant_domain/tasks/main.yml b/tests/integration/targets/nd_infra_tenant_domain/tasks/main.yml new file mode 100644 index 00000000..a18af526 --- /dev/null +++ b/tests/integration/targets/nd_infra_tenant_domain/tasks/main.yml @@ -0,0 +1,222 @@ +# Test code for the ND nd_infra_tenant_domain module +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# 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") }}' + +# Pre-requisite: Create tenants for tenant domain tests +- name: Ensure prerequisite tenants exist + cisco.nd.nd_infra_tenant: + <<: *nd_info + config: + - name: ansible_td_tenant_1 + - name: ansible_td_tenant_2 + - name: ansible_td_tenant_3 + state: merged + +- name: Ensure tenant domains do not exist before test starts + cisco.nd.nd_infra_tenant_domain: &clean_all_tenant_domains + <<: *nd_info + config: + - name: ansible_tenant_domain + - name: ansible_tenant_domain_2 + - name: ansible_tenant_domain_3 + state: deleted + + +# --- MERGED STATE TESTS --- + +# MERGED STATE TESTS: CREATE +- name: Create tenant domains with full and minimum configuration (merged state - check mode) + cisco.nd.nd_infra_tenant_domain: &create_td_merged_state + <<: *nd_info + config: + - name: ansible_tenant_domain + tenant_names: + - ansible_td_tenant_1 + - ansible_td_tenant_2 + description: Tenant domain managed by Ansible + - name: ansible_tenant_domain_2 + tenant_names: + - ansible_td_tenant_1 + state: merged + check_mode: true + register: cm_merged_create_tenant_domains + +- name: Create tenant domains with full and minimum configuration (merged state - normal mode) + cisco.nd.nd_infra_tenant_domain: + <<: *create_td_merged_state + register: nm_merged_create_tenant_domains + +- name: Create tenant domains again (merged state - idempotency) + cisco.nd.nd_infra_tenant_domain: + <<: *create_td_merged_state + register: nm_merged_create_tenant_domains_again + +- name: Asserts for tenant domains merged state creation tasks + ansible.builtin.assert: + that: + - cm_merged_create_tenant_domains is changed + - nm_merged_create_tenant_domains is changed + - nm_merged_create_tenant_domains_again is not changed + - nm_merged_create_tenant_domains.proposed.0.name == "ansible_tenant_domain" + - nm_merged_create_tenant_domains.proposed.0.tenant_names | length == 2 + - nm_merged_create_tenant_domains.proposed.0.description == "Tenant domain managed by Ansible" + - nm_merged_create_tenant_domains.proposed.1.name == "ansible_tenant_domain_2" + - nm_merged_create_tenant_domains.proposed.1.tenant_names | length == 1 + +# MERGED STATE TESTS: UPDATE +- name: Update ansible_tenant_domain membership (merged state - check mode) + cisco.nd.nd_infra_tenant_domain: &update_td_merged_state + <<: *nd_info + config: + - name: ansible_tenant_domain + tenant_names: + - ansible_td_tenant_1 + - ansible_td_tenant_2 + - ansible_td_tenant_3 + description: Updated tenant domain description + state: merged + check_mode: true + register: cm_merged_update_tenant_domain + +- name: Update ansible_tenant_domain membership (merged state - normal mode) + cisco.nd.nd_infra_tenant_domain: + <<: *update_td_merged_state + register: nm_merged_update_tenant_domain + +- name: Update ansible_tenant_domain membership again (merged state - idempotency) + cisco.nd.nd_infra_tenant_domain: + <<: *update_td_merged_state + register: nm_merged_update_tenant_domain_again + +- name: Asserts for tenant domains merged state update tasks + ansible.builtin.assert: + that: + - cm_merged_update_tenant_domain is changed + - nm_merged_update_tenant_domain is changed + - nm_merged_update_tenant_domain_again is not changed + - nm_merged_update_tenant_domain.proposed.0.name == "ansible_tenant_domain" + - nm_merged_update_tenant_domain.proposed.0.tenant_names | length == 3 + - nm_merged_update_tenant_domain.proposed.0.description == "Updated tenant domain description" + + +# --- REPLACED STATE TESTS --- + +- name: Replace tenant domain configuration (replaced state - check mode) + cisco.nd.nd_infra_tenant_domain: &replace_td_state + <<: *nd_info + config: + - name: ansible_tenant_domain + tenant_names: + - ansible_td_tenant_3 + description: Replaced tenant domain + state: replaced + check_mode: true + register: cm_replaced_tenant_domain + +- name: Replace tenant domain configuration (replaced state - normal mode) + cisco.nd.nd_infra_tenant_domain: + <<: *replace_td_state + register: nm_replaced_tenant_domain + +- name: Replace tenant domain configuration again (replaced state - idempotency) + cisco.nd.nd_infra_tenant_domain: + <<: *replace_td_state + register: nm_replaced_tenant_domain_again + +- name: Asserts for tenant domains replaced state tasks + ansible.builtin.assert: + that: + - cm_replaced_tenant_domain is changed + - nm_replaced_tenant_domain is changed + - nm_replaced_tenant_domain_again is not changed + - nm_replaced_tenant_domain.proposed.0.name == "ansible_tenant_domain" + - nm_replaced_tenant_domain.proposed.0.tenant_names | length == 1 + + +# --- OVERRIDDEN STATE TESTS --- + +- name: Override all tenant domains (overridden state - check mode) + cisco.nd.nd_infra_tenant_domain: &override_td_state + <<: *nd_info + config: + - name: ansible_tenant_domain_3 + tenant_names: + - ansible_td_tenant_1 + description: Only domain after override + state: overridden + check_mode: true + register: cm_overridden_tenant_domain + +- name: Override all tenant domains (overridden state - normal mode) + cisco.nd.nd_infra_tenant_domain: + <<: *override_td_state + register: nm_overridden_tenant_domain + +- name: Override all tenant domains again (overridden state - idempotency) + cisco.nd.nd_infra_tenant_domain: + <<: *override_td_state + register: nm_overridden_tenant_domain_again + +- name: Asserts for tenant domains overridden state tasks + ansible.builtin.assert: + that: + - cm_overridden_tenant_domain is changed + - nm_overridden_tenant_domain is changed + - nm_overridden_tenant_domain_again is not changed + - nm_overridden_tenant_domain.proposed.0.name == "ansible_tenant_domain_3" + + +# --- DELETED STATE TESTS --- + +- name: Delete tenant domains (deleted state - check mode) + cisco.nd.nd_infra_tenant_domain: &delete_td_state + <<: *nd_info + config: + - name: ansible_tenant_domain_3 + state: deleted + check_mode: true + register: cm_deleted_tenant_domain + +- name: Delete tenant domains (deleted state - normal mode) + cisco.nd.nd_infra_tenant_domain: + <<: *delete_td_state + register: nm_deleted_tenant_domain + +- name: Delete tenant domains again (deleted state - idempotency) + cisco.nd.nd_infra_tenant_domain: + <<: *delete_td_state + register: nm_deleted_tenant_domain_again + +- name: Asserts for tenant domains deleted state tasks + ansible.builtin.assert: + that: + - cm_deleted_tenant_domain is changed + - nm_deleted_tenant_domain is changed + - nm_deleted_tenant_domain_again is not changed + + +# --- CLEANUP --- + +- name: Ensure all test tenant domains are cleaned up + cisco.nd.nd_infra_tenant_domain: + <<: *clean_all_tenant_domains + +- name: Ensure all prerequisite tenants are cleaned up + cisco.nd.nd_infra_tenant: + <<: *nd_info + config: + - name: ansible_td_tenant_1 + - name: ansible_td_tenant_2 + - name: ansible_td_tenant_3 + state: deleted diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenant_domains.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenant_domains.py new file mode 100644 index 00000000..41c88a7b --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenant_domains.py @@ -0,0 +1,415 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for infra tenant_domains endpoints. + +Tests the ND Infra Tenant Domains endpoint classes +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.tenant_domains import ( + EpInfraTenantDomainsDelete, + EpInfraTenantDomainsGet, + EpInfraTenantDomainsPost, + EpInfraTenantDomainsPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpInfraTenantDomainsGet +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenant_domains_00010(): + """ + # Summary + + Verify EpInfraTenantDomainsGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpInfraTenantDomainsGet.__init__() + - EpInfraTenantDomainsGet.verb + - EpInfraTenantDomainsGet.class_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsGet() + assert instance.class_name == "EpInfraTenantDomainsGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_infra_tenant_domains_00020(): + """ + # Summary + + Verify EpInfraTenantDomainsGet path without tenant_domain_name + + ## Test + + - path returns "/api/v1/infra/tenantDomains" when tenant_domain_name is None + + ## Classes and Methods + + - EpInfraTenantDomainsGet.path + """ + with does_not_raise(): + instance = EpInfraTenantDomainsGet() + result = instance.path + assert result == "/api/v1/infra/tenantDomains" + + +def test_endpoints_api_v1_infra_tenant_domains_00030(): + """ + # Summary + + Verify EpInfraTenantDomainsGet path with tenant_domain_name + + ## Test + + - path returns "/api/v1/infra/tenantDomains/myDomain" when tenant_domain_name is set + + ## Classes and Methods + + - EpInfraTenantDomainsGet.path + - EpInfraTenantDomainsGet.tenant_domain_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsGet() + instance.tenant_domain_name = "myDomain" + result = instance.path + assert result == "/api/v1/infra/tenantDomains/myDomain" + + +def test_endpoints_api_v1_infra_tenant_domains_00040(): + """ + # Summary + + Verify EpInfraTenantDomainsGet tenant_domain_name can be set at instantiation + + ## Test + + - tenant_domain_name can be provided during instantiation + + ## Classes and Methods + + - EpInfraTenantDomainsGet.__init__() + """ + with does_not_raise(): + instance = EpInfraTenantDomainsGet(tenant_domain_name="infraDomain") + assert instance.tenant_domain_name == "infraDomain" + assert instance.path == "/api/v1/infra/tenantDomains/infraDomain" + + +# ============================================================================= +# Test: EpInfraTenantDomainsPost +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenant_domains_00100(): + """ + # Summary + + Verify EpInfraTenantDomainsPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpInfraTenantDomainsPost.__init__() + - EpInfraTenantDomainsPost.verb + - EpInfraTenantDomainsPost.class_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsPost() + assert instance.class_name == "EpInfraTenantDomainsPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_infra_tenant_domains_00110(): + """ + # Summary + + Verify EpInfraTenantDomainsPost path + + ## Test + + - path returns "/api/v1/infra/tenantDomains" for POST + + ## Classes and Methods + + - EpInfraTenantDomainsPost.path + """ + with does_not_raise(): + instance = EpInfraTenantDomainsPost() + result = instance.path + assert result == "/api/v1/infra/tenantDomains" + + +def test_endpoints_api_v1_infra_tenant_domains_00120(): + """ + # Summary + + Verify EpInfraTenantDomainsPost path with tenant_domain_name + + ## Test + + - path returns "/api/v1/infra/tenantDomains/myDomain" when tenant_domain_name is set + + ## Classes and Methods + + - EpInfraTenantDomainsPost.path + - EpInfraTenantDomainsPost.tenant_domain_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsPost() + instance.tenant_domain_name = "myDomain" + result = instance.path + assert result == "/api/v1/infra/tenantDomains/myDomain" + + +# ============================================================================= +# Test: EpInfraTenantDomainsPut +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenant_domains_00200(): + """ + # Summary + + Verify EpInfraTenantDomainsPut basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is PUT + + ## Classes and Methods + + - EpInfraTenantDomainsPut.__init__() + - EpInfraTenantDomainsPut.verb + - EpInfraTenantDomainsPut.class_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsPut() + assert instance.class_name == "EpInfraTenantDomainsPut" + assert instance.verb == HttpVerbEnum.PUT + + +def test_endpoints_api_v1_infra_tenant_domains_00210(): + """ + # Summary + + Verify EpInfraTenantDomainsPut path with tenant_domain_name + + ## Test + + - path returns "/api/v1/infra/tenantDomains/myDomain" when tenant_domain_name is set + + ## Classes and Methods + + - EpInfraTenantDomainsPut.path + - EpInfraTenantDomainsPut.tenant_domain_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsPut() + instance.tenant_domain_name = "myDomain" + result = instance.path + assert result == "/api/v1/infra/tenantDomains/myDomain" + + +def test_endpoints_api_v1_infra_tenant_domains_00220(): + """ + # Summary + + Verify EpInfraTenantDomainsPut with complex tenant_domain_name + + ## Test + + - tenant_domain_name with special characters is handled correctly + + ## Classes and Methods + + - EpInfraTenantDomainsPut.path + """ + with does_not_raise(): + instance = EpInfraTenantDomainsPut(tenant_domain_name="my-domain_123") + assert instance.path == "/api/v1/infra/tenantDomains/my-domain_123" + + +# ============================================================================= +# Test: EpInfraTenantDomainsDelete +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenant_domains_00300(): + """ + # Summary + + Verify EpInfraTenantDomainsDelete basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is DELETE + + ## Classes and Methods + + - EpInfraTenantDomainsDelete.__init__() + - EpInfraTenantDomainsDelete.verb + - EpInfraTenantDomainsDelete.class_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsDelete() + assert instance.class_name == "EpInfraTenantDomainsDelete" + assert instance.verb == HttpVerbEnum.DELETE + + +def test_endpoints_api_v1_infra_tenant_domains_00310(): + """ + # Summary + + Verify EpInfraTenantDomainsDelete path with tenant_domain_name + + ## Test + + - path returns "/api/v1/infra/tenantDomains/myDomain" when tenant_domain_name is set + + ## Classes and Methods + + - EpInfraTenantDomainsDelete.path + - EpInfraTenantDomainsDelete.tenant_domain_name + """ + with does_not_raise(): + instance = EpInfraTenantDomainsDelete() + instance.tenant_domain_name = "myDomain" + result = instance.path + assert result == "/api/v1/infra/tenantDomains/myDomain" + + +def test_endpoints_api_v1_infra_tenant_domains_00320(): + """ + # Summary + + Verify EpInfraTenantDomainsDelete without tenant_domain_name + + ## Test + + - path returns base path when tenant_domain_name is None + + ## Classes and Methods + + - EpInfraTenantDomainsDelete.path + """ + with does_not_raise(): + instance = EpInfraTenantDomainsDelete() + result = instance.path + assert result == "/api/v1/infra/tenantDomains" + + +# ============================================================================= +# Test: All HTTP methods on same endpoint +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenant_domains_00400(): + """ + # Summary + + Verify all HTTP methods work correctly on same resource + + ## Test + + - GET, POST, PUT, DELETE all return correct paths for same tenant_domain_name + + ## Classes and Methods + + - EpInfraTenantDomainsGet + - EpInfraTenantDomainsPost + - EpInfraTenantDomainsPut + - EpInfraTenantDomainsDelete + """ + domain_name = "test_domain" + + with does_not_raise(): + get_ep = EpInfraTenantDomainsGet(tenant_domain_name=domain_name) + post_ep = EpInfraTenantDomainsPost(tenant_domain_name=domain_name) + put_ep = EpInfraTenantDomainsPut(tenant_domain_name=domain_name) + delete_ep = EpInfraTenantDomainsDelete(tenant_domain_name=domain_name) + + expected_path = "/api/v1/infra/tenantDomains/test_domain" + assert get_ep.path == expected_path + assert post_ep.path == expected_path + assert put_ep.path == expected_path + assert delete_ep.path == expected_path + + assert get_ep.verb == HttpVerbEnum.GET + assert post_ep.verb == HttpVerbEnum.POST + assert put_ep.verb == HttpVerbEnum.PUT + assert delete_ep.verb == HttpVerbEnum.DELETE + + +# ============================================================================= +# Test: Pydantic validation +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenant_domains_00500(): + """ + # Summary + + Verify Pydantic validation for tenant_domain_name + + ## Test + + - Empty string is rejected for tenant_domain_name (min_length=1) + + ## Classes and Methods + + - EpInfraTenantDomainsGet.__init__() + """ + with pytest.raises(ValueError): + EpInfraTenantDomainsGet(tenant_domain_name="") + + +def test_endpoints_api_v1_infra_tenant_domains_00510(): + """ + # Summary + + Verify set_identifiers method + + ## Test + + - set_identifiers correctly sets tenant_domain_name + + ## Classes and Methods + + - EpInfraTenantDomainsGet.set_identifiers() + """ + with does_not_raise(): + instance = EpInfraTenantDomainsGet() + instance.set_identifiers("my_domain") + assert instance.tenant_domain_name == "my_domain" + assert instance.path == "/api/v1/infra/tenantDomains/my_domain" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenants.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenants.py new file mode 100644 index 00000000..b5ef076c --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_tenants.py @@ -0,0 +1,415 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for infra tenants endpoints. + +Tests the ND Infra Tenants endpoint classes +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.tenants import ( + EpInfraTenantsDelete, + EpInfraTenantsGet, + EpInfraTenantsPost, + EpInfraTenantsPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpInfraTenantsGet +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenants_00010(): + """ + # Summary + + Verify EpInfraTenantsGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpInfraTenantsGet.__init__() + - EpInfraTenantsGet.verb + - EpInfraTenantsGet.class_name + """ + with does_not_raise(): + instance = EpInfraTenantsGet() + assert instance.class_name == "EpInfraTenantsGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_infra_tenants_00020(): + """ + # Summary + + Verify EpInfraTenantsGet path without tenant_name + + ## Test + + - path returns "/api/v1/infra/tenants" when tenant_name is None + + ## Classes and Methods + + - EpInfraTenantsGet.path + """ + with does_not_raise(): + instance = EpInfraTenantsGet() + result = instance.path + assert result == "/api/v1/infra/tenants" + + +def test_endpoints_api_v1_infra_tenants_00030(): + """ + # Summary + + Verify EpInfraTenantsGet path with tenant_name + + ## Test + + - path returns "/api/v1/infra/tenants/tenant1" when tenant_name is set + + ## Classes and Methods + + - EpInfraTenantsGet.path + - EpInfraTenantsGet.tenant_name + """ + with does_not_raise(): + instance = EpInfraTenantsGet() + instance.tenant_name = "tenant1" + result = instance.path + assert result == "/api/v1/infra/tenants/tenant1" + + +def test_endpoints_api_v1_infra_tenants_00040(): + """ + # Summary + + Verify EpInfraTenantsGet tenant_name can be set at instantiation + + ## Test + + - tenant_name can be provided during instantiation + + ## Classes and Methods + + - EpInfraTenantsGet.__init__() + """ + with does_not_raise(): + instance = EpInfraTenantsGet(tenant_name="my_tenant") + assert instance.tenant_name == "my_tenant" + assert instance.path == "/api/v1/infra/tenants/my_tenant" + + +# ============================================================================= +# Test: EpInfraTenantsPost +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenants_00100(): + """ + # Summary + + Verify EpInfraTenantsPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpInfraTenantsPost.__init__() + - EpInfraTenantsPost.verb + - EpInfraTenantsPost.class_name + """ + with does_not_raise(): + instance = EpInfraTenantsPost() + assert instance.class_name == "EpInfraTenantsPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_infra_tenants_00110(): + """ + # Summary + + Verify EpInfraTenantsPost path + + ## Test + + - path returns "/api/v1/infra/tenants" for POST + + ## Classes and Methods + + - EpInfraTenantsPost.path + """ + with does_not_raise(): + instance = EpInfraTenantsPost() + result = instance.path + assert result == "/api/v1/infra/tenants" + + +def test_endpoints_api_v1_infra_tenants_00120(): + """ + # Summary + + Verify EpInfraTenantsPost path with tenant_name + + ## Test + + - path returns "/api/v1/infra/tenants/tenant1" when tenant_name is set + + ## Classes and Methods + + - EpInfraTenantsPost.path + - EpInfraTenantsPost.tenant_name + """ + with does_not_raise(): + instance = EpInfraTenantsPost() + instance.tenant_name = "tenant1" + result = instance.path + assert result == "/api/v1/infra/tenants/tenant1" + + +# ============================================================================= +# Test: EpInfraTenantsPut +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenants_00200(): + """ + # Summary + + Verify EpInfraTenantsPut basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is PUT + + ## Classes and Methods + + - EpInfraTenantsPut.__init__() + - EpInfraTenantsPut.verb + - EpInfraTenantsPut.class_name + """ + with does_not_raise(): + instance = EpInfraTenantsPut() + assert instance.class_name == "EpInfraTenantsPut" + assert instance.verb == HttpVerbEnum.PUT + + +def test_endpoints_api_v1_infra_tenants_00210(): + """ + # Summary + + Verify EpInfraTenantsPut path with tenant_name + + ## Test + + - path returns "/api/v1/infra/tenants/tenant1" when tenant_name is set + + ## Classes and Methods + + - EpInfraTenantsPut.path + - EpInfraTenantsPut.tenant_name + """ + with does_not_raise(): + instance = EpInfraTenantsPut() + instance.tenant_name = "tenant1" + result = instance.path + assert result == "/api/v1/infra/tenants/tenant1" + + +def test_endpoints_api_v1_infra_tenants_00220(): + """ + # Summary + + Verify EpInfraTenantsPut with complex tenant_name + + ## Test + + - tenant_name with special characters is handled correctly + + ## Classes and Methods + + - EpInfraTenantsPut.path + """ + with does_not_raise(): + instance = EpInfraTenantsPut(tenant_name="my-tenant_123") + assert instance.path == "/api/v1/infra/tenants/my-tenant_123" + + +# ============================================================================= +# Test: EpInfraTenantsDelete +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenants_00300(): + """ + # Summary + + Verify EpInfraTenantsDelete basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is DELETE + + ## Classes and Methods + + - EpInfraTenantsDelete.__init__() + - EpInfraTenantsDelete.verb + - EpInfraTenantsDelete.class_name + """ + with does_not_raise(): + instance = EpInfraTenantsDelete() + assert instance.class_name == "EpInfraTenantsDelete" + assert instance.verb == HttpVerbEnum.DELETE + + +def test_endpoints_api_v1_infra_tenants_00310(): + """ + # Summary + + Verify EpInfraTenantsDelete path with tenant_name + + ## Test + + - path returns "/api/v1/infra/tenants/tenant1" when tenant_name is set + + ## Classes and Methods + + - EpInfraTenantsDelete.path + - EpInfraTenantsDelete.tenant_name + """ + with does_not_raise(): + instance = EpInfraTenantsDelete() + instance.tenant_name = "tenant1" + result = instance.path + assert result == "/api/v1/infra/tenants/tenant1" + + +def test_endpoints_api_v1_infra_tenants_00320(): + """ + # Summary + + Verify EpInfraTenantsDelete without tenant_name + + ## Test + + - path returns base path when tenant_name is None + + ## Classes and Methods + + - EpInfraTenantsDelete.path + """ + with does_not_raise(): + instance = EpInfraTenantsDelete() + result = instance.path + assert result == "/api/v1/infra/tenants" + + +# ============================================================================= +# Test: All HTTP methods on same endpoint +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenants_00400(): + """ + # Summary + + Verify all HTTP methods work correctly on same resource + + ## Test + + - GET, POST, PUT, DELETE all return correct paths for same tenant_name + + ## Classes and Methods + + - EpInfraTenantsGet + - EpInfraTenantsPost + - EpInfraTenantsPut + - EpInfraTenantsDelete + """ + tenant_name = "test_tenant" + + with does_not_raise(): + get_ep = EpInfraTenantsGet(tenant_name=tenant_name) + post_ep = EpInfraTenantsPost(tenant_name=tenant_name) + put_ep = EpInfraTenantsPut(tenant_name=tenant_name) + delete_ep = EpInfraTenantsDelete(tenant_name=tenant_name) + + expected_path = "/api/v1/infra/tenants/test_tenant" + assert get_ep.path == expected_path + assert post_ep.path == expected_path + assert put_ep.path == expected_path + assert delete_ep.path == expected_path + + assert get_ep.verb == HttpVerbEnum.GET + assert post_ep.verb == HttpVerbEnum.POST + assert put_ep.verb == HttpVerbEnum.PUT + assert delete_ep.verb == HttpVerbEnum.DELETE + + +# ============================================================================= +# Test: Pydantic validation +# ============================================================================= + + +def test_endpoints_api_v1_infra_tenants_00500(): + """ + # Summary + + Verify Pydantic validation for tenant_name + + ## Test + + - Empty string is rejected for tenant_name (min_length=1) + + ## Classes and Methods + + - EpInfraTenantsGet.__init__() + """ + with pytest.raises(ValueError): + EpInfraTenantsGet(tenant_name="") + + +def test_endpoints_api_v1_infra_tenants_00510(): + """ + # Summary + + Verify set_identifiers method + + ## Test + + - set_identifiers correctly sets tenant_name + + ## Classes and Methods + + - EpInfraTenantsGet.set_identifiers() + """ + with does_not_raise(): + instance = EpInfraTenantsGet() + instance.set_identifiers("my_tenant") + assert instance.tenant_name == "my_tenant" + assert instance.path == "/api/v1/infra/tenants/my_tenant" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py new file mode 100644 index 00000000..8df746e6 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py @@ -0,0 +1,175 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage tenant_fabric_associations endpoints. + +Tests the ND Manage Tenant Fabric Associations endpoint classes +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.tenant_fabric_associations import ( + EpManageTenantFabricAssociationsGet, + EpManageTenantFabricAssociationsPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpManageTenantFabricAssociationsGet +# ============================================================================= + + +def test_endpoints_api_v1_manage_tenant_fabric_associations_00010(): + """ + # Summary + + Verify EpManageTenantFabricAssociationsGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageTenantFabricAssociationsGet.__init__() + - EpManageTenantFabricAssociationsGet.verb + - EpManageTenantFabricAssociationsGet.class_name + """ + with does_not_raise(): + instance = EpManageTenantFabricAssociationsGet() + assert instance.class_name == "EpManageTenantFabricAssociationsGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_tenant_fabric_associations_00020(): + """ + # Summary + + Verify EpManageTenantFabricAssociationsGet path + + ## Test + + - path returns "/api/v1/manage/tenantFabricAssociations" + + ## Classes and Methods + + - EpManageTenantFabricAssociationsGet.path + """ + with does_not_raise(): + instance = EpManageTenantFabricAssociationsGet() + result = instance.path + assert result == "/api/v1/manage/tenantFabricAssociations" + + +def test_endpoints_api_v1_manage_tenant_fabric_associations_00030(): + """ + # Summary + + Verify EpManageTenantFabricAssociationsGet set_identifiers is a no-op + + ## Test + + - set_identifiers does not change the path (collection-level endpoint) + + ## Classes and Methods + + - EpManageTenantFabricAssociationsGet.set_identifiers() + - EpManageTenantFabricAssociationsGet.path + """ + with does_not_raise(): + instance = EpManageTenantFabricAssociationsGet() + instance.set_identifiers(("fabric1", "tenant1")) + result = instance.path + assert result == "/api/v1/manage/tenantFabricAssociations" + + +# ============================================================================= +# Test: EpManageTenantFabricAssociationsPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_tenant_fabric_associations_00100(): + """ + # Summary + + Verify EpManageTenantFabricAssociationsPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageTenantFabricAssociationsPost.__init__() + - EpManageTenantFabricAssociationsPost.verb + - EpManageTenantFabricAssociationsPost.class_name + """ + with does_not_raise(): + instance = EpManageTenantFabricAssociationsPost() + assert instance.class_name == "EpManageTenantFabricAssociationsPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_tenant_fabric_associations_00110(): + """ + # Summary + + Verify EpManageTenantFabricAssociationsPost path + + ## Test + + - path returns "/api/v1/manage/tenantFabricAssociations" for POST + + ## Classes and Methods + + - EpManageTenantFabricAssociationsPost.path + """ + with does_not_raise(): + instance = EpManageTenantFabricAssociationsPost() + result = instance.path + assert result == "/api/v1/manage/tenantFabricAssociations" + + +# ============================================================================= +# Test: Both endpoint verbs +# ============================================================================= + + +def test_endpoints_api_v1_manage_tenant_fabric_associations_00200(): + """ + # Summary + + Verify GET and POST endpoints have same path but different verbs + + ## Test + + - Both endpoints return the same base path + - Verbs are different (GET vs POST) + + ## Classes and Methods + + - EpManageTenantFabricAssociationsGet + - EpManageTenantFabricAssociationsPost + """ + with does_not_raise(): + get_ep = EpManageTenantFabricAssociationsGet() + post_ep = EpManageTenantFabricAssociationsPost() + + assert get_ep.path == post_ep.path + assert get_ep.path == "/api/v1/manage/tenantFabricAssociations" + assert get_ep.verb == HttpVerbEnum.GET + assert post_ep.verb == HttpVerbEnum.POST From bc49cc2c9d954c5148db2434ebf9e0296f6d6f2f Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Mon, 13 Apr 2026 20:27:55 -0400 Subject: [PATCH 2/3] fix lint errors --- .../endpoints/v1/infra/tenant_domains.py | 8 ++------ .../module_utils/endpoints/v1/infra/tenants.py | 16 ++++------------ .../models/infra_tenant/infra_tenant.py | 14 +++----------- .../module_utils/orchestrators/infra_tenant.py | 17 ++++++++--------- 4 files changed, 17 insertions(+), 38 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/infra/tenant_domains.py b/plugins/module_utils/endpoints/v1/infra/tenant_domains.py index 32a9c33a..7a5166d3 100644 --- a/plugins/module_utils/endpoints/v1/infra/tenant_domains.py +++ b/plugins/module_utils/endpoints/v1/infra/tenant_domains.py @@ -73,9 +73,7 @@ class EpInfraTenantDomainsGet(_EpInfraTenantDomainsBase): - GET """ - class_name: Literal["EpInfraTenantDomainsGet"] = Field( - default="EpInfraTenantDomainsGet", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpInfraTenantDomainsGet"] = Field(default="EpInfraTenantDomainsGet", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -131,9 +129,7 @@ class EpInfraTenantDomainsPut(_EpInfraTenantDomainsBase): - PUT """ - class_name: Literal["EpInfraTenantDomainsPut"] = Field( - default="EpInfraTenantDomainsPut", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpInfraTenantDomainsPut"] = Field(default="EpInfraTenantDomainsPut", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/infra/tenants.py b/plugins/module_utils/endpoints/v1/infra/tenants.py index 59f0cbef..fa590aee 100644 --- a/plugins/module_utils/endpoints/v1/infra/tenants.py +++ b/plugins/module_utils/endpoints/v1/infra/tenants.py @@ -73,9 +73,7 @@ class EpInfraTenantsGet(_EpInfraTenantsBase): - GET """ - class_name: Literal["EpInfraTenantsGet"] = Field( - default="EpInfraTenantsGet", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpInfraTenantsGet"] = Field(default="EpInfraTenantsGet", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -102,9 +100,7 @@ class EpInfraTenantsPost(_EpInfraTenantsBase): - POST """ - class_name: Literal["EpInfraTenantsPost"] = Field( - default="EpInfraTenantsPost", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpInfraTenantsPost"] = Field(default="EpInfraTenantsPost", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -131,9 +127,7 @@ class EpInfraTenantsPut(_EpInfraTenantsBase): - PUT """ - class_name: Literal["EpInfraTenantsPut"] = Field( - default="EpInfraTenantsPut", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpInfraTenantsPut"] = Field(default="EpInfraTenantsPut", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: @@ -160,9 +154,7 @@ class EpInfraTenantsDelete(_EpInfraTenantsBase): - DELETE """ - class_name: Literal["EpInfraTenantsDelete"] = Field( - default="EpInfraTenantsDelete", frozen=True, description="Class name for backward compatibility" - ) + class_name: Literal["EpInfraTenantsDelete"] = Field(default="EpInfraTenantsDelete", frozen=True, description="Class name for backward compatibility") @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/models/infra_tenant/infra_tenant.py b/plugins/module_utils/models/infra_tenant/infra_tenant.py index d450bfc8..c91822f9 100644 --- a/plugins/module_utils/models/infra_tenant/infra_tenant.py +++ b/plugins/module_utils/models/infra_tenant/infra_tenant.py @@ -70,9 +70,7 @@ class InfraTenantModel(NDBaseModel): name: str = Field(alias="name") description: Optional[str] = Field(default=None, alias="description") - fabric_associations: Optional[List[InfraTenantFabricAssociationModel]] = Field( - default=None, alias="fabricAssociations" - ) + fabric_associations: Optional[List[InfraTenantFabricAssociationModel]] = Field(default=None, alias="fabricAssociations") # --- Serializers --- @@ -88,17 +86,11 @@ def serialize_fabric_associations( mode = (info.context or {}).get("mode", "payload") if mode == "config": - return [ - assoc.model_dump(by_alias=False, exclude_none=True) - for assoc in value - ] + return [assoc.model_dump(by_alias=False, exclude_none=True) for assoc in value] # Payload mode — not used directly (excluded via payload_exclude_fields), # but provided for completeness. - return [ - assoc.model_dump(by_alias=True, exclude_none=True) - for assoc in value - ] + return [assoc.model_dump(by_alias=True, exclude_none=True) for assoc in value] # --- Validators --- diff --git a/plugins/module_utils/orchestrators/infra_tenant.py b/plugins/module_utils/orchestrators/infra_tenant.py index db4c1308..b5e8e354 100644 --- a/plugins/module_utils/orchestrators/infra_tenant.py +++ b/plugins/module_utils/orchestrators/infra_tenant.py @@ -96,11 +96,13 @@ def _sync_fabric_associations( to_delete = [] for fname in existing_by_fabric: if fname not in proposed_by_fabric: - to_delete.append({ - "fabricName": fname, - "tenantName": tenant_name, - "associate": False, - }) + to_delete.append( + { + "fabricName": fname, + "tenantName": tenant_name, + "associate": False, + } + ) # Create or update associations to_create = [] @@ -219,10 +221,7 @@ def update(self, model_instance: InfraTenantModel, **kwargs) -> ResponseType: # Reconcile fabric associations if specified if model_instance.fabric_associations is not None: all_assocs = self._query_all_fabric_associations() - existing_for_tenant = [ - a for a in all_assocs - if a.get("tenantName") == model_instance.name - ] + existing_for_tenant = [a for a in all_assocs if a.get("tenantName") == model_instance.name] self._sync_fabric_associations( tenant_name=model_instance.name, proposed_associations=model_instance.fabric_associations, From 07c11ec9b7852adfa869d16d6a2ddc0d68875aa4 Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Mon, 13 Apr 2026 20:33:25 -0400 Subject: [PATCH 3/3] remove unused import --- .../test_endpoints_api_v1_manage_tenant_fabric_associations.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py index 8df746e6..739fa4c7 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tenant_fabric_associations.py @@ -14,7 +14,6 @@ __metaclass__ = type # pylint: enable=invalid-name -import pytest from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.tenant_fabric_associations import ( EpManageTenantFabricAssociationsGet, EpManageTenantFabricAssociationsPost,