From 8ef4cf19d17dd21b345c3852737b572ba9e0eee2 Mon Sep 17 00:00:00 2001 From: gsshreyas Date: Wed, 10 Jun 2026 11:13:08 -0400 Subject: [PATCH 1/4] [minor_change] Add ND 4.2 Links module (nd_links) Adds the nd_links module for ND 4.2 with manage / one_manage link scopes, template models, endpoints, orchestrator, strategies and fabric inventory support, plus integration tests. --- plugins/module_utils/endpoints/mixins.py | 21 + .../module_utils/endpoints/query_params.py | 24 + .../endpoints/v1/manage/base_path.py | 4 + .../endpoints/v1/manage/link_actions.py | 29 + .../module_utils/endpoints/v1/manage/links.py | 79 ++ .../endpoints/v1/one_manage/base_path.py | 22 + .../endpoints/v1/one_manage/link_actions.py | 29 + .../endpoints/v1/one_manage/links.py | 79 ++ .../module_utils/fabric_inventory_helpers.py | 91 +++ plugins/module_utils/models/links/links.py | 277 +++++++ .../models/links/templates/__init__.py | 0 .../models/links/templates/base.py | 166 +++++ .../links/templates/discriminated_union.py | 66 ++ .../models/links/templates/ebgp_vrf_lite.py | 50 ++ .../models/links/templates/ipv6_link_local.py | 26 + .../models/links/templates/layer2_dci.py | 36 + .../links/templates/layer3_dci_vrf_lite.py | 45 ++ .../models/links/templates/mpls_overlay.py | 20 + .../models/links/templates/mpls_underlay.py | 38 + .../links/templates/multisite_overlay.py | 34 + .../links/templates/multisite_underlay.py | 44 ++ .../models/links/templates/numbered.py | 35 + .../models/links/templates/preprovision.py | 27 + .../models/links/templates/unnumbered.py | 28 + .../models/links/templates/user_defined.py | 25 + .../links/templates/vpc_peer_keepalive.py | 29 + plugins/module_utils/nd_state_machine.py | 12 +- plugins/module_utils/orchestrators/base.py | 23 + plugins/module_utils/orchestrators/links.py | 313 ++++++++ .../orchestrators/strategies/base_link.py | 63 ++ .../orchestrators/strategies/manage_link.py | 68 ++ .../strategies/one_manage_link.py | 65 ++ plugins/modules/nd_manage_links.py | 678 ++++++++++++++++++ .../tasks/discriminated_union.yml | 276 +++++++ .../nd_manage_links/tasks/ebgp_vrf_lite.yml | 228 ++++++ .../nd_manage_links/tasks/layer2_dci.yml | 224 ++++++ .../targets/nd_manage_links/tasks/main.yml | 83 +++ .../tasks/multisite_overlay.yml | 231 ++++++ .../tasks/multisite_underlay.yml | 237 ++++++ .../nd_manage_links/tasks/numbered.yml | 411 +++++++++++ .../nd_manage_links/tasks/preprovision.yml | 294 ++++++++ .../nd_manage_links/tasks/unnumbered.yml | 311 ++++++++ tests/unit/module_utils/models/test_links.py | 153 ++++ .../module_utils/orchestrators/test_links.py | 115 +++ 44 files changed, 5107 insertions(+), 2 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/link_actions.py create mode 100644 plugins/module_utils/endpoints/v1/manage/links.py create mode 100644 plugins/module_utils/endpoints/v1/one_manage/base_path.py create mode 100644 plugins/module_utils/endpoints/v1/one_manage/link_actions.py create mode 100644 plugins/module_utils/endpoints/v1/one_manage/links.py create mode 100644 plugins/module_utils/fabric_inventory_helpers.py create mode 100644 plugins/module_utils/models/links/links.py create mode 100644 plugins/module_utils/models/links/templates/__init__.py create mode 100644 plugins/module_utils/models/links/templates/base.py create mode 100644 plugins/module_utils/models/links/templates/discriminated_union.py create mode 100644 plugins/module_utils/models/links/templates/ebgp_vrf_lite.py create mode 100644 plugins/module_utils/models/links/templates/ipv6_link_local.py create mode 100644 plugins/module_utils/models/links/templates/layer2_dci.py create mode 100644 plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py create mode 100644 plugins/module_utils/models/links/templates/mpls_overlay.py create mode 100644 plugins/module_utils/models/links/templates/mpls_underlay.py create mode 100644 plugins/module_utils/models/links/templates/multisite_overlay.py create mode 100644 plugins/module_utils/models/links/templates/multisite_underlay.py create mode 100644 plugins/module_utils/models/links/templates/numbered.py create mode 100644 plugins/module_utils/models/links/templates/preprovision.py create mode 100644 plugins/module_utils/models/links/templates/unnumbered.py create mode 100644 plugins/module_utils/models/links/templates/user_defined.py create mode 100644 plugins/module_utils/models/links/templates/vpc_peer_keepalive.py create mode 100644 plugins/module_utils/orchestrators/links.py create mode 100644 plugins/module_utils/orchestrators/strategies/base_link.py create mode 100644 plugins/module_utils/orchestrators/strategies/manage_link.py create mode 100644 plugins/module_utils/orchestrators/strategies/one_manage_link.py create mode 100644 plugins/modules/nd_manage_links.py create mode 100644 tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/layer2_dci.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/main.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/multisite_overlay.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/multisite_underlay.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/numbered.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/preprovision.yml create mode 100644 tests/integration/targets/nd_manage_links/tasks/unnumbered.yml create mode 100644 tests/unit/module_utils/models/test_links.py create mode 100644 tests/unit/module_utils/orchestrators/test_links.py 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/links/links.py b/plugins/module_utils/models/links/links.py new file mode 100644 index 000000000..d9344876b --- /dev/null +++ b/plugins/module_utils/models/links/links.py @@ -0,0 +1,277 @@ +# 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, 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.discriminated_union import LinkTemplateInputs +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +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="before") + @classmethod + def _inject_policy_marker(cls, data: Any) -> Any: + """Copy policy_type into template_inputs so the discriminated union resolves.""" + if not isinstance(data, dict): + return data + policy_type = data.get("policy_type") or data.get("policyType") + template_inputs = data.get("template_inputs") or data.get("templateInputs") + if policy_type and isinstance(template_inputs, dict): + template_inputs.setdefault("policy_type_marker", policy_type) + return data + + +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). For single-cluster scope, the + module swaps in MANAGE_SCOPE_IDENTIFIERS (no cluster names) at runtime. + """ + + 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" + + MANAGE_SCOPE_IDENTIFIERS: ClassVar[list[str]] = [ + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + + 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") + + 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) + 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) + return data + + def _strip_secret_template_inputs(self, data: dict[str, Any], by_alias: bool) -> None: + """Remove secret template_inputs fields from an output/diff dict in place.""" + 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): + 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 with every declared template field present. + + ND's template engine rejects payloads that omit known fields; the UI + works around this by sending type-appropriate empties for anything + the user didn't set. We mirror that after normal serialization. + """ + data = super().to_payload(**kwargs) + self._fill_template_inputs_defaults(data) + return data + + def _fill_template_inputs_defaults(self, data: dict[str, Any]) -> None: + """Restore template fields stripped by exclude_none with typed empties.""" + 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) + 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 None: + template_inputs[alias] = self._empty_for_annotation(field_info.annotation) + + @staticmethod + def _empty_for_annotation(annotation: Any) -> Any: + """Pick an empty value matching an Optional[...] field's underlying type.""" + import types + from typing import Union, get_args, get_origin + + # Under ``from __future__ import annotations`` Pydantic resolves ``X | None`` + # fields to ``types.UnionType`` rather than ``typing.Union``; accept both. + if get_origin(annotation) in (Union, types.UnionType): + non_none = [a for a in get_args(annotation) if a 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 "" + + @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"), + src_fabric_name=dict(type="str"), + dst_fabric_name=dict(type="str"), + src_switch_name=dict(type="str"), + dst_switch_name=dict(type="str"), + src_interface_name=dict(type="str"), + dst_interface_name=dict(type="str"), + src_switch_id=dict(type="str"), + dst_switch_id=dict(type="str"), + src_switch_ip=dict(type="str"), + dst_switch_ip=dict(type="str"), + link_type=dict(type="str", default="multi_cluster_planned_link"), + config_data=dict( + type="dict", + options=dict( + policy_type=dict( + type="str", + choices=[ + "numbered", + "unnumbered", + "ipv6LinkLocal", + "ebgpVrfLite", + "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..8aecf4796 --- /dev/null +++ b/plugins/module_utils/models/links/templates/base.py @@ -0,0 +1,166 @@ +# 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.). +""" + +from __future__ import annotations + +import types +from typing import Any, Union, get_args, get_origin + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict, Field +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +class InterfaceBasicsMixin(BaseModel): + """Common interface level settings shared by most policy types.""" + + interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState") + mtu: int | None = Field(default=None, alias="mtu") + speed: str | None = Field(default=None, alias="speed") + fec: str | None = Field(default=None, alias="fec") + + +class InterfaceDescriptionsMixin(BaseModel): + """Source/destination interface descriptions and freeform config strings.""" + + src_interface_description: str | None = Field(default=None, alias="srcInterfaceDescription") + dst_interface_description: str | None = Field(default=None, alias="dstInterfaceDescription") + 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") + dhcp_relay_on_dst_interface: bool | None = Field(default=None, alias="dhcpRelayOnDstInterface") + + +class BfdEchoMixin(BaseModel): + """BFD echo toggles for numbered links.""" + + bfd_echo_on_src_interface: bool | None = Field(default=None, alias="bfdEchoOnSrcInterface") + bfd_echo_on_dst_interface: bool | None = Field(default=None, alias="bfdEchoOnDstInterface") + + +class MacsecCoreMixin(BaseModel): + """MACsec on/off toggle.""" + + macsec: bool | None = Field(default=None, alias="macsec") + + +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") + + +class QkdMixin(BaseModel): + """Quantum Key Distribution fields for DCI links.""" + + qkd: bool | None = Field(default=None, alias="qkd") + ignore_certificate: bool | None = Field(default=None, alias="ignoreCertificate") + src_kme_server_ip: str | None = Field(default=None, alias="srcKmeServerIp") + dst_kme_server_ip: str | None = Field(default=None, alias="dstKmeServerIp") + 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") + dst_qkd_profile_name: str | None = Field(default=None, alias="dstQkdProfileName") + src_trustpoint_label: str | None = Field(default=None, alias="srcTrustpointLabel") + dst_trustpoint_label: str | None = Field(default=None, alias="dstTrustpointLabel") + + +class EbgpPasswordMixin(BaseModel): + """eBGP password / auth fields.""" + + enable_ebgp_password: bool | None = Field(default=None, alias="enableEbgpPassword") + 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") + inherit_ebgp_password_msd_settings: bool | None = Field(default=None, alias="inheritEbgpPasswordMsdSettings") + + +class TtagMixin(BaseModel): + """TTAG fabric setting inheritance flag.""" + + inherit_ttag_fabric_setting: bool | None = Field(default=None, alias="inheritTtagFabricSetting") + + +class NetflowMixin(BaseModel): + """Netflow monitoring fields for DCI links.""" + + netflow_on_src_interface: bool | None = Field(default=None, alias="netflowOnSrcInterface") + netflow_on_dst_interface: bool | None = Field(default=None, alias="netflowOnDstInterface") + 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") + + @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 + + def to_payload(self, **kwargs: Any) -> dict[str, Any]: + """Emit every declared field, substituting typed empties for ``None``. + + ND's template engine rejects payloads that omit known fields. The UI + works around this by sending ``""`` / ``false`` / ``0`` for anything + the user didn't set; we mirror that here. + """ + data = self.model_dump( + by_alias=True, + exclude_none=False, + mode="json", + context={"mode": "payload"}, + exclude=self.payload_exclude_fields or None, + ) + for field_name, field_info in self.__class__.model_fields.items(): + alias = field_info.alias or field_name + if alias in data and data[alias] is None: + data[alias] = self._empty_for_annotation(field_info.annotation) + return data + + @staticmethod + def _empty_for_annotation(annotation: Any) -> Any: + """Pick an empty value matching an ``Optional[...]`` field's underlying type.""" + if get_origin(annotation) in (Union, types.UnionType): + non_none = [a for a in get_args(annotation) if a 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 "" 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..c550d8d3f --- /dev/null +++ b/plugins/module_utils/models/links/templates/discriminated_union.py @@ -0,0 +1,66 @@ +# 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, Union + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + +from .ebgp_vrf_lite import EbgpVrfLiteTemplateInputs +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 + +LinkTemplateInputs = Annotated[ + Union[ + NumberedTemplateInputs, + UnnumberedTemplateInputs, + Ipv6LinkLocalTemplateInputs, + EbgpVrfLiteTemplateInputs, + Layer2DciTemplateInputs, + Layer3DciVrfLiteTemplateInputs, + MultisiteOverlayTemplateInputs, + MultisiteUnderlayTemplateInputs, + MplsOverlayTemplateInputs, + MplsUnderlayTemplateInputs, + PreprovisionTemplateInputs, + UserDefinedTemplateInputs, + VpcPeerKeepaliveTemplateInputs, + ], + Field(discriminator="policy_type_marker"), +] + + +__all__ = [ + "LinkTemplateInputs", + "NumberedTemplateInputs", + "UnnumberedTemplateInputs", + "Ipv6LinkLocalTemplateInputs", + "EbgpVrfLiteTemplateInputs", + "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..de4207b49 --- /dev/null +++ b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py @@ -0,0 +1,50 @@ +# 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, + MacsecFullMixin, + QkdMixin, + TtagMixin, +) + + +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") + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") + + 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") + routing_tag: str | None = Field(default=None, alias="routingTag") + + auto_gen_config_default_vrf: bool | None = Field(default=None, alias="autoGenConfigDefaultVrf") + auto_gen_config_nx_peer_default_vrf: bool | None = Field(default=None, alias="autoGenConfigNxPeerDefaultVrf") + auto_gen_config_peer: bool | None = Field(default=None, alias="autoGenConfigPeer") + + dci_tracking: bool | None = Field(default=None, alias="dciTracking") + + default_vrf_ebgp_neighbor_password: str | None = Field(default=None, alias="defaultVrfEbgpNeighborPassword", json_schema_extra={"secret": True}) + redistrib_ebgp_route_map_name: str | None = Field(default=None, alias="redistribEbgpRouteMapName") + template_config_gen_peer: str | None = Field(default=None, alias="templateConfigGenPeer") + vrf_name_nx_peer_switch: str | None = Field(default=None, alias="vrfNameNxPeerSwitch") 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..bb145ac9a --- /dev/null +++ b/plugins/module_utils/models/links/templates/layer2_dci.py @@ -0,0 +1,36 @@ +# 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, + MacsecFullMixin, + QkdMixin, + TtagMixin, +) + + +class Layer2DciTemplateInputs( + InterfaceDescriptionsMixin, + MacsecFullMixin, + QkdMixin, + TtagMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=layer2Dci (L2 trunk between data centers).""" + + 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") + bpdu_guard: str | None = Field(default=None, alias="bpduGuard") + port_type_fast: bool | None = Field(default=None, alias="portTypeFast") + + mtu_type: str | None = Field(default=None, alias="mtuType") + speed: str | None = Field(default=None, alias="speed") 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..b35627eca --- /dev/null +++ b/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.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, + MacsecFullMixin, + NetflowMixin, + QkdMixin, + TtagMixin, +) + + +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") + speed: str | None = Field(default=None, alias="speed") + + ip_redirects: bool | None = Field(default=None, alias="ipRedirects") + ipv4_pim: bool | None = Field(default=None, alias="ipv4Pim") + ipv6_pim: bool | None = Field(default=None, alias="ipv6Pim") 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..f431baa22 --- /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 + + +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") + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") 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..07663876b --- /dev/null +++ b/plugins/module_utils/models/links/templates/mpls_underlay.py @@ -0,0 +1,38 @@ +# 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, +) + + +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") + dci_routing_protocol: str | None = Field(default=None, alias="dciRoutingProtocol") + dci_routing_tag: str | None = Field(default=None, alias="dciRoutingTag") + ospf_area_id: str | None = Field(default=None, alias="ospfAreaId") + + sr_global_block_range: str | None = Field(default=None, alias="srGlobalBlockRange") + src_sr_index: int | None = Field(default=None, alias="srcSrIndex") + dst_sr_index: int | None = Field(default=None, alias="dstSrIndex") + + src_ip_address_mask: str | None = Field(default=None, alias="srcIpAddressMask") + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") + + link_mtu: int | None = Field(default=None, alias="linkMtu") 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..b5ca6d7a9 --- /dev/null +++ b/plugins/module_utils/models/links/templates/multisite_overlay.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 EbgpPasswordMixin, LinkTemplateBase + + +class MultisiteOverlayTemplateInputs(EbgpPasswordMixin, LinkTemplateBase): + """Template inputs for policy_type=multisiteOverlay (BGW overlay eBGP session).""" + + policy_type_marker: Literal["multisiteOverlay"] = Field(default="multisiteOverlay", exclude=True) + + src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn") + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") + src_ip_address: str | None = Field(default=None, alias="srcIpAddress") + dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") + ebgp_multihop: int | None = Field(default=None, alias="ebgpMultihop") + + ipv4_trm: bool | None = Field(default=None, alias="ipv4Trm") + ipv6_trm: bool | None = Field(default=None, alias="ipv6Trm") + + redistribute_route_server: bool | None = Field(default=None, alias="redistributeRouteServer") + route_server_routing_tag: str | None = Field(default=None, alias="routeServerRoutingTag") + skip_config_generation: bool | None = Field(default=None, alias="skipConfigGeneration") + + src_interface_description: str | None = Field(default=None, alias="srcInterfaceDescription") + dst_interface_description: str | None = Field(default=None, alias="dstInterfaceDescription") + + macsec_cipher_suite: str | None = Field(default=None, alias="macsecCipherSuite") 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..a3d13845d --- /dev/null +++ b/plugins/module_utils/models/links/templates/multisite_underlay.py @@ -0,0 +1,44 @@ +# 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, + TtagMixin, +) + + +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") + dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") + ebgp_bfd: bool | None = Field(default=None, alias="ebgpBfd") + ebgp_log_neighbor_change: bool | None = Field(default=None, alias="ebgpLogNeighborChange") + ebgp_maximum_paths: int | None = Field(default=None, alias="ebgpMaximumPaths") + ebgp_send_comboth: bool | None = Field(default=None, alias="ebgpSendComboth") + + 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") + speed: str | None = Field(default=None, alias="speed") + routing_tag: str | None = Field(default=None, alias="routingTag") + + dci_tracking_enable_flag: bool | None = Field(default=None, alias="dciTrackingEnableFlag") 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..ed142e4df --- /dev/null +++ b/plugins/module_utils/models/links/templates/preprovision.py @@ -0,0 +1,27 @@ +# 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, +) + + +class PreprovisionTemplateInputs( + InterfaceDescriptionsMixin, + LinkTemplateBase, +): + """Template inputs for policy_type=preprovision. + + Limited to the four interface description and config fields ND returns for this + policy; including others (mtu, speed, fec, interface_admin_state) breaks + idempotency because ND drops them on read. + """ + + policy_type_marker: Literal["preprovision"] = Field(default="preprovision", exclude=True) 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/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..ef0c179e4 --- /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 + + +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") + mtu: int | None = Field(default=None, alias="mtu") diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index e9adfc59e..41e1ebb96 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -74,12 +74,15 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest 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. + raw_config = self.module.params.get("config") or [] + raw_config = self.model_orchestrator.prepare_config_data(raw_config) self.proposed = NDConfigCollection.from_ansible_config( - data=self.module.params.get("config", []), model_class=self.model_class, context={"state": self.state} + data=raw_config, model_class=self.model_class, context={"state": self.state} ) self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) @@ -131,6 +134,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}") diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 3ed88ae69..9daa8e5cb 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..f23080aad --- /dev/null +++ b/plugins/module_utils/orchestrators/links.py @@ -0,0 +1,313 @@ +# 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 logging +from typing import Any, ClassVar + +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. + """ + if not isinstance(raw_config, list): + return 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 + + return proposed_policy is not None and proposed_policy != existing_policy + + 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).""" + if self._is_policy_type_change(model_instance): + 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) + ) + + 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 exact wrapper differs across operations, so we scan + the known result containers and flag an item on any recognized failure + signal. The precise create/delete 207 shape should be confirmed against a + live response and this guard tightened if a narrower contract is known. + """ + 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/modules/nd_manage_links.py b/plugins/modules/nd_manage_links.py new file mode 100644 index 000000000..1d3a4b68e --- /dev/null +++ b/plugins/modules/nd_manage_links.py @@ -0,0 +1,678 @@ +#!/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. + type: str + dst_fabric_name: + description: + - The name of the destination fabric. + type: str + 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). + type: str + dst_interface_name: + description: + - The name of the destination interface, for example C(Ethernet1/1). + type: str + link_type: + description: + - The type of the link. + type: str + default: multi_cluster_planned_link + 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(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 + - 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. + - '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 and are masked from this module''s output and diffs. A change to only a secret value is therefore not detected + as a change. 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(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(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_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 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 + link_type: multi_cluster_planned_link + 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""" +""" + +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 = module.params.get("config", []) + 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 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 + + 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) + + state_machine = NDStateMachine(module=module, model_orchestrator=orchestrator) + 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..b45637e86 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml @@ -0,0 +1,276 @@ +# 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: Attempt to change policy_type from numbered to preprovision 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: preprovision + template_inputs: + src_interface_description: should not switch policy in place + state: merged + register: policy_change_to_preprovision + ignore_errors: true + + - name: Verify numbered → preprovision policy change rejected + ansible.builtin.assert: + that: + - policy_change_to_preprovision is failed + - policy_change_to_preprovision.msg is search("policy_type") + + - 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..06f0c19bb --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml @@ -0,0 +1,228 @@ +# 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 + + - 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..07643ccf3 --- /dev/null +++ b/tests/integration/targets/nd_manage_links/tasks/numbered.yml @@ -0,0 +1,411 @@ +# 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.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 + - (gathered_numbered.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 + - (gathered_numbered.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + + - 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/models/test_links.py b/tests/unit/module_utils/models/test_links.py new file mode 100644 index 000000000..1401e358b --- /dev/null +++ b/tests/unit/module_utils/models/test_links.py @@ -0,0 +1,153 @@ +# -*- 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" + + +# --------------------------------------------------------------------------- +# 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_absent_from_config_and_diff(policy_type, template_inputs, secret_alias, secret_pyname): + """Secrets reach the controller payload but are scrubbed from output/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 secret_pyname not in config_ti + assert secret_alias not in diff_ti + + +def test_non_secret_fields_survive_in_config(): + """Non-secret template fields remain visible in output.""" + link = _link("ebgpVrfLite", {"linkMtu": 9216, "defaultVrfEbgpNeighborPassword": "secret"}) + config_ti = link.to_config()["config_data"]["template_inputs"] + assert config_ti.get("link_mtu") == 9216 + assert "default_vrf_ebgp_neighbor_password" not in config_ti + + +# --------------------------------------------------------------------------- +# 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 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..33d08e5eb --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_links.py @@ -0,0 +1,115 @@ +# -*- 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.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 From 80a7de5a5be369972f46029e4e2e9aabe24fb734 Mon Sep 17 00:00:00 2001 From: gsshreyas Date: Thu, 2 Jul 2026 08:40:28 -0400 Subject: [PATCH 2/4] [minor_change] Added gathered all to the framework and a method for masking secrets --- plugins/module_utils/models/base.py | 63 ++++++++++++- plugins/module_utils/models/links/links.py | 51 ++++++++-- .../links/templates/discriminated_union.py | 33 +++++++ plugins/module_utils/nd_output.py | 29 +++++- plugins/module_utils/nd_state_machine.py | 29 +++++- plugins/module_utils/orchestrators/links.py | 6 ++ plugins/module_utils/utils.py | 49 ++++++++++ plugins/modules/nd_manage_links.py | 8 +- .../nd_manage_links/tasks/ebgp_vrf_lite.yml | 41 ++++++++ .../nd_manage_links/tasks/numbered.yml | 24 ++++- .../unit/module_utils/mock_ansible_module.py | 3 + .../module_utils/models/test_base_secrets.py | 46 +++++++++ tests/unit/module_utils/models/test_links.py | 44 ++++++++- .../module_utils/orchestrators/test_links.py | 46 +++++++++ tests/unit/module_utils/test_nd_output.py | 86 +++++++++++++++++ tests/unit/module_utils/test_utils.py | 94 +++++++++++++++++++ 16 files changed, 621 insertions(+), 31 deletions(-) create mode 100644 tests/unit/module_utils/models/test_base_secrets.py create mode 100644 tests/unit/module_utils/test_utils.py diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 4b9fefedb..0480de924 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,14 +167,24 @@ 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 --- @@ -212,11 +259,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 index d9344876b..f0e1a3002 100644 --- a/plugins/module_utils/models/links/links.py +++ b/plugins/module_utils/models/links/links.py @@ -7,8 +7,9 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field, 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.discriminated_union import LinkTemplateInputs +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.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.utils import NO_LOG_PLACEHOLDER class LinkConfigDataModel(NDNestedModel): @@ -32,13 +33,19 @@ def merge(self, other: LinkConfigDataModel) -> LinkConfigDataModel: @model_validator(mode="before") @classmethod def _inject_policy_marker(cls, data: Any) -> Any: - """Copy policy_type into template_inputs so the discriminated union resolves.""" + """Copy policy_type into template_inputs so the discriminated union resolves. + + Returns a shallow copy rather than mutating the input in place: ``data`` + is a reference into ``module.params``, so an in-place ``setdefault`` 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") template_inputs = data.get("template_inputs") or data.get("templateInputs") - if policy_type and isinstance(template_inputs, dict): - template_inputs.setdefault("policy_type_marker", policy_type) + if policy_type and isinstance(template_inputs, dict) and "policy_type_marker" not in template_inputs: + key = "template_inputs" if data.get("template_inputs") is not None else "templateInputs" + data = {**data, key: {**template_inputs, "policy_type_marker": policy_type}} return data @@ -116,6 +123,25 @@ class NDLinkModel(NDBaseModel): config_data: LinkConfigDataModel | None = Field(default=None, alias="configData") + @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( @@ -137,11 +163,16 @@ def to_config(self, **kwargs: Any) -> dict[str, Any]: removed from after/before/proposed output and from the diff. """ data = super().to_config(**kwargs) - self._strip_secret_template_inputs(data, by_alias=False) + 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) -> None: - """Remove secret template_inputs fields from an output/diff dict in place.""" + 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" @@ -153,7 +184,11 @@ def _strip_secret_template_inputs(self, data: dict[str, Any], by_alias: bool) -> if not isinstance(template_inputs, dict): return for key in type(self.config_data.template_inputs).secret_field_keys(by_alias=by_alias): - template_inputs.pop(key, None) + 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: diff --git a/plugins/module_utils/models/links/templates/discriminated_union.py b/plugins/module_utils/models/links/templates/discriminated_union.py index c550d8d3f..29ae9526f 100644 --- a/plugins/module_utils/models/links/templates/discriminated_union.py +++ b/plugins/module_utils/models/links/templates/discriminated_union.py @@ -47,9 +47,42 @@ Field(discriminator="policy_type_marker"), ] +# 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, + 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", + "all_secret_template_input_keys", "NumberedTemplateInputs", "UnnumberedTemplateInputs", "Ipv6LinkLocalTemplateInputs", diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index a9e965325..e2b7b8a88 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -7,11 +7,13 @@ from typing import Dict, Any, Optional, List, Union 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] = [] @@ -19,8 +21,30 @@ def __init__(self, output_level: str): self._proposed: Union[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 @@ -100,6 +124,7 @@ def assign( diff: Optional[NDConfigCollection] = None, proposed: Optional[NDConfigCollection] = None, logs: Optional[List] = None, + gathered_spec: Optional[Dict[str, Any]] = None, **kwargs ) -> None: if isinstance(after, NDConfigCollection): @@ -112,4 +137,6 @@ def assign( self._proposed = proposed 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 41e1ebb96..d1569b884 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -41,7 +41,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,6 +68,16 @@ 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() @@ -81,11 +94,17 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest # identifier-only items for ``deleted``). Models that do not read the context ignore it. raw_config = self.module.params.get("config") or [] raw_config = self.model_orchestrator.prepare_config_data(raw_config) - self.proposed = NDConfigCollection.from_ansible_config( - data=raw_config, model_class=self.model_class, context={"state": self.state} - ) + 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) + 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 diff --git a/plugins/module_utils/orchestrators/links.py b/plugins/module_utils/orchestrators/links.py index f23080aad..b116d1bf1 100644 --- a/plugins/module_utils/orchestrators/links.py +++ b/plugins/module_utils/orchestrators/links.py @@ -3,6 +3,7 @@ from __future__ import annotations +import copy import logging from typing import Any, ClassVar @@ -73,9 +74,14 @@ def prepare_config_data(self, raw_config: Any) -> Any: 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 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 index 1d3a4b68e..563b87762 100644 --- a/plugins/modules/nd_manage_links.py +++ b/plugins/modules/nd_manage_links.py @@ -161,9 +161,9 @@ - The accepted keys depend on O(config.config_data.policy_type). Keys that do not belong to the selected policy type are rejected. - '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 and are masked from this module''s output and diffs. A change to only a secret value is therefore not detected - as a change. 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.' + 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. 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).' @@ -213,7 +213,7 @@ - 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 in O(config) format. No changes are made. + - 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 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 index 06f0c19bb..8ba329b75 100644 --- a/tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml +++ b/tests/integration/targets/nd_manage_links/tasks/ebgp_vrf_lite.yml @@ -131,6 +131,47 @@ - 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: diff --git a/tests/integration/targets/nd_manage_links/tasks/numbered.yml b/tests/integration/targets/nd_manage_links/tasks/numbered.yml index 07643ccf3..461b89ef3 100644 --- a/tests/integration/targets/nd_manage_links/tasks/numbered.yml +++ b/tests/integration/targets/nd_manage_links/tasks/numbered.yml @@ -127,9 +127,27 @@ ansible.builtin.assert: that: - gathered_numbered is not changed - - (gathered_numbered.after | selectattr('src_interface_name', 'eq', 'Ethernet1/30') | list) | length == 1 - - (gathered_numbered.after | selectattr('src_interface_name', 'eq', 'Ethernet1/31') | list) | length == 1 - - (gathered_numbered.after | selectattr('src_interface_name', 'eq', 'Ethernet1/32') | list) | length == 1 + - (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 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..8534acf11 --- /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, List, Optional + +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[Optional[List[str]]] = ["name"] + identifier_strategy: ClassVar[str] = "single" + name: Optional[str] = None + password: Optional[str] = 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 index 1401e358b..44898a9da 100644 --- a/tests/unit/module_utils/models/test_links.py +++ b/tests/unit/module_utils/models/test_links.py @@ -75,6 +75,22 @@ def test_user_defined_allows_extra_fields(): 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"] + + # --------------------------------------------------------------------------- # Secret handling # --------------------------------------------------------------------------- @@ -89,23 +105,41 @@ def test_user_defined_allows_extra_fields(): ("multisiteUnderlay", {"ebgpPassword": "S4"}, "ebgpPassword", "ebgp_password"), ], ) -def test_secret_in_payload_absent_from_config_and_diff(policy_type, template_inputs, secret_alias, secret_pyname): - """Secrets reach the controller payload but are scrubbed from output/diff.""" +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 secret_pyname not in config_ti + 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 remain visible in output.""" + """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 "default_vrf_ebgp_neighbor_password" not in config_ti + assert config_ti["default_vrf_ebgp_neighbor_password"] == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + + +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() # --------------------------------------------------------------------------- diff --git a/tests/unit/module_utils/orchestrators/test_links.py b/tests/unit/module_utils/orchestrators/test_links.py index 33d08e5eb..5f2ca4f81 100644 --- a/tests/unit/module_utils/orchestrators/test_links.py +++ b/tests/unit/module_utils/orchestrators/test_links.py @@ -113,3 +113,49 @@ def test_bulk_failure_message_includes_id_and_reason(): 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" 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..c0e599cac --- /dev/null +++ b/tests/unit/module_utils/test_utils.py @@ -0,0 +1,94 @@ +# -*- 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 absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +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"} From e0bcff71a14d0d280206b4099511d09bce21ee28 Mon Sep 17 00:00:00 2001 From: gsshreyas Date: Fri, 17 Jul 2026 20:55:05 -0400 Subject: [PATCH 3/4] [minor_change] nd_manage_links: send ND defaults, tolerate unsupported policies on read, add iosXeNumbered, orientation independent identity, realized preprovision, and address remaining review feedback --- plugins/module_utils/models/base.py | 22 +- plugins/module_utils/models/links/links.py | 266 ++++++++++--- .../models/links/templates/base.py | 128 +++++-- .../links/templates/discriminated_union.py | 61 ++- .../models/links/templates/ebgp_vrf_lite.py | 16 +- .../models/links/templates/ios_xe_numbered.py | 34 ++ .../models/links/templates/layer2_dci.py | 5 +- .../links/templates/layer3_dci_vrf_lite.py | 11 +- .../models/links/templates/mpls_underlay.py | 7 +- .../links/templates/multisite_overlay.py | 12 +- .../links/templates/multisite_underlay.py | 15 +- .../models/links/templates/unsupported.py | 39 ++ .../links/templates/vpc_peer_keepalive.py | 6 +- plugins/module_utils/nd_output.py | 38 +- plugins/module_utils/nd_state_machine.py | 63 ++- plugins/module_utils/orchestrators/base.py | 2 +- plugins/module_utils/orchestrators/links.py | 18 +- plugins/modules/nd_manage_links.py | 149 ++++++- .../tasks/discriminated_union.yml | 12 +- .../module_utils/models/test_base_secrets.py | 8 +- tests/unit/module_utils/models/test_links.py | 362 ++++++++++++++++++ tests/unit/module_utils/test_utils.py | 4 +- 22 files changed, 1070 insertions(+), 208 deletions(-) create mode 100644 plugins/module_utils/models/links/templates/ios_xe_numbered.py create mode 100644 plugins/module_utils/models/links/templates/unsupported.py diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 0480de924..3025cbf41 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -118,7 +118,7 @@ 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]: + 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 @@ -126,7 +126,7 @@ def secret_field_keys(cls, by_alias: bool = False) -> Set[str]: are secret, used both to keep them out of output and to register their values for ``no_log`` masking. """ - keys: Set[str] = set() + 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"): @@ -134,7 +134,7 @@ def secret_field_keys(cls, by_alias: bool = False) -> Set[str]: return keys @classmethod - def collect_secret_values(cls, config_item: Dict[str, Any]) -> Set[str]: + 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 @@ -145,7 +145,7 @@ def collect_secret_values(cls, config_item: Dict[str, Any]) -> Set[str]: 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() + values: set[str] = set() if not isinstance(config_item, dict): return values for key in cls.secret_field_keys(by_alias=False): @@ -189,13 +189,19 @@ def to_config(self, **kwargs) -> Dict[str, Any]: # --- 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 diff --git a/plugins/module_utils/models/links/links.py b/plugins/module_utils/models/links/links.py index f0e1a3002..8532ff528 100644 --- a/plugins/module_utils/models/links/links.py +++ b/plugins/module_utils/models/links/links.py @@ -5,9 +5,11 @@ from typing import Any, ClassVar, Literal -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field, model_validator +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 @@ -30,31 +32,93 @@ def merge(self, other: LinkConfigDataModel) -> LinkConfigDataModel: ) return super().merge(other) + @model_validator(mode="wrap") + @classmethod + def _tolerate_unsupported_on_read(cls, data: Any, handler: Any, info: Any) -> LinkConfigDataModel: + """Preserve unsupported/odd controller links instead of aborting the read. + + Strict parsing (discriminated union + ``extra="forbid"``) is kept for user + input -- write states thread ``context["state"]`` through validation -- so + bad config still fails fast. On a controller read (no state), a policy type + this module does not model, or a supported policy carrying a field absent + from its local model, is captured as an opaque ``UnsupportedTemplateInputs`` + record rather than raising ``ValidationError`` and aborting ``query_all``. + """ + 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 policy_type into template_inputs so the discriminated union resolves. - - Returns a shallow copy rather than mutating the input in place: ``data`` - is a reference into ``module.params``, so an in-place ``setdefault`` would - leak the internal marker back into the invocation echo. + """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") - template_inputs = data.get("template_inputs") or data.get("templateInputs") - if policy_type and isinstance(template_inputs, dict) and "policy_type_marker" not in template_inputs: - key = "template_inputs" if data.get("template_inputs") is not None else "templateInputs" - data = {**data, key: {**template_inputs, "policy_type_marker": policy_type}} - return data + 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). For single-cluster scope, the - module swaps in MANAGE_SCOPE_IDENTIFIERS (no cluster names) at runtime. + 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] = [ @@ -69,15 +133,6 @@ class NDLinkModel(NDBaseModel): ] identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "composite" - MANAGE_SCOPE_IDENTIFIERS: ClassVar[list[str]] = [ - "src_fabric_name", - "dst_fabric_name", - "src_switch_name", - "dst_switch_name", - "src_interface_name", - "dst_interface_name", - ] - unwanted_keys: ClassVar[list] = [ ["linkId"], ["linkState"], @@ -123,6 +178,125 @@ class NDLinkModel(NDBaseModel): 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 (intra-fabric physical links) --------------- + # A physical intra-fabric link has no inherent direction: ND can return it + # with the endpoints reversed relative to the playbook. For such links the + # identity is made order-independent and the diff view is canonicalized, so a + # reversed link is matched (not duplicated on merged, found on deleted, not + # delete+recreated on overridden) and compares equal. Inter-fabric links + # (VRF lite, DCI, multisite) keep their orientation -- there src/dst are + # distinct fabric roles and some directional fields are asymmetric. + + _IDENTITY_ALIAS_PAIRS: ClassVar[list] = [ + ("srcClusterName", "dstClusterName"), + ("srcFabricName", "dstFabricName"), + ("srcSwitchName", "dstSwitchName"), + ("srcInterfaceName", "dstInterfaceName"), + ] + _TEMPLATE_DIRECTIONAL_ALIAS_PAIRS: ClassVar[list] = [ + ("srcIp", "dstIp"), + ("srcIpv6", "dstIpv6"), + ("srcInterfaceDescription", "dstInterfaceDescription"), + ("srcInterfaceConfig", "dstInterfaceConfig"), + ("dhcpRelayOnSrcInterface", "dhcpRelayOnDstInterface"), + ("bfdEchoOnSrcInterface", "bfdEchoOnDstInterface"), + ] + + def _is_intra_fabric(self) -> bool: + return bool(self.src_fabric_name) and self.src_fabric_name == self.dst_fabric_name + + 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 intra-fabric links.""" + if not self._is_intra_fabric(): + return super().get_identifier_value() + src, dst = self._endpoints() + 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 (intra-fabric only). + + Comparison-only: payloads keep the user's orientation. Because both + existing and proposed links canonicalize identically, a reversed physical + link compares equal instead of showing a spurious change. + """ + if not self._is_intra_fabric(): + return + 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_ALIAS_PAIRS: + self._swap_keys(template_inputs, first, second) + + 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 unchanged ``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 get_diff(self, other: Any, exclude_unset: bool = False) -> bool: + """Diff comparison, treating a realized preprovision->numbered link as unchanged. + + Returns True (no diff) so the module keeps ND's realized ``numbered`` link + and its assigned values, stays idempotent, and never tries to convert it + back to ``preprovision``. + """ + if self._is_realized_preprovision_of(other): + return True + return super().get_diff(other, exclude_unset=exclude_unset) + @classmethod def collect_secret_values(cls, config_item: dict[str, Any]) -> set[str]: """Secret values for no_log masking, including free-form template_inputs. @@ -154,6 +328,7 @@ def to_diff_dict(self, **kwargs: Any) -> dict[str, Any]: 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]: @@ -203,18 +378,22 @@ def _remove_nested_key(data: dict, key_path: list[str]) -> None: del current[key_path[-1]] def to_payload(self, **kwargs: Any) -> dict[str, Any]: - """Serialize for POST/PUT with every declared template field present. - - ND's template engine rejects payloads that omit known fields; the UI - works around this by sending type-appropriate empties for anything - the user didn't set. We mirror that after normal serialization. - """ + """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: - """Restore template fields stripped by exclude_none with typed empties.""" + """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") @@ -224,32 +403,7 @@ def _fill_template_inputs_defaults(self, data: dict[str, Any]) -> None: if not isinstance(template_inputs, dict): return tmpl_cls = type(self.config_data.template_inputs) - 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 None: - template_inputs[alias] = self._empty_for_annotation(field_info.annotation) - - @staticmethod - def _empty_for_annotation(annotation: Any) -> Any: - """Pick an empty value matching an Optional[...] field's underlying type.""" - import types - from typing import Union, get_args, get_origin - - # Under ``from __future__ import annotations`` Pydantic resolves ``X | None`` - # fields to ``types.UnionType`` rather than ``typing.Union``; accept both. - if get_origin(annotation) in (Union, types.UnionType): - non_none = [a for a in get_args(annotation) if a 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 "" + LinkTemplateBase.apply_payload_defaults(template_inputs, tmpl_cls) @classmethod def get_argument_spec(cls) -> dict: @@ -272,7 +426,6 @@ def get_argument_spec(cls) -> dict: dst_switch_id=dict(type="str"), src_switch_ip=dict(type="str"), dst_switch_ip=dict(type="str"), - link_type=dict(type="str", default="multi_cluster_planned_link"), config_data=dict( type="dict", options=dict( @@ -283,6 +436,7 @@ def get_argument_spec(cls) -> dict: "unnumbered", "ipv6LinkLocal", "ebgpVrfLite", + "iosXeNumbered", "layer2Dci", "layer3DciVrfLite", "multisiteOverlay", diff --git a/plugins/module_utils/models/links/templates/base.py b/plugins/module_utils/models/links/templates/base.py index 8aecf4796..5e71eb99c 100644 --- a/plugins/module_utils/models/links/templates/base.py +++ b/plugins/module_utils/models/links/templates/base.py @@ -5,24 +5,40 @@ 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 -import types -from typing import Any, Union, get_args, get_origin +from typing import Any from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict, Field 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() + + +def pd(default: Any, **extra: Any) -> dict[str, Any]: + """Build a ``json_schema_extra`` dict tagging a field's documented ND default.""" + return {"payload_default": default, **extra} + class InterfaceBasicsMixin(BaseModel): """Common interface level settings shared by most policy types.""" - interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState") - mtu: int | None = Field(default=None, alias="mtu") - speed: str | None = Field(default=None, alias="speed") - fec: str | None = Field(default=None, alias="fec") + 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)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) + fec: str | None = Field(default=None, alias="fec", json_schema_extra=pd("auto")) class InterfaceDescriptionsMixin(BaseModel): @@ -37,21 +53,21 @@ class InterfaceDescriptionsMixin(BaseModel): class DhcpRelayMixin(BaseModel): """DHCP relay toggles for numbered/unnumbered links.""" - dhcp_relay_on_src_interface: bool | None = Field(default=None, alias="dhcpRelayOnSrcInterface") - dhcp_relay_on_dst_interface: bool | None = Field(default=None, alias="dhcpRelayOnDstInterface") + 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") - bfd_echo_on_dst_interface: bool | None = Field(default=None, alias="bfdEchoOnDstInterface") + 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") + macsec: bool | None = Field(default=None, alias="macsec", json_schema_extra=pd(False)) class MacsecFullMixin(MacsecCoreMixin): @@ -62,16 +78,18 @@ class MacsecFullMixin(MacsecCoreMixin): 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") + override_fabric_macsec: bool | None = Field(default=None, alias="overrideFabricMacsec", json_schema_extra=pd(False)) class QkdMixin(BaseModel): - """Quantum Key Distribution fields for DCI links.""" + """Quantum Key Distribution / MACsec key-management fields for DCI links.""" - qkd: bool | None = Field(default=None, alias="qkd") - ignore_certificate: bool | None = Field(default=None, alias="ignoreCertificate") + 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") + dst_kme_server_port_number: int | None = Field(default=None, alias="dstKmeServerPortNumber") 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") @@ -83,23 +101,23 @@ class QkdMixin(BaseModel): class EbgpPasswordMixin(BaseModel): """eBGP password / auth fields.""" - enable_ebgp_password: bool | None = Field(default=None, alias="enableEbgpPassword") + 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") - inherit_ebgp_password_msd_settings: bool | None = Field(default=None, alias="inheritEbgpPasswordMsdSettings") + ebgp_auth_key_encryption_type: str | None = Field(default=None, alias="ebgpAuthKeyEncryptionType", json_schema_extra=pd("3des")) + 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") + 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") - netflow_on_dst_interface: bool | None = Field(default=None, alias="netflowOnDstInterface") + 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") @@ -130,31 +148,51 @@ def secret_field_keys(cls, by_alias: bool = True) -> set[str]: keys.add((field_info.alias or field_name) if by_alias else field_name) return keys - def to_payload(self, **kwargs: Any) -> dict[str, Any]: - """Emit every declared field, substituting typed empties for ``None``. + @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 - ND's template engine rejects payloads that omit known fields. The UI - works around this by sending ``""`` / ``false`` / ``0`` for anything - the user didn't set; we mirror that here. + @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. """ - data = self.model_dump( - by_alias=True, - exclude_none=False, - mode="json", - context={"mode": "payload"}, - exclude=self.payload_exclude_fields or None, - ) - for field_name, field_info in self.__class__.model_fields.items(): + for field_name, field_info in tmpl_cls.model_fields.items(): + if field_info.exclude: + continue alias = field_info.alias or field_name - if alias in data and data[alias] is None: - data[alias] = self._empty_for_annotation(field_info.annotation) - return data + 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: - """Pick an empty value matching an ``Optional[...]`` field's underlying type.""" + """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 = [a for a in get_args(annotation) if a is not type(None)] + 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: @@ -164,3 +202,15 @@ def _empty_for_annotation(annotation: Any) -> Any: 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 index 29ae9526f..2c801b92a 100644 --- a/plugins/module_utils/models/links/templates/discriminated_union.py +++ b/plugins/module_utils/models/links/templates/discriminated_union.py @@ -10,11 +10,12 @@ from __future__ import annotations -from typing import Annotated, Union +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 @@ -28,24 +29,49 @@ 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[ - Union[ - NumberedTemplateInputs, - UnnumberedTemplateInputs, - Ipv6LinkLocalTemplateInputs, - EbgpVrfLiteTemplateInputs, - Layer2DciTemplateInputs, - Layer3DciVrfLiteTemplateInputs, - MultisiteOverlayTemplateInputs, - MultisiteUnderlayTemplateInputs, - MplsOverlayTemplateInputs, - MplsUnderlayTemplateInputs, - PreprovisionTemplateInputs, - UserDefinedTemplateInputs, - VpcPeerKeepaliveTemplateInputs, - ], + 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. @@ -54,6 +80,7 @@ UnnumberedTemplateInputs, Ipv6LinkLocalTemplateInputs, EbgpVrfLiteTemplateInputs, + IosXeNumberedTemplateInputs, Layer2DciTemplateInputs, Layer3DciVrfLiteTemplateInputs, MultisiteOverlayTemplateInputs, @@ -82,11 +109,13 @@ def all_secret_template_input_keys(by_alias: bool = False) -> set[str]: __all__ = [ "LinkTemplateInputs", + "SUPPORTED_POLICY_TYPES", "all_secret_template_input_keys", "NumberedTemplateInputs", "UnnumberedTemplateInputs", "Ipv6LinkLocalTemplateInputs", "EbgpVrfLiteTemplateInputs", + "IosXeNumberedTemplateInputs", "Layer2DciTemplateInputs", "Layer3DciVrfLiteTemplateInputs", "MultisiteOverlayTemplateInputs", diff --git a/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py index de4207b49..0e41cf1e4 100644 --- a/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py +++ b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py @@ -13,6 +13,7 @@ MacsecFullMixin, QkdMixin, TtagMixin, + pd, ) @@ -35,16 +36,19 @@ class EbgpVrfLiteTemplateInputs( 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") + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216)) routing_tag: str | None = Field(default=None, alias="routingTag") - auto_gen_config_default_vrf: bool | None = Field(default=None, alias="autoGenConfigDefaultVrf") - auto_gen_config_nx_peer_default_vrf: bool | None = Field(default=None, alias="autoGenConfigNxPeerDefaultVrf") - auto_gen_config_peer: bool | None = Field(default=None, alias="autoGenConfigPeer") + 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") + 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") + ) redistrib_ebgp_route_map_name: str | None = Field(default=None, alias="redistribEbgpRouteMapName") - template_config_gen_peer: str | None = Field(default=None, alias="templateConfigGenPeer") + 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..e099955c5 --- /dev/null +++ b/plugins/module_utils/models/links/templates/ios_xe_numbered.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 ( + 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")) + mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9198)) diff --git a/plugins/module_utils/models/links/templates/layer2_dci.py b/plugins/module_utils/models/links/templates/layer2_dci.py index bb145ac9a..62fff46e4 100644 --- a/plugins/module_utils/models/links/templates/layer2_dci.py +++ b/plugins/module_utils/models/links/templates/layer2_dci.py @@ -13,6 +13,7 @@ MacsecFullMixin, QkdMixin, TtagMixin, + pd, ) @@ -30,7 +31,7 @@ class Layer2DciTemplateInputs( trunk_allowed_vlans: str | None = Field(default=None, alias="trunkAllowedVlans") native_vlan: int | None = Field(default=None, alias="nativeVlan") bpdu_guard: str | None = Field(default=None, alias="bpduGuard") - port_type_fast: bool | None = Field(default=None, alias="portTypeFast") + port_type_fast: bool | None = Field(default=None, alias="portTypeFast", json_schema_extra=pd(True)) mtu_type: str | None = Field(default=None, alias="mtuType") - speed: str | None = Field(default=None, alias="speed") + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) 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 index b35627eca..5194407e9 100644 --- a/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py +++ b/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py @@ -14,6 +14,7 @@ NetflowMixin, QkdMixin, TtagMixin, + pd, ) @@ -37,9 +38,9 @@ class Layer3DciVrfLiteTemplateInputs( 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") - speed: str | None = Field(default=None, alias="speed") + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) - ip_redirects: bool | None = Field(default=None, alias="ipRedirects") - ipv4_pim: bool | None = Field(default=None, alias="ipv4Pim") - ipv6_pim: bool | None = Field(default=None, alias="ipv6Pim") + 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_underlay.py b/plugins/module_utils/models/links/templates/mpls_underlay.py index 07663876b..cdf7c0acf 100644 --- a/plugins/module_utils/models/links/templates/mpls_underlay.py +++ b/plugins/module_utils/models/links/templates/mpls_underlay.py @@ -11,6 +11,7 @@ InterfaceDescriptionsMixin, LinkTemplateBase, TtagMixin, + pd, ) @@ -25,14 +26,14 @@ class MplsUnderlayTemplateInputs( mpls_fabric_type: str | None = Field(default=None, alias="mplsFabricType") dci_routing_protocol: str | None = Field(default=None, alias="dciRoutingProtocol") - dci_routing_tag: str | None = Field(default=None, alias="dciRoutingTag") + dci_routing_tag: str | None = Field(default=None, alias="dciRoutingTag", json_schema_extra=pd("MPLS_UNDERLAY")) ospf_area_id: str | None = Field(default=None, alias="ospfAreaId") - sr_global_block_range: str | None = Field(default=None, alias="srGlobalBlockRange") + 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") dst_sr_index: int | None = Field(default=None, alias="dstSrIndex") src_ip_address_mask: str | None = Field(default=None, alias="srcIpAddressMask") dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") - link_mtu: int | None = Field(default=None, alias="linkMtu") + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216)) diff --git a/plugins/module_utils/models/links/templates/multisite_overlay.py b/plugins/module_utils/models/links/templates/multisite_overlay.py index b5ca6d7a9..0cc0899fb 100644 --- a/plugins/module_utils/models/links/templates/multisite_overlay.py +++ b/plugins/module_utils/models/links/templates/multisite_overlay.py @@ -7,7 +7,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field -from .base import EbgpPasswordMixin, LinkTemplateBase +from .base import EbgpPasswordMixin, LinkTemplateBase, pd class MultisiteOverlayTemplateInputs(EbgpPasswordMixin, LinkTemplateBase): @@ -19,14 +19,14 @@ class MultisiteOverlayTemplateInputs(EbgpPasswordMixin, LinkTemplateBase): dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") src_ip_address: str | None = Field(default=None, alias="srcIpAddress") dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") - ebgp_multihop: int | None = Field(default=None, alias="ebgpMultihop") + ebgp_multihop: int | None = Field(default=None, alias="ebgpMultihop", json_schema_extra=pd(5)) - ipv4_trm: bool | None = Field(default=None, alias="ipv4Trm") - ipv6_trm: bool | None = Field(default=None, alias="ipv6Trm") + 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") + 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") + skip_config_generation: bool | None = Field(default=None, alias="skipConfigGeneration", json_schema_extra=pd(False)) src_interface_description: str | None = Field(default=None, alias="srcInterfaceDescription") dst_interface_description: str | None = Field(default=None, alias="dstInterfaceDescription") diff --git a/plugins/module_utils/models/links/templates/multisite_underlay.py b/plugins/module_utils/models/links/templates/multisite_underlay.py index a3d13845d..ab4aa4da7 100644 --- a/plugins/module_utils/models/links/templates/multisite_underlay.py +++ b/plugins/module_utils/models/links/templates/multisite_underlay.py @@ -12,6 +12,7 @@ InterfaceDescriptionsMixin, LinkTemplateBase, TtagMixin, + pd, ) @@ -27,18 +28,18 @@ class MultisiteUnderlayTemplateInputs( src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn") dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") - ebgp_bfd: bool | None = Field(default=None, alias="ebgpBfd") - ebgp_log_neighbor_change: bool | None = Field(default=None, alias="ebgpLogNeighborChange") - ebgp_maximum_paths: int | None = Field(default=None, alias="ebgpMaximumPaths") - ebgp_send_comboth: bool | None = Field(default=None, alias="ebgpSendComboth") + 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)) + 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") - speed: str | None = Field(default=None, alias="speed") + link_mtu: int | None = Field(default=None, alias="linkMtu", json_schema_extra=pd(9216)) + speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) routing_tag: str | None = Field(default=None, alias="routingTag") - dci_tracking_enable_flag: bool | None = Field(default=None, alias="dciTrackingEnableFlag") + 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/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/vpc_peer_keepalive.py b/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py index ef0c179e4..c0784c55d 100644 --- a/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py +++ b/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py @@ -7,7 +7,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field -from .base import InterfaceDescriptionsMixin, LinkTemplateBase +from .base import InterfaceDescriptionsMixin, LinkTemplateBase, pd class VpcPeerKeepaliveTemplateInputs( @@ -25,5 +25,5 @@ class VpcPeerKeepaliveTemplateInputs( interface_vrf: str | None = Field(default=None, alias="interfaceVrf") - interface_admin_state: bool | None = Field(default=None, alias="interfaceAdminState") - mtu: int | None = Field(default=None, alias="mtu") + 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)) diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index e2b7b8a88..4e37811d5 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -2,9 +2,9 @@ # 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 @@ -15,17 +15,17 @@ 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] = {} + 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] = {} + self._gathered_spec: dict[str, Any] = {} - def format(self, **kwargs) -> 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. @@ -68,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. @@ -119,13 +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, - gathered_spec: Optional[Dict[str, Any]] = 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 @@ -135,7 +135,7 @@ 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 diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index d1569b884..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 @@ -83,6 +95,10 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest 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 @@ -92,8 +108,14 @@ def __init__(self, module: AnsibleModule, model_orchestrator: type[NDBaseOrchest # ``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. - raw_config = self.module.params.get("config") or [] - raw_config = self.model_orchestrator.prepare_config_data(raw_config) + # + # ``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 @@ -191,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 @@ -251,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 9daa8e5cb..b426c1837 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -211,7 +211,7 @@ 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]: + 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 diff --git a/plugins/module_utils/orchestrators/links.py b/plugins/module_utils/orchestrators/links.py index b116d1bf1..8f39f8b11 100644 --- a/plugins/module_utils/orchestrators/links.py +++ b/plugins/module_utils/orchestrators/links.py @@ -280,10 +280,20 @@ 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 exact wrapper differs across operations, so we scan - the known result containers and flag an item on any recognized failure - signal. The precise create/delete 207 shape should be confirmed against a - live response and this guard tightened if a narrower contract is known. + 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 diff --git a/plugins/modules/nd_manage_links.py b/plugins/modules/nd_manage_links.py index 563b87762..0b997f9b7 100644 --- a/plugins/modules/nd_manage_links.py +++ b/plugins/modules/nd_manage_links.py @@ -107,11 +107,6 @@ description: - The name of the destination interface, for example C(Ethernet1/1). type: str - link_type: - description: - - The type of the link. - type: str - default: multi_cluster_planned_link config_data: description: - The link policy configuration. @@ -124,6 +119,7 @@ - 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. @@ -139,6 +135,7 @@ - unnumbered - ipv6LinkLocal - ebgpVrfLite + - iosXeNumbered - layer2Dci - layer3DciVrfLite - multisiteOverlay @@ -159,11 +156,18 @@ - 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. 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.' + 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).' @@ -171,10 +175,13 @@ 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(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 + 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).' @@ -202,8 +209,8 @@ - '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_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).' + 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 @@ -493,7 +500,6 @@ dst_switch_name: v1-bgw1 src_interface_name: Ethernet1/12 dst_interface_name: Ethernet1/12 - link_type: multi_cluster_planned_link config_data: policy_type: multisiteUnderlay template_inputs: @@ -572,6 +578,119 @@ """ 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 @@ -662,7 +781,11 @@ def main() -> None: orchestrator = NDLinkOrchestrator(rest_send=rest_send, strategy=strategy) - state_machine = NDStateMachine(module=module, model_orchestrator=orchestrator) + # 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() diff --git a/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml index b45637e86..ce9d87a3a 100644 --- a/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml +++ b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml @@ -160,7 +160,7 @@ - policy_change_to_vrflite is failed - policy_change_to_vrflite.msg is search("policy_type") - - name: Attempt to change policy_type from numbered to preprovision in place (should fail) + - name: Reapply the original preprovision declaration over a realized (numbered) link cisco.nd.nd_manage_links: config: - src_fabric_name: '{{ test_fabric_numbered }}' @@ -174,14 +174,14 @@ template_inputs: src_interface_description: should not switch policy in place state: merged - register: policy_change_to_preprovision - ignore_errors: true + register: preprovision_over_realized - - name: Verify numbered → preprovision policy change rejected + - name: Verify the realized preprovision link is recognized and left as numbered (idempotent, no error) ansible.builtin.assert: that: - - policy_change_to_preprovision is failed - - policy_change_to_preprovision.msg is search("policy_type") + - preprovision_over_realized is not failed + - preprovision_over_realized.changed == false + - (preprovision_over_realized.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: diff --git a/tests/unit/module_utils/models/test_base_secrets.py b/tests/unit/module_utils/models/test_base_secrets.py index 8534acf11..927c3f28a 100644 --- a/tests/unit/module_utils/models/test_base_secrets.py +++ b/tests/unit/module_utils/models/test_base_secrets.py @@ -12,17 +12,17 @@ from __future__ import annotations -from typing import ClassVar, List, Optional +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[Optional[List[str]]] = ["name"] + identifiers: ClassVar[list[str] | None] = ["name"] identifier_strategy: ClassVar[str] = "single" - name: Optional[str] = None - password: Optional[str] = Field(default=None, json_schema_extra={"secret": True}, alias="pwd") + name: str | None = None + password: str | None = Field(default=None, json_schema_extra={"secret": True}, alias="pwd") class TestBaseSecretHandling: diff --git a/tests/unit/module_utils/models/test_links.py b/tests/unit/module_utils/models/test_links.py index 44898a9da..1153e7ab0 100644 --- a/tests/unit/module_utils/models/test_links.py +++ b/tests/unit/module_utils/models/test_links.py @@ -75,6 +75,41 @@ def test_user_defined_allows_extra_fields(): 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.""" @@ -91,6 +126,43 @@ def test_policy_marker_injection_does_not_mutate_input(): 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 # --------------------------------------------------------------------------- @@ -124,6 +196,40 @@ def test_non_secret_fields_survive_in_config(): 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 = { @@ -142,6 +248,236 @@ def test_collect_secret_values_empty_when_no_secrets(): 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_falls_back_on_read(): + """A supported policy carrying a field absent from its model is tolerated on read + (extra=forbid would otherwise abort the whole query).""" + link = NDLinkModel.from_response(_response_link("numbered", {"srcIp": "1.1.1.1", "someBrandNewField": "x"})) + assert link.is_unsupported_policy is True + assert link.config_data.policy_type == "numbered" + + +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_keeps_orientation(): + """Inter-fabric links (distinct fabric roles) are not collapsed by orientation.""" + 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() + + +# --------------------------------------------------------------------------- +# 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(): + """An existing numbered link (ND-realized) vs the original preprovision + declaration is no diff, so the module is idempotent and preserves ND values.""" + existing = _phys_link("LEAF1", "Ethernet1/1", "SPINE1", "Ethernet1/1", {"srcIp": "10.4.0.1", "dstIp": "10.4.0.2"}) + assert existing.get_diff(_proposed_preprovision()) is True + + +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 # --------------------------------------------------------------------------- @@ -185,3 +521,29 @@ def test_argument_spec_template_inputs_not_blanket_no_log(): 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_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/test_utils.py b/tests/unit/module_utils/test_utils.py index c0e599cac..d7d0f6e0d 100644 --- a/tests/unit/module_utils/test_utils.py +++ b/tests/unit/module_utils/test_utils.py @@ -8,9 +8,7 @@ Unit tests for module_utils/utils.py helpers. """ -from __future__ import absolute_import, annotations, division, print_function - -__metaclass__ = type # pylint: disable=invalid-name +from __future__ import annotations from ansible_collections.cisco.nd.plugins.module_utils.utils import prune_to_spec From 42acbbbe9ef24a25755ff5ea41cce67c18ed88b7 Mon Sep 17 00:00:00 2001 From: gsshreyas Date: Tue, 21 Jul 2026 17:24:33 -0400 Subject: [PATCH 4/4] [minor_change] nd_manage_links: address review feedback on validation, identity, preflight, preprovision, and orientation Co-Authored-By: Claude Opus 4.8 --- plugins/module_utils/models/links/links.py | 186 +++++++++---- .../models/links/templates/base.py | 119 +++++++-- .../models/links/templates/ebgp_vrf_lite.py | 10 +- .../models/links/templates/ios_xe_numbered.py | 5 +- .../models/links/templates/layer2_dci.py | 22 +- .../links/templates/layer3_dci_vrf_lite.py | 5 +- .../models/links/templates/mpls_overlay.py | 8 +- .../models/links/templates/mpls_underlay.py | 22 +- .../links/templates/multisite_overlay.py | 45 ++-- .../links/templates/multisite_underlay.py | 12 +- .../models/links/templates/preprovision.py | 13 +- .../links/templates/vpc_peer_keepalive.py | 2 +- plugins/module_utils/orchestrators/links.py | 56 +++- plugins/modules/nd_manage_links.py | 36 ++- .../tasks/discriminated_union.yml | 34 ++- tests/unit/module_utils/models/test_links.py | 247 +++++++++++++++++- .../module_utils/orchestrators/test_links.py | 50 ++++ 17 files changed, 732 insertions(+), 140 deletions(-) diff --git a/plugins/module_utils/models/links/links.py b/plugins/module_utils/models/links/links.py index 8532ff528..9447d910f 100644 --- a/plugins/module_utils/models/links/links.py +++ b/plugins/module_utils/models/links/links.py @@ -35,14 +35,18 @@ def merge(self, other: LinkConfigDataModel) -> LinkConfigDataModel: @model_validator(mode="wrap") @classmethod def _tolerate_unsupported_on_read(cls, data: Any, handler: Any, info: Any) -> LinkConfigDataModel: - """Preserve unsupported/odd controller links instead of aborting the read. + """Preserve genuinely unsupported controller links instead of aborting the read. - Strict parsing (discriminated union + ``extra="forbid"``) is kept for user - input -- write states thread ``context["state"]`` through validation -- so - bad config still fails fast. On a controller read (no state), a policy type - this module does not model, or a supported policy carrying a field absent - from its local model, is captured as an opaque ``UnsupportedTemplateInputs`` + 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) @@ -195,14 +199,23 @@ def describe_unsupported_policy(self) -> str: identity = self.link_id or self.get_identifier_value() return "Link {0} uses unsupported policy type '{1}'".format(identity, policy_type) - # --- Orientation independence (intra-fabric physical links) --------------- - # A physical intra-fabric link has no inherent direction: ND can return it - # with the endpoints reversed relative to the playbook. For such links the - # identity is made order-independent and the diff view is canonicalized, so a - # reversed link is matched (not duplicated on merged, found on deleted, not - # delete+recreated on overridden) and compares equal. Inter-fabric links - # (VRF lite, DCI, multisite) keep their orientation -- there src/dst are - # distinct fabric roles and some directional fields are asymmetric. + # --- 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"), @@ -210,17 +223,38 @@ def describe_unsupported_policy(self) -> str: ("srcSwitchName", "dstSwitchName"), ("srcInterfaceName", "dstInterfaceName"), ] - _TEMPLATE_DIRECTIONAL_ALIAS_PAIRS: ClassVar[list] = [ - ("srcIp", "dstIp"), - ("srcIpv6", "dstIpv6"), - ("srcInterfaceDescription", "dstInterfaceDescription"), - ("srcInterfaceConfig", "dstInterfaceConfig"), - ("dhcpRelayOnSrcInterface", "dhcpRelayOnDstInterface"), - ("bfdEchoOnSrcInterface", "bfdEchoOnDstInterface"), - ] - def _is_intra_fabric(self) -> bool: - return bool(self.src_fabric_name) and self.src_fabric_name == self.dst_fabric_name + @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 [] @@ -233,10 +267,11 @@ 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 intra-fabric links.""" - if not self._is_intra_fabric(): - return super().get_identifier_value() + """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 @@ -254,14 +289,13 @@ def _swap_keys(data: dict[str, Any], first: str, second: str) -> None: data[second] = first_value def _canonicalize_orientation(self, data: dict[str, Any]) -> None: - """Reorient a diff dict to a canonical endpoint order (intra-fabric only). + """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 physical - link compares equal instead of showing a spurious change. + 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. """ - if not self._is_intra_fabric(): - return src, dst = self._endpoints() if self._endpoint_key(src) <= self._endpoint_key(dst): return # already in canonical order @@ -269,34 +303,84 @@ def _canonicalize_orientation(self, data: dict[str, Any]) -> None: 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_ALIAS_PAIRS: + 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 unchanged ``preprovision`` - source must recognise this expected lifecycle rather than treating it as a - policy change. + 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 get_diff(self, other: Any, exclude_unset: bool = False) -> bool: - """Diff comparison, treating a realized preprovision->numbered link as unchanged. + 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. - Returns True (no diff) so the module keeps ND's realized ``numbered`` link - and its assigned values, stays idempotent, and never tries to convert it - back to ``preprovision``. + 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 True + 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. @@ -416,12 +500,20 @@ def get_argument_spec(cls) -> dict: options=dict( src_cluster_name=dict(type="str"), dst_cluster_name=dict(type="str"), - src_fabric_name=dict(type="str"), - dst_fabric_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"), - dst_interface_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"), diff --git a/plugins/module_utils/models/links/templates/base.py b/plugins/module_utils/models/links/templates/base.py index 5e71eb99c..c1dec4cdf 100644 --- a/plugins/module_utils/models/links/templates/base.py +++ b/plugins/module_utils/models/links/templates/base.py @@ -20,32 +20,51 @@ from typing import Any -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict, Field +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.""" + """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 most policy types.""" + """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)) - speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) - fec: str | None = Field(default=None, alias="fec", json_schema_extra=pd("auto")) + 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") - dst_interface_description: str | None = Field(default=None, alias="dstInterfaceDescription") + 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") @@ -88,14 +107,14 @@ class QkdMixin(BaseModel): 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") - dst_kme_server_port_number: int | None = Field(default=None, alias="dstKmeServerPortNumber") + 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") - dst_qkd_profile_name: str | None = Field(default=None, alias="dstQkdProfileName") - src_trustpoint_label: str | None = Field(default=None, alias="srcTrustpointLabel") - dst_trustpoint_label: str | None = Field(default=None, alias="dstTrustpointLabel") + 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): @@ -103,7 +122,9 @@ class EbgpPasswordMixin(BaseModel): 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")) + 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)) @@ -133,6 +154,74 @@ class LinkTemplateBase(NDNestedModel): 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}``). diff --git a/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py index 0e41cf1e4..7517ea5ac 100644 --- a/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py +++ b/plugins/module_utils/models/links/templates/ebgp_vrf_lite.py @@ -8,11 +8,13 @@ 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, ) @@ -28,15 +30,15 @@ class EbgpVrfLiteTemplateInputs( policy_type_marker: Literal["ebgpVrfLite"] = Field(default="ebgpVrfLite", exclude=True) - src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn") - dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") + 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)) + 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)) @@ -47,7 +49,7 @@ class EbgpVrfLiteTemplateInputs( 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") + 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")) diff --git a/plugins/module_utils/models/links/templates/ios_xe_numbered.py b/plugins/module_utils/models/links/templates/ios_xe_numbered.py index e099955c5..83aa15327 100644 --- a/plugins/module_utils/models/links/templates/ios_xe_numbered.py +++ b/plugins/module_utils/models/links/templates/ios_xe_numbered.py @@ -8,6 +8,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field from .base import ( + SPEED_CHOICES, InterfaceDescriptionsMixin, LinkTemplateBase, pd, @@ -30,5 +31,5 @@ class IosXeNumberedTemplateInputs( 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")) - mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9198)) + 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/layer2_dci.py b/plugins/module_utils/models/links/templates/layer2_dci.py index 62fff46e4..1d53b399c 100644 --- a/plugins/module_utils/models/links/templates/layer2_dci.py +++ b/plugins/module_utils/models/links/templates/layer2_dci.py @@ -8,30 +8,38 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field from .base import ( + SPEED_CHOICES, InterfaceDescriptionsMixin, LinkTemplateBase, MacsecFullMixin, QkdMixin, - TtagMixin, + con, pd, ) +BPDU_GUARD_CHOICES = ("enable", "disable", "default") +MTU_TYPE_CHOICES = ("default", "jumbo") + class Layer2DciTemplateInputs( InterfaceDescriptionsMixin, MacsecFullMixin, QkdMixin, - TtagMixin, LinkTemplateBase, ): - """Template inputs for policy_type=layer2Dci (L2 trunk between data centers).""" + """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") - bpdu_guard: str | None = Field(default=None, alias="bpduGuard") + 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") - speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) + 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 index 5194407e9..345359f9a 100644 --- a/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py +++ b/plugins/module_utils/models/links/templates/layer3_dci_vrf_lite.py @@ -8,6 +8,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field from .base import ( + SPEED_CHOICES, InterfaceDescriptionsMixin, LinkTemplateBase, MacsecFullMixin, @@ -38,8 +39,8 @@ class Layer3DciVrfLiteTemplateInputs( 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)) - speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) + 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)) diff --git a/plugins/module_utils/models/links/templates/mpls_overlay.py b/plugins/module_utils/models/links/templates/mpls_overlay.py index f431baa22..989537711 100644 --- a/plugins/module_utils/models/links/templates/mpls_overlay.py +++ b/plugins/module_utils/models/links/templates/mpls_overlay.py @@ -7,7 +7,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field -from .base import LinkTemplateBase +from .base import LinkTemplateBase, con class MplsOverlayTemplateInputs(LinkTemplateBase): @@ -15,6 +15,6 @@ class MplsOverlayTemplateInputs(LinkTemplateBase): policy_type_marker: Literal["mplsOverlay"] = Field(default="mplsOverlay", exclude=True) - src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn") - dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") - dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") + 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 index cdf7c0acf..5f138c3c5 100644 --- a/plugins/module_utils/models/links/templates/mpls_underlay.py +++ b/plugins/module_utils/models/links/templates/mpls_underlay.py @@ -11,9 +11,13 @@ InterfaceDescriptionsMixin, LinkTemplateBase, TtagMixin, + con, pd, ) +MPLS_FABRIC_TYPE_CHOICES = ("mplsLdp", "mplsSr") +DCI_ROUTING_PROTOCOL_CHOICES = ("ospf", "is-is") + class MplsUnderlayTemplateInputs( InterfaceDescriptionsMixin, @@ -24,16 +28,18 @@ class MplsUnderlayTemplateInputs( policy_type_marker: Literal["mplsUnderlay"] = Field(default="mplsUnderlay", exclude=True) - mpls_fabric_type: str | None = Field(default=None, alias="mplsFabricType") - dci_routing_protocol: str | None = Field(default=None, alias="dciRoutingProtocol") - dci_routing_tag: str | None = Field(default=None, alias="dciRoutingTag", json_schema_extra=pd("MPLS_UNDERLAY")) + 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") - dst_sr_index: int | None = Field(default=None, alias="dstSrIndex") + 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") - dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") + 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)) + 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 index 0cc0899fb..a171a35a9 100644 --- a/plugins/module_utils/models/links/templates/multisite_overlay.py +++ b/plugins/module_utils/models/links/templates/multisite_overlay.py @@ -7,19 +7,39 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field -from .base import EbgpPasswordMixin, LinkTemplateBase, pd - - -class MultisiteOverlayTemplateInputs(EbgpPasswordMixin, LinkTemplateBase): - """Template inputs for policy_type=multisiteOverlay (BGW overlay eBGP session).""" +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") - dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") - src_ip_address: str | None = Field(default=None, alias="srcIpAddress") - dst_ip_address: str | None = Field(default=None, alias="dstIpAddress") - ebgp_multihop: int | None = Field(default=None, alias="ebgpMultihop", json_schema_extra=pd(5)) + 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)) @@ -27,8 +47,3 @@ class MultisiteOverlayTemplateInputs(EbgpPasswordMixin, LinkTemplateBase): 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)) - - src_interface_description: str | None = Field(default=None, alias="srcInterfaceDescription") - dst_interface_description: str | None = Field(default=None, alias="dstInterfaceDescription") - - macsec_cipher_suite: str | None = Field(default=None, alias="macsecCipherSuite") diff --git a/plugins/module_utils/models/links/templates/multisite_underlay.py b/plugins/module_utils/models/links/templates/multisite_underlay.py index ab4aa4da7..35a8fff42 100644 --- a/plugins/module_utils/models/links/templates/multisite_underlay.py +++ b/plugins/module_utils/models/links/templates/multisite_underlay.py @@ -8,10 +8,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field from .base import ( + SPEED_CHOICES, EbgpPasswordMixin, InterfaceDescriptionsMixin, LinkTemplateBase, TtagMixin, + con, pd, ) @@ -26,11 +28,11 @@ class MultisiteUnderlayTemplateInputs( policy_type_marker: Literal["multisiteUnderlay"] = Field(default="multisiteUnderlay", exclude=True) - src_ebgp_asn: str | None = Field(default=None, alias="srcEbgpAsn") - dst_ebgp_asn: str | None = Field(default=None, alias="dstEbgpAsn") + 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)) + 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") @@ -38,8 +40,8 @@ class MultisiteUnderlayTemplateInputs( 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)) - speed: str | None = Field(default=None, alias="speed", json_schema_extra=pd("auto")) + 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/preprovision.py b/plugins/module_utils/models/links/templates/preprovision.py index ed142e4df..8d8b2a25d 100644 --- a/plugins/module_utils/models/links/templates/preprovision.py +++ b/plugins/module_utils/models/links/templates/preprovision.py @@ -8,8 +8,10 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field from .base import ( + SPEED_CHOICES, InterfaceDescriptionsMixin, LinkTemplateBase, + pd, ) @@ -19,9 +21,14 @@ class PreprovisionTemplateInputs( ): """Template inputs for policy_type=preprovision. - Limited to the four interface description and config fields ND returns for this - policy; including others (mtu, speed, fec, interface_admin_state) breaks - idempotency because ND drops them on read. + 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/vpc_peer_keepalive.py b/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py index c0784c55d..294094270 100644 --- a/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py +++ b/plugins/module_utils/models/links/templates/vpc_peer_keepalive.py @@ -26,4 +26,4 @@ class VpcPeerKeepaliveTemplateInputs( 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)) + mtu: int | None = Field(default=None, alias="mtu", json_schema_extra=pd(9216, minimum=576, maximum=9216)) diff --git a/plugins/module_utils/orchestrators/links.py b/plugins/module_utils/orchestrators/links.py index 8f39f8b11..fa42cd4d3 100644 --- a/plugins/module_utils/orchestrators/links.py +++ b/plugins/module_utils/orchestrators/links.py @@ -5,7 +5,7 @@ import copy import logging -from typing import Any, ClassVar +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 @@ -206,7 +206,46 @@ def _is_policy_type_change(self, model_instance: NDLinkModel) -> bool: if model_instance.config_data and model_instance.config_data.policy_type: proposed_policy = model_instance.config_data.policy_type - return proposed_policy is not None and proposed_policy != existing_policy + 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).""" @@ -232,16 +271,9 @@ def create_bulk(self, model_instances: list[NDLinkModel], **kwargs: Any) -> Resp def update(self, model_instance: NDLinkModel, **kwargs: Any) -> ResponseType: """PUT /links/{linkId}; rejects cross policy updates (needs delete and recreate).""" - if self._is_policy_type_change(model_instance): - 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) - ) + # 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) diff --git a/plugins/modules/nd_manage_links.py b/plugins/modules/nd_manage_links.py index 0b997f9b7..dfaf72fb2 100644 --- a/plugins/modules/nd_manage_links.py +++ b/plugins/modules/nd_manage_links.py @@ -64,11 +64,15 @@ 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. @@ -102,11 +106,15 @@ 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. @@ -725,7 +733,10 @@ def determine_strategy(module: AnsibleModule) -> ManageLinkStrategy | OneManageL ) if link_scope == "auto": - config = module.params.get("config", []) + # ``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( @@ -738,6 +749,28 @@ def determine_strategy(module: AnsibleModule) -> ManageLinkStrategy | OneManageL 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()) @@ -767,6 +800,7 @@ def main() -> None: try: strategy = determine_strategy(module) NDLinkModel.identifiers = strategy.identifier_fields + validate_scope_identity(module, strategy) sender = Sender() sender.ansible_module = module diff --git a/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml index ce9d87a3a..7b41eeba6 100644 --- a/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml +++ b/tests/integration/targets/nd_manage_links/tasks/discriminated_union.yml @@ -160,7 +160,7 @@ - policy_change_to_vrflite is failed - policy_change_to_vrflite.msg is search("policy_type") - - name: Reapply the original preprovision declaration over a realized (numbered) link + - 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 }}' @@ -172,17 +172,43 @@ config_data: policy_type: preprovision template_inputs: - src_interface_description: should not switch policy in place + src_interface_description: persistent intent applied on realized link state: merged register: preprovision_over_realized - - name: Verify the realized preprovision link is recognized and left as numbered (idempotent, no error) + # 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 == false + - 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: diff --git a/tests/unit/module_utils/models/test_links.py b/tests/unit/module_utils/models/test_links.py index 1153e7ab0..fb027ece6 100644 --- a/tests/unit/module_utils/models/test_links.py +++ b/tests/unit/module_utils/models/test_links.py @@ -285,12 +285,19 @@ def test_unsupported_policy_read_does_not_raise_and_is_flagged(): assert raw.get("interfaceVrf") == "default" -def test_supported_policy_with_unknown_field_falls_back_on_read(): - """A supported policy carrying a field absent from its model is tolerated on read - (extra=forbid would otherwise abort the whole query).""" +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 True + 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(): @@ -426,11 +433,65 @@ def test_payload_keeps_user_orientation(): assert payload["configData"]["templateInputs"]["srcIp"] == "10.0.0.2" -def test_inter_fabric_identity_keeps_orientation(): - """Inter-fabric links (distinct fabric roles) are not collapsed by orientation.""" +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() + 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 # --------------------------------------------------------------------------- @@ -454,10 +515,74 @@ def _proposed_preprovision(): def test_realized_preprovision_numbered_is_treated_as_unchanged(): - """An existing numbered link (ND-realized) vs the original preprovision - declaration is no diff, so the module is idempotent and preserves ND values.""" - existing = _phys_link("LEAF1", "Ethernet1/1", "SPINE1", "Ethernet1/1", {"srcIp": "10.4.0.1", "dstIp": "10.4.0.2"}) + """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(): @@ -530,6 +655,108 @@ def test_argument_spec_omits_read_only_link_type(): 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( diff --git a/tests/unit/module_utils/orchestrators/test_links.py b/tests/unit/module_utils/orchestrators/test_links.py index 5f2ca4f81..b1453d464 100644 --- a/tests/unit/module_utils/orchestrators/test_links.py +++ b/tests/unit/module_utils/orchestrators/test_links.py @@ -14,6 +14,7 @@ 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 @@ -159,3 +160,52 @@ def test_prepare_config_data_backfills_on_copy_not_input(): 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