diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index b1f40f41c..4508f0334 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -191,3 +191,24 @@ class PolicyGroupIdMixin(BaseModel): min_length=1, description="Policy Group ID (e.g., POLICY-GROUP-143310)", ) + + +class SrcClusterNameMixin(BaseModel): + """Mixin for endpoints that require src_cluster_name parameter.""" + + src_cluster_name: str | None = Field(default=None, min_length=1, description="Source cluster name") + + +class DstClusterNameMixin(BaseModel): + """Mixin for endpoints that require dst_cluster_name parameter.""" + + dst_cluster_name: str | None = Field(default=None, min_length=1, description="Destination cluster name") + + +class IsLogicalLinkMixin(BaseModel): + """Mixin for endpoints that accept isLogicalLink parameter.""" + + is_logical_link: BooleanStringEnum = Field( + default=BooleanStringEnum.FALSE, + description="Indicates if the link is a logical link", + ) diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py index 0f40f37a4..8fb4d5a68 100644 --- a/plugins/module_utils/endpoints/query_params.py +++ b/plugins/module_utils/endpoints/query_params.py @@ -140,6 +140,30 @@ def is_empty(self) -> bool: return len(self.model_dump(exclude_none=True, exclude_defaults=True)) == 0 +class UrlEncodedEndpointQueryParams(EndpointQueryParams): + """EndpointQueryParams whose ``to_query_string`` percent-encodes values. + + The base class emits raw values; this variant URL-encodes each value so an + identifier containing reserved characters (for example a ticket id, fabric, + cluster or switch name with ``&``, ``=``, ``+``, ``#`` or a space) cannot + alter how the controller parses the query string. Field names are still + converted snake_case -> camelCase. + """ + + def to_query_string(self) -> str: + params = [] + for field_name, field_value in self.model_dump(exclude_none=True).items(): + api_key = self._to_camel_case(field_name) + if isinstance(field_value, bool): + api_value = str(field_value).lower() + elif isinstance(field_value, Enum): + api_value = field_value.value + else: + api_value = str(field_value) + params.append("{0}={1}".format(api_key, quote(api_value, safe=""))) + return "&".join(params) + + class LuceneQueryParams(BaseModel): """ # Summary diff --git a/plugins/module_utils/endpoints/v1/manage/base_path.py b/plugins/module_utils/endpoints/v1/manage/base_path.py index 52bb4e567..3925b5c1f 100644 --- a/plugins/module_utils/endpoints/v1/manage/base_path.py +++ b/plugins/module_utils/endpoints/v1/manage/base_path.py @@ -76,3 +76,7 @@ def path(cls, *segments: str) -> str: if not segments: return cls.API return f"{cls.API}/{'/'.join(segments)}" + + +# Alias retained for links endpoints that import ``BasePathLinks`` directly. +BasePathLinks = BasePath diff --git a/plugins/module_utils/endpoints/v1/manage/link_actions.py b/plugins/module_utils/endpoints/v1/manage/link_actions.py new file mode 100644 index 000000000..912093e50 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/link_actions.py @@ -0,0 +1,29 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePathLinks +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.links import LinkMutateParams +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class LinkActionsRemovePost(NDEndpointBaseModel): + """POST /api/v1/manage/linkActions/remove for bulk delete by linkId.""" + + class_name: Literal["LinkActionsRemovePost"] = Field(default="LinkActionsRemovePost", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinkMutateParams = Field(default_factory=LinkMutateParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePathLinks.path("linkActions", "remove") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/links.py b/plugins/module_utils/endpoints/v1/manage/links.py new file mode 100644 index 000000000..9bc2dbfe7 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/links.py @@ -0,0 +1,79 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, + FabricNameMixin, + LinkUuidMixin, + SwitchIdMixin, + TicketIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import UrlEncodedEndpointQueryParams +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +from .base_path import BasePathLinks + + +class LinksGetParams(FabricNameMixin, ClusterNameMixin, TicketIdMixin, SwitchIdMixin, UrlEncodedEndpointQueryParams): + """URL-encoded query params for GET /links (single cluster): fabricName, clusterName, ticketId, switchId.""" + + +class LinkMutateParams(ClusterNameMixin, TicketIdMixin, UrlEncodedEndpointQueryParams): + """URL-encoded query params for link create/update/delete (single cluster): clusterName, ticketId.""" + + +class LinksGet(NDEndpointBaseModel): + """GET /api/v1/manage/links for single cluster scope.""" + + class_name: Literal["LinksGet"] = Field(default="LinksGet", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinksGetParams = Field(default_factory=LinksGetParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePathLinks.path("links") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +class LinksPost(NDEndpointBaseModel): + """POST /api/v1/manage/links for bulk create.""" + + class_name: Literal["LinksPost"] = Field(default="LinksPost", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinkMutateParams = Field(default_factory=LinkMutateParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePathLinks.path("links") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +class LinkPut(LinkUuidMixin, NDEndpointBaseModel): + """PUT /api/v1/manage/links/{linkId} for single update (linkId set via link_uuid).""" + + class_name: Literal["LinkPut"] = Field(default="LinkPut", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinkMutateParams = Field(default_factory=LinkMutateParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePathLinks.path("links", self.link_uuid) if self.link_uuid else BasePathLinks.path("links") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.PUT diff --git a/plugins/module_utils/endpoints/v1/one_manage/base_path.py b/plugins/module_utils/endpoints/v1/one_manage/base_path.py new file mode 100644 index 000000000..0ef948ee6 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/one_manage/base_path.py @@ -0,0 +1,22 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + + +class BasePath: + """Builds absolute paths anchored at /api/v1/manage for multi cluster link endpoints.""" + + API: Final = "/api/v1/manage" + + @classmethod + def path(cls, *segments: str) -> str: + """Join ``segments`` onto the base API path; returns the bare base if none given.""" + if not segments: + return cls.API + return "{0}/{1}".format(cls.API, "/".join(segments)) diff --git a/plugins/module_utils/endpoints/v1/one_manage/link_actions.py b/plugins/module_utils/endpoints/v1/one_manage/link_actions.py new file mode 100644 index 000000000..c4553fb96 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/one_manage/link_actions.py @@ -0,0 +1,29 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.one_manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.one_manage.links import LinkMutateParams +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class LinkActionsRemovePost(NDEndpointBaseModel): + """POST /api/v1/manage/linkActions/remove for bulk delete (multi cluster).""" + + class_name: Literal["LinkActionsRemovePost"] = Field(default="LinkActionsRemovePost", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinkMutateParams = Field(default_factory=LinkMutateParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePath.path("linkActions", "remove") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/one_manage/links.py b/plugins/module_utils/endpoints/v1/one_manage/links.py new file mode 100644 index 000000000..9fd7dd221 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/one_manage/links.py @@ -0,0 +1,79 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + DstClusterNameMixin, + FabricNameMixin, + LinkUuidMixin, + SrcClusterNameMixin, + TicketIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import UrlEncodedEndpointQueryParams +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +from .base_path import BasePath + + +class LinksGetParams(FabricNameMixin, SrcClusterNameMixin, DstClusterNameMixin, UrlEncodedEndpointQueryParams): + """URL-encoded query params for GET /links (multi cluster): fabricName, srcClusterName, dstClusterName.""" + + +class LinkMutateParams(TicketIdMixin, UrlEncodedEndpointQueryParams): + """URL-encoded query params for link create/update/delete (multi cluster): ticketId.""" + + +class LinksGet(NDEndpointBaseModel): + """GET /api/v1/manage/links for multi cluster scope.""" + + class_name: Literal["LinksGet"] = Field(default="LinksGet", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinksGetParams = Field(default_factory=LinksGetParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePath.path("links") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +class LinksPost(NDEndpointBaseModel): + """POST /api/v1/manage/links for bulk create (multi cluster).""" + + class_name: Literal["LinksPost"] = Field(default="LinksPost", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinkMutateParams = Field(default_factory=LinkMutateParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePath.path("links") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +class LinkPut(LinkUuidMixin, NDEndpointBaseModel): + """PUT /api/v1/manage/links/{linkId} for single update (multi cluster; linkId set via link_uuid).""" + + class_name: Literal["LinkPut"] = Field(default="LinkPut", frozen=True, description="Class name for backward compatibility") + endpoint_params: LinkMutateParams = Field(default_factory=LinkMutateParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + base = BasePath.path("links", self.link_uuid) if self.link_uuid else BasePath.path("links") + query_string = self.endpoint_params.to_query_string() + return "{0}?{1}".format(base, query_string) if query_string else base + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.PUT diff --git a/plugins/module_utils/fabric_inventory_helpers.py b/plugins/module_utils/fabric_inventory_helpers.py new file mode 100644 index 000000000..8eeca29c6 --- /dev/null +++ b/plugins/module_utils/fabric_inventory_helpers.py @@ -0,0 +1,91 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Helpers that extend FabricSwitchInventory with the by-name lookup and +priority-based resolve() semantics that the upstream class does not +provide. Kept here (not in fabric_inventory.py) so the vendored upstream +file stays byte-identical to the source PR. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.fabric_inventory import FabricSwitchInventory +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_data_models import SwitchDataModel + + +class _FabricInventorySenderAdapter: + """Bridge RestSend to the ``nd.request(verb=...)`` / ``nd.module.fail_json`` / + ``nd.rest_send.response_current`` shape that FabricSwitchInventory expects.""" + + def __init__(self, rest_send: Any) -> None: + self.rest_send = rest_send + self.module = rest_send.sender.ansible_module + + def request(self, path: str, verb: str | None = None, **_kwargs: Any) -> Any: + self.rest_send.path = path + self.rest_send.verb = verb + self.rest_send.commit() + if not self.rest_send.success: + raise Exception("Request failed {0}".format(self.rest_send.error_summary)) + return self.rest_send.response_current.get("DATA", {}) + + +def inventory_for_fabric(rest_send: Any, fabric_name: str, log: logging.Logger) -> FabricSwitchInventory: + """Build a FabricSwitchInventory for a fabric using RestSend + SwitchDataModel.""" + adapter = _FabricInventorySenderAdapter(rest_send) + return FabricSwitchInventory.from_fabric(adapter, fabric_name, log, SwitchDataModel) + + +def by_name(inventory: FabricSwitchInventory) -> dict[str, list[Any]]: + """Return switches keyed by hostname. Value is a list to expose collisions.""" + result: dict[str, list[Any]] = {} + for sw in inventory.switches: + name = getattr(sw, "hostname", None) + if name: + result.setdefault(name, []).append(sw) + return result + + +def resolve( + inventory: FabricSwitchInventory, + switch_id: str | None = None, + switch_ip: str | None = None, + switch_name: str | None = None, + fabric_name: str | None = None, + side: str = "", +) -> str | None: + """Priority: switch_id > switch_ip > switch_name. Raises on miss or ambiguous name.""" + if switch_id: + return switch_id + + side_prefix = "{0}_".format(side) if side else "" + fabric_suffix = " in fabric '{0}'".format(fabric_name) if fabric_name else "" + + if switch_ip: + sw = inventory.by_ip().get(switch_ip) + if not sw: + raise Exception( + "Could not resolve {0}switch_ip='{1}'{2}. " "No switch with that management IP was found.".format(side_prefix, switch_ip, fabric_suffix) + ) + return sw.switch_id + + if switch_name: + matches = by_name(inventory).get(switch_name, []) + if len(matches) == 1: + return matches[0].switch_id + if len(matches) > 1: + ids = [m.switch_id for m in matches] + raise Exception( + "{0}switch_name='{1}' is ambiguous{2} (matches {3} switches: {4}). " + "Use {0}switch_ip or {0}switch_id to disambiguate.".format(side_prefix, switch_name, fabric_suffix, len(matches), ", ".join(ids)) + ) + raise Exception( + "Could not resolve {0}switch_name='{1}'{2}. " "No switch with that hostname was found.".format(side_prefix, switch_name, fabric_suffix) + ) + + return None diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 4b9fefedb..3025cbf41 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -8,7 +8,7 @@ from typing import Any, ClassVar, Dict, List, Literal, Optional, Set, Tuple, Union from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict -from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset, NO_LOG_PLACEHOLDER def _strip_none_values(data): @@ -117,6 +117,43 @@ def _build_payload_nested(self, data: Dict[str, Any]) -> Dict[str, Any]: return result + @classmethod + def secret_field_keys(cls, by_alias: bool = False) -> set[str]: + """Names of fields tagged ``json_schema_extra={"secret": True}``. + + Aliases when ``by_alias`` is True (payload shape), else Python field + names (config/input shape). The single source of truth for which fields + are secret, used both to keep them out of output and to register their + values for ``no_log`` masking. + """ + keys: set[str] = set() + for field_name, field_info in cls.model_fields.items(): + extra = field_info.json_schema_extra + if isinstance(extra, dict) and extra.get("secret"): + keys.add((field_info.alias or field_name) if by_alias else field_name) + return keys + + @classmethod + def collect_secret_values(cls, config_item: dict[str, Any]) -> set[str]: + """Secret string values in a raw Ansible config item, for no_log masking. + + Ansible auto-masks ``no_log`` argument-spec params, but not values in + free-form/nested dicts it does not statically model. ``NDStateMachine`` + registers whatever this returns with ``module.no_log_values`` so the + value-based scrubber strips them from the invocation echo and result. + + Default: top-level fields tagged secret. Models with secrets nested in a + free-form dict (e.g. links ``template_inputs``) override to add those. + """ + values: set[str] = set() + if not isinstance(config_item, dict): + return values + for key in cls.secret_field_keys(by_alias=False): + value = config_item.get(key) + if value: + values.add(value) + return values + def to_payload(self, **kwargs) -> Dict[str, Any]: """Convert model to API payload format (aliased keys, nested structures).""" data = self.model_dump( @@ -130,25 +167,41 @@ def to_payload(self, **kwargs) -> Dict[str, Any]: return self._build_payload_nested(data) def to_config(self, **kwargs) -> Dict[str, Any]: - """Convert model to Ansible config format (Python field names, flat structure).""" - return self.model_dump( + """Convert model to Ansible config format (Python field names, flat structure). + + Secret-tagged fields are masked to ``NO_LOG_PLACEHOLDER`` in output + (after/before/proposed/gathered): the key stays visible so callers see + the field is set, but the value is never shown. The real value remains + only in to_payload() (the controller request). + """ + data = self.model_dump( by_alias=False, exclude_none=True, context={"mode": "config"}, exclude=self.config_exclude_fields or None, **kwargs, ) + for key in self.secret_field_keys(by_alias=False): + if key in data: + data[key] = NO_LOG_PLACEHOLDER + return data # --- Core Deserialization --- @classmethod - def from_response(cls, response: Dict[str, Any], **kwargs) -> "NDBaseModel": - """Create model instance from API response dict (validation context ``mode=response``).""" - context = {"mode": "response", **(kwargs.pop("context", None) or {})} + def from_response(cls, response: dict[str, Any], **kwargs) -> "NDBaseModel": + """Create model instance from API response dict. + + Marks the validation context with both ``mode="response"`` (resource-manager + convention) and ``source="response"`` (links tolerant-read convention) so + models can be lenient about controller-only shapes (e.g. links tolerate + policy types they cannot model) without relaxing validation of user input. + """ + context = {"mode": "response", **(kwargs.pop("context", None) or {}), "source": "response"} return cls.model_validate(response, by_alias=True, context=context, **kwargs) @classmethod - def from_config(cls, ansible_config: Dict[str, Any], **kwargs) -> "NDBaseModel": + def from_config(cls, ansible_config: dict[str, Any], **kwargs) -> "NDBaseModel": """Create model instance from Ansible config dict. Strips None values recursively before validation so that Ansible's @@ -212,11 +265,17 @@ def get_identifier_value(self) -> Optional[Union[str, int, Tuple[Any, ...]]]: # --- Diff & Merge --- def to_diff_dict(self, **kwargs) -> Dict[str, Any]: - """Export for diff comparison, excluding sensitive fields.""" + """Export for diff comparison, excluding sensitive fields. + + Secret-tagged fields are excluded from the comparison so a change to + only a secret is not (and cannot be) detected as a diff, keeping runs + idempotent when the controller does not echo secrets back on read. + """ + exclude = set(self.exclude_from_diff) | self.secret_field_keys(by_alias=False) return self.model_dump( by_alias=True, exclude_none=True, - exclude=self.exclude_from_diff or None, + exclude=exclude or None, mode="json", **kwargs, ) diff --git a/plugins/module_utils/models/links/links.py b/plugins/module_utils/models/links/links.py new file mode 100644 index 000000000..9447d910f --- /dev/null +++ b/plugins/module_utils/models/links/links.py @@ -0,0 +1,558 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Any, ClassVar, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field, ValidationError, model_validator +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.links.templates.base import LinkTemplateBase +from ansible_collections.cisco.nd.plugins.module_utils.models.links.templates.discriminated_union import LinkTemplateInputs, all_secret_template_input_keys +from ansible_collections.cisco.nd.plugins.module_utils.models.links.templates.unsupported import UnsupportedTemplateInputs +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.utils import NO_LOG_PLACEHOLDER + + +class LinkConfigDataModel(NDNestedModel): + """configData block: policy_type drives which templateInputs subclass parses.""" + + policy_type: str | None = Field(default=None, alias="policyType") + template_inputs: LinkTemplateInputs | None = Field(default=None, alias="templateInputs") + template_name: str | None = Field(default=None, alias="templateName") + + def merge(self, other: LinkConfigDataModel) -> LinkConfigDataModel: + """Reject inline policy_type changes; ND requires delete and recreate for those.""" + if isinstance(other, LinkConfigDataModel) and self.policy_type and other.policy_type and self.policy_type != other.policy_type: + raise Exception( + "Cannot change policy_type from '{0}' to '{1}' on an existing link. " + "ND requires deleting the link first and recreating with the new " + "policy_type. Run this module with state=deleted for this link, " + "then re-run with state=merged.".format(self.policy_type, other.policy_type) + ) + return super().merge(other) + + @model_validator(mode="wrap") + @classmethod + def _tolerate_unsupported_on_read(cls, data: Any, handler: Any, info: Any) -> LinkConfigDataModel: + """Preserve genuinely unsupported controller links instead of aborting the read. + + Strict parsing (discriminated union + write-contract validation) is kept for + user input -- write states thread ``context["state"]`` -- so bad config still + fails fast. On a controller read a policy type this module does not model + (no matching discriminator) is captured as an opaque ``UnsupportedTemplateInputs`` + record rather than raising ``ValidationError`` and aborting ``query_all``. + + A *supported* policy carrying extra or out-of-range response fields no longer + reaches this fallback: ``LinkTemplateBase._drop_unknown_on_read`` strips unknown + keys on reads and the write-contract checks are skipped for responses, so such a + link resolves to its real policy model and stays mutable. + """ + try: + return handler(data) + except ValidationError: + context = info.context or {} + # Tolerate only controller reads (from_response marks source="response"). + # User input -- and any direct construction -- stays strictly validated. + if context.get("source") != "response" or not isinstance(data, dict): + raise + return cls._build_unsupported(data) + + @classmethod + def _build_unsupported(cls, data: dict[str, Any]) -> LinkConfigDataModel: + """Build a fallback config_data preserving the raw policy type and inputs.""" + policy_type = data.get("policy_type") or data.get("policyType") + template_name = data.get("template_name") or data.get("templateName") + raw = data.get("template_inputs") + if raw is None: + raw = data.get("templateInputs") + template_inputs = dict(raw) if isinstance(raw, dict) else {} + template_inputs.pop("policy_type_marker", None) + fallback = UnsupportedTemplateInputs.model_validate(template_inputs) + return cls.model_construct(policy_type=policy_type, template_name=template_name, template_inputs=fallback) + + @model_validator(mode="after") + def _require_template_name_for_user_defined(self, info: Any) -> LinkConfigDataModel: + """userDefined requires template_name (ND schema / OpenAPI). + + Enforced only on user-supplied config -- write states thread + ``context["state"]`` through validation -- so a controller read that + somehow omits it is tolerated rather than aborting ``query_all``. + """ + context = info.context or {} + if context.get("state") and self.policy_type == "userDefined" and not self.template_name: + raise ValueError("template_name is required when config_data.policy_type is 'userDefined'.") + return self + + @model_validator(mode="before") + @classmethod + def _inject_policy_marker(cls, data: Any) -> Any: + """Copy the public ``policy_type`` into ``template_inputs`` as the internal + discriminator so the discriminated union resolves. + + The documented ``policy_type`` is the sole authority for schema selection: + any caller-supplied ``policy_type_marker`` is overwritten, so free-form + input cannot select a different template model than the declared policy. + ``template_inputs`` is located by key presence (not truthiness) so an + explicit empty dict validates identically under the snake_case and the + API-alias spelling. + + Returns a shallow copy rather than mutating in place: ``data`` is a + reference into ``module.params``, so an in-place write would leak the + internal marker back into the invocation echo. + """ + if not isinstance(data, dict): + return data + policy_type = data.get("policy_type") or data.get("policyType") + if not policy_type: + return data + key = "template_inputs" if "template_inputs" in data else "templateInputs" if "templateInputs" in data else None + if key is None: + return data + template_inputs = data.get(key) + if not isinstance(template_inputs, dict): + return data + return {**data, key: {**template_inputs, "policy_type_marker": policy_type}} + + +class NDLinkModel(NDBaseModel): + """Nexus Dashboard Link configuration model. + + Identity is the composite (src_cluster, dst_cluster, src_fabric, dst_fabric, + src_switch, dst_switch, src_intf, dst_intf). The module overwrites + ``identifiers`` at runtime from the active strategy's ``identifier_fields`` + (see ``nd_manage_links.main``); single-cluster (manage) scope drops the + cluster-name keys. + """ + + identifiers: ClassVar[list[str] | None] = [ + "src_cluster_name", + "dst_cluster_name", + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "composite" + + unwanted_keys: ClassVar[list] = [ + ["linkId"], + ["linkState"], + ["linkDiscovered"], + ["linkPlanned"], + ["linkPresent"], + ["portChannel"], + ["srcSwitchInfo"], + ["dstSwitchInfo"], + ] + + exclude_from_diff: ClassVar[set] = { + "link_id", + "link_type", + "src_switch_id", + "dst_switch_id", + "src_switch_ip", + "dst_switch_ip", + } + + payload_exclude_fields: ClassVar[set] = { + "link_id", + "link_type", + "src_switch_ip", + "dst_switch_ip", + } + + src_cluster_name: str | None = Field(default=None, alias="srcClusterName") + dst_cluster_name: str | None = Field(default=None, alias="dstClusterName") + src_fabric_name: str | None = Field(default=None, alias="srcFabricName") + dst_fabric_name: str | None = Field(default=None, alias="dstFabricName") + src_switch_name: str | None = Field(default=None, alias="srcSwitchName") + dst_switch_name: str | None = Field(default=None, alias="dstSwitchName") + src_interface_name: str | None = Field(default=None, alias="srcInterfaceName") + dst_interface_name: str | None = Field(default=None, alias="dstInterfaceName") + + link_type: str | None = Field(default=None, alias="linkType") + link_id: str | None = Field(default=None, alias="linkId") + src_switch_id: str | None = Field(default=None, alias="srcSwitchId") + dst_switch_id: str | None = Field(default=None, alias="dstSwitchId") + src_switch_ip: str | None = Field(default=None, alias="srcSwitchIp") + dst_switch_ip: str | None = Field(default=None, alias="dstSwitchIp") + + config_data: LinkConfigDataModel | None = Field(default=None, alias="configData") + + @property + def is_unsupported_policy(self) -> bool: + """True when this link uses a policy type the module does not model. + + Such links are read from ND as opaque records (see + ``LinkConfigDataModel._tolerate_unsupported_on_read``); the module keeps + them visible but never modifies or implicitly deletes them. + """ + template_inputs = getattr(self.config_data, "template_inputs", None) + return isinstance(template_inputs, UnsupportedTemplateInputs) + + def describe_unsupported_policy(self) -> str: + """Human-readable identity + policy type for an unsupported link.""" + policy_type = getattr(self.config_data, "policy_type", None) + identity = self.link_id or self.get_identifier_value() + return "Link {0} uses unsupported policy type '{1}'".format(identity, policy_type) + + # --- Orientation independence (physical links, intra- and inter-fabric) ---- + # A physical link has no inherent direction: ND can return it with the + # endpoints reversed relative to the playbook, whether the two ends are in the + # same fabric (numbered/unnumbered) or different fabrics (VRF lite, DCI, + # multisite, MPLS). For every link the identity is made order-independent and + # the diff view is canonicalized, so a reversed cable is matched (not duplicated + # on merged, found on deleted, not delete+recreated on overridden) and compares + # equal. Payloads keep the user's orientation; canonicalization is comparison + # only. The directional template pairs are derived per policy from the model's + # own fields, so each policy gets its own src/dst set. + # + # Known limitation: a few inter-fabric policies pair a masked source address + # with a plain destination (srcIpAddressMask / dstIpAddress); the mask lives on + # the field, not the endpoint, so a value swap would move it to the wrong end. + # Those asymmetric address fields are deliberately NOT reoriented -- identity is + # still orientation-independent, but declare such a link in ND's orientation to + # stay idempotent on the addressing fields. + + _IDENTITY_ALIAS_PAIRS: ClassVar[list] = [ + ("srcClusterName", "dstClusterName"), + ("srcFabricName", "dstFabricName"), + ("srcSwitchName", "dstSwitchName"), + ("srcInterfaceName", "dstInterfaceName"), + ] + + @staticmethod + def _dst_counterpart(alias: str) -> str | None: + """The destination alias paired with a source-side alias, or None. + + Handles a leading ``src`` prefix (``srcIp`` -> ``dstIp``) and an infix + ``Src`` (``dhcpRelayOnSrcInterface`` -> ``dhcpRelayOnDstInterface``). Only a + clean src<->dst rename is treated as directional, so asymmetric address/mask + pairs (whose two aliases are not a rename of each other) are left alone. + """ + if alias.startswith("src"): + return "dst" + alias[3:] + if "Src" in alias: + return alias.replace("Src", "Dst") + return None + + def _template_directional_pairs(self) -> list: + """The (srcAlias, dstAlias) template-input pairs to swap when this link is + reversed, derived from this link's own policy model so each policy gets its + own directional set (every symmetric src/dst field the model declares).""" + template_inputs = getattr(self.config_data, "template_inputs", None) + if template_inputs is None: + return [] + aliases = {(field_info.alias or name) for name, field_info in type(template_inputs).model_fields.items()} + pairs: list = [] + paired: set = set() + for alias in sorted(aliases): + counterpart = self._dst_counterpart(alias) + if counterpart and counterpart != alias and counterpart in aliases and alias not in paired and counterpart not in paired: + pairs.append((alias, counterpart)) + paired.update((alias, counterpart)) + return pairs + + def _endpoints(self) -> tuple: + ids = self.identifiers or [] + src = tuple(getattr(self, f, None) for f in ids if f.startswith("src_")) + dst = tuple(getattr(self, f, None) for f in ids if f.startswith("dst_")) + return src, dst + + @staticmethod + def _endpoint_key(endpoint: tuple) -> tuple: + return tuple("" if value is None else str(value) for value in endpoint) + + def get_identifier_value(self) -> Any: + """Composite identity, made orientation-independent for every physical link + (intra- and inter-fabric) so a reversed cable resolves to one identity.""" + src, dst = self._endpoints() + if not any(src) and not any(dst): + return super().get_identifier_value() + low, high = sorted((src, dst), key=self._endpoint_key) + return low + high + + @staticmethod + def _swap_keys(data: dict[str, Any], first: str, second: str) -> None: + """Swap two keys in place, preserving exclude_none (drop None results).""" + if first not in data and second not in data: + return + first_value, second_value = data.get(first), data.get(second) + data.pop(first, None) + data.pop(second, None) + if second_value is not None: + data[first] = second_value + if first_value is not None: + data[second] = first_value + + def _canonicalize_orientation(self, data: dict[str, Any]) -> None: + """Reorient a diff dict to a canonical endpoint order for any physical link. + + Comparison-only: payloads keep the user's orientation. Because both existing + and proposed links canonicalize identically, a reversed cable compares equal + instead of showing a spurious change. Both the endpoint identity fields and + the policy's directional template fields are swapped. + """ + src, dst = self._endpoints() + if self._endpoint_key(src) <= self._endpoint_key(dst): + return # already in canonical order + for first, second in self._IDENTITY_ALIAS_PAIRS: + self._swap_keys(data, first, second) + template_inputs = (data.get("configData") or {}).get("templateInputs") + if isinstance(template_inputs, dict): + for first, second in self._template_directional_pairs(): + self._swap_keys(template_inputs, first, second) + + # User-managed interface fields that survive the preprovision->numbered + # realization: after ND converts the link, the preprovision declaration still + # governs these (persistent declarative intent). ND-assigned addresses and + # numbered-only fields are preserved and never compared against the declaration. + _PREPROVISION_MANAGED_FIELDS: ClassVar[tuple] = ( + "src_interface_description", + "dst_interface_description", + "src_interface_config", + "dst_interface_config", + ) + + def _is_realized_preprovision_of(self, other: Any) -> bool: + """True when ``self`` (existing, from ND) is the realized ``numbered`` form + of ``other`` (proposed, a ``preprovision`` declaration). + + After a planned ``preprovision`` link comes up and ND runs + Save/Recalculate, ND converts it to a ``numbered`` link with + controller-assigned addresses. Reapplying the ``preprovision`` source must + recognise this expected lifecycle rather than treating it as a policy change. + """ + self_policy = getattr(self.config_data, "policy_type", None) + other_policy = getattr(getattr(other, "config_data", None), "policy_type", None) + return self_policy == "numbered" and other_policy == "preprovision" + + def _preprovision_managed_fields_match(self, other: Any) -> bool: + """True when every user-managed field the proposed ``preprovision`` declaration + actually set already matches this realized ``numbered`` link. + + Only fields the user explicitly declared are compared (merged semantics), so + an omitted field is never a change. ND-assigned addresses and numbered-only + fields are ignored entirely. + """ + existing_ti = getattr(self.config_data, "template_inputs", None) + proposed_ti = getattr(getattr(other, "config_data", None), "template_inputs", None) + declared = getattr(proposed_ti, "model_fields_set", set()) + for field_name in self._PREPROVISION_MANAGED_FIELDS: + if field_name not in declared: + continue + if getattr(existing_ti, field_name, None) != getattr(proposed_ti, field_name, None): + return False + return True + + def get_diff(self, other: Any, exclude_unset: bool = False) -> bool: + """Diff comparison. + + Persistent-intent lifecycle for a realized ``preprovision``->``numbered`` link: + the link stays ``numbered`` on ND, but the ``preprovision`` declaration still + governs the interface description/config fields. Report no diff only when those + user-managed fields are unchanged; a later edit to one of them is a real change + (so the module keeps ND's realized numbered link and its assigned addresses, + yet still applies description/config updates). + """ + if self._is_realized_preprovision_of(other): + return self._preprovision_managed_fields_match(other) + return super().get_diff(other, exclude_unset=exclude_unset) + + def merge(self, other: Any) -> "NDBaseModel": + """Merge a proposed declaration into this existing link. + + For a realized ``preprovision``->``numbered`` link, overlay only the + user-managed interface description/config fields onto the existing + ``numbered`` link and keep it ``numbered``, so the resulting update is a valid + ``numbered`` PUT that preserves ND-assigned addresses and numbered-only fields + (never a rejected policy change). Every other case uses the standard merge. + """ + if self._is_realized_preprovision_of(other): + existing_ti = self.config_data.template_inputs + proposed_ti = getattr(other.config_data, "template_inputs", None) + declared = getattr(proposed_ti, "model_fields_set", set()) + for field_name in self._PREPROVISION_MANAGED_FIELDS: + if field_name in declared: + setattr(existing_ti, field_name, getattr(proposed_ti, field_name, None)) + return self + return super().merge(other) + + @classmethod + def collect_secret_values(cls, config_item: dict[str, Any]) -> set[str]: + """Secret values for no_log masking, including free-form template_inputs. + + Extends the base (top-level secret fields) to also cover the secret keys + nested inside the free-form ``config_data.template_inputs`` dict, which + Ansible cannot mark ``no_log`` in the argument spec. + """ + values = super().collect_secret_values(config_item) + if isinstance(config_item, dict): + config_data = config_item.get("config_data") or {} + template_inputs = config_data.get("template_inputs") or {} + if isinstance(template_inputs, dict): + for key in all_secret_template_input_keys(by_alias=False): + value = template_inputs.get(key) + if value: + values.add(value) + return values + + def to_diff_dict(self, **kwargs: Any) -> dict[str, Any]: + """Serialize for diff comparison and strip noise keys ND returns on read.""" + data = self.model_dump( + by_alias=True, + exclude_none=True, + exclude=self.exclude_from_diff or None, + mode="json", + **kwargs, + ) + for key_path in self.unwanted_keys: + self._remove_nested_key(data, key_path) + self._strip_secret_template_inputs(data, by_alias=True) + self._canonicalize_orientation(data) + return data + + def to_config(self, **kwargs: Any) -> dict[str, Any]: + """Config/output view with secret template_inputs fields scrubbed. + + Secrets are kept only in to_payload() (the controller request); they are + removed from after/before/proposed output and from the diff. + """ + data = super().to_config(**kwargs) + self._strip_secret_template_inputs(data, by_alias=False, mask=True) + return data + + def _strip_secret_template_inputs(self, data: dict[str, Any], by_alias: bool, mask: bool = False) -> None: + """Handle secret template_inputs fields in an output/diff dict in place. + + ``mask=True`` (output) replaces a present secret with ``NO_LOG_PLACEHOLDER`` + so the key stays visible; ``mask=False`` (diff) pops it entirely so a + secret-only change is never detected. + """ + if self.config_data is None or self.config_data.template_inputs is None: + return + config_data_key = "configData" if by_alias else "config_data" + template_inputs_key = "templateInputs" if by_alias else "template_inputs" + config_data = data.get(config_data_key) + if not isinstance(config_data, dict): + return + template_inputs = config_data.get(template_inputs_key) + if not isinstance(template_inputs, dict): + return + for key in type(self.config_data.template_inputs).secret_field_keys(by_alias=by_alias): + if mask: + if key in template_inputs: + template_inputs[key] = NO_LOG_PLACEHOLDER + else: + template_inputs.pop(key, None) + + @staticmethod + def _remove_nested_key(data: dict, key_path: list[str]) -> None: + """Remove a key from a dict by path (noop if any segment is missing).""" + current = data + for key in key_path[:-1]: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return + if isinstance(current, dict) and key_path[-1] in current: + del current[key_path[-1]] + + def to_payload(self, **kwargs: Any) -> dict[str, Any]: + """Serialize for POST/PUT, sending ND's documented defaults for unset template fields.""" + data = super().to_payload(**kwargs) + self._fill_template_inputs_defaults(data) + return data + + def _fill_template_inputs_defaults(self, data: dict[str, Any]) -> None: + """Fill every unset template field so the payload carries all keys. + + ND's Jython template engine references every declared field by key, so a + missing key raises during template execution. Each field the user did not + set is sent with its documented ND default when it has one (``mtu 9216``, + ``speed auto``), otherwise as a typed empty (``""`` / ``0`` / ``false``). + Secrets have no documented default, so an unset secret is sent as ``""``; + it is still excluded from the diff, so a secret-only change never triggers + an update. Re-supply a secret when updating a link, or it is written empty. + """ + if self.config_data is None or self.config_data.template_inputs is None: + return + config_data = data.get("configData") + if not isinstance(config_data, dict): + return + template_inputs = config_data.get("templateInputs") + if not isinstance(template_inputs, dict): + return + tmpl_cls = type(self.config_data.template_inputs) + LinkTemplateBase.apply_payload_defaults(template_inputs, tmpl_cls) + + @classmethod + def get_argument_spec(cls) -> dict: + """Ansible argument spec; template_inputs is a generic dict (Pydantic validates).""" + return dict( + config=dict( + type="list", + elements="dict", + required=False, + options=dict( + src_cluster_name=dict(type="str"), + dst_cluster_name=dict(type="str"), + # Fabric and interface names on both ends are mandatory parts of + # every link's composite identity (Manage and OneManage). Requiring + # them here fails an incomplete item early at the Ansible boundary + # with a field-specific message, instead of later with an + # implementation-oriented composite-identifier exception. The + # top-level ``config`` stays optional so ``state: gathered`` (no + # config) still runs. Cluster identity for OneManage is scope + # dependent and validated at runtime once ``link_scope`` resolves. + src_fabric_name=dict(type="str", required=True), + dst_fabric_name=dict(type="str", required=True), + src_switch_name=dict(type="str"), + dst_switch_name=dict(type="str"), + src_interface_name=dict(type="str", required=True), + dst_interface_name=dict(type="str", required=True), + src_switch_id=dict(type="str"), + dst_switch_id=dict(type="str"), + src_switch_ip=dict(type="str"), + dst_switch_ip=dict(type="str"), + config_data=dict( + type="dict", + options=dict( + policy_type=dict( + type="str", + choices=[ + "numbered", + "unnumbered", + "ipv6LinkLocal", + "ebgpVrfLite", + "iosXeNumbered", + "layer2Dci", + "layer3DciVrfLite", + "multisiteOverlay", + "multisiteUnderlay", + "mplsOverlay", + "mplsUnderlay", + "preprovision", + "userDefined", + "vpcPeerKeepalive", + ], + ), + template_name=dict(type="str"), + template_inputs=dict(type="dict"), + ), + ), + ), + required_one_of=[ + ["src_switch_name", "src_switch_ip", "src_switch_id"], + ["dst_switch_name", "dst_switch_ip", "dst_switch_id"], + ], + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) diff --git a/plugins/module_utils/models/links/templates/__init__.py b/plugins/module_utils/models/links/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/models/links/templates/base.py b/plugins/module_utils/models/links/templates/base.py new file mode 100644 index 000000000..c1dec4cdf --- /dev/null +++ b/plugins/module_utils/models/links/templates/base.py @@ -0,0 +1,305 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Shared base class and field group mixins for link template input models. + +Policy specific models compose the mixins they need instead of redeclaring +common fields (interface basics, descriptions, MACsec, QKD, etc.). + +Fields that ND documents with a default carry that default via +``json_schema_extra={"payload_default": }`` (see ``pd()``). ND's Jython +template engine requires every declared key to be present, so on create/update +the payload fill sends the documented default for any field the user did not set +(``mtu 9216`` / ``speed auto`` instead of the schema-violating ``mtu: 0`` / +``speed: ""``) and a typed empty for every field that has no documented default, +including secrets. Secrets are still excluded from the diff, so an unset secret +sent as ``""`` never triggers an update; re-supply it on update or it is blanked. +""" + +from __future__ import annotations + +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict, Field, model_validator +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + +# Sentinel: a field with no documented ND default (sent as a typed empty when unset). +_NO_DEFAULT = object() + +# Shared OpenAPI enum value sets (links_spec.json). Reused across policy types. +SPEED_CHOICES = ("auto", "10Mb", "100Mb", "1Gb", "2.5Gb", "5Gb", "10Gb", "25Gb", "40Gb", "50Gb", "100Gb", "200Gb", "400Gb", "800Gb") +FEC_CHOICES = ("auto", "fcFec", "off", "rsCons16", "rsFec", "rsIeee") +BGP_AUTH_KEY_ENCRYPTION_CHOICES = ("3des", "type6", "type7") + + +def pd(default: Any, **extra: Any) -> dict[str, Any]: + """Build a ``json_schema_extra`` dict tagging a field's documented ND default, + plus any write-contract constraints (``choices``/``minimum``/``maximum``/ + ``max_length``/``required``) enforced by :meth:`LinkTemplateBase._enforce_write_contract`. + """ + return {"payload_default": default, **extra} + + +def con(**extra: Any) -> dict[str, Any]: + """Like :func:`pd` but for a field with no documented ND default: carries only + write-contract constraints (``choices``/``minimum``/``maximum``/``max_length``/ + ``required``). Used where the OpenAPI spec constrains a field but gives no default. + """ + return dict(extra) + + +class InterfaceBasicsMixin(BaseModel): + """Common interface level settings shared by the intra-fabric addressed policies + (numbered, unnumbered, ipv6LinkLocal). ``mtu`` is required by the OpenAPI write + contract for all three; the module still fills the documented default (9216) when + the user omits it, so requiredness only bites a field with no default.""" + + interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState", json_schema_extra=pd(True)) + mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9216, minimum=576, maximum=9216, required=True)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto", choices=SPEED_CHOICES)) + fec: str | None = Field(default=None, alias="fec", json_schema_extra=pd("auto", choices=FEC_CHOICES)) + + +class InterfaceDescriptionsMixin(BaseModel): + """Source/destination interface descriptions and freeform config strings.""" + + src_interface_description: str | None = Field(default=None, alias="srcInterfaceDescription", json_schema_extra=con(max_length=254)) + dst_interface_description: str | None = Field(default=None, alias="dstInterfaceDescription", json_schema_extra=con(max_length=254)) + src_interface_config: str | None = Field(default=None, alias="srcInterfaceConfig") + dst_interface_config: str | None = Field(default=None, alias="dstInterfaceConfig") + + +class DhcpRelayMixin(BaseModel): + """DHCP relay toggles for numbered/unnumbered links.""" + + dhcp_relay_on_src_interface: bool | None = Field(default=None, alias="dhcpRelayOnSrcInterface", json_schema_extra=pd(False)) + dhcp_relay_on_dst_interface: bool | None = Field(default=None, alias="dhcpRelayOnDstInterface", json_schema_extra=pd(False)) + + +class BfdEchoMixin(BaseModel): + """BFD echo toggles for numbered links.""" + + bfd_echo_on_src_interface: bool | None = Field(default=None, alias="bfdEchoOnSrcInterface", json_schema_extra=pd(False)) + bfd_echo_on_dst_interface: bool | None = Field(default=None, alias="bfdEchoOnDstInterface", json_schema_extra=pd(False)) + + +class MacsecCoreMixin(BaseModel): + """MACsec on/off toggle.""" + + macsec: bool | None = Field(default=None, alias="macsec", json_schema_extra=pd(False)) + + +class MacsecFullMixin(MacsecCoreMixin): + """Full MACsec configuration for DCI style links (cipher/keys/override).""" + + macsec_cipher_suite: str | None = Field(default=None, alias="macsecCipherSuite") + macsec_primary_cryptographic_algorithm: str | None = Field(default=None, alias="macsecPrimaryCryptographicAlgorithm") + macsec_primary_key_string: str | None = Field(default=None, alias="macsecPrimaryKeyString", json_schema_extra={"secret": True}) + macsec_fallback_cryptographic_algorithm: str | None = Field(default=None, alias="macsecFallbackCryptographicAlgorithm") + macsec_fallback_key_string: str | None = Field(default=None, alias="macsecFallbackKeyString", json_schema_extra={"secret": True}) + override_fabric_macsec: bool | None = Field(default=None, alias="overrideFabricMacsec", json_schema_extra=pd(False)) + + +class QkdMixin(BaseModel): + """Quantum Key Distribution / MACsec key-management fields for DCI links.""" + + qkd: bool | None = Field(default=None, alias="qkd", json_schema_extra=pd(False)) + ignore_certificate: bool | None = Field(default=None, alias="ignoreCertificate", json_schema_extra=pd(False)) + src_kme_server_ip: str | None = Field(default=None, alias="srcKmeServerIp") + dst_kme_server_ip: str | None = Field(default=None, alias="dstKmeServerIp") + src_kme_server_port_number: int | None = Field(default=None, alias="srcKmeServerPortNumber", json_schema_extra=con(minimum=0, maximum=65535)) + dst_kme_server_port_number: int | None = Field(default=None, alias="dstKmeServerPortNumber", json_schema_extra=con(minimum=0, maximum=65535)) + src_macsec_key_chain_prefix: str | None = Field(default=None, alias="srcMacsecKeyChainPrefix") + dst_macsec_key_chain_prefix: str | None = Field(default=None, alias="dstMacsecKeyChainPrefix") + src_qkd_profile_name: str | None = Field(default=None, alias="srcQkdProfileName", json_schema_extra=con(max_length=63)) + dst_qkd_profile_name: str | None = Field(default=None, alias="dstQkdProfileName", json_schema_extra=con(max_length=63)) + src_trustpoint_label: str | None = Field(default=None, alias="srcTrustpointLabel", json_schema_extra=con(max_length=64)) + dst_trustpoint_label: str | None = Field(default=None, alias="dstTrustpointLabel", json_schema_extra=con(max_length=64)) + + +class EbgpPasswordMixin(BaseModel): + """eBGP password / auth fields.""" + + enable_ebgp_password: bool | None = Field(default=None, alias="enableEbgpPassword", json_schema_extra=pd(True)) + ebgp_password: str | None = Field(default=None, alias="ebgpPassword", json_schema_extra={"secret": True}) + ebgp_auth_key_encryption_type: str | None = Field( + default=None, alias="ebgpAuthKeyEncryptionType", json_schema_extra=pd("3des", choices=BGP_AUTH_KEY_ENCRYPTION_CHOICES) + ) + inherit_ebgp_password_msd_settings: bool | None = Field(default=None, alias="inheritEbgpPasswordMsdSettings", json_schema_extra=pd(True)) + + +class TtagMixin(BaseModel): + """TTAG fabric setting inheritance flag.""" + + inherit_ttag_fabric_setting: bool | None = Field(default=None, alias="inheritTtagFabricSetting", json_schema_extra=pd(True)) + + +class NetflowMixin(BaseModel): + """Netflow monitoring fields for DCI links.""" + + netflow_on_src_interface: bool | None = Field(default=None, alias="netflowOnSrcInterface", json_schema_extra=pd(False)) + netflow_on_dst_interface: bool | None = Field(default=None, alias="netflowOnDstInterface", json_schema_extra=pd(False)) + src_netflow_monitor_name: str | None = Field(default=None, alias="srcNetflowMonitorName") + dst_netflow_monitor_name: str | None = Field(default=None, alias="dstNetflowMonitorName") + + +class LinkTemplateBase(NDNestedModel): + """Base for all policy specific template input models. + + ``extra="forbid"`` ensures fields that don't belong to the selected policy + type (e.g. ``ebgp_multihop`` on a numbered link) are rejected by Pydantic + instead of silently dropped. ``UserDefinedTemplateInputs`` overrides this + back to ``extra="allow"`` because its shape is open. + """ + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="before") + @classmethod + def _drop_unknown_on_read(cls, data: Any, info: Any) -> Any: + """Keep controller reads tolerant of forward/legacy response fields. + + ``extra="forbid"`` is the write-time guard that rejects a field belonging to + another policy. On a controller read (``from_response`` marks + ``source="response"``) that same strictness would misclassify a *supported* + policy carrying an extra response field as an opaque unsupported link (it once + did for a valid ``preprovision`` link returning ``mtu``/``speed``). Drop the + unknown keys on read only, so the link resolves to its real policy model and + stays mutable. Write input keeps every key, so ``extra="forbid"`` still + rejects a wrong field. ``extra="allow"`` models (userDefined) are exempt. + """ + context = info.context or {} + if context.get("source") != "response" or not isinstance(data, dict): + return data + if cls.model_config.get("extra") == "allow": + return data + known = {"policy_type_marker"} + for field_name, field_info in cls.model_fields.items(): + known.add(field_name) + if field_info.alias: + known.add(field_info.alias) + return {key: value for key, value in data.items() if key in known} + + @model_validator(mode="after") + def _enforce_write_contract(self, info: Any) -> LinkTemplateBase: + """Enforce the OpenAPI write contract (required / enum / numeric bounds / + string length) on user writes only. + + Write states thread ``context["state"]``; controller reads mark + ``source="response"``. Enforcement runs only for writes so invalid intent + fails before check mode proposes a change or any mutating request is sent, + while gathered stays tolerant of legacy / forward-compatible / already-invalid + controller records. A required field that carries a documented default is not + forced on the user (the payload fill supplies it); only a required field with + no default must be provided, otherwise it would be sent as a typed empty and + rejected by ND. + """ + context = info.context or {} + if not context.get("state") or context.get("source") == "response": + return self + policy = getattr(self, "policy_type_marker", type(self).__name__) + for field_name, field_info in type(self).model_fields.items(): + extra = field_info.json_schema_extra + if not isinstance(extra, dict): + continue + alias = field_info.alias or field_name + value = getattr(self, field_name, None) + if value is None: + if extra.get("required") and "payload_default" not in extra: + raise ValueError("'{0}' is required for policy_type '{1}'.".format(alias, policy)) + continue + choices = extra.get("choices") + if choices is not None and value not in choices: + raise ValueError("'{0}'={1!r} is not a valid choice for policy_type '{2}'. Valid choices: {3}.".format(alias, value, policy, list(choices))) + minimum = extra.get("minimum") + if minimum is not None and isinstance(value, int) and not isinstance(value, bool) and value < minimum: + raise ValueError("'{0}'={1} is below the minimum {2} for policy_type '{3}'.".format(alias, value, minimum, policy)) + maximum = extra.get("maximum") + if maximum is not None and isinstance(value, int) and not isinstance(value, bool) and value > maximum: + raise ValueError("'{0}'={1} exceeds the maximum {2} for policy_type '{3}'.".format(alias, value, maximum, policy)) + max_length = extra.get("max_length") + if max_length is not None and isinstance(value, str) and len(value) > max_length: + raise ValueError("'{0}' exceeds the maximum length of {1} characters for policy_type '{2}'.".format(alias, max_length, policy)) + return self + + @classmethod + def secret_field_keys(cls, by_alias: bool = True) -> set[str]: + """Keys of secret fields (tagged ``json_schema_extra={"secret": True}``). + + Returns aliases when ``by_alias`` is True (payload/diff shape), else the + Python field names (config/output shape). Used to scrub secrets from + module output and diffs while keeping them in the controller payload. + """ + keys: set[str] = set() + for field_name, field_info in cls.model_fields.items(): + extra = field_info.json_schema_extra + if isinstance(extra, dict) and extra.get("secret"): + keys.add((field_info.alias or field_name) if by_alias else field_name) + return keys + + @staticmethod + def _documented_default(field_info: Any) -> Any: + """Return the field's documented ND default, or ``_NO_DEFAULT`` if none.""" + extra = field_info.json_schema_extra + if isinstance(extra, dict) and "payload_default" in extra: + return extra["payload_default"] + return _NO_DEFAULT + + @classmethod + def apply_payload_defaults(cls, template_inputs: dict[str, Any], tmpl_cls: type) -> None: + """Fill an aliased ``templateInputs`` dict for a create/update request. + + ND's Jython template engine references every known field by key, so a + missing key raises during template execution: the payload must carry all + declared fields. For each field the user did not set: + + - fields with a documented ND default are sent with that default, so we + send ``mtu 9216`` / ``interface_admin_state true`` / ``fec auto`` / + ``speed auto`` instead of the schema-violating typed empties + ``0`` / ``false`` / ``""``; + - every other field (including secrets) is sent as a typed empty so the + key is present. Secrets are still excluded from the diff, so a + secret-only change never triggers an update; re-supply a secret when + updating a link, otherwise it is written empty. + """ + for field_name, field_info in tmpl_cls.model_fields.items(): + if field_info.exclude: + continue + alias = field_info.alias or field_name + if template_inputs.get(alias) is not None: + continue # user-provided value; keep as-is + default = cls._documented_default(field_info) + if default is not _NO_DEFAULT: + template_inputs[alias] = default + else: + template_inputs[alias] = cls._empty_for_annotation(field_info.annotation) + + @staticmethod + def _empty_for_annotation(annotation: Any) -> Any: + """Typed empty matching an ``Optional[...]`` field's underlying type.""" + import types + from typing import Union, get_args, get_origin + + if get_origin(annotation) in (Union, types.UnionType): + non_none = [candidate for candidate in get_args(annotation) if candidate is not type(None)] + if non_none: + annotation = non_none[0] + if annotation is bool: + return False + if annotation is int: + return 0 + if annotation is float: + return 0.0 + return "" + + def to_payload(self, **kwargs: Any) -> dict[str, Any]: + """Serialize for POST/PUT, sending ND's documented defaults for unset fields.""" + data = self.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + context={"mode": "payload"}, + exclude=self.payload_exclude_fields or None, + ) + self.apply_payload_defaults(data, self.__class__) + return data diff --git a/plugins/module_utils/models/links/templates/discriminated_union.py b/plugins/module_utils/models/links/templates/discriminated_union.py new file mode 100644 index 000000000..2c801b92a --- /dev/null +++ b/plugins/module_utils/models/links/templates/discriminated_union.py @@ -0,0 +1,128 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Discriminated union type for link template inputs. + +``LinkTemplateInputs`` is an Annotated Union over every policy type model in +this package. Pydantic picks the correct subclass from the ``policy_type_marker`` +literal field during parsing, giving per policy field validation for free. +""" + +from __future__ import annotations + +from typing import Annotated + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .ebgp_vrf_lite import EbgpVrfLiteTemplateInputs +from .ios_xe_numbered import IosXeNumberedTemplateInputs +from .ipv6_link_local import Ipv6LinkLocalTemplateInputs +from .layer2_dci import Layer2DciTemplateInputs +from .layer3_dci_vrf_lite import Layer3DciVrfLiteTemplateInputs +from .mpls_overlay import MplsOverlayTemplateInputs +from .mpls_underlay import MplsUnderlayTemplateInputs +from .multisite_overlay import MultisiteOverlayTemplateInputs +from .multisite_underlay import MultisiteUnderlayTemplateInputs +from .numbered import NumberedTemplateInputs +from .preprovision import PreprovisionTemplateInputs +from .unnumbered import UnnumberedTemplateInputs +from .user_defined import UserDefinedTemplateInputs +from .vpc_peer_keepalive import VpcPeerKeepaliveTemplateInputs + +# ``A | B`` here is a runtime union over pydantic model classes, not a deferred +# annotation, so ``from __future__ import annotations`` does not defer it. It works +# (pydantic's metaclass supports ``__or__``), but pylint's static check cannot see +# that, so scope-disable ``unsupported-binary-operation`` for just this assignment. +# pylint: disable=unsupported-binary-operation +LinkTemplateInputs = Annotated[ + NumberedTemplateInputs + | UnnumberedTemplateInputs + | Ipv6LinkLocalTemplateInputs + | EbgpVrfLiteTemplateInputs + | IosXeNumberedTemplateInputs + | Layer2DciTemplateInputs + | Layer3DciVrfLiteTemplateInputs + | MultisiteOverlayTemplateInputs + | MultisiteUnderlayTemplateInputs + | MplsOverlayTemplateInputs + | MplsUnderlayTemplateInputs + | PreprovisionTemplateInputs + | UserDefinedTemplateInputs + | VpcPeerKeepaliveTemplateInputs, + Field(discriminator="policy_type_marker"), +] +# pylint: enable=unsupported-binary-operation + +# Policy types this module strictly models and can create/update. Reads of any +# other (valid) ND policy type fall back to a permissive opaque record so the +# full-fabric query is never aborted by an unrelated, unsupported link. +SUPPORTED_POLICY_TYPES = ( + "numbered", + "unnumbered", + "ipv6LinkLocal", + "ebgpVrfLite", + "iosXeNumbered", + "layer2Dci", + "layer3DciVrfLite", + "multisiteOverlay", + "multisiteUnderlay", + "mplsOverlay", + "mplsUnderlay", + "preprovision", + "userDefined", + "vpcPeerKeepalive", +) + +# Every policy-type model in the union, used to enumerate secret keys across all +# of them without knowing which one a given link resolves to. +_LINK_TEMPLATE_INPUT_MODELS = ( + NumberedTemplateInputs, + UnnumberedTemplateInputs, + Ipv6LinkLocalTemplateInputs, + EbgpVrfLiteTemplateInputs, + IosXeNumberedTemplateInputs, + Layer2DciTemplateInputs, + Layer3DciVrfLiteTemplateInputs, + MultisiteOverlayTemplateInputs, + MultisiteUnderlayTemplateInputs, + MplsOverlayTemplateInputs, + MplsUnderlayTemplateInputs, + PreprovisionTemplateInputs, + UserDefinedTemplateInputs, + VpcPeerKeepaliveTemplateInputs, +) + + +def all_secret_template_input_keys(by_alias: bool = False) -> set[str]: + """Union of secret (``no_log``-worthy) key names across every policy type. + + ``template_inputs`` is a free-form dict, so Ansible cannot mark its secret + keys ``no_log`` in the argument spec. The module registers these values with + ``module.no_log_values`` at runtime instead; this returns the key names to + look for, derived from the models so it never drifts as fields are added. + """ + keys: set[str] = set() + for model in _LINK_TEMPLATE_INPUT_MODELS: + keys |= model.secret_field_keys(by_alias=by_alias) + return keys + + +__all__ = [ + "LinkTemplateInputs", + "SUPPORTED_POLICY_TYPES", + "all_secret_template_input_keys", + "NumberedTemplateInputs", + "UnnumberedTemplateInputs", + "Ipv6LinkLocalTemplateInputs", + "EbgpVrfLiteTemplateInputs", + "IosXeNumberedTemplateInputs", + "Layer2DciTemplateInputs", + "Layer3DciVrfLiteTemplateInputs", + "MultisiteOverlayTemplateInputs", + "MultisiteUnderlayTemplateInputs", + "MplsOverlayTemplateInputs", + "MplsUnderlayTemplateInputs", + "PreprovisionTemplateInputs", + "UserDefinedTemplateInputs", + "VpcPeerKeepaliveTemplateInputs", +] diff --git a/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py new file mode 100644 index 000000000..7517ea5ac --- /dev/null +++ b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py @@ -0,0 +1,56 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + BGP_AUTH_KEY_ENCRYPTION_CHOICES, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecFullMixin, + QkdMixin, + TtagMixin, + con, + pd, +) + + +class EbgpVrfLiteTemplateInputs( + InterfaceDescriptionsMixin, + MacsecFullMixin, + QkdMixin, + TtagMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=ebgpVrfLite (eBGP peering over VRF lite link).""" + + policy_type_marker: Literal["ebgpVrfLite"] = Field(default="ebgpVrfLite", exclude=True) + + src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn", json_schema_extra=con(required=True)) + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn", json_schema_extra=con(required=True)) + + src_ip_address_mask: str | None = Field(default=None, alias="srcIpAddressMask") + src_ipv6_address_mask: str | None = Field(default=None, alias="srcIpv6AddressMask") + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") + dst_ipv6_address: str | None = Field(default=None, alias="dstIpv6Address") + + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) + routing_tag: str | None = Field(default=None, alias="routingTag") + + auto_gen_config_default_vrf: bool | None = Field(default=None, alias="autoGenConfigDefaultVrf", json_schema_extra=pd(False)) + auto_gen_config_nx_peer_default_vrf: bool | None = Field(default=None, alias="autoGenConfigNxPeerDefaultVrf", json_schema_extra=pd(False)) + auto_gen_config_peer: bool | None = Field(default=None, alias="autoGenConfigPeer", json_schema_extra=pd(False)) + + dci_tracking: bool | None = Field(default=None, alias="dciTracking", json_schema_extra=pd(False)) + + default_vrf_ebgp_neighbor_password: str | None = Field(default=None, alias="defaultVrfEbgpNeighborPassword", json_schema_extra={"secret": True}) + default_vrf_ebgp_password_key_encryption_type: str | None = Field( + default=None, alias="defaultVrfEbgpPasswordKeyEncryptionType", json_schema_extra=pd("3des", choices=BGP_AUTH_KEY_ENCRYPTION_CHOICES) + ) + redistrib_ebgp_route_map_name: str | None = Field(default=None, alias="redistribEbgpRouteMapName") + template_config_gen_peer: str | None = Field(default=None, alias="templateConfigGenPeer", json_schema_extra=pd("Ext_VRF_Lite_Jython")) + vrf_name_nx_peer_switch: str | None = Field(default=None, alias="vrfNameNxPeerSwitch") diff --git a/plugins/module_utils/models/links/templates/ios_xe_numbered.py b/plugins/module_utils/models/links/templates/ios_xe_numbered.py new file mode 100644 index 000000000..83aa15327 --- /dev/null +++ b/plugins/module_utils/models/links/templates/ios_xe_numbered.py @@ -0,0 +1,35 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + SPEED_CHOICES, + InterfaceDescriptionsMixin, + LinkTemplateBase, + pd, +) + + +class IosXeNumberedTemplateInputs( + InterfaceDescriptionsMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=iosXeNumbered (IOS-XE intra-fabric numbered link). + + ND emits this policy for a normal Campus (vxlanCampus) intra-fabric link, so it + is modeled as a first-class supported type. Note the IOS-XE MTU range differs + from NX-OS (default 9198, not 9216). + """ + + policy_type_marker: Literal["iosXeNumbered"] = Field(default="iosXeNumbered", exclude=True) + + interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState", json_schema_extra=pd(True)) + src_ip: str | None = Field(default=None, alias="srcIp") + dst_ip: str | None = Field(default=None, alias="dstIp") + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto", choices=SPEED_CHOICES)) + mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9198, minimum=1500, maximum=9198)) diff --git a/plugins/module_utils/models/links/templates/ipv6_link_local.py b/plugins/module_utils/models/links/templates/ipv6_link_local.py new file mode 100644 index 000000000..4e9650944 --- /dev/null +++ b/plugins/module_utils/models/links/templates/ipv6_link_local.py @@ -0,0 +1,26 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + InterfaceBasicsMixin, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecCoreMixin, +) + + +class Ipv6LinkLocalTemplateInputs( + InterfaceBasicsMixin, + InterfaceDescriptionsMixin, + MacsecCoreMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=ipv6LinkLocal (auto fe80::/10 addressing).""" + + policy_type_marker: Literal["ipv6LinkLocal"] = Field(default="ipv6LinkLocal", exclude=True) diff --git a/plugins/module_utils/models/links/templates/layer2_dci.py b/plugins/module_utils/models/links/templates/layer2_dci.py new file mode 100644 index 000000000..1d53b399c --- /dev/null +++ b/plugins/module_utils/models/links/templates/layer2_dci.py @@ -0,0 +1,45 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + SPEED_CHOICES, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecFullMixin, + QkdMixin, + con, + pd, +) + +BPDU_GUARD_CHOICES = ("enable", "disable", "default") +MTU_TYPE_CHOICES = ("default", "jumbo") + + +class Layer2DciTemplateInputs( + InterfaceDescriptionsMixin, + MacsecFullMixin, + QkdMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=layer2Dci (L2 trunk between data centers). + + ``inheritTtagFabricSetting`` (TtagMixin) is intentionally NOT composed here: the + OpenAPI ``layer2DciConfig`` schema does not define it, so sending it is off + contract. + """ + + policy_type_marker: Literal["layer2Dci"] = Field(default="layer2Dci", exclude=True) + + trunk_allowed_vlans: str | None = Field(default=None, alias="trunkAllowedVlans") + native_vlan: int | None = Field(default=None, alias="nativeVlan", json_schema_extra=con(minimum=1, maximum=4094)) + bpdu_guard: str | None = Field(default=None, alias="bpduGuard", json_schema_extra=con(choices=BPDU_GUARD_CHOICES)) + port_type_fast: bool | None = Field(default=None, alias="portTypeFast", json_schema_extra=pd(True)) + + mtu_type: str | None = Field(default=None, alias="mtuType", json_schema_extra=con(choices=MTU_TYPE_CHOICES)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto", choices=SPEED_CHOICES)) diff --git a/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py b/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py new file mode 100644 index 000000000..345359f9a --- /dev/null +++ b/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py @@ -0,0 +1,47 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + SPEED_CHOICES, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecFullMixin, + NetflowMixin, + QkdMixin, + TtagMixin, + pd, +) + + +class Layer3DciVrfLiteTemplateInputs( + InterfaceDescriptionsMixin, + MacsecFullMixin, + QkdMixin, + TtagMixin, + NetflowMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=layer3DciVrfLite (VRF lite stitched DCI link).""" + + policy_type_marker: Literal["layer3DciVrfLite"] = Field(default="layer3DciVrfLite", exclude=True) + + src_ip_address_mask: str | None = Field(default=None, alias="srcIpAddressMask") + dst_ip_address_mask: str | None = Field(default=None, alias="dstIpAddressMask") + src_ipv6_address_mask: str | None = Field(default=None, alias="srcIpv6AddressMask") + dst_ipv6_address_mask: str | None = Field(default=None, alias="dstIpv6AddressMask") + + src_vrf_name: str | None = Field(default=None, alias="srcVrfName") + dst_vrf_name: str | None = Field(default=None, alias="dstVrfName") + + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto", choices=SPEED_CHOICES)) + + ip_redirects: bool | None = Field(default=None, alias="ipRedirects", json_schema_extra=pd(False)) + ipv4_pim: bool | None = Field(default=None, alias="ipv4Pim", json_schema_extra=pd(False)) + ipv6_pim: bool | None = Field(default=None, alias="ipv6Pim", json_schema_extra=pd(False)) diff --git a/plugins/module_utils/models/links/templates/mpls_overlay.py b/plugins/module_utils/models/links/templates/mpls_overlay.py new file mode 100644 index 000000000..989537711 --- /dev/null +++ b/plugins/module_utils/models/links/templates/mpls_overlay.py @@ -0,0 +1,20 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import LinkTemplateBase, con + + +class MplsOverlayTemplateInputs(LinkTemplateBase): + """Template inputs for policy_type=mplsOverlay (MPLS SR loopback eBGP peering).""" + + policy_type_marker: Literal["mplsOverlay"] = Field(default="mplsOverlay", exclude=True) + + src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn", json_schema_extra=con(required=True)) + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn", json_schema_extra=con(required=True)) + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress", json_schema_extra=con(required=True)) diff --git a/plugins/module_utils/models/links/templates/mpls_underlay.py b/plugins/module_utils/models/links/templates/mpls_underlay.py new file mode 100644 index 000000000..5f138c3c5 --- /dev/null +++ b/plugins/module_utils/models/links/templates/mpls_underlay.py @@ -0,0 +1,45 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + InterfaceDescriptionsMixin, + LinkTemplateBase, + TtagMixin, + con, + pd, +) + +MPLS_FABRIC_TYPE_CHOICES = ("mplsLdp", "mplsSr") +DCI_ROUTING_PROTOCOL_CHOICES = ("ospf", "is-is") + + +class MplsUnderlayTemplateInputs( + InterfaceDescriptionsMixin, + TtagMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=mplsUnderlay (ISIS or OSPF with segment routing).""" + + policy_type_marker: Literal["mplsUnderlay"] = Field(default="mplsUnderlay", exclude=True) + + mpls_fabric_type: str | None = Field(default=None, alias="mplsFabricType", json_schema_extra=con(choices=MPLS_FABRIC_TYPE_CHOICES, required=True)) + dci_routing_protocol: str | None = Field( + default=None, alias="dciRoutingProtocol", json_schema_extra=con(choices=DCI_ROUTING_PROTOCOL_CHOICES, required=True) + ) + dci_routing_tag: str | None = Field(default=None, alias="dciRoutingTag", json_schema_extra=pd("MPLS_UNDERLAY", required=True)) + ospf_area_id: str | None = Field(default=None, alias="ospfAreaId") + + sr_global_block_range: str | None = Field(default=None, alias="srGlobalBlockRange", json_schema_extra=pd("16000-23999")) + src_sr_index: int | None = Field(default=None, alias="srcSrIndex", json_schema_extra=con(minimum=0, maximum=471804)) + dst_sr_index: int | None = Field(default=None, alias="dstSrIndex", json_schema_extra=con(minimum=0, maximum=471804)) + + src_ip_address_mask: str | None = Field(default=None, alias="srcIpAddressMask", json_schema_extra=con(required=True)) + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress", json_schema_extra=con(required=True)) + + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) diff --git a/plugins/module_utils/models/links/templates/multisite_overlay.py b/plugins/module_utils/models/links/templates/multisite_overlay.py new file mode 100644 index 000000000..a171a35a9 --- /dev/null +++ b/plugins/module_utils/models/links/templates/multisite_overlay.py @@ -0,0 +1,49 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + EbgpPasswordMixin, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecFullMixin, + QkdMixin, + con, + pd, +) + + +class MultisiteOverlayTemplateInputs( + InterfaceDescriptionsMixin, + MacsecFullMixin, + QkdMixin, + EbgpPasswordMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=multisiteOverlay (BGW overlay eBGP session). + + Composes the MACsec, QKD and interface-description mixins so the full OpenAPI + ``multisiteOverlayConfig`` shape (macsec/qkd/interface-config fields) is modeled; + previously these were absent, so a controller read of such a link was + misclassified as unsupported. + """ + + policy_type_marker: Literal["multisiteOverlay"] = Field(default="multisiteOverlay", exclude=True) + + src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn", json_schema_extra=con(required=True)) + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn", json_schema_extra=con(required=True)) + src_ip_address: str | None = Field(default=None, alias="srcIpAddress", json_schema_extra=con(required=True)) + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress", json_schema_extra=con(required=True)) + ebgp_multihop: int | None = Field(default=None, alias="ebgpMultihop", json_schema_extra=pd(5, minimum=2, maximum=255)) + + ipv4_trm: bool | None = Field(default=None, alias="ipv4Trm", json_schema_extra=pd(False)) + ipv6_trm: bool | None = Field(default=None, alias="ipv6Trm", json_schema_extra=pd(False)) + + redistribute_route_server: bool | None = Field(default=None, alias="redistributeRouteServer", json_schema_extra=pd(False)) + route_server_routing_tag: str | None = Field(default=None, alias="routeServerRoutingTag") + skip_config_generation: bool | None = Field(default=None, alias="skipConfigGeneration", json_schema_extra=pd(False)) diff --git a/plugins/module_utils/models/links/templates/multisite_underlay.py b/plugins/module_utils/models/links/templates/multisite_underlay.py new file mode 100644 index 000000000..35a8fff42 --- /dev/null +++ b/plugins/module_utils/models/links/templates/multisite_underlay.py @@ -0,0 +1,47 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + SPEED_CHOICES, + EbgpPasswordMixin, + InterfaceDescriptionsMixin, + LinkTemplateBase, + TtagMixin, + con, + pd, +) + + +class MultisiteUnderlayTemplateInputs( + InterfaceDescriptionsMixin, + EbgpPasswordMixin, + TtagMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=multisiteUnderlay (BGW underlay reachability).""" + + policy_type_marker: Literal["multisiteUnderlay"] = Field(default="multisiteUnderlay", exclude=True) + + src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn", json_schema_extra=con(required=True)) + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn", json_schema_extra=con(required=True)) + ebgp_bfd: bool | None = Field(default=None, alias="ebgpBfd", json_schema_extra=pd(False)) + ebgp_log_neighbor_change: bool | None = Field(default=None, alias="ebgpLogNeighborChange", json_schema_extra=pd(False)) + ebgp_maximum_paths: int | None = Field(default=None, alias="ebgpMaximumPaths", json_schema_extra=pd(64, minimum=1, maximum=64)) + ebgp_send_comboth: bool | None = Field(default=None, alias="ebgpSendComboth", json_schema_extra=pd(False)) + + src_ip_address_mask: str | None = Field(default=None, alias="srcIpAddressMask") + src_ipv6_address_mask: str | None = Field(default=None, alias="srcIpv6AddressMask") + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") + dst_ipv6_address: str | None = Field(default=None, alias="dstIpv6Address") + + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto", choices=SPEED_CHOICES)) + routing_tag: str | None = Field(default=None, alias="routingTag") + + dci_tracking_enable_flag: bool | None = Field(default=None, alias="dciTrackingEnableFlag", json_schema_extra=pd(False)) diff --git a/plugins/module_utils/models/links/templates/numbered.py b/plugins/module_utils/models/links/templates/numbered.py new file mode 100644 index 000000000..431edae2b --- /dev/null +++ b/plugins/module_utils/models/links/templates/numbered.py @@ -0,0 +1,35 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + BfdEchoMixin, + DhcpRelayMixin, + InterfaceBasicsMixin, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecCoreMixin, +) + + +class NumberedTemplateInputs( + InterfaceBasicsMixin, + InterfaceDescriptionsMixin, + DhcpRelayMixin, + BfdEchoMixin, + MacsecCoreMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=numbered (IPv4/IPv6 addressed P2P link).""" + + policy_type_marker: Literal["numbered"] = Field(default="numbered", exclude=True) + + src_ip: str | None = Field(default=None, alias="srcIp") + dst_ip: str | None = Field(default=None, alias="dstIp") + src_ipv6: str | None = Field(default=None, alias="srcIpv6") + dst_ipv6: str | None = Field(default=None, alias="dstIpv6") diff --git a/plugins/module_utils/models/links/templates/preprovision.py b/plugins/module_utils/models/links/templates/preprovision.py new file mode 100644 index 000000000..8d8b2a25d --- /dev/null +++ b/plugins/module_utils/models/links/templates/preprovision.py @@ -0,0 +1,34 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + SPEED_CHOICES, + InterfaceDescriptionsMixin, + LinkTemplateBase, + pd, +) + + +class PreprovisionTemplateInputs( + InterfaceDescriptionsMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=preprovision. + + Per the OpenAPI ``preprovisionConfig`` schema this policy carries the interface + description/config fields plus ``mtu`` and ``speed`` (but not ``fec`` or + ``interfaceAdminState``, unlike numbered/unnumbered). ``mtu``/``speed`` were + previously omitted, which made a controller read of a preprovision link with + those fields fall back to the opaque unsupported record (and hence immutable). + """ + + policy_type_marker: Literal["preprovision"] = Field(default="preprovision", exclude=True) + + mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto", choices=SPEED_CHOICES)) diff --git a/plugins/module_utils/models/links/templates/unnumbered.py b/plugins/module_utils/models/links/templates/unnumbered.py new file mode 100644 index 000000000..9e007eed5 --- /dev/null +++ b/plugins/module_utils/models/links/templates/unnumbered.py @@ -0,0 +1,28 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import ( + DhcpRelayMixin, + InterfaceBasicsMixin, + InterfaceDescriptionsMixin, + LinkTemplateBase, + MacsecCoreMixin, +) + + +class UnnumberedTemplateInputs( + InterfaceBasicsMixin, + InterfaceDescriptionsMixin, + DhcpRelayMixin, + MacsecCoreMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=unnumbered (borrows IP from another interface).""" + + policy_type_marker: Literal["unnumbered"] = Field(default="unnumbered", exclude=True) diff --git a/plugins/module_utils/models/links/templates/unsupported.py b/plugins/module_utils/models/links/templates/unsupported.py new file mode 100644 index 000000000..bde70ca8d --- /dev/null +++ b/plugins/module_utils/models/links/templates/unsupported.py @@ -0,0 +1,39 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Permissive fallback template model for policy types this module does not model. + +ND 4.2 defines 22 link policy types; this module strictly models a subset for +create/update. The controller can still return a link using any of the others +(for example ``ipfmNumbered`` or ``routedFabric``), and a strict discriminated +union would raise ``ValidationError`` on the full-fabric read -- aborting every +state, even for unrelated supported links. + +``UnsupportedTemplateInputs`` preserves such a link verbatim (``extra="allow"``, +no field validation) so it survives the read as an opaque record. It is selected +by ``LinkConfigDataModel`` only when strict parsing fails on a controller read; +user input stays strictly validated against the supported policy types. +""" + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ConfigDict, Field + +from .base import LinkTemplateBase + +# Internal discriminator value for the fallback model (never a real ND policyType). +UNSUPPORTED_POLICY_MARKER = "__unsupported__" + + +class UnsupportedTemplateInputs(LinkTemplateBase): + """Opaque, read-only preservation of an unsupported policy's templateInputs.""" + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + use_enum_values=True, + ) + + policy_type_marker: Literal["__unsupported__"] = Field(default=UNSUPPORTED_POLICY_MARKER, exclude=True) diff --git a/plugins/module_utils/models/links/templates/user_defined.py b/plugins/module_utils/models/links/templates/user_defined.py new file mode 100644 index 000000000..7a29a1826 --- /dev/null +++ b/plugins/module_utils/models/links/templates/user_defined.py @@ -0,0 +1,25 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ConfigDict, Field + +from .base import LinkTemplateBase + + +class UserDefinedTemplateInputs(LinkTemplateBase): + """Template inputs for policy_type=userDefined; shape is open (custom template_name).""" + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + use_enum_values=True, + ) + + policy_type_marker: Literal["userDefined"] = Field(default="userDefined", exclude=True) + + allowed_vlans: str | None = Field(default=None, alias="allowedVlans") + mtu: int | None = Field(default=None, alias="mtu") diff --git a/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py b/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py new file mode 100644 index 000000000..294094270 --- /dev/null +++ b/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py @@ -0,0 +1,29 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .base import InterfaceDescriptionsMixin, LinkTemplateBase, pd + + +class VpcPeerKeepaliveTemplateInputs( + InterfaceDescriptionsMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=vpcPeerKeepalive (vPC peer heartbeat link).""" + + policy_type_marker: Literal["vpcPeerKeepalive"] = Field(default="vpcPeerKeepalive", exclude=True) + + src_ip: str | None = Field(default=None, alias="srcIp") + dst_ip: str | None = Field(default=None, alias="dstIp") + src_ipv6: str | None = Field(default=None, alias="srcIpv6") + dst_ipv6: str | None = Field(default=None, alias="dstIpv6") + + interface_vrf: str | None = Field(default=None, alias="interfaceVrf") + + interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState", json_schema_extra=pd(True)) + mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index a9e965325..4e37811d5 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -2,25 +2,49 @@ # 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 __future__ import absolute_import, annotations, division, print_function -from typing import Dict, Any, Optional, List, Union +from typing import Any from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.utils import prune_to_spec class NDOutput: - def __init__(self, output_level: str): + def __init__(self, output_level: str, state: str = ""): self._output_level: str = output_level + self._state: str = state self._changed: bool = False - self._before: Union[NDConfigCollection, List] = [] - self._after: Union[NDConfigCollection, List] = [] - self._diff: Union[NDConfigCollection, List] = [] - self._proposed: Union[NDConfigCollection, List] = [] - self._logs: List = [] - self._extra: Dict[str, Any] = {} - - def format(self, **kwargs) -> Dict[str, Any]: + self._before: NDConfigCollection | list = [] + self._after: NDConfigCollection | list = [] + self._diff: NDConfigCollection | list = [] + self._proposed: NDConfigCollection | list = [] + self._logs: list = [] + self._extra: dict[str, Any] = {} + # Argument-spec ``config.options`` mapping used to prune gathered output + # down to valid module arguments so it round-trips as ``config``. + self._gathered_spec: dict[str, Any] = {} + + def format(self, **kwargs) -> dict[str, Any]: + # Read-only gathered state follows the Ansible resource-module + # convention: return the fetched objects under a ``gathered`` key and + # omit the change-oriented before/after/diff/proposed keys. + if self._state == "gathered": + gathered_items = self._after.to_ansible_config() if isinstance(self._after, NDConfigCollection) else self._after + if self._gathered_spec and isinstance(gathered_items, list): + gathered_items = [prune_to_spec(item, self._gathered_spec) for item in gathered_items] + gathered_output = { + "output_level": self._output_level, + "changed": False, + "gathered": gathered_items, + } + if self._output_level == "debug": + gathered_output["logs"] = self._logs + if self._extra: + gathered_output.update(self._extra) + gathered_output.update(**kwargs) + return gathered_output + if isinstance(self._before, NDConfigCollection) and isinstance(self._after, NDConfigCollection) and self._before.get_diff_collection(self._after): self._changed = True @@ -44,7 +68,7 @@ def format(self, **kwargs) -> Dict[str, Any]: return output - def format_with_verbosity(self, verbosity: int, results: Optional[Results] = None, **kwargs) -> Dict[str, Any]: + def format_with_verbosity(self, verbosity: int, results: Results | None = None, **kwargs) -> dict[str, Any]: """ Build output dict filtered by CLI verbosity level. @@ -95,12 +119,13 @@ def format_with_verbosity(self, verbosity: int, results: Optional[Results] = Non def assign( self, - after: Optional[NDConfigCollection] = None, - before: Optional[NDConfigCollection] = None, - diff: Optional[NDConfigCollection] = None, - proposed: Optional[NDConfigCollection] = None, - logs: Optional[List] = None, - **kwargs + after: NDConfigCollection | None = None, + before: NDConfigCollection | None = None, + diff: NDConfigCollection | None = None, + proposed: NDConfigCollection | None = None, + logs: list | None = None, + gathered_spec: dict[str, Any] | None = None, + **kwargs, ) -> None: if isinstance(after, NDConfigCollection): self._after = after @@ -110,6 +135,8 @@ def assign( self._diff = diff if isinstance(proposed, NDConfigCollection): self._proposed = proposed - if isinstance(logs, List): + if isinstance(logs, list): self._logs = logs + if isinstance(gathered_spec, dict): + self._gathered_spec = gathered_spec self._extra.update(**kwargs) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index e9adfc59e..f5372d239 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -25,9 +25,21 @@ class NDStateMachine: Generic State Machine for Nexus Dashboard (Bulk Support). """ - def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchestrator] | NDBaseOrchestrator): + def __init__( + self, + module: AnsibleModule, + model_orchestrator: type[NDBaseOrchestrator] | NDBaseOrchestrator, + config: list | None = None, + ): """ Initialize the ND State Machine. + + ``config``: optional caller-prepared config list used to build the + proposed collection. When omitted, ``module.params["config"]`` is used. + Callers that need ``prepare_config_data`` transforms (switch-id backfill, + payload nesting) must run it themselves and pass the result here (or write + it back to ``module.params["config"]``); the state machine no longer calls + ``prepare_config_data`` so non-idempotent orchestrators are not run twice. """ self.module = module @@ -41,7 +53,10 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest self.rest_send.response_handler = ResponseHandler() # Operation tracking - self.output = NDOutput(output_level=module.params.get("output_level", "normal")) + self.output = NDOutput( + output_level=module.params.get("output_level", "normal"), + state=module.params.get("state", ""), + ) self.results = Results() self.results.state = self.module.params.get("state", "") self.results.check_mode = self.module.check_mode @@ -65,24 +80,53 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest self.supports_bulk_create = self.model_orchestrator.supports_bulk_create self.supports_bulk_delete = self.model_orchestrator.supports_bulk_delete + # Mask secret input values in the invocation echo. Ansible auto-masks + # ``no_log`` argument-spec params, but secrets in free-form/nested dicts + # have no static suboption to flag; the model declares them via + # ``collect_secret_values`` and we register them here, generically for + # every module rather than per-module boilerplate. ``no_log_values`` is + # always present on a real AnsibleModule; guard for module stubs. + if hasattr(self.module, "no_log_values"): + for config_item in self.module.params.get("config") or []: + self.module.no_log_values |= self.model_class.collect_secret_values(config_item) + # Initialize collections try: response_data = self.model_orchestrator.query_all() # State of configuration objects in ND before change execution self.before = NDConfigCollection.from_api_response(response_data=response_data, model_class=self.model_class) + # Surface controller objects whose type this module does not model. They + # are preserved as opaque read-only records (see the links tolerant read + # path) and are protected from implicit/explicit modification below. + self._warn_unsupported(self.before) # State of current configuration objects in ND during change execution self.existing = self.before.copy() # Ongoing collection of configuration objects that were changed self.sent = NDConfigCollection(model_class=self.model_class) - # Collection of configuration objects given by user. + # Collection of configuration objects given by user. Coalesce None to + # an empty list so read-only states (e.g. gathered) with no config work. # ``context={"state": ...}`` is threaded into pydantic validation so models can apply # state-aware validation (e.g. require certain fields for write states while accepting # identifier-only items for ``deleted``). Models that do not read the context ignore it. - self.proposed = NDConfigCollection.from_ansible_config( - data=self.module.params.get("config", []), model_class=self.model_class, context={"state": self.state} - ) - - self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) + # + # ``prepare_config_data`` (switch-id backfill, payload transforms) is + # the caller's responsibility. Workflow coordinators already run it and + # write the result back to ``module.params["config"]``; ``nd_manage_links`` + # passes its prepared copy via ``config=``. Running it here as well would + # double-transform non-idempotent orchestrators (e.g. nd_vrf/nd_network), + # reverting user-supplied fields to their hardcoded defaults. + raw_config = config if config is not None else (self.module.params.get("config") or []) + self.proposed = NDConfigCollection.from_ansible_config(data=raw_config, model_class=self.model_class, context={"state": self.state}) + + # Argument-spec ``config.options`` drives pruning of gathered output + # so it round-trips cleanly as ``config``. Derived from the model, + # so it is generic across modules and needs no per-module wiring. + gathered_spec = {} + get_argument_spec = getattr(self.model_class, "get_argument_spec", None) + if callable(get_argument_spec): + gathered_spec = get_argument_spec().get("config", {}).get("options", {}) or {} + + self.output.assign(after=self.existing, before=self.before, proposed=self.proposed, gathered_spec=gathered_spec) except Exception as e: raise NDStateMachineError(f"Initialization failed: {str(e)}") from e @@ -131,6 +175,11 @@ def manage_state(self) -> None: # depend on a switch's capability to host the interface type (PR #275 scope decision). self._manage_delete_state() + elif self.state == "gathered": + # Read-only state: __init__ already queried the existing objects and + # assigned them as ``after`` in the output, so no changes are made. + pass + else: raise NDStateMachineError(f"Invalid state: {self.state}") @@ -164,6 +213,12 @@ def _manage_create_update_state(self) -> None: try: # Extract identifier identifier = proposed_item.get_identifier_value() + # Never modify an existing object this module preserves read-only + # (unsupported policy type); fail with a focused message instead of + # silently converting it via replace/override. + existing_match = self.existing.get(identifier) + if existing_match is not None and getattr(existing_match, "is_unsupported_policy", False): + raise NDStateMachineError(existing_match.describe_unsupported_policy() + "; this module cannot modify it.") # Determine diff status # For merged state, only compare fields explicitly provided by # the user so that Pydantic default values do not trigger false @@ -224,19 +279,40 @@ def _manage_create_update_state(self) -> None: # Log operation self.output.assign(after=self.existing) + def _warn_unsupported(self, collection) -> None: + """Warn once per object whose type this module preserves read-only.""" + if not hasattr(self.module, "warn"): + return + for item in collection: + if getattr(item, "is_unsupported_policy", False): + self.module.warn(item.describe_unsupported_policy() + "; it is read-only and will not be modified or deleted by this module.") + def _manage_override_deletions(self) -> None: """ Delete items not in proposed config (for overridden state). """ diff_identifiers = self.before.get_diff_identifiers(self.proposed) - items_to_delete = [existing_item for identifier in diff_identifiers if (existing_item := self.existing.get(identifier)) is not None] + # Never implicitly delete an unsupported (opaque) object during reconciliation; + # it is absent from the user's proposed config only because it cannot be modeled. + items_to_delete = [ + existing_item + for identifier in diff_identifiers + if (existing_item := self.existing.get(identifier)) is not None and not getattr(existing_item, "is_unsupported_policy", False) + ] self._delete_items(items_to_delete) def _manage_delete_state(self) -> None: """Handle deleted state.""" - items_to_delete = [ - existing_item for proposed_item in self.proposed if (existing_item := self.existing.get(proposed_item.get_identifier_value())) is not None - ] + items_to_delete = [] + for proposed_item in self.proposed: + existing_item = self.existing.get(proposed_item.get_identifier_value()) + if existing_item is None: + continue + # An explicit delete that resolves to an unsupported object fails with a + # focused message rather than blindly removing something we cannot model. + if getattr(existing_item, "is_unsupported_policy", False): + raise NDStateMachineError(existing_item.describe_unsupported_policy() + "; this module cannot delete it.") + items_to_delete.append(existing_item) self._delete_items(items_to_delete) def _delete_items(self, items: list[NDBaseModel]) -> None: diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 3ed88ae69..b426c1837 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -1,4 +1,5 @@ # Copyright: (c) 2026, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Shreyas Srish (@shrsr) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -51,6 +52,10 @@ class NDBaseOrchestrator(BaseModel, Generic[ModelType]): supports_bulk_create: ClassVar[bool] = False supports_bulk_delete: ClassVar[bool] = False + # bulk_payload_key is the JSON wrapper key the bulk endpoint expects, + # e.g. "links" for /api/v1/manage/links POST. + bulk_payload_key: ClassVar[str] = "items" + # NOTE: if not defined by subclasses, return an error as they are required create_endpoint: type[NDEndpointBaseModel] update_endpoint: type[NDEndpointBaseModel] @@ -206,6 +211,24 @@ def validate_bulk_endpoints(self): raise ValueError(f"'{self.__class__.__name__}' has 'supports_bulk_delete=True' but 'delete_bulk_endpoint' is not defined.") return self + def _post_bulk(self, endpoint: NDEndpointBaseModel, items: list[Any], operation_type: OperationType = OperationType.QUERY) -> dict[str, Any]: + """POST a bulk payload as a single request through the RestSend pipeline. + + Wraps ``items`` under ``bulk_payload_key`` and sends one POST so the + response_handler and result aggregation run as usual. The response has + the same shape the bulk endpoint returns + (``{bulk_payload_key: []}``), so the existing + per-item failure surfacing (``_raise_on_bulk_failures``) works unchanged. + + ``operation_type`` is forwarded to ``_request`` so bulk writes are + registered with the correct action (CREATE/DELETE) instead of QUERY. + """ + if not items: + return {} + + payload_key = self.bulk_payload_key + return self._request(endpoint.path, endpoint.verb, data={payload_key: items}, operation_type=operation_type) or {} + @requires_bulk_support("supports_bulk_create") def create_bulk(self, model_instances: list[ModelType], **kwargs) -> ResponseType: raise NotImplementedError diff --git a/plugins/module_utils/orchestrators/links.py b/plugins/module_utils/orchestrators/links.py new file mode 100644 index 000000000..fa42cd4d3 --- /dev/null +++ b/plugins/module_utils/orchestrators/links.py @@ -0,0 +1,361 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import copy +import logging +from typing import Any, ClassVar, Sequence + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import model_validator +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum, OperationType +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.links.links import NDLinkModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.base_link import BaseLinkStrategy +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.fabric_inventory import FabricSwitchInventory +from ansible_collections.cisco.nd.plugins.module_utils.fabric_inventory_helpers import ( + by_name as inventory_by_name, + inventory_for_fabric, +) + + +class NDLinkOrchestrator(NDBaseOrchestrator["NDLinkModel"]): + """Orchestrator for ND Link operations. + + Delegates endpoint selection to a BaseLinkStrategy so the same orchestrator + works with single-cluster and multi-cluster scopes. Endpoint classes are + derived from the strategy at construction time, so callers only need to + pass ``sender`` and ``strategy``. + """ + + model_class: ClassVar[type[NDBaseModel]] = NDLinkModel + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True + bulk_payload_key: ClassVar[str] = "links" + + strategy: BaseLinkStrategy | None = None + + @model_validator(mode="before") + @classmethod + def _wire_endpoints_from_strategy(cls, data: Any) -> Any: + """Populate required endpoint fields from the strategy before validation.""" + if not isinstance(data, dict): + return data + strategy = data.get("strategy") + if strategy is None: + return data + data.setdefault("create_endpoint", strategy.links_post_cls) + data.setdefault("update_endpoint", strategy.link_put_cls) + data.setdefault("delete_endpoint", strategy.link_actions_remove_post_cls) + data.setdefault("query_one_endpoint", strategy.links_get_cls) + data.setdefault("query_all_endpoint", strategy.links_get_cls) + data.setdefault("create_bulk_endpoint", strategy.links_post_cls) + data.setdefault("delete_bulk_endpoint", strategy.link_actions_remove_post_cls) + return data + + def model_post_init(self, __context: Any) -> None: + """Initialize per instance caches after Pydantic construction.""" + if self.strategy is None: + raise ValueError("NDLinkOrchestrator requires a strategy instance") + object.__setattr__(self, "_link_id_map", {}) + object.__setattr__(self, "_existing_by_key", {}) + object.__setattr__(self, "_switch_index_by_fabric", {}) + object.__setattr__(self, "_log", logging.getLogger("nd.LinkOrchestrator")) + + def _index_for_fabric(self, fabric_name: str) -> FabricSwitchInventory: + if fabric_name not in self._switch_index_by_fabric: + self._switch_index_by_fabric[fabric_name] = inventory_for_fabric(self.rest_send, fabric_name, self._log) + return self._switch_index_by_fabric[fabric_name] + + def prepare_config_data(self, raw_config: Any) -> Any: + """Backfill switch_name and switch_id on each entry. + + Identity uses switch_name, so callers who only supplied IP or serial need it + populated before the proposed collection is built. + + Operates on a copy: ``raw_config`` is a reference into ``module.params``, + so backfilling in place would leak the resolved switch_name/switch_id + back into the invocation echo. + """ + if not isinstance(raw_config, list): + return raw_config + raw_config = copy.deepcopy(raw_config) + for entry in raw_config: + if not isinstance(entry, dict): + continue + self._backfill_switch_for_side(entry, "src") + self._backfill_switch_for_side(entry, "dst") + return raw_config + + def _backfill_switch_for_side(self, entry: dict[str, Any], side: str) -> None: + """Priority switch_id > switch_ip > switch_name. Higher priority overwrites lower with the canonical hostname from the index.""" + name_key = "{0}_switch_name".format(side) + ip_key = "{0}_switch_ip".format(side) + id_key = "{0}_switch_id".format(side) + fabric_key = "{0}_fabric_name".format(side) + + sid = entry.get(id_key) + ip = entry.get(ip_key) + name = entry.get(name_key) + + if not (sid or ip or name): + return + + fabric = entry.get(fabric_key) + if not fabric: + return + + index = self._index_for_fabric(fabric) + + if sid: + sw = index.by_id().get(sid) + if not sw: + raise Exception("Could not find switch with {0}_switch_id='{1}' in fabric '{2}'.".format(side, sid, fabric)) + if sw.hostname: + entry[name_key] = sw.hostname + return + + if ip: + sw = index.by_ip().get(ip) + if not sw: + raise Exception( + "Could not resolve {0}_switch_ip='{1}' in fabric '{2}'. " "No switch with that management IP was found.".format(side, ip, fabric) + ) + entry[id_key] = sw.switch_id + if sw.hostname: + entry[name_key] = sw.hostname + return + + matches = inventory_by_name(index).get(name, []) + if len(matches) == 1: + entry[id_key] = matches[0].switch_id + return + if len(matches) > 1: + ids = [m.switch_id for m in matches] + raise Exception( + "{0}_switch_name='{1}' is ambiguous in fabric '{2}' " + "(matches {3} switches: {4}). Use {0}_switch_ip or " + "{0}_switch_id to disambiguate.".format(side, name, fabric, len(matches), ", ".join(ids)) + ) + raise Exception("Could not resolve {0}_switch_name='{1}' in fabric '{2}'. " "No switch with that hostname was found.".format(side, name, fabric)) + + def query_all(self, model_instance: NDLinkModel | None = None, **kwargs: Any) -> ResponseType: + """GET all links in scope and populate linkId / policy_type caches.""" + try: + endpoint = self.strategy.links_get_cls() + self.strategy.configure_read(endpoint, **kwargs) + result = self._request(endpoint.path, HttpVerbEnum.GET, not_found_ok=True) + + if isinstance(result, dict): + links_list = result.get("items", result.get("links", [])) + elif isinstance(result, list): + links_list = result + else: + links_list = [] + + self._build_caches(links_list) + return links_list + except Exception as e: + raise Exception("Query all links failed: {0}".format(e)) from e + + def _build_caches(self, links_list: list[dict[str, Any]]) -> None: + """Populate ``_link_id_map`` (for PUT and DELETE) and ``_existing_by_key`` (for policy change detection).""" + link_id_map = {} + existing_by_key = {} + for link_data in links_list: + try: + model = NDLinkModel.from_response(link_data) + composite_key = model.get_identifier_value() + link_id = link_data.get("linkId") + if composite_key and link_id: + link_id_map[composite_key] = link_id + existing_policy = (link_data.get("configData") or {}).get("policyType") + if existing_policy: + existing_by_key[composite_key] = existing_policy + except (ValueError, KeyError): + continue + object.__setattr__(self, "_link_id_map", link_id_map) + object.__setattr__(self, "_existing_by_key", existing_by_key) + + def _resolve_link_id(self, model_instance: NDLinkModel) -> str: + """Look up the API generated linkId for a model's composite identity.""" + try: + composite_key = model_instance.get_identifier_value() + except ValueError as e: + raise ValueError("Cannot resolve linkId - invalid composite key: {0}".format(e)) from e + + link_id = self._link_id_map.get(composite_key) + if not link_id: + raise ValueError("Cannot resolve linkId for {0}. Link may not exist on ND or " "query_all() wasn't called.".format(composite_key)) + return link_id + + def _is_policy_type_change(self, model_instance: NDLinkModel) -> bool: + """Return True if this update would change policy_type on an existing link.""" + try: + composite_key = model_instance.get_identifier_value() + except ValueError: + return False + + existing_policy = self._existing_by_key.get(composite_key) + if not existing_policy: + return False + + proposed_policy = None + if model_instance.config_data and model_instance.config_data.policy_type: + proposed_policy = model_instance.config_data.policy_type + + if proposed_policy is None or proposed_policy == existing_policy: + return False + + # Realized preprovision is not a policy change: ND converts a planned + # preprovision link to numbered, and reapplying the preprovision declaration + # keeps it numbered (persistent intent, updating only the user-managed + # interface fields). Allow it through instead of rejecting the update. + if existing_policy == "numbered" and proposed_policy == "preprovision": + return False + + return True + + def _raise_if_policy_type_change(self, model_instance: NDLinkModel) -> None: + """Raise if this update would change ``policy_type`` on an existing link. + + ND rejects a cross-policy PUT (the link must be deleted and recreated), so + this must fail before any mutation. Called from :meth:`preflight` (which the + state machine runs in BOTH check and normal mode) so a dry-run cannot report + a transition as a valid change; also kept in :meth:`update` as defense in + depth for direct callers. + """ + if not self._is_policy_type_change(model_instance): + return + composite_key = model_instance.get_identifier_value() + existing_policy = self._existing_by_key.get(composite_key) + proposed_policy = model_instance.config_data.policy_type + raise Exception( + "Cannot change policy_type from '{0}' to '{1}' on existing link {2}. " + "ND requires deleting the link first and recreating with the new " + "policy_type. Run this module with state=deleted for this link, " + "then re-run with state=merged.".format(existing_policy, proposed_policy, composite_key) + ) + + def preflight(self, model_instances: Sequence[NDLinkModel]) -> None: + """Pre-mutation validation run by the state machine in both check and normal + mode. Rejects cross-policy transitions here so check mode is a reliable + preflight gate rather than approving a change that normal mode will reject. + """ + for model_instance in model_instances: + self._raise_if_policy_type_change(model_instance) + + def create(self, model_instance: NDLinkModel, **kwargs: Any) -> ResponseType: + """Single create delegates to the bulk path (ND only exposes bulk POST).""" + return self.create_bulk([model_instance]) + + def create_bulk(self, model_instances: list[NDLinkModel], **kwargs: Any) -> ResponseType: + """Bulk POST with 207 body failure surfacing. Switch ids are backfilled in prepare_config_data. + + Items are sent in a single POST through the RestSend pipeline + (see ``NDBaseOrchestrator._post_bulk``). + """ + if not model_instances: + return {} + try: + endpoint = self.strategy.links_post_cls() + self.strategy.configure_mutation(endpoint) + items = [inst.to_payload() for inst in model_instances] + response = self._post_bulk(endpoint, items, operation_type=OperationType.CREATE) + self._raise_on_bulk_failures(response, op="create") + return response + except Exception as e: + raise Exception("Bulk create failed: {0}".format(e)) from e + + def update(self, model_instance: NDLinkModel, **kwargs: Any) -> ResponseType: + """PUT /links/{linkId}; rejects cross policy updates (needs delete and recreate).""" + # Primary enforcement is in preflight (runs in both modes); this is defense + # in depth for any direct caller that bypasses the state machine. + self._raise_if_policy_type_change(model_instance) + + try: + link_id = self._resolve_link_id(model_instance) + endpoint = self.strategy.link_put_cls() + endpoint.link_uuid = link_id + self.strategy.configure_mutation(endpoint) + + return self._request(endpoint.path, endpoint.verb, data=model_instance.to_payload(), operation_type=OperationType.UPDATE) + except Exception as e: + raise Exception("Update failed for {0}: {1}".format(model_instance.get_identifier_value(), e)) from e + + def delete(self, model_instance: NDLinkModel, **kwargs: Any) -> ResponseType: + """Single delete delegates to the bulk path (ND only exposes bulk remove).""" + return self.delete_bulk([model_instance]) + + def delete_bulk(self, model_instances: list[NDLinkModel], **kwargs: Any) -> ResponseType: + """Bulk POST /linkActions/remove with 207 body failure surfacing. + + Items are sent in a single POST through the RestSend pipeline + (see ``NDBaseOrchestrator._post_bulk``). + """ + if not model_instances: + return {} + try: + link_ids = [self._resolve_link_id(inst) for inst in model_instances] + endpoint = self.strategy.link_actions_remove_post_cls() + self.strategy.configure_mutation(endpoint) + response = self._post_bulk(endpoint, link_ids, operation_type=OperationType.DELETE) + self._raise_on_bulk_failures(response, op="delete") + return response + except Exception as e: + raise Exception("Bulk delete failed: {0}".format(e)) from e + + @staticmethod + def _raise_on_bulk_failures(response: Any, op: str) -> None: + """Raise if ND's 207 multi-status body reports any per-item failures. + + ND treats 207 as success at the HTTP layer, so partial failures only show + up in the body. The OpenAPI contract for bulk create (POST /links) and + delete (POST /linkActions/remove) is ``{"links": [{"linkId", "message", + "status"}]}`` with ``status`` in ``{"success", "failure"}`` -- the + ``links`` + ``status`` path below matches that exactly. The remaining + containers/signals are a defensive superset kept until the shape is + confirmed against a broader set of live responses. + + # TODO(4.2.1) TBD + # Workaround for an ND API discrepancy: bulk link create/delete returns + # HTTP 207 with per-item failures only in the body, but ResponseHandler + # classifies 207 as success. Remove this body scan once the central + # NdV1Strategy multi-status detector (PR #398) covers the links envelope; + # that detector needs "links" added to _MULTISTATUS_ITEM_KEYS and "linkId" + # to _MULTISTATUS_ITEM_LABEL_KEYS ("failure"/"message" already covered). + """ + if not isinstance(response, dict): + return + + def _is_failure(item: dict) -> bool: + status = str(item.get("status") or item.get("result") or "").lower() + if status in ("failure", "failed", "error"): + return True + if item.get("success") is False: + return True + return bool(item.get("error") or item.get("errorMessage")) + + def _detail(item: dict) -> str: + ident = item.get("linkId") or item.get("id") or item.get("uuid") or "" + message = item.get("message") or item.get("errorMessage") or item.get("error") or item.get("msg") or "unknown error" + return "{0}: {1}".format(ident, message) + + failures = [] + # Result containers whose items must be inspected for a failure signal. + for key in ("links", "items", "results"): + value = response.get(key) + if isinstance(value, list): + failures.extend(item for item in value if isinstance(item, dict) and _is_failure(item)) + # Containers whose mere presence means the listed items failed. + for key in ("failed", "failures", "errors"): + value = response.get(key) + if isinstance(value, list): + failures.extend(item for item in value if isinstance(item, dict)) + + if not failures: + return + details = "; ".join(_detail(item) for item in failures) + raise Exception("ND reported {0} per-item {1} failure(s): {2}".format(len(failures), op, details)) diff --git a/plugins/module_utils/orchestrators/strategies/base_link.py b/plugins/module_utils/orchestrators/strategies/base_link.py new file mode 100644 index 000000000..2718acc9a --- /dev/null +++ b/plugins/module_utils/orchestrators/strategies/base_link.py @@ -0,0 +1,63 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel + + +class BaseLinkStrategy(ABC): + """Abstract base for link endpoint strategies (single vs multi cluster scope).""" + + def __init__( + self, + fabric_name: str, + cluster_name: str | None = None, + ticket_id: str | None = None, + **kwargs: Any, + ) -> None: + """Store connection level context shared by every scope. + + ``fabric_name`` is required and becomes the ``fabricName`` query param on + all reads. ``cluster_name`` and ``ticket_id`` are used only by the + single cluster scope. + """ + self.fabric_name = fabric_name + self.cluster_name = cluster_name + self.ticket_id = ticket_id + + @property + @abstractmethod + def links_get_cls(self) -> type[NDEndpointBaseModel]: + """Endpoint class for GET (list/filter) links.""" + + @property + @abstractmethod + def links_post_cls(self) -> type[NDEndpointBaseModel]: + """Endpoint class for POST (bulk create) links.""" + + @property + @abstractmethod + def link_put_cls(self) -> type[NDEndpointBaseModel]: + """Endpoint class for PUT (single update) link.""" + + @property + @abstractmethod + def link_actions_remove_post_cls(self) -> type[NDEndpointBaseModel]: + """Endpoint class for POST (bulk delete) links.""" + + @property + @abstractmethod + def identifier_fields(self) -> list[str]: + """Model fields forming the composite identity for this scope.""" + + @abstractmethod + def configure_read(self, endpoint: Any, **kwargs: Any) -> None: + """Populate ``endpoint.endpoint_params`` for a GET (read) request in this scope.""" + + @abstractmethod + def configure_mutation(self, endpoint: Any) -> None: + """Populate ``endpoint.endpoint_params`` for a create/update/delete request in this scope.""" diff --git a/plugins/module_utils/orchestrators/strategies/manage_link.py b/plugins/module_utils/orchestrators/strategies/manage_link.py new file mode 100644 index 000000000..7ee9acf44 --- /dev/null +++ b/plugins/module_utils/orchestrators/strategies/manage_link.py @@ -0,0 +1,68 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Strategy for single cluster (NDFC) link operations. + +API surface lives under /api/v1/manage/links (same base URL as multi cluster). +Single vs. multi cluster is expressed via query params and identity field +count, not the URL path. +""" + +from __future__ import annotations + +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.link_actions import LinkActionsRemovePost +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.links import LinkPut, LinksGet, LinksPost +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.base_link import BaseLinkStrategy + + +class ManageLinkStrategy(BaseLinkStrategy): + """Single cluster (NDFC) scope; 6 field identity, no cluster names.""" + + @property + def links_get_cls(self) -> type[NDEndpointBaseModel]: + return LinksGet + + @property + def links_post_cls(self) -> type[NDEndpointBaseModel]: + return LinksPost + + @property + def link_put_cls(self) -> type[NDEndpointBaseModel]: + return LinkPut + + @property + def link_actions_remove_post_cls(self) -> type[NDEndpointBaseModel]: + return LinkActionsRemovePost + + @property + def identifier_fields(self) -> list[str]: + return [ + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + + def configure_read(self, endpoint: Any, **kwargs: Any) -> None: + """Populate GET /manage/links query params (fabricName required; others optional).""" + params = endpoint.endpoint_params + params.fabric_name = self.fabric_name + if self.cluster_name: + params.cluster_name = self.cluster_name + if self.ticket_id: + params.ticket_id = self.ticket_id + if kwargs.get("switch_id"): + params.switch_id = kwargs["switch_id"] + + def configure_mutation(self, endpoint: Any) -> None: + """Populate clusterName / ticketId query params for create/update/delete.""" + params = endpoint.endpoint_params + if self.cluster_name: + params.cluster_name = self.cluster_name + if self.ticket_id: + params.ticket_id = self.ticket_id diff --git a/plugins/module_utils/orchestrators/strategies/one_manage_link.py b/plugins/module_utils/orchestrators/strategies/one_manage_link.py new file mode 100644 index 000000000..d9aab1421 --- /dev/null +++ b/plugins/module_utils/orchestrators/strategies/one_manage_link.py @@ -0,0 +1,65 @@ +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Strategy for multi cluster ("One Manage") link operations. + +Same URL surface as the single cluster strategy; differs only in the identity +field set (includes cluster names) and the query param shape (srcClusterName / +dstClusterName instead of clusterName). +""" + +from __future__ import annotations + +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.one_manage.link_actions import LinkActionsRemovePost +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.one_manage.links import LinkPut, LinksGet, LinksPost +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.base_link import BaseLinkStrategy + + +class OneManageLinkStrategy(BaseLinkStrategy): + """Multi cluster scope; 8 field identity including cluster names.""" + + @property + def links_get_cls(self) -> type[NDEndpointBaseModel]: + return LinksGet + + @property + def links_post_cls(self) -> type[NDEndpointBaseModel]: + return LinksPost + + @property + def link_put_cls(self) -> type[NDEndpointBaseModel]: + return LinkPut + + @property + def link_actions_remove_post_cls(self) -> type[NDEndpointBaseModel]: + return LinkActionsRemovePost + + @property + def identifier_fields(self) -> list[str]: + return [ + "src_cluster_name", + "dst_cluster_name", + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + + def configure_read(self, endpoint: Any, **kwargs: Any) -> None: + """Populate GET /manage/links query params (fabricName required; cluster filters optional).""" + params = endpoint.endpoint_params + params.fabric_name = self.fabric_name + if kwargs.get("src_cluster_name"): + params.src_cluster_name = kwargs["src_cluster_name"] + if kwargs.get("dst_cluster_name"): + params.dst_cluster_name = kwargs["dst_cluster_name"] + + def configure_mutation(self, endpoint: Any) -> None: + """Populate ticketId query param for create/update/delete (cluster identity is in the payload).""" + if self.ticket_id: + endpoint.endpoint_params.ticket_id = self.ticket_id diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 3724ddaa9..966ff3edf 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -16,6 +16,10 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# Ansible's placeholder for masked secret values. Used to keep secret keys visible +# in module output while hiding their values (instead of dropping the key). +NO_LOG_PLACEHOLDER = "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True): if keys is None: @@ -72,6 +76,51 @@ def issubset(subset: Any, superset: Any) -> bool: return True +def prune_to_spec(data: Any, options_spec: dict) -> Any: + """Prune a dict to only the keys defined by an Ansible argument spec. + + Why this exists: read-only ``gathered`` output is meant to be copy-pasted + back as ``config``. The model can carry more than the module exposes + (response-only fields such as ``link_id``), and Ansible validates suboptions + at every nesting level, so any stray key would fail with "Unsupported + parameters". Pruning to the argument spec guarantees a clean round-trip. + + Walks ``data`` in lockstep with ``options_spec`` (an argument spec + ``options`` mapping) and keeps only keys the spec declares, recursing into + nested ``dict`` and ``list`` of ``dict`` suboptions that carry their own + ``options``. Suboptions without an ``options`` mapping (scalars, lists of + scalars, free-form dicts) are copied verbatim; recursion stops there so + free-form content (e.g. ``template_inputs``) is preserved untouched. + + ``no_log`` suboptions are masked to ``NO_LOG_PLACEHOLDER`` (the key stays + visible so the reader knows the field is set, but the secret value is never + surfaced from an API read). + """ + if not isinstance(data, dict): + return data + + pruned = {} + for key, value in data.items(): + if key not in options_spec: + continue + + sub_spec = options_spec[key] + if isinstance(sub_spec, dict) and sub_spec.get("no_log"): + pruned[key] = NO_LOG_PLACEHOLDER + continue + + sub_options = sub_spec.get("options") if isinstance(sub_spec, dict) else None + + if sub_options and sub_spec.get("type") == "dict": + pruned[key] = prune_to_spec(value, sub_options) if isinstance(value, dict) else value + elif sub_options and sub_spec.get("type") == "list" and sub_spec.get("elements") == "dict": + pruned[key] = [prune_to_spec(item, sub_options) for item in value] if isinstance(value, list) else value + else: + pruned[key] = value + + return pruned + + def remove_unwanted_keys(data: dict, unwanted_keys: list[str | list[str]]) -> dict: """Remove unwanted keys from dict (supports nested paths).""" data = deepcopy(data) diff --git a/plugins/modules/nd_manage_links.py b/plugins/modules/nd_manage_links.py new file mode 100644 index 000000000..dfaf72fb2 --- /dev/null +++ b/plugins/modules/nd_manage_links.py @@ -0,0 +1,835 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +DOCUMENTATION = r""" +--- +module: nd_manage_links +version_added: "2.0.0" +short_description: Manages links on Cisco Nexus Dashboard. +description: +- Manages network links between switches on Cisco Nexus Dashboard. +- Supports both single-cluster and multi-cluster (One Manage) scopes. +- Supports bulk operations for efficient creation and deletion. +- Auto-detects scope from config, or use O(link_scope) to override. +author: +- Shreyas Srish (@shrsr) +options: + fabric_name: + description: + - Name of the fabric. Required for querying links. + type: str + required: true + link_scope: + description: + - Which API scope to use for link operations. + - V(auto) auto-detects based on the presence of cluster fields in config. + - V(manage) uses the single-cluster scope (6-field identity). + - V(one_manage) uses the multi-cluster scope (8-field identity including cluster names). + type: str + choices: [ auto, manage, one_manage ] + default: auto + cluster_name: + description: + - Target cluster name for multi-cluster operations. + - Only used when O(link_scope=one_manage) or auto-detected. + type: str + ticket_id: + description: + - Change Control Ticket Id for multi-cluster operations. + - Only used when O(link_scope=one_manage) or auto-detected. + type: str + config: + description: + - A list of link configurations to manage. + - Required for all states except O(state=gathered). + type: list + elements: dict + required: false + suboptions: + src_cluster_name: + description: + - The name of the source cluster. + - Only used in the multi-cluster (One Manage) scope. + type: str + dst_cluster_name: + description: + - The name of the destination cluster. + - Only used in the multi-cluster (One Manage) scope. + type: str + src_fabric_name: + description: + - The name of the source fabric. + - Required within each O(config) item; it is a mandatory part of the link identity. + type: str + required: true + dst_fabric_name: + description: + - The name of the destination fabric. + - Required within each O(config) item; it is a mandatory part of the link identity. + type: str + required: true + src_switch_name: + description: + - The hostname of the source switch. + - At least one of O(config.src_switch_name), O(config.src_switch_ip) or O(config.src_switch_id) is required to identify the source switch. + - When more than one is set, priority is O(config.src_switch_id), then O(config.src_switch_ip), then O(config.src_switch_name). + type: str + dst_switch_name: + description: + - The hostname of the destination switch. + - At least one of O(config.dst_switch_name), O(config.dst_switch_ip) or O(config.dst_switch_id) is required to identify the destination switch. + - When more than one is set, priority is O(config.dst_switch_id), then O(config.dst_switch_ip), then O(config.dst_switch_name). + type: str + src_switch_ip: + description: + - The management IP address of the source switch. + - Useful when switch hostnames are ambiguous within a fabric. + type: str + dst_switch_ip: + description: + - The management IP address of the destination switch. + - Useful when switch hostnames are ambiguous within a fabric. + type: str + src_switch_id: + description: + - The serial number or identifier of the source switch. + type: str + dst_switch_id: + description: + - The serial number or identifier of the destination switch. + type: str + src_interface_name: + description: + - The name of the source interface, for example C(Ethernet1/1). + - Required within each O(config) item; it is a mandatory part of the link identity. + type: str + required: true + dst_interface_name: + description: + - The name of the destination interface, for example C(Ethernet1/1). + - Required within each O(config) item; it is a mandatory part of the link identity. + type: str + required: true + config_data: + description: + - The link policy configuration. + type: dict + suboptions: + policy_type: + description: + - The link policy type, which determines the valid keys under O(config.config_data.template_inputs). + - V(numbered) is an IPv4/IPv6 addressed point-to-point link. + - V(unnumbered) is an unnumbered link that borrows its address from another interface. + - V(ipv6LinkLocal) uses automatic C(fe80::/10) link-local addressing. + - V(ebgpVrfLite) is an eBGP peering over a VRF Lite link. + - V(iosXeNumbered) is an IOS-XE intra-fabric numbered link (for example a Campus C(vxlanCampus) intra-fabric link). + - V(layer2Dci) is a Layer 2 trunk between data centers. + - V(layer3DciVrfLite) is a VRF Lite stitched Layer 3 DCI link. + - V(multisiteOverlay) is a Border Gateway overlay eBGP session. + - V(multisiteUnderlay) is a Border Gateway underlay reachability link. + - V(mplsOverlay) is an MPLS Segment Routing loopback eBGP peering. + - V(mplsUnderlay) is an MPLS underlay using IS-IS or OSPF with segment routing. + - V(preprovision) pre-provisions a link before the switches are online. + - V(userDefined) uses a custom template named by O(config.config_data.template_name). + - V(vpcPeerKeepalive) is a vPC peer keepalive (heartbeat) link. + type: str + choices: + - numbered + - unnumbered + - ipv6LinkLocal + - ebgpVrfLite + - iosXeNumbered + - layer2Dci + - layer3DciVrfLite + - multisiteOverlay + - multisiteUnderlay + - mplsOverlay + - mplsUnderlay + - preprovision + - userDefined + - vpcPeerKeepalive + template_name: + description: + - The name of the link template. + - Required when O(config.config_data.policy_type=userDefined). + type: str + template_inputs: + description: + - The policy specific input fields for the link. + - This is a free-form dictionary validated per policy type by the module. It is intentionally not modeled as static suboptions because the + valid keys differ by O(config.config_data.policy_type); the supported keys for each policy type are enumerated below. + - The accepted keys depend on O(config.config_data.policy_type). Keys that do not belong to the selected policy type are rejected. + - ND's template engine requires every declared key to be present, so for any documented field you omit the module sends ND's documented + default (for example C(mtu) 9216, C(interface_admin_state) C(true), C(fec) C(auto)); fields with no documented default are sent as a + typed empty value (C("") / C(0) / C(false)), including secrets. See the note on secret fields below. + - This module can create and update the policy types listed below. Other valid ND policy types (for example C(ipfmNumbered) or + C(routedFabric)) are still read back by O(state=gathered) and preserved as opaque records; the module warns about them and never + modifies or deletes them. + - 'In the lists below, a key shown as C(name) (int) or C(name) (bool) takes that type; all other keys are strings.' + - 'Secret fields - C(ebgp_password), C(default_vrf_ebgp_neighbor_password), C(macsec_primary_key_string) and C(macsec_fallback_key_string) + are sent to the controller but their values are masked as C(VALUE_SPECIFIED_IN_NO_LOG_PARAMETER) in this module''s output and excluded + from diffs. A change to only a secret value is therefore not detected as a change. Nexus Dashboard does not return secret values on + read, so re-supply a secret whenever you update a link, otherwise it is written empty. Because O(config.config_data.template_inputs) is + a free-form dict these values are not individually marked no_log, so they may appear in task invocation logs at high verbosity.' + - 'Common interface fields, available on V(numbered), V(unnumbered) and V(ipv6LinkLocal) - C(interface_admin_state) (bool), C(mtu) (int), + C(speed), C(fec), C(src_interface_description), C(dst_interface_description), C(src_interface_config), C(dst_interface_config) + and C(macsec) (bool).' + - 'V(numbered) - the common interface fields plus C(src_ip), C(dst_ip), C(src_ipv6), C(dst_ipv6), C(dhcp_relay_on_src_interface) (bool), + C(dhcp_relay_on_dst_interface) (bool), C(bfd_echo_on_src_interface) (bool) and C(bfd_echo_on_dst_interface) (bool).' + - 'V(unnumbered) - the common interface fields plus C(dhcp_relay_on_src_interface) (bool) and C(dhcp_relay_on_dst_interface) (bool).' + - 'V(ipv6LinkLocal) - the common interface fields only.' + - 'V(iosXeNumbered) - C(interface_admin_state) (bool), C(src_ip), C(dst_ip), C(speed), C(mtu) (int, IOS-XE default 9198) + and the interface description fields.' + - 'V(ebgpVrfLite) - C(src_ebgp_asn), C(dst_ebgp_asn), C(src_ip_address_mask), C(src_ipv6_address_mask), C(dst_ip_address), C(dst_ipv6_address), + C(link_mtu) (int), C(routing_tag), C(auto_gen_config_default_vrf) (bool), C(auto_gen_config_nx_peer_default_vrf) (bool), + C(auto_gen_config_peer) (bool), C(dci_tracking) (bool), C(default_vrf_ebgp_neighbor_password), + C(default_vrf_ebgp_password_key_encryption_type), C(redistrib_ebgp_route_map_name), C(template_config_gen_peer), + C(vrf_name_nx_peer_switch), the interface description fields, the full MACsec fields, the QKD fields + and C(inherit_ttag_fabric_setting) (bool).' + - 'V(layer2Dci) - C(trunk_allowed_vlans), C(native_vlan) (int), C(bpdu_guard), C(port_type_fast) (bool), C(mtu_type), C(speed), + the interface description fields, the full MACsec fields, the QKD fields and C(inherit_ttag_fabric_setting) (bool).' + - 'V(layer3DciVrfLite) - C(src_ip_address_mask), C(dst_ip_address_mask), C(src_ipv6_address_mask), C(dst_ipv6_address_mask), C(src_vrf_name), + C(dst_vrf_name), C(link_mtu) (int), C(speed), C(ip_redirects) (bool), C(ipv4_pim) (bool), C(ipv6_pim) (bool), the interface description fields, + the full MACsec fields, the QKD fields, the Netflow fields and C(inherit_ttag_fabric_setting) (bool).' + - 'V(multisiteOverlay) - C(src_ebgp_asn), C(dst_ebgp_asn), C(src_ip_address), C(dst_ip_address), C(ebgp_multihop) (int), C(ipv4_trm) (bool), + C(ipv6_trm) (bool), C(redistribute_route_server) (bool), C(route_server_routing_tag), C(skip_config_generation) (bool), + C(src_interface_description), C(dst_interface_description), C(macsec_cipher_suite) and the eBGP password fields.' + - 'V(multisiteUnderlay) - C(src_ebgp_asn), C(dst_ebgp_asn), C(ebgp_bfd) (bool), C(ebgp_log_neighbor_change) (bool), C(ebgp_maximum_paths) (int), + C(ebgp_send_comboth) (bool), C(src_ip_address_mask), C(src_ipv6_address_mask), C(dst_ip_address), C(dst_ipv6_address), C(link_mtu) (int), + C(speed), C(routing_tag), C(dci_tracking_enable_flag) (bool), the interface description fields, the eBGP password fields + and C(inherit_ttag_fabric_setting) (bool).' + - 'V(mplsOverlay) - C(src_ebgp_asn), C(dst_ebgp_asn) and C(dst_ip_address).' + - 'V(mplsUnderlay) - C(mpls_fabric_type), C(dci_routing_protocol), C(dci_routing_tag), C(ospf_area_id), C(sr_global_block_range), + C(src_sr_index) (int), C(dst_sr_index) (int), C(src_ip_address_mask), C(dst_ip_address), C(link_mtu) (int), the interface description fields + and C(inherit_ttag_fabric_setting) (bool).' + - 'V(preprovision) - C(src_interface_description), C(dst_interface_description), C(src_interface_config) and C(dst_interface_config) only.' + - 'V(vpcPeerKeepalive) - C(src_ip), C(dst_ip), C(src_ipv6), C(dst_ipv6), C(interface_vrf), C(interface_admin_state) (bool), C(mtu) (int) + and the interface description fields.' + - 'V(userDefined) - an open set of fields validated by ND; C(allowed_vlans) and C(mtu) (int) are recognized, + and O(config.config_data.template_name) must be set.' + - 'eBGP password fields - C(enable_ebgp_password) (bool), C(ebgp_password), C(ebgp_auth_key_encryption_type) + and C(inherit_ebgp_password_msd_settings) (bool).' + - 'Full MACsec fields - C(macsec) (bool), C(macsec_cipher_suite), C(macsec_primary_cryptographic_algorithm), C(macsec_primary_key_string), + C(macsec_fallback_cryptographic_algorithm), C(macsec_fallback_key_string) and C(override_fabric_macsec) (bool).' + - 'QKD (Quantum Key Distribution) fields - C(qkd) (bool), C(ignore_certificate) (bool), C(src_kme_server_ip), C(dst_kme_server_ip), + C(src_kme_server_port_number) (int), C(dst_kme_server_port_number) (int), C(src_macsec_key_chain_prefix), C(dst_macsec_key_chain_prefix), + C(src_qkd_profile_name), C(dst_qkd_profile_name), C(src_trustpoint_label) and C(dst_trustpoint_label).' + - 'Netflow fields - C(netflow_on_src_interface) (bool), C(netflow_on_dst_interface) (bool), C(src_netflow_monitor_name) + and C(dst_netflow_monitor_name).' + type: dict + state: + description: + - Use V(merged) to create or update the links in O(config). Links that are not listed are left unchanged. + - Use V(replaced) to replace the configuration of the listed links with O(config). + - Use V(overridden) to make the links match O(config) exactly. Links in scope that are not listed are deleted. + - Use V(deleted) to delete the listed links. + - Use V(gathered) to read the existing links in scope and return them under the C(gathered) key in O(config) format. No changes are made. + type: str + choices: [ merged, replaced, overridden, deleted, gathered ] + default: merged +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +""" + +EXAMPLES = r""" +- name: Create a numbered (IPv4 point-to-point) link + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + speed: auto + fec: auto + interface_admin_state: true + src_interface_description: ansible numbered source + dst_interface_description: ansible numbered destination + state: merged + +- name: Create multiple numbered links in one bulk call + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.31.1 + dst_ip: 10.99.31.2 + mtu: 9216 + state: merged + +- name: Identify switches by management IP (avoids hostname collisions) + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_ip: 192.0.2.10 + dst_switch_ip: 192.0.2.11 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.0.0.1 + dst_ip: 10.0.0.2 + mtu: 9216 + interface_admin_state: true + state: merged + +- name: Create an unnumbered link + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + speed: auto + fec: auto + interface_admin_state: true + state: merged + +- name: Create an IPv6 link-local link + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: ipv6LinkLocal + template_inputs: + interface_admin_state: true + mtu: 9216 + state: merged + +- name: Create a vPC peer keepalive link + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: leaf-2 + src_interface_name: mgmt0 + dst_interface_name: mgmt0 + config_data: + policy_type: vpcPeerKeepalive + template_inputs: + src_ip: 10.1.1.1 + dst_ip: 10.1.1.2 + interface_vrf: management + interface_admin_state: true + mtu: 1500 + state: merged + +- name: Pre-provision a link before the switches are online + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: ansible preprovision source + dst_interface_description: ansible preprovision destination + state: merged + +- name: Create a Layer 2 DCI trunk link between two fabrics + cisco.nd.nd_manage_links: + fabric_name: ansible-fab-a + link_scope: manage + config: + - src_fabric_name: ansible-fab-a + dst_fabric_name: ansible-fab-b + src_switch_name: bgw-1 + dst_switch_name: bgw-2 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: layer2Dci + template_inputs: + trunk_allowed_vlans: "100,200,300" + native_vlan: 1 + port_type_fast: true + mtu_type: jumbo + speed: auto + state: merged + +- name: Create a Layer 3 DCI VRF Lite link + cisco.nd.nd_manage_links: + fabric_name: ansible-fab-a + link_scope: manage + config: + - src_fabric_name: ansible-fab-a + dst_fabric_name: ansible-fab-b + src_switch_name: border-1 + dst_switch_name: border-2 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: layer3DciVrfLite + template_inputs: + src_ip_address_mask: "10.99.50.1/30" + dst_ip_address_mask: "10.99.50.2/30" + src_vrf_name: tenant-a + dst_vrf_name: tenant-a + link_mtu: 9216 + speed: auto + state: merged + +- name: Create an eBGP VRF Lite link + cisco.nd.nd_manage_links: + fabric_name: ansible-fab-a + link_scope: manage + config: + - src_fabric_name: ansible-fab-a + dst_fabric_name: ansible-fab-b + src_switch_name: border-1 + dst_switch_name: border-2 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.30.1/30" + dst_ip_address: "10.99.30.2" + link_mtu: 9216 + state: merged + +- name: Create an MPLS underlay link (segment routing) + cisco.nd.nd_manage_links: + fabric_name: ansible-fab-a + link_scope: manage + config: + - src_fabric_name: ansible-fab-a + dst_fabric_name: ansible-fab-b + src_switch_name: pe-1 + dst_switch_name: pe-2 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: mplsUnderlay + template_inputs: + dci_routing_protocol: isis + sr_global_block_range: "16000-23999" + src_sr_index: 100 + dst_sr_index: 101 + src_ip_address_mask: "10.99.60.1/30" + dst_ip_address: "10.99.60.2" + link_mtu: 9216 + state: merged + +- name: Create an MPLS overlay eBGP peering + cisco.nd.nd_manage_links: + fabric_name: ansible-fab-a + link_scope: manage + config: + - src_fabric_name: ansible-fab-a + dst_fabric_name: ansible-fab-b + src_switch_name: pe-1 + dst_switch_name: pe-2 + src_interface_name: loopback0 + dst_interface_name: loopback0 + config_data: + policy_type: mplsOverlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + dst_ip_address: 10.99.61.2 + state: merged + +- name: Create a multi-cluster multisite underlay link (One Manage, bulk) + cisco.nd.nd_manage_links: + fabric_name: fab1 + config: + - src_cluster_name: cluster-191 + dst_cluster_name: cluster-187 + src_fabric_name: fab2 + dst_fabric_name: fab1 + src_switch_name: v1-bgw2 + dst_switch_name: v1-bgw1 + src_interface_name: Ethernet1/12 + dst_interface_name: Ethernet1/12 + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "200" + dst_ebgp_asn: "100" + src_ip_address_mask: "30.30.30.10/31" + dst_ip_address: "30.30.30.11" + link_mtu: 9216 + ebgp_maximum_paths: 2 + state: merged + +- name: Create a multisite overlay link with explicit scope and change control + cisco.nd.nd_manage_links: + fabric_name: fab1 + link_scope: one_manage + cluster_name: cluster-191 + ticket_id: CHG-12345 + config: + - src_cluster_name: cluster-191 + dst_cluster_name: cluster-187 + src_fabric_name: fab2 + dst_fabric_name: fab1 + src_switch_name: v1-bgw2 + dst_switch_name: v1-bgw1 + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + config_data: + policy_type: multisiteOverlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address: 10.99.40.1 + dst_ip_address: 10.99.40.2 + ebgp_multihop: 5 + state: merged + +- name: Create a user-defined link using a custom template + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + config: + - src_fabric_name: ansible-fab + dst_fabric_name: ansible-fab + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: userDefined + template_name: my_custom_link_template + template_inputs: + allowed_vlans: "10,20,30" + mtu: 9216 + state: merged + +- name: Gather all links in a fabric (read-only) + cisco.nd.nd_manage_links: + fabric_name: ansible-fab + link_scope: manage + state: gathered + register: all_links + +- name: Delete links (bulk) + cisco.nd.nd_manage_links: + fabric_name: fab1 + config: + - src_cluster_name: cluster-191 + dst_cluster_name: cluster-187 + src_fabric_name: fab2 + dst_fabric_name: fab1 + src_switch_name: v1-bgw2 + dst_switch_name: v1-bgw1 + src_interface_name: Ethernet1/12 + dst_interface_name: Ethernet1/12 + state: deleted +""" + +RETURN = r""" +changed: + description: Whether the module changed, or in check mode would change, the link configuration. + returned: always + type: bool + sample: true +output_level: + description: The output verbosity level in effect for the run, echoing the O(output_level) parameter. + returned: always + type: str + sample: normal +before: + description: + - The existing links matching O(config) before the module ran, structured the same as the O(config) parameter. + - An empty list when no matching link existed. + returned: when O(state) is V(merged), V(replaced), V(overridden) or V(deleted) + type: list + elements: dict + sample: + - src_fabric_name: fabric1 + dst_fabric_name: fabric1 + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.0.0.1 + dst_ip: 10.0.0.2 +after: + description: + - The link configuration after the module ran, structured the same as the O(config) parameter. + - In check mode, the configuration that would result had the module run outside of check mode. + returned: when O(state) is V(merged), V(replaced), V(overridden) or V(deleted) + type: list + elements: dict + sample: + - src_fabric_name: fabric1 + dst_fabric_name: fabric1 + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.0.0.1 + dst_ip: 10.0.0.2 +diff: + description: The per-link difference between C(before) and C(after). + returned: when O(state) is V(merged), V(replaced), V(overridden) or V(deleted) + type: list + elements: dict + sample: + - src_fabric_name: fabric1 + dst_fabric_name: fabric1 + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + template_inputs: + dst_ip: 10.0.0.2 +proposed: + description: + - The link configuration as sent to Nexus Dashboard, derived from O(config). + - Secret template inputs (for example C(ebgp_password)) are masked as C(VALUE_SPECIFIED_IN_NO_LOG_PARAMETER). + returned: when O(state) is V(merged), V(replaced), V(overridden) or V(deleted) + type: list + elements: dict + sample: + - src_fabric_name: fabric1 + dst_fabric_name: fabric1 + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.0.0.1 + dst_ip: 10.0.0.2 +gathered: + description: + - The links read from Nexus Dashboard, structured so the list can be copied back into the O(config) parameter. + - Read-only response keys (for example the link identifier) are pruned and secret template inputs are masked. + returned: when O(state) is V(gathered) + type: list + elements: dict + sample: + - src_fabric_name: fabric1 + dst_fabric_name: fabric1 + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.0.0.1 + dst_ip: 10.0.0.2 +logs: + description: Ordered list of the operations the module performed against Nexus Dashboard. + returned: when O(output_level=debug) + type: list + elements: str + sample: + - "Created link leaf-1:Ethernet1/1 <-> spine-1:Ethernet1/1" +msg: + description: The error message describing why the module failed. + returned: on failure + type: str + sample: "Initialization failed: ..." +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDStateMachineError +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.links.links import NDLinkModel +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.links import NDLinkOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.manage_link import ManageLinkStrategy +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.one_manage_link import OneManageLinkStrategy +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender + + +def determine_strategy(module: AnsibleModule) -> ManageLinkStrategy | OneManageLinkStrategy | None: + """Pick the link strategy from O(link_scope) or autodetect from config cluster fields.""" + link_scope = module.params.get("link_scope", "auto") + fabric_name = module.params.get("fabric_name") + cluster_name = module.params.get("cluster_name") + ticket_id = module.params.get("ticket_id") + + if link_scope == "manage": + return ManageLinkStrategy(fabric_name=fabric_name, cluster_name=cluster_name, ticket_id=ticket_id) + + if link_scope == "one_manage": + return OneManageLinkStrategy( + fabric_name=fabric_name, + cluster_name=cluster_name, + ticket_id=ticket_id, + ) + + if link_scope == "auto": + # ``config`` is optional (e.g. ``state: gathered`` with no config); Ansible + # supplies the key with value None, so ``.get("config", [])`` returns None, + # not the []-fallback. Coalesce with ``or []`` before iterating. + config = module.params.get("config") or [] + has_cluster_fields = any(item.get("src_cluster_name") or item.get("dst_cluster_name") for item in config) + if has_cluster_fields: + return OneManageLinkStrategy( + fabric_name=fabric_name, + cluster_name=cluster_name, + ticket_id=ticket_id, + ) + return ManageLinkStrategy(fabric_name=fabric_name, cluster_name=cluster_name, ticket_id=ticket_id) + + module.fail_json(msg="Invalid link_scope: {0}".format(link_scope)) + + +def validate_scope_identity(module: AnsibleModule, strategy: ManageLinkStrategy | OneManageLinkStrategy | None) -> None: + """Validate scope-dependent link identity once ``link_scope`` is resolved. + + The argspec marks the fabric and interface names required on every item (they + are identity for both scopes). Cluster names are identity only for OneManage, + so requiredness cannot be expressed statically in the argspec; enforce it here. + Fail early with a field-specific message instead of a late controller error. + ``state: gathered`` supplies no config, so it is naturally exempt. + """ + if not isinstance(strategy, OneManageLinkStrategy): + return + for index, item in enumerate(module.params.get("config") or []): + if not isinstance(item, dict): + continue + missing = [name for name in ("src_cluster_name", "dst_cluster_name") if not item.get(name)] + if missing: + module.fail_json( + msg="config item {0} is missing OneManage identity field(s): {1}. " + "Source and destination cluster names are required in the one_manage scope.".format(index, ", ".join(missing)) + ) + + +def main() -> None: + argument_spec = nd_argument_spec() + argument_spec.update(NDLinkModel.get_argument_spec()) + argument_spec.update( + fabric_name=dict(type="str", required=True), + link_scope=dict( + type="str", + default="auto", + choices=["auto", "manage", "one_manage"], + ), + cluster_name=dict(type="str"), + ticket_id=dict(type="str"), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ("state", "merged", ["config"]), + ("state", "replaced", ["config"]), + ("state", "overridden", ["config"]), + ("state", "deleted", ["config"]), + ], + ) + require_pydantic(module) + + try: + strategy = determine_strategy(module) + NDLinkModel.identifiers = strategy.identifier_fields + validate_scope_identity(module, strategy) + + sender = Sender() + sender.ansible_module = module + rest_send = RestSend( + { + "check_mode": module.check_mode, + "state": module.params.get("state"), + } + ) + rest_send.sender = sender + rest_send.response_handler = ResponseHandler() + + orchestrator = NDLinkOrchestrator(rest_send=rest_send, strategy=strategy) + + # Resolve switch identities (name/ip/id backfill) on a copy so the raw + # user config in module.params -- and thus the invocation echo -- stays + # untouched, then hand the prepared list to the state machine directly. + prepared_config = orchestrator.prepare_config_data(module.params.get("config") or []) + state_machine = NDStateMachine(module=module, model_orchestrator=orchestrator, config=prepared_config) + state_machine.manage_state() + + result = state_machine.output.format() + module.exit_json(**result) + + except NDStateMachineError as e: + module.fail_json(msg=str(e)) + except Exception as e: + module.fail_json(msg="Unexpected error: {0}".format(str(e))) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml new file mode 100644 index 000000000..7b41eeba6 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml @@ -0,0 +1,302 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links discriminated-union (negative) tests + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_numbered }}' + link_scope: manage + block: + - name: Create a numbered link baseline for discriminated-union tests + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + interface_admin_state: true + state: merged + + - name: Attempt to set ebgp_multihop on a numbered link (should fail) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + ebgp_multihop: 5 + state: merged + register: invalid_field_numbered + ignore_errors: true + + - name: Verify invalid field on numbered was rejected by Pydantic + ansible.builtin.assert: + that: + - invalid_field_numbered is failed + - invalid_field_numbered.msg is search("ebgp_multihop") + or invalid_field_numbered.msg is search("[Ee]xtra") + or invalid_field_numbered.msg is search("not permitted") + + - name: Attempt to set src_ip on an unnumbered link (should fail) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + config_data: + policy_type: unnumbered + template_inputs: + src_ip: 10.99.40.1 + dst_ip: 10.99.40.2 + mtu: 9216 + state: merged + register: invalid_field_unnumbered + ignore_errors: true + + - name: Verify invalid field on unnumbered was rejected by Pydantic + ansible.builtin.assert: + that: + - invalid_field_unnumbered is failed + - invalid_field_unnumbered.msg is search("src_ip") + or invalid_field_unnumbered.msg is search("[Ee]xtra") + or invalid_field_unnumbered.msg is search("not permitted") + + - name: Attempt to set src_ebgp_asn on a preprovision link (should fail) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/41 + dst_interface_name: Ethernet1/41 + config_data: + policy_type: preprovision + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + state: merged + register: invalid_field_preprovision + ignore_errors: true + + - name: Verify invalid field on preprovision was rejected by Pydantic + ansible.builtin.assert: + that: + - invalid_field_preprovision is failed + - invalid_field_preprovision.msg is search("src_ebgp_asn") + or invalid_field_preprovision.msg is search("[Ee]xtra") + or invalid_field_preprovision.msg is search("not permitted") + + - name: Attempt to set trunk_allowed_vlans on a numbered link (should fail) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/42 + dst_interface_name: Ethernet1/42 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.42.1 + dst_ip: 10.99.42.2 + trunk_allowed_vlans: "100,200" + state: merged + register: invalid_field_l2dci_on_numbered + ignore_errors: true + + - name: Verify l2dci-only field on numbered was rejected by Pydantic + ansible.builtin.assert: + that: + - invalid_field_l2dci_on_numbered is failed + - invalid_field_l2dci_on_numbered.msg is search("trunk_allowed_vlans") + or invalid_field_l2dci_on_numbered.msg is search("[Ee]xtra") + or invalid_field_l2dci_on_numbered.msg is search("not permitted") + + - name: Attempt to change policy_type from numbered to ebgpVrfLite in place (should fail) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: 10.99.30.1/24 + dst_ip_address: 10.99.30.2 + state: merged + register: policy_change_to_vrflite + ignore_errors: true + + - name: Verify numbered → ebgpVrfLite policy change rejected + ansible.builtin.assert: + that: + - policy_change_to_vrflite is failed + - policy_change_to_vrflite.msg is search("policy_type") + + - name: Reapply a preprovision declaration over a realized (numbered) link (persistent intent applies the user field, no policy switch) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: persistent intent applied on realized link + state: merged + register: preprovision_over_realized + + # Persistent intent: the link stays numbered (no policy-switch error), and because the + # declared interface description differs from the realized link it is a real change and + # is applied as a numbered PUT (not a rejected policy change). + - name: Verify persistent intent recognizes the realized link, keeps it numbered, and applies the user field + ansible.builtin.assert: + that: + - preprovision_over_realized is not failed + - preprovision_over_realized.changed == true + - (preprovision_over_realized.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "numbered" + + - name: Reapply the same preprovision declaration again (now idempotent under persistent intent) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: persistent intent applied on realized link + state: merged + register: preprovision_over_realized_again + + - name: Verify the second reapply is idempotent - still numbered, no change + ansible.builtin.assert: + that: + - preprovision_over_realized_again is not failed + - preprovision_over_realized_again.changed == false + - (preprovision_over_realized_again.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "numbered" + + - name: Clean up baseline numbered link + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + + - name: Exercise cross-policy delete+recreate (opt-in; requires compatible fabric) + when: nd_test_cross_policy_fabric is defined + block: + - name: Pre-clean any existing link on Eth1/30 + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ nd_test_cross_policy_fabric }}' + dst_fabric_name: '{{ nd_test_cross_policy_fabric }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + + - name: Create a numbered link first + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ nd_test_cross_policy_fabric }}' + dst_fabric_name: '{{ nd_test_cross_policy_fabric }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + interface_admin_state: true + state: merged + + - name: Delete the numbered link + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ nd_test_cross_policy_fabric }}' + dst_fabric_name: '{{ nd_test_cross_policy_fabric }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + + - name: Recreate on same interface with new policy type (ebgpVrfLite) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ nd_test_cross_policy_fabric }}' + dst_fabric_name: '{{ nd_test_cross_policy_fabric }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: 10.99.30.1/24 + dst_ip_address: 10.99.30.2 + link_mtu: 9216 + state: merged + register: recreate_result + + - name: Verify delete+recreate worked for policy type change + ansible.builtin.assert: + that: + - recreate_result is changed + - (recreate_result.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "ebgpVrfLite" + - (recreate_result.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_ebgp_asn == "65001" + + - name: Final cleanup of cross-policy link + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ nd_test_cross_policy_fabric }}' + dst_fabric_name: '{{ nd_test_cross_policy_fabric }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted diff --git a/tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml b/tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml new file mode 100644 index 000000000..8ba329b75 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml @@ -0,0 +1,269 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links ebgpVrfLite policy lifecycle (inter-fabric) + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_vrflite_src }}' + link_scope: manage + block: + - name: Ensure ebgpVrfLite links do not exist before tests + cisco.nd.nd_manage_links: &vrflite_cleanup + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + state: deleted + + - name: Create two ebgpVrfLite links (merged - check mode) + cisco.nd.nd_manage_links: &vrflite_create + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.30.1/30" + dst_ip_address: "10.99.30.2" + link_mtu: 9216 + src_interface_description: ansible vrflite 30 source + dst_interface_description: ansible vrflite 30 destination + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.31.1/30" + dst_ip_address: "10.99.31.2" + link_mtu: 9216 + state: merged + check_mode: true + register: cm_vrflite_create + + - name: Create two ebgpVrfLite links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *vrflite_create + register: nm_vrflite_create + + - name: Create two ebgpVrfLite links again (idempotency) + cisco.nd.nd_manage_links: + <<: *vrflite_create + register: nm_vrflite_create_again + + - name: Asserts for ebgpVrfLite create + ansible.builtin.assert: + that: + - cm_vrflite_create is changed + - cm_vrflite_create.proposed | length == 2 + - cm_vrflite_create.proposed.0.config_data.policy_type == "ebgpVrfLite" + - cm_vrflite_create.proposed.0.config_data.template_inputs.src_ebgp_asn == "65001" + + - nm_vrflite_create is changed + - (nm_vrflite_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "ebgpVrfLite" + - (nm_vrflite_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.link_mtu == 9216 + + - nm_vrflite_create_again is not changed + + - name: Update ebgpVrfLite link Eth1/30 (merged - check mode) + cisco.nd.nd_manage_links: &vrflite_update + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.30.1/30" + dst_ip_address: "10.99.30.2" + link_mtu: 1500 + src_interface_description: updated vrflite source + state: merged + check_mode: true + register: cm_vrflite_update + + - name: Update ebgpVrfLite link Eth1/30 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *vrflite_update + register: nm_vrflite_update + + - name: Update ebgpVrfLite link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *vrflite_update + register: nm_vrflite_update_again + + - name: Asserts for ebgpVrfLite update + ansible.builtin.assert: + that: + - cm_vrflite_update is changed + - cm_vrflite_update.proposed.0.config_data.template_inputs.link_mtu == 1500 + + - nm_vrflite_update is changed + - (nm_vrflite_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.link_mtu == 1500 + - (nm_vrflite_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_interface_description == "updated vrflite source" + + - nm_vrflite_update_again is not changed + + # ----------------------------------------------------------------------- + # Secret handling (check mode, no controller write): a free-form + # template_inputs secret must be stripped from proposed and masked in the + # invocation echo. Applied-state stripping and diff exclusion are covered + # by unit tests; they are not asserted against live state here because the + # controller-side prerequisites for a secret vary by lab. + # ----------------------------------------------------------------------- + - name: Set a secret on ebgpVrfLite link Eth1/30 (merged - check mode) + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.30.1/30" + dst_ip_address: "10.99.30.2" + link_mtu: 1500 + default_vrf_ebgp_neighbor_password: ansibleSecretKeyAAA + state: merged + check_mode: true + register: cm_vrflite_secret + + - name: Asserts for ebgpVrfLite secret handling + ansible.builtin.assert: + that: + # Secret is masked (not the real value) in module output, key stays visible. + - cm_vrflite_secret.proposed.0.config_data.template_inputs.default_vrf_ebgp_neighbor_password == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + # Non-secret fields in the same block survive. + - cm_vrflite_secret.proposed.0.config_data.template_inputs.link_mtu == 1500 + # The invocation echo is also masked (shows VALUE_SPECIFIED_IN_NO_LOG_PARAMETER + # at high verbosity), via the module's runtime no_log_values registration, covered by + # unit tests (collect_secret_values); not asserted here because ``invocation`` + # is not reliably present in registered results. + + - name: Replace ebgpVrfLite link Eth1/30 (replaced - check mode) + cisco.nd.nd_manage_links: &vrflite_replace + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: ebgpVrfLite + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.30.1/30" + dst_ip_address: "10.99.30.2" + link_mtu: 9216 + state: replaced + check_mode: true + register: cm_vrflite_replace + + - name: Replace ebgpVrfLite link Eth1/30 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *vrflite_replace + register: nm_vrflite_replace + + - name: Replace ebgpVrfLite link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *vrflite_replace + register: nm_vrflite_replace_again + + - name: Asserts for ebgpVrfLite replace + ansible.builtin.assert: + that: + - cm_vrflite_replace is changed + - nm_vrflite_replace is changed + - (nm_vrflite_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.link_mtu == 9216 + - (nm_vrflite_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - nm_vrflite_replace_again is not changed + + - name: Delete ebgpVrfLite link Eth1/30 (deleted - check mode) + cisco.nd.nd_manage_links: &vrflite_delete_one + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + check_mode: true + register: cm_vrflite_delete + + - name: Delete ebgpVrfLite link Eth1/30 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *vrflite_delete_one + register: nm_vrflite_delete + + - name: Delete ebgpVrfLite link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *vrflite_delete_one + register: nm_vrflite_delete_again + + - name: Bulk delete remaining ebgpVrfLite link + cisco.nd.nd_manage_links: &vrflite_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_vrflite_src }}' + dst_fabric_name: '{{ test_fabric_vrflite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + state: deleted + register: nm_vrflite_bulk_delete + + - name: Bulk delete remaining ebgpVrfLite link again (idempotency) + cisco.nd.nd_manage_links: + <<: *vrflite_bulk_delete + register: nm_vrflite_bulk_delete_again + + - name: Asserts for ebgpVrfLite delete + ansible.builtin.assert: + that: + - cm_vrflite_delete is changed + - nm_vrflite_delete is changed + - (nm_vrflite_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 0 + - nm_vrflite_delete_again is not changed + + - nm_vrflite_bulk_delete is changed + - (nm_vrflite_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - nm_vrflite_bulk_delete_again is not changed + + - name: Final cleanup of ebgpVrfLite links + cisco.nd.nd_manage_links: + <<: *vrflite_cleanup diff --git a/tests/integration/targets/nd_manage_links/tasks/layer2_dci.yml b/tests/integration/targets/nd_manage_links/tasks/layer2_dci.yml new file mode 100644 index 000000000..9d5ca6cf7 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/layer2_dci.yml @@ -0,0 +1,224 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links layer2Dci policy lifecycle (inter-fabric) + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_l2dci_src }}' + link_scope: manage + block: + - name: Ensure layer2Dci links do not exist before tests + cisco.nd.nd_manage_links: &l2dci_cleanup + config: + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + state: deleted + + - name: Create two layer2Dci links (merged - check mode) + cisco.nd.nd_manage_links: &l2dci_create + config: + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: layer2Dci + template_inputs: + trunk_allowed_vlans: "100,200,300" + native_vlan: 1 + port_type_fast: true + mtu_type: jumbo + speed: auto + src_interface_description: ansible l2dci 30 source + dst_interface_description: ansible l2dci 30 destination + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: layer2Dci + template_inputs: + trunk_allowed_vlans: "all" + native_vlan: 1 + mtu_type: jumbo + speed: auto + state: merged + check_mode: true + register: cm_l2dci_create + + - name: Create two layer2Dci links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *l2dci_create + register: nm_l2dci_create + + - name: Create two layer2Dci links again (idempotency) + cisco.nd.nd_manage_links: + <<: *l2dci_create + register: nm_l2dci_create_again + + - name: Asserts for layer2Dci create + ansible.builtin.assert: + that: + - cm_l2dci_create is changed + - cm_l2dci_create.proposed | length == 2 + - cm_l2dci_create.proposed.0.config_data.policy_type == "layer2Dci" + - cm_l2dci_create.proposed.0.config_data.template_inputs.trunk_allowed_vlans == "100,200,300" + + - nm_l2dci_create is changed + - (nm_l2dci_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "layer2Dci" + - (nm_l2dci_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.trunk_allowed_vlans == "100,200,300" + + - nm_l2dci_create_again is not changed + + - name: Update layer2Dci link Eth1/30 (merged - check mode) + cisco.nd.nd_manage_links: &l2dci_update + config: + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: layer2Dci + template_inputs: + trunk_allowed_vlans: "100-150" + native_vlan: 10 + src_interface_description: updated l2dci source + speed: auto + state: merged + check_mode: true + register: cm_l2dci_update + + - name: Update layer2Dci link Eth1/30 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *l2dci_update + register: nm_l2dci_update + + - name: Update layer2Dci link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *l2dci_update + register: nm_l2dci_update_again + + - name: Asserts for layer2Dci update + ansible.builtin.assert: + that: + - cm_l2dci_update is changed + - cm_l2dci_update.proposed.0.config_data.template_inputs.trunk_allowed_vlans == "100-150" + + - nm_l2dci_update is changed + - (nm_l2dci_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.trunk_allowed_vlans == "100-150" + - (nm_l2dci_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.native_vlan == 10 + + - nm_l2dci_update_again is not changed + + - name: Replace layer2Dci link Eth1/30 (replaced - check mode) + cisco.nd.nd_manage_links: &l2dci_replace + config: + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: layer2Dci + template_inputs: + trunk_allowed_vlans: "all" + native_vlan: 1 + mtu_type: jumbo + speed: auto + state: replaced + check_mode: true + register: cm_l2dci_replace + + - name: Replace layer2Dci link Eth1/30 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *l2dci_replace + register: nm_l2dci_replace + + - name: Replace layer2Dci link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *l2dci_replace + register: nm_l2dci_replace_again + + - name: Asserts for layer2Dci replace + ansible.builtin.assert: + that: + - cm_l2dci_replace is changed + - nm_l2dci_replace is changed + - (nm_l2dci_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.trunk_allowed_vlans == "all" + - (nm_l2dci_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - nm_l2dci_replace_again is not changed + + - name: Delete layer2Dci link Eth1/30 (deleted - check mode) + cisco.nd.nd_manage_links: &l2dci_delete_one + config: + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + check_mode: true + register: cm_l2dci_delete + + - name: Delete layer2Dci link Eth1/30 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *l2dci_delete_one + register: nm_l2dci_delete + + - name: Delete layer2Dci link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *l2dci_delete_one + register: nm_l2dci_delete_again + + - name: Bulk delete remaining layer2Dci link + cisco.nd.nd_manage_links: &l2dci_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_l2dci_src }}' + dst_fabric_name: '{{ test_fabric_l2dci_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + state: deleted + register: nm_l2dci_bulk_delete + + - name: Bulk delete remaining layer2Dci link again (idempotency) + cisco.nd.nd_manage_links: + <<: *l2dci_bulk_delete + register: nm_l2dci_bulk_delete_again + + - name: Asserts for layer2Dci delete + ansible.builtin.assert: + that: + - cm_l2dci_delete is changed + - nm_l2dci_delete is changed + - (nm_l2dci_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 0 + - nm_l2dci_delete_again is not changed + + - nm_l2dci_bulk_delete is changed + - (nm_l2dci_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - nm_l2dci_bulk_delete_again is not changed + + - name: Final cleanup of layer2Dci links + cisco.nd.nd_manage_links: + <<: *l2dci_cleanup diff --git a/tests/integration/targets/nd_manage_links/tasks/main.yml b/tests/integration/targets/nd_manage_links/tasks/main.yml new file mode 100644 index 000000000..8d8c1314e --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/main.yml @@ -0,0 +1,83 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# 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 + +# Test variables. Override any of these via inventory or extra vars at playbook time. +# The fabric names below are what the tests assume exist in ND. Either create fabrics +# matching these names in ND 4.2, or override the corresponding nd_test_fabric_* var. +- name: Set test fabric and switch variables + ansible.builtin.set_fact: + test_switch_a: '{{ nd_test_switch_a | default("n9k-z17-62") }}' + test_switch_b: '{{ nd_test_switch_b | default("n9k-z17-63") }}' + + # Optional management IPs. By default per policy tests resolve switches by name. + # Set these (and switch each per policy file to src_switch_ip/dst_switch_ip) to + # exercise IP based resolution if your lab has duplicate hostnames. + test_switch_a_ip: '{{ nd_test_switch_a_ip | default("") }}' + test_switch_b_ip: '{{ nd_test_switch_b_ip | default("") }}' + + # Optional manage-scope change-control context. Empty by default so runs work + # on labs without change control. Set these to exercise the clusterName / + # ticketId query params on manage reads and mutations. + test_cluster_name: '{{ nd_test_cluster_name | default("") }}' + test_ticket_id: '{{ nd_test_ticket_id | default("") }}' + + test_fabric_numbered: '{{ nd_test_fabric_numbered | default(nd_test_fabric | default("ansible-fab-numbered")) }}' + test_fabric_unnumbered: '{{ nd_test_fabric_unnumbered | default("ansible-fab-unnumbered") }}' + test_fabric_preprovision: '{{ nd_test_fabric_preprovision | default("ansible-fab-preprov") }}' + + test_fabric_l2dci_src: '{{ nd_test_fabric_l2dci_src | default("ansible-fab-l2dci-a") }}' + test_fabric_l2dci_dst: '{{ nd_test_fabric_l2dci_dst | default("ansible-fab-l2dci-b") }}' + + test_fabric_msite_src: '{{ nd_test_fabric_msite_src | default("ansible-fab-msd-a") }}' + test_fabric_msite_dst: '{{ nd_test_fabric_msite_dst | default("ansible-fab-msd-b") }}' + + test_fabric_vrflite_src: '{{ nd_test_fabric_vrflite_src | default("ansible-fab-vrflite-a") }}' + test_fabric_vrflite_dst: '{{ nd_test_fabric_vrflite_dst | default("ansible-fab-vrflite-b") }}' + + # Per policy enable flags. Numbered runs by default (matches existing CI). Flip + # any of these to true (via inventory or extra vars) to opt in to that policy's tests. + run_numbered: '{{ nd_test_run_numbered | default(true) }}' + run_unnumbered: '{{ nd_test_run_unnumbered | default(false) }}' + run_preprovision: '{{ nd_test_run_preprovision | default(false) }}' + run_layer2_dci: '{{ nd_test_run_layer2_dci | default(false) }}' + run_multisite_overlay: '{{ nd_test_run_multisite_overlay | default(false) }}' + run_multisite_underlay: '{{ nd_test_run_multisite_underlay | default(false) }}' + run_ebgp_vrf_lite: '{{ nd_test_run_ebgp_vrf_lite | default(false) }}' + +- name: Run nd_manage_links lifecycle for numbered policy + ansible.builtin.import_tasks: numbered.yml + when: run_numbered | bool + +- name: Run nd_manage_links lifecycle for unnumbered policy + ansible.builtin.import_tasks: unnumbered.yml + when: run_unnumbered | bool + +- name: Run nd_manage_links lifecycle for preprovision policy + ansible.builtin.import_tasks: preprovision.yml + when: run_preprovision | bool + +- name: Run nd_manage_links lifecycle for layer2Dci policy + ansible.builtin.import_tasks: layer2_dci.yml + when: run_layer2_dci | bool + +- name: Run nd_manage_links lifecycle for multisiteOverlay policy + ansible.builtin.import_tasks: multisite_overlay.yml + when: run_multisite_overlay | bool + +- name: Run nd_manage_links lifecycle for multisiteUnderlay policy + ansible.builtin.import_tasks: multisite_underlay.yml + when: run_multisite_underlay | bool + +- name: Run nd_manage_links lifecycle for ebgpVrfLite policy + ansible.builtin.import_tasks: ebgp_vrf_lite.yml + when: run_ebgp_vrf_lite | bool + +- name: Run nd_manage_links discriminated union (negative) tests + ansible.builtin.import_tasks: discriminated_union.yml + when: run_numbered | bool diff --git a/tests/integration/targets/nd_manage_links/tasks/multisite_overlay.yml b/tests/integration/targets/nd_manage_links/tasks/multisite_overlay.yml new file mode 100644 index 000000000..f0fd1a756 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/multisite_overlay.yml @@ -0,0 +1,231 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links multisiteOverlay policy lifecycle (inter-fabric) + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_msite_src }}' + link_scope: manage + block: + - name: Ensure multisiteOverlay links do not exist before tests + cisco.nd.nd_manage_links: &msite_overlay_cleanup + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/41 + dst_interface_name: Ethernet1/41 + state: deleted + + - name: Create two multisiteOverlay links (merged - check mode) + cisco.nd.nd_manage_links: &msite_overlay_create + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + config_data: + policy_type: multisiteOverlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address: 10.99.40.1 + dst_ip_address: 10.99.40.2 + ebgp_multihop: 5 + ipv4_trm: false + ipv6_trm: false + src_interface_description: ansible msite-overlay 40 source + dst_interface_description: ansible msite-overlay 40 destination + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/41 + dst_interface_name: Ethernet1/41 + config_data: + policy_type: multisiteOverlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address: 10.99.41.1 + dst_ip_address: 10.99.41.2 + ebgp_multihop: 5 + state: merged + check_mode: true + register: cm_msite_overlay_create + + - name: Create two multisiteOverlay links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_overlay_create + register: nm_msite_overlay_create + + - name: Create two multisiteOverlay links again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_overlay_create + register: nm_msite_overlay_create_again + + - name: Asserts for multisiteOverlay create + ansible.builtin.assert: + that: + - cm_msite_overlay_create is changed + - cm_msite_overlay_create.proposed | length == 2 + - cm_msite_overlay_create.proposed.0.config_data.policy_type == "multisiteOverlay" + - cm_msite_overlay_create.proposed.0.config_data.template_inputs.src_ebgp_asn == "65001" + + - nm_msite_overlay_create is changed + - (nm_msite_overlay_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/40') | first).config_data.policy_type == "multisiteOverlay" + - (nm_msite_overlay_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/40') | first).config_data.template_inputs.ebgp_multihop == 5 + + - nm_msite_overlay_create_again is not changed + + - name: Update multisiteOverlay link Eth1/40 (merged - check mode) + cisco.nd.nd_manage_links: &msite_overlay_update + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + config_data: + policy_type: multisiteOverlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address: 10.99.40.1 + dst_ip_address: 10.99.40.2 + ebgp_multihop: 10 + ipv4_trm: true + src_interface_description: updated msite-overlay source + state: merged + check_mode: true + register: cm_msite_overlay_update + + - name: Update multisiteOverlay link Eth1/40 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_overlay_update + register: nm_msite_overlay_update + + - name: Update multisiteOverlay link Eth1/40 again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_overlay_update + register: nm_msite_overlay_update_again + + - name: Asserts for multisiteOverlay update + ansible.builtin.assert: + that: + - cm_msite_overlay_update is changed + - cm_msite_overlay_update.proposed.0.config_data.template_inputs.ebgp_multihop == 10 + + - nm_msite_overlay_update is changed + - (nm_msite_overlay_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/40') | first).config_data.template_inputs.ebgp_multihop == 10 + - (nm_msite_overlay_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/40') | first).config_data.template_inputs.ipv4_trm == true + + - nm_msite_overlay_update_again is not changed + + - name: Replace multisiteOverlay link Eth1/40 (replaced - check mode) + cisco.nd.nd_manage_links: &msite_overlay_replace + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + config_data: + policy_type: multisiteOverlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address: 10.99.40.1 + dst_ip_address: 10.99.40.2 + ebgp_multihop: 5 + state: replaced + check_mode: true + register: cm_msite_overlay_replace + + - name: Replace multisiteOverlay link Eth1/40 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_overlay_replace + register: nm_msite_overlay_replace + + - name: Replace multisiteOverlay link Eth1/40 again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_overlay_replace + register: nm_msite_overlay_replace_again + + - name: Asserts for multisiteOverlay replace + ansible.builtin.assert: + that: + - cm_msite_overlay_replace is changed + - nm_msite_overlay_replace is changed + - (nm_msite_overlay_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/40') | first).config_data.template_inputs.ebgp_multihop == 5 + - (nm_msite_overlay_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/41') | list) | length == 1 + - nm_msite_overlay_replace_again is not changed + + - name: Delete multisiteOverlay link Eth1/40 (deleted - check mode) + cisco.nd.nd_manage_links: &msite_overlay_delete_one + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/40 + dst_interface_name: Ethernet1/40 + state: deleted + check_mode: true + register: cm_msite_overlay_delete + + - name: Delete multisiteOverlay link Eth1/40 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_overlay_delete_one + register: nm_msite_overlay_delete + + - name: Delete multisiteOverlay link Eth1/40 again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_overlay_delete_one + register: nm_msite_overlay_delete_again + + - name: Bulk delete remaining multisiteOverlay link + cisco.nd.nd_manage_links: &msite_overlay_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/41 + dst_interface_name: Ethernet1/41 + state: deleted + register: nm_msite_overlay_bulk_delete + + - name: Bulk delete remaining multisiteOverlay link again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_overlay_bulk_delete + register: nm_msite_overlay_bulk_delete_again + + - name: Asserts for multisiteOverlay delete + ansible.builtin.assert: + that: + - cm_msite_overlay_delete is changed + - nm_msite_overlay_delete is changed + - (nm_msite_overlay_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/40') | list) | length == 0 + - nm_msite_overlay_delete_again is not changed + + - nm_msite_overlay_bulk_delete is changed + - (nm_msite_overlay_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/41') | list) | length == 0 + - nm_msite_overlay_bulk_delete_again is not changed + + - name: Final cleanup of multisiteOverlay links + cisco.nd.nd_manage_links: + <<: *msite_overlay_cleanup diff --git a/tests/integration/targets/nd_manage_links/tasks/multisite_underlay.yml b/tests/integration/targets/nd_manage_links/tasks/multisite_underlay.yml new file mode 100644 index 000000000..efa392f6f --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/multisite_underlay.yml @@ -0,0 +1,237 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links multisiteUnderlay policy lifecycle (inter-fabric) + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_msite_src }}' + link_scope: manage + block: + - name: Ensure multisiteUnderlay links do not exist before tests + cisco.nd.nd_manage_links: &msite_underlay_cleanup + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/42 + dst_interface_name: Ethernet1/42 + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/43 + dst_interface_name: Ethernet1/43 + state: deleted + + - name: Create two multisiteUnderlay links (merged - check mode) + cisco.nd.nd_manage_links: &msite_underlay_create + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/42 + dst_interface_name: Ethernet1/42 + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.42.1/30" + dst_ip_address: "10.99.42.2" + link_mtu: 9216 + speed: auto + ebgp_bfd: false + ebgp_maximum_paths: 2 + src_interface_description: ansible msite-underlay 42 source + dst_interface_description: ansible msite-underlay 42 destination + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/43 + dst_interface_name: Ethernet1/43 + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.43.1/30" + dst_ip_address: "10.99.43.2" + link_mtu: 9216 + speed: auto + ebgp_maximum_paths: 2 + state: merged + check_mode: true + register: cm_msite_underlay_create + + - name: Create two multisiteUnderlay links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_underlay_create + register: nm_msite_underlay_create + + - name: Create two multisiteUnderlay links again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_underlay_create + register: nm_msite_underlay_create_again + + - name: Asserts for multisiteUnderlay create + ansible.builtin.assert: + that: + - cm_msite_underlay_create is changed + - cm_msite_underlay_create.proposed | length == 2 + - cm_msite_underlay_create.proposed.0.config_data.policy_type == "multisiteUnderlay" + - cm_msite_underlay_create.proposed.0.config_data.template_inputs.src_ebgp_asn == "65001" + + - nm_msite_underlay_create is changed + - (nm_msite_underlay_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/42') | first).config_data.policy_type == "multisiteUnderlay" + - (nm_msite_underlay_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/42') | first).config_data.template_inputs.link_mtu == 9216 + + - nm_msite_underlay_create_again is not changed + + - name: Update multisiteUnderlay link Eth1/42 (merged - check mode) + cisco.nd.nd_manage_links: &msite_underlay_update + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/42 + dst_interface_name: Ethernet1/42 + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.42.1/30" + dst_ip_address: "10.99.42.2" + link_mtu: 1500 + speed: auto + ebgp_bfd: false + ebgp_maximum_paths: 2 + src_interface_description: updated msite-underlay source + state: merged + check_mode: true + register: cm_msite_underlay_update + + - name: Update multisiteUnderlay link Eth1/42 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_underlay_update + register: nm_msite_underlay_update + + - name: Update multisiteUnderlay link Eth1/42 again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_underlay_update + register: nm_msite_underlay_update_again + + - name: Asserts for multisiteUnderlay update + ansible.builtin.assert: + that: + - cm_msite_underlay_update is changed + - cm_msite_underlay_update.proposed.0.config_data.template_inputs.link_mtu == 1500 + + - nm_msite_underlay_update is changed + - (nm_msite_underlay_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/42') | first).config_data.template_inputs.link_mtu == 1500 + + - nm_msite_underlay_update_again is not changed + + - name: Replace multisiteUnderlay link Eth1/42 (replaced - check mode) + cisco.nd.nd_manage_links: &msite_underlay_replace + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/42 + dst_interface_name: Ethernet1/42 + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "65001" + dst_ebgp_asn: "65002" + src_ip_address_mask: "10.99.42.1/30" + dst_ip_address: "10.99.42.2" + link_mtu: 9216 + speed: auto + ebgp_maximum_paths: 2 + state: replaced + check_mode: true + register: cm_msite_underlay_replace + + - name: Replace multisiteUnderlay link Eth1/42 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_underlay_replace + register: nm_msite_underlay_replace + + - name: Replace multisiteUnderlay link Eth1/42 again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_underlay_replace + register: nm_msite_underlay_replace_again + + - name: Asserts for multisiteUnderlay replace + ansible.builtin.assert: + that: + - cm_msite_underlay_replace is changed + - nm_msite_underlay_replace is changed + - (nm_msite_underlay_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/42') | first).config_data.template_inputs.link_mtu == 9216 + - (nm_msite_underlay_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/43') | list) | length == 1 + - nm_msite_underlay_replace_again is not changed + + - name: Delete multisiteUnderlay link Eth1/42 (deleted - check mode) + cisco.nd.nd_manage_links: &msite_underlay_delete_one + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/42 + dst_interface_name: Ethernet1/42 + state: deleted + check_mode: true + register: cm_msite_underlay_delete + + - name: Delete multisiteUnderlay link Eth1/42 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *msite_underlay_delete_one + register: nm_msite_underlay_delete + + - name: Delete multisiteUnderlay link Eth1/42 again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_underlay_delete_one + register: nm_msite_underlay_delete_again + + - name: Bulk delete remaining multisiteUnderlay link + cisco.nd.nd_manage_links: &msite_underlay_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_msite_src }}' + dst_fabric_name: '{{ test_fabric_msite_dst }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/43 + dst_interface_name: Ethernet1/43 + state: deleted + register: nm_msite_underlay_bulk_delete + + - name: Bulk delete remaining multisiteUnderlay link again (idempotency) + cisco.nd.nd_manage_links: + <<: *msite_underlay_bulk_delete + register: nm_msite_underlay_bulk_delete_again + + - name: Asserts for multisiteUnderlay delete + ansible.builtin.assert: + that: + - cm_msite_underlay_delete is changed + - nm_msite_underlay_delete is changed + - (nm_msite_underlay_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/42') | list) | length == 0 + - nm_msite_underlay_delete_again is not changed + + - nm_msite_underlay_bulk_delete is changed + - (nm_msite_underlay_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/43') | list) | length == 0 + - nm_msite_underlay_bulk_delete_again is not changed + + - name: Final cleanup of multisiteUnderlay links + cisco.nd.nd_manage_links: + <<: *msite_underlay_cleanup diff --git a/tests/integration/targets/nd_manage_links/tasks/numbered.yml b/tests/integration/targets/nd_manage_links/tasks/numbered.yml new file mode 100644 index 000000000..461b89ef3 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/numbered.yml @@ -0,0 +1,429 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links numbered policy lifecycle + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_numbered }}' + link_scope: manage + # Exercised only when the vars are set (omitted otherwise) so the + # clusterName / ticketId query-param wiring is covered on labs that + # support change control. + cluster_name: '{{ test_cluster_name | default(omit, true) }}' + ticket_id: '{{ test_ticket_id | default(omit, true) }}' + block: + - name: Ensure numbered links do not exist before tests + cisco.nd.nd_manage_links: &numbered_cleanup + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + state: deleted + + - name: Create three numbered links (merged - check mode) + cisco.nd.nd_manage_links: &numbered_create + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + speed: auto + fec: auto + interface_admin_state: true + src_interface_description: ansible numbered 30 source + dst_interface_description: ansible numbered 30 destination + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.31.1 + dst_ip: 10.99.31.2 + mtu: 9216 + speed: auto + fec: auto + interface_admin_state: true + src_interface_description: ansible numbered 31 source + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.32.1 + dst_ip: 10.99.32.2 + mtu: 9216 + interface_admin_state: true + state: merged + check_mode: true + register: cm_numbered_create + + - name: Create three numbered links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *numbered_create + register: nm_numbered_create + + - name: Create three numbered links again (idempotency) + cisco.nd.nd_manage_links: + <<: *numbered_create + register: nm_numbered_create_again + + - name: Asserts for numbered create + ansible.builtin.assert: + that: + - cm_numbered_create is changed + - cm_numbered_create.proposed | length == 3 + - cm_numbered_create.proposed.0.config_data.policy_type == "numbered" + - cm_numbered_create.proposed.0.config_data.template_inputs.src_ip == "10.99.30.1" + - cm_numbered_create.proposed.0.config_data.template_inputs.mtu == 9216 + + - nm_numbered_create is changed + - (nm_numbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 + - (nm_numbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - (nm_numbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + - (nm_numbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "numbered" + - (nm_numbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_ip == "10.99.30.1" + + - nm_numbered_create_again is not changed + + - name: Gather numbered links (read-only, no config) + cisco.nd.nd_manage_links: + state: gathered + register: gathered_numbered + + - name: Asserts for numbered gathered + ansible.builtin.assert: + that: + - gathered_numbered is not changed + - (gathered_numbered.gathered | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 + - (gathered_numbered.gathered | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - (gathered_numbered.gathered | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + # Gathered is pruned to the module's argument spec: response-only keys + # (e.g. link_id) must not leak, and the config-shaped keys must survive. + - (gathered_numbered.gathered | selectattr('link_id', 'defined') | list) | length == 0 + - (gathered_numbered.gathered | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "numbered" + - (gathered_numbered.gathered | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_ip == "10.99.30.1" + + - name: Re-apply gathered numbered links as config (round-trip) + cisco.nd.nd_manage_links: + state: merged + config: "{{ gathered_numbered.gathered }}" + register: numbered_roundtrip + + - name: Assert gathered output round-trips cleanly with no changes + ansible.builtin.assert: + that: + # Pasting gathered straight back as config must be idempotent, proving + # it validates against the argument spec at every nesting level. + - numbered_roundtrip is not changed + + - name: Update numbered link Eth1/30 (merged - check mode) + cisco.nd.nd_manage_links: &numbered_update + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 1500 + speed: auto + fec: auto + interface_admin_state: true + src_interface_description: updated numbered source + dst_interface_description: updated numbered destination + state: merged + check_mode: true + register: cm_numbered_update + + - name: Update numbered link Eth1/30 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *numbered_update + register: nm_numbered_update + + - name: Update numbered link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *numbered_update + register: nm_numbered_update_again + + - name: Asserts for numbered update + ansible.builtin.assert: + that: + - cm_numbered_update is changed + - cm_numbered_update.proposed.0.config_data.template_inputs.mtu == 1500 + + - nm_numbered_update is changed + - (nm_numbered_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.mtu == 1500 + - (nm_numbered_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_interface_description == "updated numbered source" + + - nm_numbered_update_again is not changed + + - name: Replace numbered link Eth1/30 (replaced - check mode) + cisco.nd.nd_manage_links: &numbered_replace + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + interface_admin_state: true + state: replaced + check_mode: true + register: cm_numbered_replace + + - name: Replace numbered link Eth1/30 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *numbered_replace + register: nm_numbered_replace + + - name: Replace numbered link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *numbered_replace + register: nm_numbered_replace_again + + - name: Asserts for numbered replace + ansible.builtin.assert: + that: + - cm_numbered_replace is changed + - cm_numbered_replace.proposed.0.config_data.template_inputs.mtu == 9216 + + - nm_numbered_replace is changed + - (nm_numbered_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.mtu == 9216 + - (nm_numbered_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - (nm_numbered_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + + - nm_numbered_replace_again is not changed + + - name: Override down to a single numbered link (overridden - check mode) + cisco.nd.nd_manage_links: &numbered_override + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.30.1 + dst_ip: 10.99.30.2 + mtu: 9216 + interface_admin_state: true + state: overridden + check_mode: true + register: cm_numbered_override + + - name: Override down to a single numbered link (overridden - normal mode) + cisco.nd.nd_manage_links: + <<: *numbered_override + register: nm_numbered_override + + - name: Override down to a single numbered link again (idempotency) + cisco.nd.nd_manage_links: + <<: *numbered_override + register: nm_numbered_override_again + + - name: Asserts for numbered override + ansible.builtin.assert: + that: + - cm_numbered_override is changed + + - nm_numbered_override is changed + - (nm_numbered_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 + - (nm_numbered_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - (nm_numbered_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 0 + + - nm_numbered_override_again is not changed + + - name: Re-create Eth1/31 and Eth1/32 for delete coverage + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.31.1 + dst_ip: 10.99.31.2 + mtu: 9216 + interface_admin_state: true + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.32.1 + dst_ip: 10.99.32.2 + mtu: 9216 + interface_admin_state: true + state: merged + + - name: Delete numbered link Eth1/30 (deleted - check mode) + cisco.nd.nd_manage_links: &numbered_delete_one + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + check_mode: true + register: cm_numbered_delete + + - name: Delete numbered link Eth1/30 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *numbered_delete_one + register: nm_numbered_delete + + - name: Delete numbered link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *numbered_delete_one + register: nm_numbered_delete_again + + - name: Bulk delete remaining numbered links + cisco.nd.nd_manage_links: &numbered_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + state: deleted + register: nm_numbered_bulk_delete + + - name: Bulk delete remaining numbered links again (idempotency) + cisco.nd.nd_manage_links: + <<: *numbered_bulk_delete + register: nm_numbered_bulk_delete_again + + - name: Asserts for numbered delete + ansible.builtin.assert: + that: + - cm_numbered_delete is changed + - nm_numbered_delete is changed + - (nm_numbered_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 0 + - nm_numbered_delete_again is not changed + + - nm_numbered_bulk_delete is changed + - (nm_numbered_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - (nm_numbered_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 0 + + - nm_numbered_bulk_delete_again is not changed + + - name: Final cleanup of numbered links + cisco.nd.nd_manage_links: + <<: *numbered_cleanup + + # one_manage scope: CREATE-only coverage on a single cluster. ND does not + # return src/dstClusterName on a single-cluster link read, so the 8-field + # one_manage identity cannot round-trip; idempotency/update/delete via + # one_manage are only testable on a real multi-cluster lab. Here we exercise + # the one_manage create path and clean up via manage scope (6-field identity, + # which does round-trip). Gated on test_cluster_name; uses Ethernet1/33. + - name: Ensure numbered one_manage link does not exist (manage-scope cleanup) + cisco.nd.nd_manage_links: &numbered_one_manage_manage_cleanup + link_scope: manage + config: + - src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/33 + dst_interface_name: Ethernet1/33 + state: deleted + when: test_cluster_name | length > 0 + + - name: Create numbered link via one_manage scope (single cluster, create-only) + cisco.nd.nd_manage_links: + link_scope: one_manage + config: + - src_cluster_name: '{{ test_cluster_name }}' + dst_cluster_name: '{{ test_cluster_name }}' + src_fabric_name: '{{ test_fabric_numbered }}' + dst_fabric_name: '{{ test_fabric_numbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/33 + dst_interface_name: Ethernet1/33 + config_data: + policy_type: numbered + template_inputs: + src_ip: 10.99.33.1 + dst_ip: 10.99.33.2 + mtu: 9216 + state: merged + register: nm_numbered_one_manage + when: test_cluster_name | length > 0 + + - name: Asserts for numbered one_manage create + ansible.builtin.assert: + that: + - nm_numbered_one_manage is changed + - (nm_numbered_one_manage.after | selectattr('src_interface_name', 'eq', 'Ethernet1/33') | list) | length == 1 + when: test_cluster_name | length > 0 + + - name: Cleanup numbered one_manage link (manage scope) + cisco.nd.nd_manage_links: + <<: *numbered_one_manage_manage_cleanup + when: test_cluster_name | length > 0 diff --git a/tests/integration/targets/nd_manage_links/tasks/preprovision.yml b/tests/integration/targets/nd_manage_links/tasks/preprovision.yml new file mode 100644 index 000000000..ad43ac6cd --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/preprovision.yml @@ -0,0 +1,294 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links preprovision policy lifecycle + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_preprovision }}' + link_scope: manage + block: + - name: Ensure preprovision links do not exist before tests + cisco.nd.nd_manage_links: &preprovision_cleanup + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + state: deleted + + - name: Create three preprovision links (merged - check mode) + cisco.nd.nd_manage_links: &preprovision_create + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: ansible preprovision 30 source + dst_interface_description: ansible preprovision 30 destination + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: ansible preprovision 31 source + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: ansible preprovision 32 source + state: merged + check_mode: true + register: cm_preprovision_create + + - name: Create three preprovision links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *preprovision_create + register: nm_preprovision_create + + - name: Create three preprovision links again (idempotency) + cisco.nd.nd_manage_links: + <<: *preprovision_create + register: nm_preprovision_create_again + + - name: Asserts for preprovision create + ansible.builtin.assert: + that: + - cm_preprovision_create is changed + - cm_preprovision_create.proposed | length == 3 + - cm_preprovision_create.proposed.0.config_data.policy_type == "preprovision" + + - nm_preprovision_create is changed + - (nm_preprovision_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "preprovision" + - (nm_preprovision_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_interface_description == "ansible preprovision 30 source" + + - nm_preprovision_create_again is not changed + + - name: Update preprovision link Eth1/30 (merged - check mode) + cisco.nd.nd_manage_links: &preprovision_update + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: updated preprovision source + dst_interface_description: updated preprovision destination + state: merged + check_mode: true + register: cm_preprovision_update + + - name: Update preprovision link Eth1/30 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *preprovision_update + register: nm_preprovision_update + + - name: Update preprovision link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *preprovision_update + register: nm_preprovision_update_again + + - name: Asserts for preprovision update + ansible.builtin.assert: + that: + - cm_preprovision_update is changed + - cm_preprovision_update.proposed.0.config_data.template_inputs.src_interface_description == "updated preprovision source" + + - nm_preprovision_update is changed + - (nm_preprovision_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_interface_description == "updated preprovision source" + + - nm_preprovision_update_again is not changed + + - name: Replace preprovision link Eth1/30 (replaced - check mode) + cisco.nd.nd_manage_links: &preprovision_replace + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: replaced preprovision source + state: replaced + check_mode: true + register: cm_preprovision_replace + + - name: Replace preprovision link Eth1/30 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *preprovision_replace + register: nm_preprovision_replace + + - name: Replace preprovision link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *preprovision_replace + register: nm_preprovision_replace_again + + - name: Asserts for preprovision replace + ansible.builtin.assert: + that: + - cm_preprovision_replace is changed + - nm_preprovision_replace is changed + - (nm_preprovision_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - (nm_preprovision_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + - nm_preprovision_replace_again is not changed + + - name: Override down to a single preprovision link (overridden - check mode) + cisco.nd.nd_manage_links: &preprovision_override + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: override preprovision source + state: overridden + check_mode: true + register: cm_preprovision_override + + - name: Override down to a single preprovision link (overridden - normal mode) + cisco.nd.nd_manage_links: + <<: *preprovision_override + register: nm_preprovision_override + + - name: Override down to a single preprovision link again (idempotency) + cisco.nd.nd_manage_links: + <<: *preprovision_override + register: nm_preprovision_override_again + + - name: Asserts for preprovision override + ansible.builtin.assert: + that: + - cm_preprovision_override is changed + - nm_preprovision_override is changed + - (nm_preprovision_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 + - (nm_preprovision_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - (nm_preprovision_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 0 + - nm_preprovision_override_again is not changed + + - name: Re-create Eth1/31 and Eth1/32 for delete coverage + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: ansible preprovision 31 source + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + config_data: + policy_type: preprovision + template_inputs: + src_interface_description: ansible preprovision 32 source + state: merged + + - name: Delete preprovision link Eth1/30 (deleted - check mode) + cisco.nd.nd_manage_links: &preprovision_delete_one + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + check_mode: true + register: cm_preprovision_delete + + - name: Delete preprovision link Eth1/30 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *preprovision_delete_one + register: nm_preprovision_delete + + - name: Delete preprovision link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *preprovision_delete_one + register: nm_preprovision_delete_again + + - name: Bulk delete remaining preprovision links + cisco.nd.nd_manage_links: &preprovision_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + - src_fabric_name: '{{ test_fabric_preprovision }}' + dst_fabric_name: '{{ test_fabric_preprovision }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + state: deleted + register: nm_preprovision_bulk_delete + + - name: Bulk delete remaining preprovision links again (idempotency) + cisco.nd.nd_manage_links: + <<: *preprovision_bulk_delete + register: nm_preprovision_bulk_delete_again + + - name: Asserts for preprovision delete + ansible.builtin.assert: + that: + - cm_preprovision_delete is changed + - nm_preprovision_delete is changed + - (nm_preprovision_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 0 + - nm_preprovision_delete_again is not changed + + - nm_preprovision_bulk_delete is changed + - (nm_preprovision_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - (nm_preprovision_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 0 + - nm_preprovision_bulk_delete_again is not changed + + - name: Final cleanup of preprovision links + cisco.nd.nd_manage_links: + <<: *preprovision_cleanup diff --git a/tests/integration/targets/nd_manage_links/tasks/unnumbered.yml b/tests/integration/targets/nd_manage_links/tasks/unnumbered.yml new file mode 100644 index 000000000..d61d1344d --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/unnumbered.yml @@ -0,0 +1,311 @@ +# Test code for the cisco.nd.nd_manage_links module. +# Copyright: (c) 2026, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Run nd_manage_links unnumbered policy lifecycle + module_defaults: + cisco.nd.nd_manage_links: + output_level: '{{ nd_output_level | default("debug") }}' + fabric_name: '{{ test_fabric_unnumbered }}' + link_scope: manage + block: + - name: Ensure unnumbered links do not exist before tests + cisco.nd.nd_manage_links: &unnumbered_cleanup + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + state: deleted + + - name: Create three unnumbered links (merged - check mode) + cisco.nd.nd_manage_links: &unnumbered_create + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + speed: auto + fec: auto + interface_admin_state: true + src_interface_description: ansible unnumbered 30 source + dst_interface_description: ansible unnumbered 30 destination + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + interface_admin_state: true + src_interface_description: ansible unnumbered 31 source + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + interface_admin_state: true + state: merged + check_mode: true + register: cm_unnumbered_create + + - name: Create three unnumbered links (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *unnumbered_create + register: nm_unnumbered_create + + - name: Create three unnumbered links again (idempotency) + cisco.nd.nd_manage_links: + <<: *unnumbered_create + register: nm_unnumbered_create_again + + - name: Asserts for unnumbered create + ansible.builtin.assert: + that: + - cm_unnumbered_create is changed + - cm_unnumbered_create.proposed | length == 3 + - cm_unnumbered_create.proposed.0.config_data.policy_type == "unnumbered" + + - nm_unnumbered_create is changed + - (nm_unnumbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.policy_type == "unnumbered" + - (nm_unnumbered_create.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.mtu == 9216 + + - nm_unnumbered_create_again is not changed + + - name: Update unnumbered link Eth1/30 (merged - check mode) + cisco.nd.nd_manage_links: &unnumbered_update + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 1500 + speed: auto + fec: auto + interface_admin_state: false + src_interface_description: updated unnumbered source + dst_interface_description: updated unnumbered destination + state: merged + check_mode: true + register: cm_unnumbered_update + + - name: Update unnumbered link Eth1/30 (merged - normal mode) + cisco.nd.nd_manage_links: + <<: *unnumbered_update + register: nm_unnumbered_update + + - name: Update unnumbered link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *unnumbered_update + register: nm_unnumbered_update_again + + - name: Asserts for unnumbered update + ansible.builtin.assert: + that: + - cm_unnumbered_update is changed + - cm_unnumbered_update.proposed.0.config_data.template_inputs.mtu == 1500 + + - nm_unnumbered_update is changed + - (nm_unnumbered_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.mtu == 1500 + - (nm_unnumbered_update.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.src_interface_description == "updated unnumbered source" + + - nm_unnumbered_update_again is not changed + + - name: Replace unnumbered link Eth1/30 (replaced - check mode) + cisco.nd.nd_manage_links: &unnumbered_replace + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + interface_admin_state: true + state: replaced + check_mode: true + register: cm_unnumbered_replace + + - name: Replace unnumbered link Eth1/30 (replaced - normal mode) + cisco.nd.nd_manage_links: + <<: *unnumbered_replace + register: nm_unnumbered_replace + + - name: Replace unnumbered link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *unnumbered_replace + register: nm_unnumbered_replace_again + + - name: Asserts for unnumbered replace + ansible.builtin.assert: + that: + - cm_unnumbered_replace is changed + - nm_unnumbered_replace is changed + - (nm_unnumbered_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | first).config_data.template_inputs.mtu == 9216 + - (nm_unnumbered_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - (nm_unnumbered_replace.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + - nm_unnumbered_replace_again is not changed + + - name: Override down to a single unnumbered link (overridden - check mode) + cisco.nd.nd_manage_links: &unnumbered_override + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + interface_admin_state: true + state: overridden + check_mode: true + register: cm_unnumbered_override + + - name: Override down to a single unnumbered link (overridden - normal mode) + cisco.nd.nd_manage_links: + <<: *unnumbered_override + register: nm_unnumbered_override + + - name: Override down to a single unnumbered link again (idempotency) + cisco.nd.nd_manage_links: + <<: *unnumbered_override + register: nm_unnumbered_override_again + + - name: Asserts for unnumbered override + ansible.builtin.assert: + that: + - cm_unnumbered_override is changed + - nm_unnumbered_override is changed + - (nm_unnumbered_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 + - (nm_unnumbered_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - (nm_unnumbered_override.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 0 + - nm_unnumbered_override_again is not changed + + - name: Re-create Eth1/31 and Eth1/32 for delete coverage + cisco.nd.nd_manage_links: + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + interface_admin_state: true + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + config_data: + policy_type: unnumbered + template_inputs: + mtu: 9216 + interface_admin_state: true + state: merged + + - name: Delete unnumbered link Eth1/30 (deleted - check mode) + cisco.nd.nd_manage_links: &unnumbered_delete_one + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/30 + dst_interface_name: Ethernet1/30 + state: deleted + check_mode: true + register: cm_unnumbered_delete + + - name: Delete unnumbered link Eth1/30 (deleted - normal mode) + cisco.nd.nd_manage_links: + <<: *unnumbered_delete_one + register: nm_unnumbered_delete + + - name: Delete unnumbered link Eth1/30 again (idempotency) + cisco.nd.nd_manage_links: + <<: *unnumbered_delete_one + register: nm_unnumbered_delete_again + + - name: Bulk delete remaining unnumbered links + cisco.nd.nd_manage_links: &unnumbered_bulk_delete + config: + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/31 + dst_interface_name: Ethernet1/31 + - src_fabric_name: '{{ test_fabric_unnumbered }}' + dst_fabric_name: '{{ test_fabric_unnumbered }}' + src_switch_name: '{{ test_switch_a }}' + dst_switch_name: '{{ test_switch_b }}' + src_interface_name: Ethernet1/32 + dst_interface_name: Ethernet1/32 + state: deleted + register: nm_unnumbered_bulk_delete + + - name: Bulk delete remaining unnumbered links again (idempotency) + cisco.nd.nd_manage_links: + <<: *unnumbered_bulk_delete + register: nm_unnumbered_bulk_delete_again + + - name: Asserts for unnumbered delete + ansible.builtin.assert: + that: + - cm_unnumbered_delete is changed + - nm_unnumbered_delete is changed + - (nm_unnumbered_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 0 + - nm_unnumbered_delete_again is not changed + + - nm_unnumbered_bulk_delete is changed + - (nm_unnumbered_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 0 + - (nm_unnumbered_bulk_delete.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 0 + - nm_unnumbered_bulk_delete_again is not changed + + - name: Final cleanup of unnumbered links + cisco.nd.nd_manage_links: + <<: *unnumbered_cleanup diff --git a/tests/unit/module_utils/mock_ansible_module.py b/tests/unit/module_utils/mock_ansible_module.py index bbba7e4ab..8ade33a90 100644 --- a/tests/unit/module_utils/mock_ansible_module.py +++ b/tests/unit/module_utils/mock_ansible_module.py @@ -65,6 +65,9 @@ class MockAnsibleModule: "check_mode": False, } supports_check_mode = True + # Real AnsibleModule initializes this in __init__; mirror it so code that + # registers secret values for no_log masking works under test. + no_log_values = set() def __init__(self) -> None: self.warnings: list[str] = [] diff --git a/tests/unit/module_utils/models/test_base_secrets.py b/tests/unit/module_utils/models/test_base_secrets.py new file mode 100644 index 000000000..927c3f28a --- /dev/null +++ b/tests/unit/module_utils/models/test_base_secrets.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Shreyas Srish (@shrsr) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for the generic (base-model) secret handling: a single +``json_schema_extra={"secret": True}`` tag drives both no_log value collection +and output/diff stripping, while the value is kept in the controller payload. +""" + +from __future__ import annotations + +from typing import ClassVar + +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 _DemoModel(NDBaseModel): + identifiers: ClassVar[list[str] | None] = ["name"] + identifier_strategy: ClassVar[str] = "single" + name: str | None = None + password: str | None = Field(default=None, json_schema_extra={"secret": True}, alias="pwd") + + +class TestBaseSecretHandling: + def test_secret_field_keys(self): + assert _DemoModel.secret_field_keys(by_alias=False) == {"password"} + assert _DemoModel.secret_field_keys(by_alias=True) == {"pwd"} + + def test_masked_in_config_absent_from_diff(self): + model = _DemoModel(name="u1", pwd="p@ss") + # Config keeps the key but masks the value (never the real secret). + assert model.to_config()["password"] == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + # Diff still excludes the secret entirely, so a secret-only change is not detected. + assert "pwd" not in model.to_diff_dict() + + def test_kept_in_payload(self): + model = _DemoModel(name="u1", pwd="p@ss") + assert model.to_payload().get("pwd") == "p@ss" + + def test_collect_secret_values_from_raw_config(self): + assert _DemoModel.collect_secret_values({"name": "u1", "password": "p@ss"}) == {"p@ss"} + assert _DemoModel.collect_secret_values({"name": "u1"}) == set() diff --git a/tests/unit/module_utils/models/test_links.py b/tests/unit/module_utils/models/test_links.py new file mode 100644 index 000000000..fb027ece6 --- /dev/null +++ b/tests/unit/module_utils/models/test_links.py @@ -0,0 +1,776 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for the nd_manage_links Pydantic models. + +Covers the discriminated-union template resolution, secret-field masking in +output/diff, policy-type-change rejection, and the argument spec contract. +""" + +from __future__ import annotations + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.models.links.links import ( + LinkConfigDataModel, + NDLinkModel, +) +from pydantic import ValidationError + + +def _link(policy_type, template_inputs): + """Build a minimal valid NDLinkModel for the given policy.""" + return NDLinkModel( + srcFabricName="fab1", + dstFabricName="fab1", + srcSwitchName="leaf-1", + dstSwitchName="spine-1", + srcInterfaceName="Ethernet1/1", + dstInterfaceName="Ethernet1/1", + configData={"policyType": policy_type, "templateInputs": template_inputs}, + ) + + +# --------------------------------------------------------------------------- +# Discriminated union +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "policy_type,expected_cls", + [ + ("numbered", "NumberedTemplateInputs"), + ("unnumbered", "UnnumberedTemplateInputs"), + ("ipv6LinkLocal", "Ipv6LinkLocalTemplateInputs"), + ("ebgpVrfLite", "EbgpVrfLiteTemplateInputs"), + ("layer2Dci", "Layer2DciTemplateInputs"), + ("layer3DciVrfLite", "Layer3DciVrfLiteTemplateInputs"), + ("multisiteOverlay", "MultisiteOverlayTemplateInputs"), + ("multisiteUnderlay", "MultisiteUnderlayTemplateInputs"), + ("mplsOverlay", "MplsOverlayTemplateInputs"), + ("mplsUnderlay", "MplsUnderlayTemplateInputs"), + ("preprovision", "PreprovisionTemplateInputs"), + ("userDefined", "UserDefinedTemplateInputs"), + ("vpcPeerKeepalive", "VpcPeerKeepaliveTemplateInputs"), + ], +) +def test_policy_type_resolves_to_template_class(policy_type, expected_cls): + """Each policy_type selects the matching template_inputs subclass.""" + link = _link(policy_type, {}) + assert type(link.config_data.template_inputs).__name__ == expected_cls + + +def test_wrong_field_for_policy_is_rejected(): + """A field that does not belong to the policy is rejected (extra=forbid).""" + with pytest.raises(ValidationError): + _link("numbered", {"ebgpMultihop": 5}) # ebgpMultihop belongs to multisiteOverlay + + +def test_user_defined_allows_extra_fields(): + """userDefined is an open shape and accepts unknown keys.""" + link = _link("userDefined", {"someVendorField": "x", "allowedVlans": "10,20"}) + assert type(link.config_data.template_inputs).__name__ == "UserDefinedTemplateInputs" + + +def _user_defined_config(template_name=None): + config = { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "a", + "dst_switch_name": "b", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": "userDefined", "template_inputs": {"custom_setting": "x"}}, + } + if template_name is not None: + config["config_data"]["template_name"] = template_name + return config + + +def test_user_defined_requires_template_name_on_write(): + """On a write state, userDefined without template_name is rejected locally + (ND schema requires it) rather than failing later at the controller.""" + with pytest.raises(ValidationError): + NDLinkModel.from_config(_user_defined_config(), context={"state": "merged"}) + + +def test_user_defined_accepts_template_name_on_write(): + """userDefined with template_name validates on a write state.""" + link = NDLinkModel.from_config(_user_defined_config(template_name="custom_tmpl"), context={"state": "merged"}) + assert link.config_data.template_name == "custom_tmpl" + + +def test_user_defined_missing_template_name_tolerated_on_read(): + """A controller read (no state context) is not blocked if template_name is absent, + so query_all never aborts on an odd userDefined response.""" + link = NDLinkModel.from_config(_user_defined_config()) + assert type(link.config_data.template_inputs).__name__ == "UserDefinedTemplateInputs" + + +def test_policy_marker_injection_does_not_mutate_input(): + """The internal policy_type_marker is not written back into the caller's dict + (which is module.params), so it never leaks into the invocation echo.""" + config_item = { + "src_fabric_name": "f1", + "dst_fabric_name": "f2", + "src_switch_name": "a", + "dst_switch_name": "b", + "src_interface_name": "Ethernet1/30", + "dst_interface_name": "Ethernet1/30", + "config_data": {"policy_type": "numbered", "template_inputs": {"src_ip": "10.0.0.1"}}, + } + NDLinkModel.from_config(config_item) + assert "policy_type_marker" not in config_item["config_data"]["template_inputs"] + + +def test_caller_supplied_marker_cannot_override_policy_type(): + """policy_type is the sole schema authority; a caller-injected marker is ignored, + so an invalid field for the real policy is still rejected.""" + config_item = { + "src_fabric_name": "f1", + "dst_fabric_name": "f2", + "src_switch_name": "a", + "dst_switch_name": "b", + "src_interface_name": "Ethernet1/30", + "dst_interface_name": "Ethernet1/30", + "config_data": { + "policy_type": "numbered", + "template_inputs": {"policy_type_marker": "userDefined", "unexpected": "value"}, + }, + } + with pytest.raises(ValidationError): + NDLinkModel.from_config(config_item) + + +@pytest.mark.parametrize("template_inputs_key", ["template_inputs", "templateInputs"]) +def test_empty_template_inputs_consistent_across_spellings(template_inputs_key): + """An explicit empty template_inputs resolves the same under either spelling + (the snake_case empty dict used to fall through and fail to inject the marker).""" + link = NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f2", + "src_switch_name": "a", + "dst_switch_name": "b", + "src_interface_name": "Ethernet1/30", + "dst_interface_name": "Ethernet1/30", + "config_data": {"policy_type": "numbered", template_inputs_key: {}}, + } + ) + assert type(link.config_data.template_inputs).__name__ == "NumberedTemplateInputs" + + +# --------------------------------------------------------------------------- +# Secret handling +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "policy_type,template_inputs,secret_alias,secret_pyname", + [ + ("ebgpVrfLite", {"defaultVrfEbgpNeighborPassword": "S1"}, "defaultVrfEbgpNeighborPassword", "default_vrf_ebgp_neighbor_password"), + ("ebgpVrfLite", {"macsecPrimaryKeyString": "S2"}, "macsecPrimaryKeyString", "macsec_primary_key_string"), + ("layer2Dci", {"macsecFallbackKeyString": "S3"}, "macsecFallbackKeyString", "macsec_fallback_key_string"), + ("multisiteUnderlay", {"ebgpPassword": "S4"}, "ebgpPassword", "ebgp_password"), + ], +) +def test_secret_in_payload_masked_in_config_absent_from_diff(policy_type, template_inputs, secret_alias, secret_pyname): + """Secrets reach the controller payload, are masked in output, and excluded from diff.""" + link = _link(policy_type, template_inputs) + payload_ti = link.to_payload()["configData"]["templateInputs"] + config_ti = link.to_config()["config_data"]["template_inputs"] + diff_ti = link.to_diff_dict()["configData"]["templateInputs"] + assert payload_ti.get(secret_alias) == list(template_inputs.values())[0] + assert config_ti[secret_pyname] == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + assert secret_alias not in diff_ti + + +def test_non_secret_fields_survive_in_config(): + """Non-secret template fields stay visible; secrets are masked, not dropped.""" + link = _link("ebgpVrfLite", {"linkMtu": 9216, "defaultVrfEbgpNeighborPassword": "secret"}) + config_ti = link.to_config()["config_data"]["template_inputs"] + assert config_ti.get("link_mtu") == 9216 + assert config_ti["default_vrf_ebgp_neighbor_password"] == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + + +def test_minimal_numbered_payload_sends_documented_defaults(): + """A minimal numbered create sends ND's documented defaults for defaulted fields + (mtu 9216, admin_state true, fec auto, speed auto) instead of schema-violating + typed empties, while keeping every other known field present (ND's template + references each key, so a missing key fails template execution).""" + link = _link("numbered", {"srcIp": "10.99.30.1", "dstIp": "10.99.30.2"}) + ti = link.to_payload()["configData"]["templateInputs"] + # documented defaults, not typed empties + assert ti["mtu"] == 9216 + assert ti["interfaceAdminState"] is True + assert ti["fec"] == "auto" + assert ti["speed"] == "auto" + assert ti["macsec"] is False + # user-supplied values preserved + assert ti["srcIp"] == "10.99.30.1" + assert ti["dstIp"] == "10.99.30.2" + # no-default fields stay present as typed empties so ND's template has the keys + assert ti["srcIpv6"] == "" + assert ti["srcInterfaceDescription"] == "" + + +def test_unset_secret_sent_empty_but_excluded_from_diff(): + """An unset secret is sent as an empty key (ND's template requires the key to be + present) but is excluded from the diff, so a secret-only change never triggers an + update. Documented defaults still flow for the other fields.""" + link = _link("multisiteUnderlay", {"srcEbgpAsn": "1", "dstEbgpAsn": "2"}) + payload_ti = link.to_payload()["configData"]["templateInputs"] + diff_ti = link.to_diff_dict()["configData"]["templateInputs"] + assert payload_ti["ebgpPassword"] == "" + assert "ebgpPassword" not in diff_ti + assert payload_ti["ebgpMaximumPaths"] == 64 + assert payload_ti["enableEbgpPassword"] is True + + +def test_collect_secret_values_finds_free_form_template_input_secrets(): + """collect_secret_values surfaces free-form template_inputs secrets for no_log.""" + config_item = { + "src_interface_name": "Ethernet1/30", + "config_data": { + "policy_type": "ebgpVrfLite", + "template_inputs": {"ebgp_password": "S3cret!", "link_mtu": 9216}, + }, + } + assert NDLinkModel.collect_secret_values(config_item) == {"S3cret!"} + + +def test_collect_secret_values_empty_when_no_secrets(): + """No secrets (e.g. gathered/empty config) yields an empty set, no error.""" + assert NDLinkModel.collect_secret_values({}) == set() + assert NDLinkModel.collect_secret_values({"config_data": {"template_inputs": {"link_mtu": 9216}}}) == set() + + +# --------------------------------------------------------------------------- +# Unsupported policy tolerance (read fallback) +# --------------------------------------------------------------------------- + + +def _response_link(policy_type, template_inputs, link_id="LINK-UUID-1", iface="Ethernet1/1"): + return { + "srcClusterName": "c1", + "dstClusterName": "c1", + "srcFabricName": "f1", + "dstFabricName": "f1", + "srcSwitchName": "a", + "dstSwitchName": "b", + "srcInterfaceName": iface, + "dstInterfaceName": iface, + "linkId": link_id, + "configData": {"policyType": policy_type, "templateInputs": template_inputs}, + } + + +def test_iosxe_numbered_is_supported(): + """iosXeNumbered is first-class (Campus requirement), not a fallback.""" + link = NDLinkModel.from_response(_response_link("iosXeNumbered", {"srcIp": "1.1.1.1", "dstIp": "1.1.1.2", "mtu": 9198})) + assert type(link.config_data.template_inputs).__name__ == "IosXeNumberedTemplateInputs" + assert link.is_unsupported_policy is False + + +def test_unsupported_policy_read_does_not_raise_and_is_flagged(): + """A valid but unmodeled controller policy is preserved as an opaque record.""" + link = NDLinkModel.from_response(_response_link("ipfmNumbered", {"srcIp": "9.9.9.9", "interfaceVrf": "default", "mtu": 1500})) + assert link.is_unsupported_policy is True + assert link.config_data.policy_type == "ipfmNumbered" + raw = link.config_data.template_inputs.model_dump(by_alias=True) + assert raw.get("srcIp") == "9.9.9.9" + assert raw.get("interfaceVrf") == "default" + + +def test_supported_policy_with_unknown_field_stays_mutable_on_read(): + """A supported policy carrying an extra response field must resolve to its real + policy model (mutable), NOT fall back to the opaque unsupported record. The + unknown key is dropped on read so extra=forbid does not misclassify the link + (mikewiebe finding: a valid preprovision link returning mtu/speed became immutable).""" + link = NDLinkModel.from_response(_response_link("numbered", {"srcIp": "1.1.1.1", "someBrandNewField": "x"})) + assert link.is_unsupported_policy is False + assert link.config_data.policy_type == "numbered" + assert type(link.config_data.template_inputs).__name__ == "NumberedTemplateInputs" + # the unknown key is dropped, the known one is kept + dumped = link.config_data.template_inputs.model_dump(by_alias=True) + assert dumped.get("srcIp") == "1.1.1.1" + assert "someBrandNewField" not in dumped + + +def test_unsupported_policy_rejected_on_write(): + """User input for an unmodeled policy type is strictly rejected (not tolerated).""" + with pytest.raises(ValidationError): + NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "a", + "dst_switch_name": "b", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": "ipfmNumbered", "template_inputs": {"srcIp": "9.9.9.9"}}, + }, + context={"state": "merged"}, + ) + + +def test_describe_unsupported_policy_message(): + link = NDLinkModel.from_response(_response_link("routedFabric", {"srcEbgpAsn": "1"}, link_id="LINK-UUID-77")) + msg = link.describe_unsupported_policy() + assert "LINK-UUID-77" in msg and "routedFabric" in msg + + +def test_collection_read_tolerates_unsupported_alongside_supported(): + """A full-fabric read with one unsupported link still parses every link and + flags only the unsupported one (no abort).""" + from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection + + response = [ + _response_link("numbered", {"srcIp": "1.1.1.1", "dstIp": "1.1.1.2"}, link_id="L1", iface="Ethernet1/1"), + _response_link("ipfmNumbered", {"srcIp": "9.9.9.9"}, link_id="L2", iface="Ethernet1/2"), + ] + coll = NDConfigCollection.from_api_response(response_data=response, model_class=NDLinkModel) + items = list(coll) + assert len(items) == 2 + unsupported = [i for i in items if i.is_unsupported_policy] + assert [i.link_id for i in unsupported] == ["L2"] + + +# --------------------------------------------------------------------------- +# Orientation independence (intra-fabric physical links) +# --------------------------------------------------------------------------- + + +def _phys_link(src_sw, src_if, dst_sw, dst_if, template_inputs=None, src_fabric="f1", dst_fabric="f1"): + return NDLinkModel.from_response( + { + "srcClusterName": "c1", + "dstClusterName": "c1", + "srcFabricName": src_fabric, + "dstFabricName": dst_fabric, + "srcSwitchName": src_sw, + "dstSwitchName": dst_sw, + "srcInterfaceName": src_if, + "dstInterfaceName": dst_if, + "configData": {"policyType": "numbered", "templateInputs": template_inputs or {}}, + } + ) + + +def test_intra_fabric_identity_is_orientation_independent(): + """The same physical intra-fabric cable has one identity in either orientation.""" + fwd = _phys_link("LEAF1", "Ethernet1/49", "SPINE1", "Ethernet1/1") + rev = _phys_link("SPINE1", "Ethernet1/1", "LEAF1", "Ethernet1/49") + assert fwd.get_identifier_value() == rev.get_identifier_value() + + +def test_reversed_intra_fabric_link_compares_equal(): + """A reversed link with correspondingly swapped directional values -- including + the asymmetric DHCP relay / BFD echo per-interface toggles -- shows no diff, so + merged stays idempotent instead of trying to recreate/update it.""" + fwd = _phys_link( + "LEAF1", + "Ethernet1/49", + "SPINE1", + "Ethernet1/1", + { + "srcIp": "10.0.0.1", + "dstIp": "10.0.0.2", + "dhcpRelayOnSrcInterface": True, + "dhcpRelayOnDstInterface": False, + "bfdEchoOnSrcInterface": True, + "bfdEchoOnDstInterface": False, + }, + ) + rev = _phys_link( + "SPINE1", + "Ethernet1/1", + "LEAF1", + "Ethernet1/49", + { + "srcIp": "10.0.0.2", + "dstIp": "10.0.0.1", + "dhcpRelayOnSrcInterface": False, + "dhcpRelayOnDstInterface": True, + "bfdEchoOnSrcInterface": False, + "bfdEchoOnDstInterface": True, + }, + ) + assert fwd.to_diff_dict() == rev.to_diff_dict() + + +def test_reversed_link_with_unswapped_toggle_still_diffs(): + """The other failure mode: a reversed link whose directional toggle was NOT + correspondingly swapped is a genuine asymmetric change and must still diff, so + canonicalization never masks a real per-interface difference.""" + fwd = _phys_link( + "LEAF1", + "Ethernet1/49", + "SPINE1", + "Ethernet1/1", + {"srcIp": "10.0.0.1", "dstIp": "10.0.0.2", "dhcpRelayOnSrcInterface": True, "dhcpRelayOnDstInterface": False}, + ) + # Endpoints reversed and IPs swapped, but the DHCP toggle left on the src end: + # after canonicalization this leaves dhcp_relay on the wrong interface. + rev = _phys_link( + "SPINE1", + "Ethernet1/1", + "LEAF1", + "Ethernet1/49", + {"srcIp": "10.0.0.2", "dstIp": "10.0.0.1", "dhcpRelayOnSrcInterface": True, "dhcpRelayOnDstInterface": False}, + ) + assert fwd.to_diff_dict() != rev.to_diff_dict() + + +def test_payload_keeps_user_orientation(): + """Canonicalization is comparison-only; the payload keeps the user's orientation.""" + rev = _phys_link("SPINE1", "Ethernet1/1", "LEAF1", "Ethernet1/49", {"srcIp": "10.0.0.2", "dstIp": "10.0.0.1"}) + payload = rev.to_payload() + assert payload["srcSwitchName"] == "SPINE1" + assert payload["configData"]["templateInputs"]["srcIp"] == "10.0.0.2" + + +def test_inter_fabric_identity_is_orientation_independent(): + """A physical inter-fabric cable (distinct fabrics) has one identity in either + orientation, so a reversed declaration is matched, not duplicated/missed/recreated.""" + a = _phys_link("X", "Ethernet1/1", "Y", "Ethernet1/1", src_fabric="f1", dst_fabric="f2") + b = _phys_link("Y", "Ethernet1/1", "X", "Ethernet1/1", src_fabric="f2", dst_fabric="f1") + assert a.get_identifier_value() == b.get_identifier_value() + + +def _ebgp_vrf_lite_link(src_sw, src_fabric, dst_sw, dst_fabric, template_inputs): + return NDLinkModel.from_response( + { + "srcClusterName": "c1", + "dstClusterName": "c1", + "srcFabricName": src_fabric, + "dstFabricName": dst_fabric, + "srcSwitchName": src_sw, + "dstSwitchName": dst_sw, + "srcInterfaceName": "Ethernet1/1", + "dstInterfaceName": "Ethernet1/1", + "configData": {"policyType": "ebgpVrfLite", "templateInputs": template_inputs}, + } + ) + + +def test_reversed_inter_fabric_link_compares_equal(): + """A reversed inter-fabric (VRF-lite) cable with its symmetric directional fields + correspondingly swapped (srcEbgpAsn/dstEbgpAsn, per-interface descriptions) shows + no diff, so merged stays idempotent across fabrics.""" + fwd = _ebgp_vrf_lite_link( + "X", + "f1", + "Y", + "f2", + {"srcEbgpAsn": "100", "dstEbgpAsn": "200", "srcInterfaceDescription": "to-Y", "dstInterfaceDescription": "to-X"}, + ) + rev = _ebgp_vrf_lite_link( + "Y", + "f2", + "X", + "f1", + {"srcEbgpAsn": "200", "dstEbgpAsn": "100", "srcInterfaceDescription": "to-X", "dstInterfaceDescription": "to-Y"}, + ) + assert fwd.get_identifier_value() == rev.get_identifier_value() + assert fwd.to_diff_dict() == rev.to_diff_dict() + + +def test_directional_pairs_are_policy_specific(): + """The directional swap set is derived per policy from the model's own fields: + ebgpVrfLite pairs the symmetric srcEbgpAsn/dstEbgpAsn; numbered pairs srcIp/dstIp + and the dhcp/bfd per-interface toggles. The asymmetric srcIpAddressMask/dstIpAddress + pair is deliberately excluded (a value swap would move the mask to the wrong end).""" + ebgp = _ebgp_vrf_lite_link("X", "f1", "Y", "f2", {"srcEbgpAsn": "100", "dstEbgpAsn": "200"}) + pairs = set(ebgp._template_directional_pairs()) + assert ("srcEbgpAsn", "dstEbgpAsn") in pairs + assert ("srcIpAddressMask", "dstIpAddress") not in pairs # asymmetric, intentionally not reoriented + numbered = _phys_link("X", "Ethernet1/1", "Y", "Ethernet1/1", {"srcIp": "1.1.1.1", "dstIp": "1.1.1.2"}) + npairs = set(numbered._template_directional_pairs()) + assert ("srcIp", "dstIp") in npairs + assert ("dhcpRelayOnSrcInterface", "dhcpRelayOnDstInterface") in npairs + + +# --------------------------------------------------------------------------- +# Realized preprovision -> numbered lifecycle +# --------------------------------------------------------------------------- + + +def _proposed_preprovision(): + return NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "LEAF1", + "dst_switch_name": "SPINE1", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": "preprovision", "template_inputs": {"src_interface_description": "planned"}}, + }, + context={"state": "merged"}, + ) + + +def test_realized_preprovision_numbered_is_treated_as_unchanged(): + """Persistent intent: an ND-realized numbered link whose user-managed fields + already match the preprovision declaration is no diff, so the module is + idempotent and preserves ND-assigned addresses/numbered-only fields.""" + existing = _phys_link( + "LEAF1", + "Ethernet1/1", + "SPINE1", + "Ethernet1/1", + {"srcIp": "10.4.0.1", "dstIp": "10.4.0.2", "srcInterfaceDescription": "planned"}, + ) + assert existing.get_diff(_proposed_preprovision()) is True + + +def test_realized_preprovision_user_field_change_is_a_diff(): + """Persistent intent: after realization, a change to a user-managed field + (interface description) in the preprovision declaration is a real diff, so the + module updates it instead of silently ignoring the edit.""" + existing = _phys_link( + "LEAF1", + "Ethernet1/1", + "SPINE1", + "Ethernet1/1", + {"srcIp": "10.4.0.1", "dstIp": "10.4.0.2", "srcInterfaceDescription": "planned"}, + ) + assert existing.get_diff(_proposed_preprovision()) is True + # Change the declared description -> now a diff. + changed = NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "LEAF1", + "dst_switch_name": "SPINE1", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": "preprovision", "template_inputs": {"src_interface_description": "updated"}}, + }, + context={"state": "merged"}, + ) + assert existing.get_diff(changed) is False # False == has a diff + + +def test_realized_preprovision_merge_stays_numbered_and_applies_user_field(): + """Persistent intent: merging the changed preprovision declaration onto the + realized link keeps it numbered (a valid PUT, not a rejected policy change), + preserves ND-assigned addresses, and applies the new interface description.""" + existing = _phys_link( + "LEAF1", + "Ethernet1/1", + "SPINE1", + "Ethernet1/1", + {"srcIp": "10.4.0.1", "dstIp": "10.4.0.2", "srcInterfaceDescription": "planned"}, + ) + changed = NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "LEAF1", + "dst_switch_name": "SPINE1", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": "preprovision", "template_inputs": {"src_interface_description": "updated"}}, + }, + context={"state": "merged"}, + ) + merged = existing.merge(changed) + assert merged.config_data.policy_type == "numbered" # stays numbered (no policy change) + assert merged.config_data.template_inputs.src_interface_description == "updated" # user field applied + assert merged.config_data.template_inputs.src_ip == "10.4.0.1" # ND-assigned address preserved + + +def test_non_realized_policy_difference_still_diffs(): + """A genuine policy difference (numbered vs unnumbered) is still a change.""" + existing = _phys_link("LEAF1", "Ethernet1/1", "SPINE1", "Ethernet1/1", {"srcIp": "10.4.0.1", "dstIp": "10.4.0.2"}) + proposed_unnumbered = NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "LEAF1", + "dst_switch_name": "SPINE1", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": "unnumbered", "template_inputs": {}}, + }, + context={"state": "merged"}, + ) + assert existing.get_diff(proposed_unnumbered) is False + + +# --------------------------------------------------------------------------- +# Policy-type-change rejection +# --------------------------------------------------------------------------- + + +def test_merge_rejects_policy_type_change(): + """Merging configs with a different policy_type is rejected.""" + existing = LinkConfigDataModel(policyType="numbered", templateInputs={}) + proposed = LinkConfigDataModel(policyType="unnumbered", templateInputs={}) + with pytest.raises(Exception): + existing.merge(proposed) + + +def test_merge_allows_same_policy_type(): + """Merging configs with the same policy_type is allowed.""" + existing = LinkConfigDataModel(policyType="numbered", templateInputs={"srcIp": "1.1.1.1"}) + proposed = LinkConfigDataModel(policyType="numbered", templateInputs={"dstIp": "1.1.1.2"}) + merged = existing.merge(proposed) + assert merged.policy_type == "numbered" + + +# --------------------------------------------------------------------------- +# Argument spec contract +# --------------------------------------------------------------------------- + + +def test_argument_spec_state_choices_include_gathered(): + spec = NDLinkModel.get_argument_spec() + assert spec["state"]["choices"] == ["merged", "replaced", "overridden", "deleted", "gathered"] + + +def test_argument_spec_config_not_required(): + """config is optional so state=gathered needs no config.""" + spec = NDLinkModel.get_argument_spec() + assert spec["config"].get("required", False) is False + + +def test_argument_spec_template_inputs_not_blanket_no_log(): + """template_inputs is not blanket no_log; secret masking is done at the model + layer so non-secret fields stay visible in output.""" + spec = NDLinkModel.get_argument_spec() + template_inputs = spec["config"]["options"]["config_data"]["options"]["template_inputs"] + assert template_inputs.get("no_log") is not True + + +def test_argument_spec_omits_read_only_link_type(): + """link_type is a read-only response field (ND schema: readOnly, absent from + linkPost) so it is not a settable option.""" + spec = NDLinkModel.get_argument_spec() + assert "link_type" not in spec["config"]["options"] + + +def test_argument_spec_requires_identity_fields(): + """Fabric and interface names on both ends are required within each config item + (mandatory identity), while top-level config stays optional so gathered runs.""" + spec = NDLinkModel.get_argument_spec() + options = spec["config"]["options"] + for name in ("src_fabric_name", "dst_fabric_name", "src_interface_name", "dst_interface_name"): + assert options[name].get("required") is True, name + assert spec["config"].get("required") is not True # top-level config optional (gathered) + + +# --------------------------------------------------------------------------- +# Write-contract enforcement (OpenAPI): strict on write, tolerant on read +# --------------------------------------------------------------------------- + + +def _write_config(policy_type, template_inputs, state="merged"): + return NDLinkModel.from_config( + { + "src_fabric_name": "f1", + "dst_fabric_name": "f1", + "src_switch_name": "a", + "dst_switch_name": "b", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": policy_type, "template_inputs": template_inputs}, + }, + context={"state": state}, + ) + + +def test_out_of_range_mtu_rejected_on_write(): + """mtu below the OpenAPI minimum fails at the Ansible boundary, before check mode + proposes a change or any request is sent (mikewiebe: mtu 0 reached ND and blocked delete).""" + with pytest.raises(ValidationError): + _write_config("numbered", {"mtu": 0}) + + +def test_invalid_speed_enum_rejected_on_write(): + """An invalid speed enum fails on write instead of only at the controller.""" + with pytest.raises(ValidationError): + _write_config("numbered", {"speed": "invalid-speed"}) + + +def test_description_over_max_length_rejected_on_write(): + """Interface description longer than 254 chars fails on write.""" + with pytest.raises(ValidationError): + _write_config("numbered", {"srcInterfaceDescription": "x" * 255}) + + +def test_required_asn_missing_rejected_on_write(): + """ebgpVrfLite requires srcEbgpAsn/dstEbgpAsn (no default), so omitting them fails on write.""" + with pytest.raises(ValidationError): + _write_config("ebgpVrfLite", {}) + + +def test_required_asn_present_ok_on_write(): + """ebgpVrfLite with the required ASNs validates on write.""" + link = _write_config("ebgpVrfLite", {"srcEbgpAsn": "65001", "dstEbgpAsn": "65002"}) + assert link.config_data.template_inputs.src_ebgp_asn == "65001" + + +def test_required_field_with_default_not_forced_on_write(): + """mtu is required by the spec but carries a documented default, so the module + supplies it and the user is not forced to provide it (no false rejection).""" + link = _write_config("numbered", {"speed": "auto"}) + assert link.config_data.policy_type == "numbered" + + +def test_out_of_range_and_bad_enum_tolerated_on_read(): + """Controller reads stay tolerant: an already-invalid ND record (mtu 0, unknown + speed) is still gathered so it can be inspected and repaired/deleted.""" + link = NDLinkModel.from_response(_response_link("numbered", {"mtu": 0, "speed": "weird"})) + assert link.is_unsupported_policy is False + assert link.config_data.template_inputs.mtu == 0 + assert link.config_data.template_inputs.speed == "weird" + + +def test_multisite_overlay_accepts_macsec_fields_on_write(): + """multisiteOverlay now models the macsec/qkd fields (previously missing), so a + write that sets them validates instead of being rejected as unknown.""" + link = _write_config( + "multisiteOverlay", + { + "srcEbgpAsn": "65001", + "dstEbgpAsn": "65002", + "srcIpAddress": "10.0.0.1", + "dstIpAddress": "10.0.0.2", + "macsec": True, + "macsecPrimaryKeyString": "s3cret", + }, + ) + assert type(link.config_data.template_inputs).__name__ == "MultisiteOverlayTemplateInputs" + assert link.config_data.template_inputs.macsec is True + + +def test_layer2dci_ttag_field_removed(): + """inheritTtagFabricSetting is not in the OpenAPI layer2DciConfig schema, so it is + no longer a layer2Dci field and is rejected as unknown on write.""" + with pytest.raises(ValidationError): + _write_config("layer2Dci", {"inheritTtagFabricSetting": True}) + + +def test_link_type_still_read_from_response(): + """link_type remains a model field populated on read (e.g. gathered/before/after).""" + link = NDLinkModel.from_response( + { + "srcFabricName": "f1", + "dstFabricName": "f1", + "srcSwitchName": "a", + "dstSwitchName": "b", + "srcInterfaceName": "Ethernet1/1", + "dstInterfaceName": "Ethernet1/1", + "linkType": "lan_planned_link", + "configData": {"policyType": "numbered", "templateInputs": {}}, + } + ) + assert link.link_type == "lan_planned_link" + # ...but it is never sent back to the controller. + assert "linkType" not in link.to_payload() diff --git a/tests/unit/module_utils/orchestrators/test_links.py b/tests/unit/module_utils/orchestrators/test_links.py new file mode 100644 index 000000000..b1453d464 --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_links.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems, Inc. + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for the nd_manage_links strategies and orchestrator helpers. + +Covers strategy-driven endpoint query-param population with URL encoding for +both link scopes and the bulk per-item failure guard. +""" + +from __future__ import annotations + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.models.links.links import NDLinkModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.links import NDLinkOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.manage_link import ManageLinkStrategy +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.one_manage_link import OneManageLinkStrategy + +# --------------------------------------------------------------------------- +# Strategy -> endpoint path building (URL encoded) +# --------------------------------------------------------------------------- + + +def test_manage_read_path_encodes_params(): + strategy = ManageLinkStrategy(fabric_name="fab a", cluster_name="cl&1", ticket_id="CHG 9") + endpoint = strategy.links_get_cls() + strategy.configure_read(endpoint) + path = endpoint.path + assert path.startswith("/api/v1/manage/links?") + assert "fabricName=fab%20a" in path + assert "clusterName=cl%261" in path + assert "ticketId=CHG%209" in path + assert " " not in path and "&cl&1" not in path # nothing left raw + + +def test_manage_mutation_paths_encode_cluster_and_ticket(): + strategy = ManageLinkStrategy(fabric_name="fab1", cluster_name="c x", ticket_id="t&1") + post = strategy.links_post_cls() + strategy.configure_mutation(post) + assert "clusterName=c%20x" in post.path and "ticketId=t%261" in post.path + + remove = strategy.link_actions_remove_post_cls() + strategy.configure_mutation(remove) + assert post.path.split("?")[0].endswith("/links") + assert remove.path.split("?")[0].endswith("/linkActions/remove") + + +def test_manage_put_path_includes_link_id_before_query(): + strategy = ManageLinkStrategy(fabric_name="fab1", ticket_id="t1") + put = strategy.link_put_cls() + put.link_uuid = "LINK-123" + strategy.configure_mutation(put) + assert put.path.startswith("/api/v1/manage/links/LINK-123?") + assert "ticketId=t1" in put.path + + +def test_one_manage_read_path_encodes_cluster_filters(): + strategy = OneManageLinkStrategy(fabric_name="fab1", ticket_id="CHG&7") + endpoint = strategy.links_get_cls() + strategy.configure_read(endpoint, src_cluster_name="c+a", dst_cluster_name="c b") + path = endpoint.path + assert "fabricName=fab1" in path + assert "srcClusterName=c%2Ba" in path + assert "dstClusterName=c%20b" in path + + +def test_one_manage_mutation_uses_ticket_id_only(): + strategy = OneManageLinkStrategy(fabric_name="fab1", cluster_name="ignored", ticket_id="CHG&7") + post = strategy.links_post_cls() + strategy.configure_mutation(post) + assert "ticketId=CHG%267" in post.path + assert "clusterName" not in post.path # cluster identity is in the payload for one_manage + + +def test_empty_params_produce_bare_path(): + strategy = ManageLinkStrategy(fabric_name=None) + endpoint = strategy.links_post_cls() + strategy.configure_mutation(endpoint) + assert endpoint.path == "/api/v1/manage/links" # no trailing '?' + + +# --------------------------------------------------------------------------- +# Bulk per-item failure guard +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "response,should_raise", + [ + ({"links": [{"linkId": "a", "status": "success"}]}, False), + ({"links": [{"linkId": "a"}]}, False), + ([], False), + ("not-a-dict", False), + ({"links": [{"linkId": "a", "status": "failure"}]}, True), + ({"items": [{"id": "b", "success": False}]}, True), + ({"results": [{"uuid": "c", "error": "boom"}]}, True), + ({"failed": [{"linkId": "d", "message": "nope"}]}, True), + ({"links": [{"linkId": "a", "status": "success"}, {"linkId": "e", "status": "failed"}]}, True), + ], +) +def test_raise_on_bulk_failures(response, should_raise): + if should_raise: + with pytest.raises(Exception): + NDLinkOrchestrator._raise_on_bulk_failures(response, "create") + else: + NDLinkOrchestrator._raise_on_bulk_failures(response, "create") + + +def test_bulk_failure_message_includes_id_and_reason(): + with pytest.raises(Exception) as exc: + NDLinkOrchestrator._raise_on_bulk_failures({"links": [{"linkId": "L9", "status": "failure", "message": "bad mtu"}]}, "create") + text = str(exc.value) + assert "L9" in text and "bad mtu" in text + + +# --------------------------------------------------------------------------- +# prepare_config_data must not mutate the caller's config (module.params), +# or resolved switch_name/switch_id leak into the invocation echo. +# --------------------------------------------------------------------------- + + +class _FakeSwitch: + def __init__(self, switch_id, hostname): + self.switch_id = switch_id + self.hostname = hostname + + +class _FakeIndex: + def __init__(self, by_ip_map): + self._by_ip = by_ip_map + + def by_ip(self): + return self._by_ip + + def by_id(self): + return {} + + +def _orchestrator(): + from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend + + return NDLinkOrchestrator(rest_send=RestSend({"check_mode": False}), strategy=ManageLinkStrategy(fabric_name="f")) + + +def test_prepare_config_data_backfills_on_copy_not_input(): + orch = _orchestrator() + # Pre-seed the fabric index cache so no controller call is made. + orch._switch_index_by_fabric["f"] = _FakeIndex({"1.1.1.1": _FakeSwitch("SID1", "host1")}) + raw = [{"src_fabric_name": "f", "src_switch_ip": "1.1.1.1", "dst_fabric_name": "f", "dst_switch_ip": "1.1.1.1"}] + + result = orch.prepare_config_data(raw) + + # Input (module.params) is untouched, nothing leaks into the invocation echo. + assert "src_switch_name" not in raw[0] + assert "src_switch_id" not in raw[0] + # The returned copy carries the resolved identity used to build the proposed collection. + assert result is not raw + assert result[0]["src_switch_name"] == "host1" + assert result[0]["src_switch_id"] == "SID1" + + +# --------------------------------------------------------------------------- +# preflight: policy-transition validation runs in both check and normal mode +# --------------------------------------------------------------------------- + + +def _link_model(policy_type, template_inputs=None): + return NDLinkModel.from_config( + { + "src_fabric_name": "f", + "dst_fabric_name": "f", + "src_switch_name": "leaf1", + "dst_switch_name": "spine1", + "src_interface_name": "Ethernet1/1", + "dst_interface_name": "Ethernet1/1", + "config_data": {"policy_type": policy_type, "template_inputs": template_inputs or {}}, + }, + context={"state": "merged"}, + ) + + +def test_preflight_rejects_policy_transition(): + """A cross-policy update (numbered -> unnumbered) is rejected in preflight, which + the state machine runs in BOTH modes, so check mode cannot approve a change that + normal mode rejects.""" + orch = _orchestrator() + proposed = _link_model("unnumbered") + object.__setattr__(orch, "_existing_by_key", {proposed.get_identifier_value(): "numbered"}) + with pytest.raises(Exception, match="Cannot change policy_type"): + orch.preflight([proposed]) + + +def test_preflight_allows_realized_preprovision(): + """Reapplying a preprovision declaration over an ND-realized numbered link is + persistent intent, not a policy change, so preflight lets it through.""" + orch = _orchestrator() + proposed = _link_model("preprovision", {"src_interface_description": "planned"}) + object.__setattr__(orch, "_existing_by_key", {proposed.get_identifier_value(): "numbered"}) + orch.preflight([proposed]) # must not raise + + +def test_is_policy_type_change_false_for_realized_preprovision(): + """The realized preprovision->numbered case is explicitly not treated as a + policy change by the guard.""" + orch = _orchestrator() + proposed = _link_model("preprovision") + object.__setattr__(orch, "_existing_by_key", {proposed.get_identifier_value(): "numbered"}) + assert orch._is_policy_type_change(proposed) is False diff --git a/tests/unit/module_utils/test_nd_output.py b/tests/unit/module_utils/test_nd_output.py index 1af946a58..c62f48d97 100644 --- a/tests/unit/module_utils/test_nd_output.py +++ b/tests/unit/module_utils/test_nd_output.py @@ -215,6 +215,92 @@ def response(policy): assert output.format()["changed"] is True +class TestNDOutputGatheredState: + """Tests for the read-only gathered state output convention.""" + + def test_gathered_uses_gathered_key(self): + """state='gathered' surfaces fetched objects under a 'gathered' key.""" + output = NDOutput("normal", state="gathered") + output._after = [{"name": "link1"}] + result = output.format() + assert result["gathered"] == [{"name": "link1"}] + + def test_gathered_omits_change_oriented_keys(self): + """state='gathered' omits before/after/diff/proposed.""" + output = NDOutput("info", state="gathered") + result = output.format() + for key in ("after", "before", "diff", "proposed"): + assert key not in result + + def test_gathered_is_not_changed(self): + """gathered is read-only, so changed is always False.""" + output = NDOutput("normal", state="gathered") + result = output.format() + assert result["changed"] is False + + def test_gathered_debug_includes_logs(self): + """Debug output_level still surfaces logs under gathered.""" + output = NDOutput("debug", state="gathered") + output.assign(logs=["l1"]) + result = output.format() + assert result["logs"] == ["l1"] + + def test_non_gathered_state_keeps_classic_shape(self): + """Non-gathered states retain the before/after/diff shape and no gathered key.""" + output = NDOutput("normal", state="merged") + result = output.format() + assert "gathered" not in result + assert "after" in result and "before" in result and "diff" in result + + def test_gathered_prunes_to_argument_spec(self): + """gathered_spec drops response-only keys so output round-trips as config.""" + output = NDOutput("normal", state="gathered") + output._after = [{"src_switch_name": "leaf1", "link_id": "UUID-123"}] + output.assign(gathered_spec={"src_switch_name": {"type": "str"}}) + result = output.format() + assert result["gathered"] == [{"src_switch_name": "leaf1"}] + + def test_gathered_prunes_nested_but_keeps_free_form(self): + """Pruning recurses into modeled dicts but leaves free-form dicts intact.""" + output = NDOutput("normal", state="gathered") + output._after = [ + { + "src_switch_name": "leaf1", + "config_data": { + "policy_type": "numbered", + "template_inputs": {"anyKey": 1}, + "server_only": "drop-me", + }, + } + ] + output.assign( + gathered_spec={ + "src_switch_name": {"type": "str"}, + "config_data": { + "type": "dict", + "options": { + "policy_type": {"type": "str"}, + "template_inputs": {"type": "dict"}, + }, + }, + } + ) + result = output.format() + assert result["gathered"] == [ + { + "src_switch_name": "leaf1", + "config_data": {"policy_type": "numbered", "template_inputs": {"anyKey": 1}}, + } + ] + + def test_gathered_without_spec_is_unpruned(self): + """No gathered_spec means no pruning (backward compatible).""" + output = NDOutput("normal", state="gathered") + output._after = [{"src_switch_name": "leaf1", "link_id": "UUID-123"}] + result = output.format() + assert result["gathered"] == [{"src_switch_name": "leaf1", "link_id": "UUID-123"}] + + # ============================================================================= # Test: NDOutput.assign # ============================================================================= diff --git a/tests/unit/module_utils/test_utils.py b/tests/unit/module_utils/test_utils.py new file mode 100644 index 000000000..d7d0f6e0d --- /dev/null +++ b/tests/unit/module_utils/test_utils.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Shreyas Srish (@shrsr) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for module_utils/utils.py helpers. +""" + +from __future__ import annotations + +from ansible_collections.cisco.nd.plugins.module_utils.utils import prune_to_spec + + +class TestPruneToSpec: + """Tests for prune_to_spec, which trims data to an argument spec's options.""" + + def test_drops_keys_not_in_spec(self): + """Keys absent from the spec (e.g. response-only fields) are removed.""" + data = {"name": "leaf1", "link_id": "UUID-123"} + spec = {"name": {"type": "str"}} + assert prune_to_spec(data, spec) == {"name": "leaf1"} + + def test_keeps_scalar_and_scalar_list_verbatim(self): + """Scalars and lists of scalars pass through unchanged.""" + data = {"name": "leaf1", "interface_names": ["Eth1/1", "Eth1/2"]} + spec = {"name": {"type": "str"}, "interface_names": {"type": "list", "elements": "str"}} + assert prune_to_spec(data, spec) == data + + def test_recurses_into_modeled_dict(self): + """A dict suboption with options is pruned recursively.""" + data = {"config_data": {"policy_type": "numbered", "server_only": "x"}} + spec = {"config_data": {"type": "dict", "options": {"policy_type": {"type": "str"}}}} + assert prune_to_spec(data, spec) == {"config_data": {"policy_type": "numbered"}} + + def test_recurses_into_list_of_modeled_dicts(self): + """A list of dicts with options is pruned element by element.""" + data = {"links": [{"name": "a", "extra": 1}, {"name": "b", "extra": 2}]} + spec = {"links": {"type": "list", "elements": "dict", "options": {"name": {"type": "str"}}}} + assert prune_to_spec(data, spec) == {"links": [{"name": "a"}, {"name": "b"}]} + + def test_free_form_dict_left_untouched(self): + """A dict suboption without options is free-form and preserved verbatim.""" + data = {"template_inputs": {"anyKey": 1, "nested": {"deep": True}}} + spec = {"template_inputs": {"type": "dict"}} + assert prune_to_spec(data, spec) == data + + def test_deeply_nested_recursion(self): + """Recursion follows options through several levels.""" + data = {"config_data": {"network_os": {"policy": {"mtu": "9216", "server_only": "x"}}}} + spec = { + "config_data": { + "type": "dict", + "options": { + "network_os": { + "type": "dict", + "options": {"policy": {"type": "dict", "options": {"mtu": {"type": "str"}}}}, + } + }, + } + } + assert prune_to_spec(data, spec) == {"config_data": {"network_os": {"policy": {"mtu": "9216"}}}} + + def test_masks_no_log_keys(self): + """no_log suboptions are masked so gathered never surfaces the real secret.""" + data = {"name": "leaf1", "password": "hunter2"} + spec = {"name": {"type": "str"}, "password": {"type": "str", "no_log": True}} + assert prune_to_spec(data, spec) == {"name": "leaf1", "password": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER"} + + def test_masks_nested_no_log_keys(self): + """no_log suboptions nested inside a modeled dict are also masked.""" + data = {"config_data": {"policy_type": "numbered", "password": "hunter2"}} + spec = { + "config_data": { + "type": "dict", + "options": {"policy_type": {"type": "str"}, "password": {"type": "str", "no_log": True}}, + } + } + expected = {"config_data": {"policy_type": "numbered", "password": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER"}} + assert prune_to_spec(data, spec) == expected + + def test_non_dict_data_returned_as_is(self): + """Non-dict input is returned unchanged.""" + assert prune_to_spec("leaf1", {"name": {"type": "str"}}) == "leaf1" + + def test_mismatched_type_values_kept(self): + """If data shape does not match the spec's declared type, value is kept as-is.""" + # spec says dict, but data has a scalar -> keep verbatim rather than crash + data = {"config_data": "unexpected"} + spec = {"config_data": {"type": "dict", "options": {"policy_type": {"type": "str"}}}} + assert prune_to_spec(data, spec) == {"config_data": "unexpected"}