From 6ef104e2c1aa067d33138f77bca27ca42694b294 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 19 May 2026 16:47:15 -1000 Subject: [PATCH 01/12] Add nd_fabric_update_group module for ND 4.2 update groups Add the nd_fabric_update_group module to manage Fabric Software Management update groups (the ND 4.2 successor to NDFC/ND 3.x image policies). Built on the Gen 3 architecture: FabricUpdateGroupModel, FabricUpdateGroupOrchestrator, smart endpoints, and NDStateMachine. Supports merged, replaced, overridden, and deleted states. - update_group_switches / installation_order_devices accept switch IPs or serial numbers; IPs are resolved to switchIds via FabricContext. - Two ND 4.2.1 wire workarounds: installationOrderDevices is silently dropped on write (excluded from the idempotency diff, TODO(4.2.1)); the ND-managed default group named "None" is filtered from query results. - Adds UpdateGroupNameMixin to endpoints/mixins.py. - 58 unit tests (model + orchestrator) and an integration test target. Note: this work is postponed pending an ND-side fix for a zero-switch "ghost group" defect that breaks idempotent reconciliation of overridden state. A bug has been filed with the ND developers. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/module_utils/endpoints/mixins.py | 8 +- .../v1/manage/fabric_update_group.py | 216 +++++ .../models/fabric_update_group/__init__.py | 0 .../fabric_update_group.py | 193 +++++ .../orchestrators/fabric_update_group.py | 366 ++++++++ plugins/modules/nd_fabric_update_group.py | 222 +++++ .../nd_fabric_update_group/tasks/deleted.yaml | 33 + .../nd_fabric_update_group/tasks/main.yaml | 48 ++ .../nd_fabric_update_group/tasks/merged.yaml | 70 ++ .../tasks/overridden.yaml | 28 + .../tasks/replaced.yaml | 36 + .../nd_fabric_update_group/tasks/setup.yaml | 21 + .../nd_fabric_update_group/vars/main.yaml | 64 ++ .../test_fabric_update_group.json | 308 +++++++ .../models/test_fabric_update_group.py | 653 ++++++++++++++ .../orchestrators/test_fabric_update_group.py | 811 ++++++++++++++++++ 16 files changed, 3076 insertions(+), 1 deletion(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/fabric_update_group.py create mode 100644 plugins/module_utils/models/fabric_update_group/__init__.py create mode 100644 plugins/module_utils/models/fabric_update_group/fabric_update_group.py create mode 100644 plugins/module_utils/orchestrators/fabric_update_group.py create mode 100644 plugins/modules/nd_fabric_update_group.py create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/deleted.yaml create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/main.yaml create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/merged.yaml create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/overridden.yaml create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/replaced.yaml create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/setup.yaml create mode 100644 tests/integration/targets/nd_fabric_update_group/vars/main.yaml create mode 100644 tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json create mode 100644 tests/unit/module_utils/models/test_fabric_update_group.py create mode 100644 tests/unit/module_utils/orchestrators/test_fabric_update_group.py diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index 68e0338cb..a3af93024 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -13,11 +13,11 @@ from typing import Optional -from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, Field, ) +from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum class ClusterNameMixin(BaseModel): @@ -86,6 +86,12 @@ class SwitchSerialNumberMixin(BaseModel): switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") +class UpdateGroupNameMixin(BaseModel): + """Mixin for endpoints that require update_group_name parameter.""" + + update_group_name: Optional[str] = Field(default=None, min_length=1, description="Update group name") + + class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" diff --git a/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py new file mode 100644 index 000000000..fd4763127 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py @@ -0,0 +1,216 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Update Group endpoint models. + +This module contains endpoint definitions for fabric update group operations +under the ND Manage Fabric Software Management API. + +## Endpoints + +- `EpFabricUpdateGroupListGet` - List update groups in a fabric + (GET /api/v1/manage/fabrics/{fabric_name}/updateGroups) +- `EpFabricUpdateGroupPost` - Create one or more update groups in a fabric + (POST /api/v1/manage/fabrics/{fabric_name}/updateGroups) +- `EpFabricUpdateGroupGet` - Get a specific update group by name + (GET /api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}) +- `EpFabricUpdateGroupPut` - Update an existing update group + (PUT /api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}) +- `EpFabricUpdateGroupDelete` - Delete an update group + (DELETE /api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}) +""" + +from __future__ import annotations + +from typing import ClassVar, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import FabricNameMixin, UpdateGroupNameMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpFabricUpdateGroupBase(UpdateGroupNameMixin, FabricNameMixin, NDEndpointBaseModel): + """ + Base class for ND Manage Fabric Update Group endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabric_name}/updateGroups endpoint. + + Subclasses may override: + - ``_require_update_group_name``: set to ``False`` for collection-level endpoints + (list, create) that do not include an update group name in the path. + """ + + _require_update_group_name: ClassVar[bool] = True + + def set_identifiers(self, identifier: IdentifierKey = None): + self.update_group_name = identifier + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with required fabric_name and optional update_group_name. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set when accessing `path` + - If `update_group_name` is required but not set + """ + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + if self._require_update_group_name and self.update_group_name is None: + raise ValueError(f"{type(self).__name__}.path: update_group_name must be set before accessing path.") + segments = ["fabrics", self.fabric_name, "updateGroups"] + if self.update_group_name is not None: + segments.append(self.update_group_name) + return BasePath.path(*segments) + + +class EpFabricUpdateGroupListGet(_EpFabricUpdateGroupBase): + """ + # Summary + + ND Manage Fabric Update Group List GET endpoint. + + ## Path + + - `/api/v1/manage/fabrics/{fabric_name}/updateGroups` + + ## Verb + + - GET + + ## Usage + + ```python + ep = EpFabricUpdateGroupListGet() + ep.fabric_name = "SITE1" + rest_send.path = ep.path + rest_send.verb = ep.verb + ``` + """ + + _require_update_group_name: ClassVar[bool] = False + + class_name: Literal["EpFabricUpdateGroupListGet"] = Field( + default="EpFabricUpdateGroupListGet", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpFabricUpdateGroupPost(_EpFabricUpdateGroupBase): + """ + # Summary + + ND Manage Fabric Update Group POST endpoint. + + ## Path + + - `/api/v1/manage/fabrics/{fabric_name}/updateGroups` + + ## Verb + + - POST + + ## Usage + + ```python + ep = EpFabricUpdateGroupPost() + ep.fabric_name = "SITE1" + rest_send.path = ep.path + rest_send.verb = ep.verb + rest_send.payload = {...} + ``` + """ + + _require_update_group_name: ClassVar[bool] = False + + class_name: Literal["EpFabricUpdateGroupPost"] = Field(default="EpFabricUpdateGroupPost", frozen=True, description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpFabricUpdateGroupGet(_EpFabricUpdateGroupBase): + """ + # Summary + + ND Manage Fabric Update Group GET endpoint. + + ## Path + + - `/api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}` + + ## Verb + + - GET + """ + + class_name: Literal["EpFabricUpdateGroupGet"] = Field(default="EpFabricUpdateGroupGet", frozen=True, description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpFabricUpdateGroupPut(_EpFabricUpdateGroupBase): + """ + # Summary + + ND Manage Fabric Update Group PUT endpoint. + + ## Path + + - `/api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}` + + ## Verb + + - PUT + """ + + class_name: Literal["EpFabricUpdateGroupPut"] = Field(default="EpFabricUpdateGroupPut", frozen=True, description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +class EpFabricUpdateGroupDelete(_EpFabricUpdateGroupBase): + """ + # Summary + + ND Manage Fabric Update Group DELETE endpoint. + + ## Path + + - `/api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}` + + ## Verb + + - DELETE + """ + + class_name: Literal["EpFabricUpdateGroupDelete"] = Field( + default="EpFabricUpdateGroupDelete", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.DELETE diff --git a/plugins/module_utils/models/fabric_update_group/__init__.py b/plugins/module_utils/models/fabric_update_group/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py new file mode 100644 index 000000000..55f6cdc6e --- /dev/null +++ b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py @@ -0,0 +1,193 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Pydantic model for a fabric update group on Nexus Dashboard. + +A fabric update group ties together a set of switches in a fabric with the image / package install plan +and orchestration knobs (execution mode, contingency, analysis, maintenance, reports) used by the +Fabric Software Management workflow. Identifier: `update_group_name` (single, fabric-scoped). +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Dict, List, Literal, Optional + +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.nested import NDNestedModel + + +class InstallImageDataModel(NDNestedModel): + """ + # Summary + + Install image data sub-block of a fabric update group. + + Wire shape (under `installImageData`): + + ```json + { + "epldImageName": "n9000-epld.9.3.13.img", + "nosImageName": "nxos.9.3.13.bin", + "installPackageNames": ["nxos.CSC...rpm"], + "uninstallPackage": true + } + ``` + + ## Raises + + None + """ + + nos_image_name: Optional[str] = Field(default=None, alias="nosImageName") + epld_image_name: Optional[str] = Field(default=None, alias="epldImageName") + install_package_names: Optional[List[str]] = Field(default=None, alias="installPackageNames") + uninstall_package: Optional[bool] = Field(default=None, alias="uninstallPackage") + + +class UpdateReportCheckModel(NDNestedModel): + """ + # Summary + + Item in the `updateReportChecks` list. Each item names a pre / post upgrade report check. + + Wire shape: + + ```json + { "reportCheckName": "sh_version" } + ``` + + ## Raises + + None + """ + + report_check_name: str = Field(alias="reportCheckName") + + +# Enum literal aliases for readability +ExecutionLiteral = Literal["parallel", "serial"] +ContingencyLiteral = Literal["continue", "pause"] +AnalysisLiteral = Literal["snapshot", "noAnalysis", "fullAnalysis", "usePreExistingAnalysis"] +ReportSelectionLiteral = Literal["noReport", "basic", "advanced"] +ReportsLiteral = Literal["noReport", "usePreExistingReports", "useDefaultPreAndPostReports", "useAdvancePreAndPostReports"] + + +class FabricUpdateGroupModel(NDBaseModel): + """ + # Summary + + Fabric update group configuration for Nexus Dashboard. + + Identifier: `update_group_name` (single). Fabric scope is supplied externally by the orchestrator, + not stored on the model. + + ## Raises + + None + """ + + identifiers: ClassVar[Optional[List[str]]] = ["update_group_name"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + + # TODO(4.2.1) ND silently drops `installationOrderDevices` on the updateGroups create/update endpoints. + # The POST/PUT accept the field without error, but GET (single and list) never echoes it back. We still + # send it (ND may consume it during the actual upgrade run), but it must be excluded from the idempotency + # diff - otherwise a re-applied, unchanged config is perpetually reported as `changed` because the wire + # state never carries the field. Observed on ND 4.2.1, fabric SITE1. + exclude_from_diff: ClassVar[set] = {"installation_order_devices"} + + # --- Fields --- + + update_group_name: str = Field(alias="updateGroupName") + execution: Optional[ExecutionLiteral] = Field(default=None, alias="execution") + contingency: Optional[ContingencyLiteral] = Field(default=None, alias="contingency") + analysis: Optional[AnalysisLiteral] = Field(default=None, alias="analysis") + is_maintenance: Optional[bool] = Field(default=None, alias="isMaintenance") + is_disruptive_update: Optional[bool] = Field(default=None, alias="isDisruptiveUpdate") + update_group_switches: Optional[List[str]] = Field(default=None, alias="updateGroupSwitches") + install_image_data: Optional[InstallImageDataModel] = Field(default=None, alias="installImageData") + installation_order_devices: Optional[List[str]] = Field(default=None, alias="installationOrderDevices") + recommended_version: Optional[str] = Field(default=None, alias="recommendedVersion") + latest_recommended_version: Optional[str] = Field(default=None, alias="latestRecommendedVersion") + report_selection: Optional[ReportSelectionLiteral] = Field(default=None, alias="reportSelection") + reports: Optional[ReportsLiteral] = Field(default=None, alias="reports") + update_report_checks: Optional[List[UpdateReportCheckModel]] = Field(default=None, alias="updateReportChecks") + + # --- Validators (Deserialization) --- + + @model_validator(mode="before") + @classmethod + def _drop_unwanted_top_level_keys(cls, data: Any) -> Any: + """ + # Summary + + Strip keys that ND may echo back in a GET response but that we do not store on the model. + + ## Raises + + None + """ + if isinstance(data, dict): + for key in ("fabricName", "createTime", "modifyTime"): + data.pop(key, None) + return data + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + return dict( + fabric_name=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + options=dict( + update_group_name=dict(type="str", required=True), + execution=dict(type="str", choices=["parallel", "serial"]), + contingency=dict(type="str", choices=["continue", "pause"]), + analysis=dict( + type="str", + choices=["snapshot", "noAnalysis", "fullAnalysis", "usePreExistingAnalysis"], + ), + is_maintenance=dict(type="bool"), + is_disruptive_update=dict(type="bool"), + update_group_switches=dict(type="list", elements="str"), + install_image_data=dict( + type="dict", + options=dict( + nos_image_name=dict(type="str"), + epld_image_name=dict(type="str"), + install_package_names=dict(type="list", elements="str"), + uninstall_package=dict(type="bool"), + ), + ), + installation_order_devices=dict(type="list", elements="str"), + recommended_version=dict(type="str"), + latest_recommended_version=dict(type="str"), + report_selection=dict(type="str", choices=["noReport", "basic", "advanced"]), + reports=dict( + type="str", + choices=[ + "noReport", + "usePreExistingReports", + "useDefaultPreAndPostReports", + "useAdvancePreAndPostReports", + ], + ), + update_report_checks=dict( + type="list", + elements="dict", + options=dict( + report_check_name=dict(type="str", required=True), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py new file mode 100644 index 000000000..453e6942d --- /dev/null +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -0,0 +1,366 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Fabric update group orchestrator for Nexus Dashboard. + +Implements CRUD operations for fabric update groups via the ND Manage Fabric Software Management API. +Fabric name is supplied by the module's top-level `fabric_name` option and propagated to every endpoint +instance prior to path generation; per-config identifier is `update_group_name`. + +`update_group_switches` and `installation_order_devices` accept either switch IP addresses or switch +serial numbers (switchIds). IPs are resolved to switchIds via `FabricContext` before being sent on the +wire; switchIds in GET responses are converted back to IPs so playbook authors see consistent IP-based +output even though the wire stores serials. + +POST is bulk-only: the wire shape is `{"updateGroups": [...]}`, returning HTTP 207 with per-item +`status` ("success" or "error"). `create()` wraps a single payload in the bulk shape and inspects +the per-item status before returning. `create_bulk()` sends N groups in a single POST. + +PUT, DELETE, and per-name GET take the single update group as a flat dict / path-only. +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Type + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.fabric_update_group import ( + EpFabricUpdateGroupDelete, + EpFabricUpdateGroupGet, + EpFabricUpdateGroupListGet, + EpFabricUpdateGroupPost, + EpFabricUpdateGroupPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.fabric_context import FabricContext +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_update_group.fabric_update_group import FabricUpdateGroupModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType + +# Wire payload keys whose list-valued contents are switch identifiers (IP or switchId) +_SWITCH_LIST_PAYLOAD_KEYS = ("updateGroupSwitches", "installationOrderDevices") + + +class FabricUpdateGroupOrchestrator(NDBaseOrchestrator[FabricUpdateGroupModel]): + """ + # Summary + + Orchestrator for fabric update group CRUD on Nexus Dashboard. + + Reads `fabric_name` from module params and applies it to every endpoint instance before path + generation. Resolves switch IPs to switchIds (and back) via `FabricContext` so users can specify + either form in `update_group_switches` / `installation_order_devices`. POST is bulk-only - single + create calls are wrapped in `{"updateGroups": [payload]}`. + + ## Raises + + ### RuntimeError + + - Via `create` if the create API request fails or any per-item status is "error". + - Via `update` if the update API request fails. + - Via `delete` if the delete API request fails. + - Via `query_one` if the query API request fails. + - Via `query_all` if the query API request fails. + - Via `_resolve_switch_id` if the user-provided switch IP cannot be matched in the fabric. + """ + + model_class: ClassVar[Type[NDBaseModel]] = FabricUpdateGroupModel + supports_bulk_create: ClassVar[bool] = True + + create_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupPost + update_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupPut + delete_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupDelete + query_one_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupListGet + create_bulk_endpoint: Type[NDEndpointBaseModel] | None = EpFabricUpdateGroupPost + + _fabric_context: FabricContext | None = None + + @property + def fabric_name(self) -> str: + """ + # Summary + + Return `fabric_name` from module params. + + ## Raises + + None + """ + return self.rest_send.params.get("fabric_name") + + @property + def fabric_context(self) -> FabricContext: + """ + # Summary + + Return a lazily-initialized `FabricContext` for this orchestrator's fabric. Used to resolve + switch IPs to switchIds (and back). + + ## Raises + + None + """ + if self._fabric_context is None: + self._fabric_context = FabricContext(rest_send=self.rest_send, fabric_name=self.fabric_name) + return self._fabric_context + + def _configure_endpoint(self, api_endpoint): + """ + # Summary + + Set `fabric_name` on an endpoint instance before path generation. + + ## Raises + + None + """ + api_endpoint.fabric_name = self.fabric_name + return api_endpoint + + @staticmethod + def _looks_like_ip(value: str) -> bool: + """ + # Summary + + Heuristic: a string with a dot is an IP address; anything else is treated as a switch serial + number (switchIds in the field, e.g. `FDO12345ABC`, never contain dots). + + ## Raises + + None + """ + return isinstance(value, str) and "." in value + + def _resolve_switch_id(self, value: str) -> str: + """ + # Summary + + Resolve a user-supplied switch identifier to a switchId. IP addresses are looked up via + `FabricContext`; switchId strings are returned unchanged. + + ## Raises + + ### RuntimeError + + - If the value looks like an IP but no switch with that `fabricManagementIp` exists in the fabric. + """ + if self._looks_like_ip(value): + return self.fabric_context.get_switch_id(value) + return value + + def _resolve_switches_in_payload(self, payload: dict) -> dict: + """ + # Summary + + Replace IP entries in `updateGroupSwitches` and `installationOrderDevices` with the matching + switchIds before sending. Mutates the payload dict in place and returns it. + + ## Raises + + ### RuntimeError + + - If any IP in either list cannot be resolved in the fabric (propagated from `_resolve_switch_id`). + """ + for key in _SWITCH_LIST_PAYLOAD_KEYS: + values = payload.get(key) + if isinstance(values, list): + payload[key] = [self._resolve_switch_id(v) for v in values] + return payload + + def _denormalize_switches_in_response(self, item: Any) -> Any: + """ + # Summary + + For a single GET response dict, replace switchIds in `updateGroupSwitches` and + `installationOrderDevices` with their matching IPs so the user sees consistent IP-based output. + + Denormalization is best-effort: if the switch map cannot be loaded (e.g. the inventory call + fails), the response is returned unmodified rather than raising — switchIds shown to the user + are still correct, just not user-friendlier IPs. Per-switchId lookups that miss the map (stale + serials) are also passed through unchanged. + + ## Raises + + None + """ + if not isinstance(item, dict): + return item + if not any(isinstance(item.get(key), list) for key in _SWITCH_LIST_PAYLOAD_KEYS): + return item + try: + switch_map_by_id = self.fabric_context.switch_map_by_id + except Exception: # pylint: disable=broad-except + return item + for key in _SWITCH_LIST_PAYLOAD_KEYS: + values = item.get(key) + if isinstance(values, list): + item[key] = [switch_map_by_id.get(v, v) for v in values] + return item + + @staticmethod + def _raise_on_207_item_errors(result: Any, expected_names: list[str]) -> None: + """ + # Summary + + Inspect a POST 207 response of shape `{"updateGroups": [{"updateGroupName": "X", "status": "...", "message": "..."}]}` + and raise `RuntimeError` if any item reports `status != "success"`. + + ## Raises + + ### RuntimeError + + - If any item in the response reports a non-success status. + """ + if not isinstance(result, dict): + return + items = result.get("updateGroups") + if not isinstance(items, list): + return + failures = [item for item in items if isinstance(item, dict) and item.get("status") and item.get("status") != "success"] + if failures: + details = ", ".join(f"{item.get('updateGroupName')}: {item.get('status')} - {item.get('message')}" for item in failures) + raise RuntimeError(f"Per-item failures in bulk create response: {details}") + + def create(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: + """ + # Summary + + Create a single fabric update group. Resolves any IPs in `update_group_switches` / + `installation_order_devices` to switchIds, wraps the payload in the bulk `{"updateGroups": [...]}` + shape required by the ND POST endpoint, and inspects per-item status in the 207 response. + + ## Raises + + ### RuntimeError + + - If a switch IP cannot be resolved, the POST request fails, or any per-item status is not "success". + """ + try: + api_endpoint = self._configure_endpoint(self.create_endpoint()) + payload = self._resolve_switches_in_payload(model_instance.to_payload()) + body = {"updateGroups": [payload]} + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=body) + self._raise_on_207_item_errors(result, [model_instance.update_group_name]) + return result + except Exception as e: + raise RuntimeError(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e + + def create_bulk(self, model_instances: list[FabricUpdateGroupModel], **kwargs) -> ResponseType: + """ + # Summary + + Create multiple fabric update groups in a single POST. Resolves switch IPs to switchIds on each + payload before sending. The wire endpoint accepts a list of groups under the `updateGroups` key + and returns per-item status in a 207 response. + + ## Raises + + ### RuntimeError + + - If a switch IP cannot be resolved, the POST request fails, or any per-item status is not "success". + """ + try: + api_endpoint = self._configure_endpoint(self.create_bulk_endpoint()) # pyright: ignore[reportOptionalCall] + payloads = [self._resolve_switches_in_payload(m.to_payload()) for m in model_instances] + body = {"updateGroups": payloads} + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=body) + self._raise_on_207_item_errors(result, [m.update_group_name for m in model_instances]) + return result + except Exception as e: + raise RuntimeError(f"Bulk create failed: {e}") from e + + def update(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: + """ + # Summary + + Update a single fabric update group by name. Resolves any IPs in `update_group_switches` / + `installation_order_devices` to switchIds before sending. PUT body is the flat group dict + (no `updateGroups` wrapper). + + ## Raises + + ### RuntimeError + + - If a switch IP cannot be resolved or the PUT request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.update_endpoint()) + api_endpoint.set_identifiers(model_instance.update_group_name) + payload = self._resolve_switches_in_payload(model_instance.to_payload()) + return self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=payload) + except Exception as e: + raise RuntimeError(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e + + def delete(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: + """ + # Summary + + Delete a single fabric update group by name. + + ## Raises + + ### RuntimeError + + - If the DELETE request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.delete_endpoint()) + api_endpoint.set_identifiers(model_instance.update_group_name) + return self._request(path=api_endpoint.path, verb=api_endpoint.verb) + except Exception as e: + raise RuntimeError(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e + + def query_one(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: + """ + # Summary + + Query a single fabric update group by name. Returns the flat group dict (not wrapped) with + switchIds converted back to their fabric management IPs for user-friendly output. + + ## Raises + + ### RuntimeError + + - If the GET request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.query_one_endpoint()) + api_endpoint.set_identifiers(model_instance.update_group_name) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb) + return self._denormalize_switches_in_response(result) + except Exception as e: + raise RuntimeError(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e + + def query_all(self, model_instance: FabricUpdateGroupModel | None = None, **kwargs) -> ResponseType: + """ + # Summary + + Query all user-managed fabric update groups in the configured fabric. Extracts the list from the + `updateGroups` key in the response, drops the ND-managed default group, and converts switchIds + back to IPs in each remaining item. + + ## Raises + + ### RuntimeError + + - If the GET request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.query_all_endpoint()) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, not_found_ok=True) + items: list = [] + if isinstance(result, dict): + items = result.get("updateGroups", []) or [] + # ND returns a system-managed default update group named "None" (the literal string) that + # holds switches not assigned to any user-defined group. It is intentional ND behavior, not + # an API discrepancy. Users cannot create or delete it, so it must never appear in module + # output: `state: overridden` would try to delete a group ND manages, and a future + # `state: gathered` would emit an unusable playbook task for it. The `updateGroup` schema + # carries no system/default flag, so the only discriminator is the name. Drop it here. + items = [g for g in items if isinstance(g, dict) and g.get("updateGroupName") not in (None, "", "None")] + return [self._denormalize_switches_in_response(item) for item in items] + except Exception as e: + raise RuntimeError(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py new file mode 100644 index 000000000..672ecc7b7 --- /dev/null +++ b/plugins/modules/nd_fabric_update_group.py @@ -0,0 +1,222 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_fabric_update_group +version_added: "1.4.0" +short_description: Manage fabric update groups (Fabric Software Management) on Cisco Nexus Dashboard +description: +- Manage fabric update groups under O(fabric_name) on Cisco Nexus Dashboard (ND). +- A fabric update group ties together a set of switches with an image / package install plan and orchestration knobs + (execution mode, contingency, analysis, maintenance, reports) used by the Fabric Software Management workflow. +- This is the ND 4.2 successor to image policies in ND 3.x / NDFC. +author: +- Allen Robel (@allenrobel) +options: + fabric_name: + description: + - The name of the fabric in which to manage update groups. + type: str + required: true + config: + description: + - The list of fabric update groups to configure. + type: list + elements: dict + suboptions: + update_group_name: + description: + - The name of the update group. + - The O(config.update_group_name) must be defined when creating, updating or deleting an update group. + type: str + required: true + execution: + description: + - The execution strategy for the upgrade run. + - V(serial) upgrades switches one at a time. V(parallel) upgrades them concurrently. + type: str + choices: [ parallel, serial ] + contingency: + description: + - The contingency behavior on per-switch failure during the upgrade run. + - V(continue) skips the failed switch and proceeds. V(pause) halts the run for operator intervention. + type: str + choices: [ continue, pause ] + analysis: + description: + - The pre / post analysis level. + - V(snapshot) captures a snapshot only. + - V(noAnalysis) skips analysis entirely. + - V(fullAnalysis) runs the full analysis pipeline. + - V(usePreExistingAnalysis) reuses prior analysis results. + type: str + choices: [ snapshot, noAnalysis, fullAnalysis, usePreExistingAnalysis ] + is_maintenance: + description: + - Whether to place switches in maintenance mode before upgrading. + type: bool + is_disruptive_update: + description: + - Whether the upgrade is allowed to be disruptive. + type: bool + update_group_switches: + description: + - The list of switches that belong to this update group. + - Each entry may be a switch fabric management IP address or a switch serial number (switchId). + - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. + type: list + elements: str + installation_order_devices: + description: + - The order in which switches are upgraded when O(config.execution=serial). + - Each entry may be a switch fabric management IP address or a switch serial number (switchId). + - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. + type: list + elements: str + recommended_version: + description: + - The recommended target software version for this group. + type: str + latest_recommended_version: + description: + - The latest available recommended software version for this group. + type: str + report_selection: + description: + - The report detail level. + type: str + choices: [ noReport, basic, advanced ] + reports: + description: + - The report generation strategy. + type: str + choices: [ noReport, usePreExistingReports, useDefaultPreAndPostReports, useAdvancePreAndPostReports ] + install_image_data: + description: + - The image / package install specification for this group. + type: dict + suboptions: + nos_image_name: + description: + - The NXOS image filename to install. + type: str + epld_image_name: + description: + - The EPLD image filename to install. + type: str + install_package_names: + description: + - The list of SMU / patch package filenames to install. + type: list + elements: str + uninstall_package: + description: + - Whether to uninstall existing SMUs before installing the new packages. + type: bool + update_report_checks: + description: + - The list of named pre / post upgrade report checks to run. + type: list + elements: dict + suboptions: + report_check_name: + description: + - The name of the report check to run. + type: str + required: true + state: + description: + - The desired state of the fabric update groups on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new update groups and update existing ones to match the provided configuration. + Update groups on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the update groups specified in the configuration. Fields not provided are reset. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + Update groups on ND but not in the configuration will be deleted. Use with caution. + - Use O(state=deleted) to delete the update groups specified in the configuration. + type: str + default: merged + choices: [ merged, replaced, overridden, deleted ] +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module is only supported on Nexus Dashboard 4.2.1 or higher. +""" + +EXAMPLES = r""" +- name: Create a fabric update group + cisco.nd.nd_fabric_update_group: + fabric_name: SITE1 + config: + - update_group_name: leaf_group + execution: serial + contingency: continue + analysis: snapshot + is_maintenance: true + is_disruptive_update: true + update_group_switches: + # Either IP addresses or switch serial numbers may be used. + - 192.168.7.11 + - 192.168.7.12 + install_image_data: + nos_image_name: nxos.9.3.13.bin + epld_image_name: n9000-epld.9.3.13.img + install_package_names: + - nxos.CSCwh77779-n9k_ALL-1.0.0-9.3.13.lib32_n9000.rpm + uninstall_package: true + report_selection: advanced + reports: useDefaultPreAndPostReports + update_report_checks: + - report_check_name: sh_version + state: merged + +- name: Delete an update group + cisco.nd.nd_fabric_update_group: + fabric_name: SITE1 + config: + - update_group_name: leaf_group + state: deleted +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_update_group.fabric_update_group import FabricUpdateGroupModel +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.fabric_update_group import FabricUpdateGroupOrchestrator + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update(FabricUpdateGroupModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=FabricUpdateGroupOrchestrator, + ) + + try: + nd_state_machine.manage_state() + module.exit_json(**nd_state_machine.output.format()) + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}", **nd_state_machine.output.format()) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/deleted.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/deleted.yaml new file mode 100644 index 000000000..cdd0f9836 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/deleted.yaml @@ -0,0 +1,33 @@ +--- +# Deleted state tests for nd_fabric_update_group +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +- name: "DELETED: Delete ansible_leaf_group (check mode)" + cisco.nd.nd_fabric_update_group: &delete_leaf + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: ansible_leaf_group + state: deleted + check_mode: true + register: cm_deleted_leaf + +- name: "DELETED: Delete ansible_leaf_group (normal mode)" + cisco.nd.nd_fabric_update_group: *delete_leaf + register: nm_deleted_leaf + +- name: "DELETED: Verify ansible_leaf_group removed" + ansible.builtin.assert: + that: + - cm_deleted_leaf is changed + - nm_deleted_leaf is changed + - nm_deleted_leaf.after | selectattr('update_group_name', 'equalto', 'ansible_leaf_group') | list | length == 0 + +- name: "DELETED IDEMPOTENT: Delete again should be no-op" + cisco.nd.nd_fabric_update_group: *delete_leaf + register: nm_deleted_leaf_idem + +- name: "DELETED IDEMPOTENT: Verify no change" + ansible.builtin.assert: + that: + - nm_deleted_leaf_idem is not changed diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml new file mode 100644 index 000000000..f19b198c3 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml @@ -0,0 +1,48 @@ +--- +# Test code for the nd_fabric_update_group module +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# +# --- Usage --- +# +# Run the test suite with ansible-test: +# +# ansible-test integration nd_fabric_update_group +# +# Override test variables in tests/integration/inventory.networking [nd:vars]: +# nd_test_fabric_name - fabric to use (default: SITE1) +# nd_test_update_switch_a - serial of first switch in fabric +# nd_test_update_switch_b - serial of second switch in fabric +# nd_test_nos_image_name - NXOS image filename in ND software repo +# nd_test_epld_image_name - EPLD image filename in ND software repo + +- name: Test that we have a Nexus Dashboard host, username and password + ansible.builtin.fail: + msg: 'Please define the following variables: ansible_host, ansible_user and ansible_password.' + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + +- name: Set vars + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ nd_output_level | default("debug") }}' + +- name: Run nd_fabric_update_group state tests + block: + - name: Pre-test cleanup + ansible.builtin.include_tasks: setup.yaml + + - name: Run merged state tests + ansible.builtin.include_tasks: merged.yaml + + - name: Run replaced state tests + ansible.builtin.include_tasks: replaced.yaml + + - name: Run overridden state tests + ansible.builtin.include_tasks: overridden.yaml + + - name: Run deleted state tests + ansible.builtin.include_tasks: deleted.yaml + module_defaults: + cisco.nd.nd_fabric_update_group: + timeout: 300 diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/merged.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/merged.yaml new file mode 100644 index 000000000..72ff67715 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/merged.yaml @@ -0,0 +1,70 @@ +--- +# Merged state tests for nd_fabric_update_group +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# --- MERGED CREATE --- + +- name: "MERGED CREATE: Create ansible_leaf_group (check mode)" + cisco.nd.nd_fabric_update_group: &merge_leaf + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ update_group_leaf }}" + state: merged + check_mode: true + register: cm_merged_create_leaf + +- name: "MERGED CREATE: Create ansible_leaf_group (normal mode)" + cisco.nd.nd_fabric_update_group: *merge_leaf + register: nm_merged_create_leaf + +- name: "MERGED CREATE: Verify leaf group creation" + ansible.builtin.assert: + that: + - cm_merged_create_leaf is changed + - nm_merged_create_leaf is changed + - nm_merged_create_leaf.after | selectattr('update_group_name', 'equalto', 'ansible_leaf_group') | list | length == 1 + +# --- MERGED IDEMPOTENCY --- + +- name: "MERGED IDEMPOTENT: Re-apply create (check mode)" + cisco.nd.nd_fabric_update_group: *merge_leaf + check_mode: true + register: cm_merged_idem_leaf + +- name: "MERGED IDEMPOTENT: Re-apply create (normal mode)" + cisco.nd.nd_fabric_update_group: *merge_leaf + register: nm_merged_idem_leaf + +- name: "MERGED IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - cm_merged_idem_leaf is not changed + - nm_merged_idem_leaf is not changed + +# --- MERGED UPDATE --- + +- name: "MERGED UPDATE: Change execution and contingency (check mode)" + cisco.nd.nd_fabric_update_group: &merge_leaf_updated + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ update_group_leaf_updated }}" + state: merged + check_mode: true + register: cm_merged_update_leaf + +- name: "MERGED UPDATE: Change execution and contingency (normal mode)" + cisco.nd.nd_fabric_update_group: *merge_leaf_updated + register: nm_merged_update_leaf + +- name: "MERGED UPDATE: Verify leaf group updated" + vars: + leaf_after: "{{ nm_merged_update_leaf.after | selectattr('update_group_name', 'equalto', 'ansible_leaf_group') | first }}" + ansible.builtin.assert: + that: + - cm_merged_update_leaf is changed + - nm_merged_update_leaf is changed + - leaf_after.execution == "parallel" + - leaf_after.contingency == "pause" + - leaf_after.report_selection == "advanced" diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/overridden.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/overridden.yaml new file mode 100644 index 000000000..a82baee90 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/overridden.yaml @@ -0,0 +1,28 @@ +--- +# Overridden state tests for nd_fabric_update_group +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# Create a second group so overridden has something to remove. +- name: "OVERRIDDEN PRE: Add ansible_spine_group via merged" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ update_group_spine }}" + state: merged + +- name: "OVERRIDDEN: Override with only ansible_leaf_group present" + cisco.nd.nd_fabric_update_group: &override_leaf_only + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ update_group_leaf }}" + state: overridden + register: nm_overridden_leaf + +- name: "OVERRIDDEN: Verify spine group was removed and leaf group remains" + ansible.builtin.assert: + that: + - nm_overridden_leaf is changed + - nm_overridden_leaf.after | selectattr('update_group_name', 'equalto', 'ansible_leaf_group') | list | length == 1 + - nm_overridden_leaf.after | selectattr('update_group_name', 'equalto', 'ansible_spine_group') | list | length == 0 diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/replaced.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/replaced.yaml new file mode 100644 index 000000000..ddf954de4 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/replaced.yaml @@ -0,0 +1,36 @@ +--- +# Replaced state tests for nd_fabric_update_group +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +- name: "REPLACED: Replace ansible_leaf_group with a minimal config (check mode)" + cisco.nd.nd_fabric_update_group: &replace_leaf + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: ansible_leaf_group + execution: serial + contingency: continue + analysis: noAnalysis + is_maintenance: false + is_disruptive_update: false + update_group_switches: + - "{{ test_switch_a }}" + state: replaced + check_mode: true + register: cm_replaced_leaf + +- name: "REPLACED: Replace ansible_leaf_group (normal mode)" + cisco.nd.nd_fabric_update_group: *replace_leaf + register: nm_replaced_leaf + +- name: "REPLACED: Verify replacement" + vars: + leaf_after: "{{ nm_replaced_leaf.after | selectattr('update_group_name', 'equalto', 'ansible_leaf_group') | first }}" + ansible.builtin.assert: + that: + - cm_replaced_leaf is changed + - nm_replaced_leaf is changed + - leaf_after.execution == "serial" + - leaf_after.contingency == "continue" + - leaf_after.analysis == "noAnalysis" + - leaf_after.update_group_switches | length == 1 diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/setup.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/setup.yaml new file mode 100644 index 000000000..058e61d29 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/setup.yaml @@ -0,0 +1,21 @@ +--- +# Pre-test cleanup: delete any update groups left over from prior failed runs. +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +- name: "SETUP: Remove ansible_leaf_group if present" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: ansible_leaf_group + state: deleted + failed_when: false + +- name: "SETUP: Remove ansible_spine_group if present" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: ansible_spine_group + state: deleted + failed_when: false diff --git a/tests/integration/targets/nd_fabric_update_group/vars/main.yaml b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml new file mode 100644 index 000000000..8a50725c4 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml @@ -0,0 +1,64 @@ +--- +# Variables for nd_fabric_update_group integration tests. +# +# `update_group_switches` and `installation_order_devices` accept either switch IP addresses +# or switch serial numbers (switchIds). IPs are resolved to switchIds via the fabric +# inventory before being sent on the wire. +# +# Override the following in your inventory or extra-vars to match a real ND 4.2 testbed: +# nd_test_fabric_name - fabric in which to create update groups +# nd_test_update_switch_a - IP or serial of the first switch in the fabric +# nd_test_update_switch_b - IP or serial of the second switch in the fabric +# nd_test_nos_image_name - filename of an NXOS image present on the ND software repository +# nd_test_epld_image_name - filename of an EPLD image present on the ND software repository + +test_fabric_name: "{{ nd_test_fabric_name | default('SITE1') }}" +test_switch_a: "{{ nd_test_update_switch_a | default('192.168.7.11') }}" +test_switch_b: "{{ nd_test_update_switch_b | default('192.168.7.12') }}" +test_nos_image_name: "{{ nd_test_nos_image_name | default('nxos.9.3.13.bin') }}" +test_epld_image_name: "{{ nd_test_epld_image_name | default('n9000-epld.9.3.13.img') }}" + +# Update group configs used across tests. +update_group_leaf: + update_group_name: ansible_leaf_group + execution: serial + contingency: continue + analysis: snapshot + is_maintenance: true + is_disruptive_update: false + update_group_switches: + - "{{ test_switch_a }}" + - "{{ test_switch_b }}" + installation_order_devices: + - "{{ test_switch_a }}" + - "{{ test_switch_b }}" + report_selection: basic + reports: noReport + +update_group_leaf_updated: + update_group_name: ansible_leaf_group + execution: parallel + contingency: pause + analysis: snapshot + is_maintenance: true + is_disruptive_update: false + update_group_switches: + - "{{ test_switch_a }}" + - "{{ test_switch_b }}" + installation_order_devices: + - "{{ test_switch_a }}" + - "{{ test_switch_b }}" + report_selection: advanced + reports: noReport + +update_group_spine: + update_group_name: ansible_spine_group + execution: parallel + contingency: continue + analysis: noAnalysis + is_maintenance: false + is_disruptive_update: false + update_group_switches: + - "{{ test_switch_a }}" + report_selection: noReport + reports: noReport diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json new file mode 100644 index 000000000..30fbd56dc --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -0,0 +1,308 @@ +{ + "TEST_NOTES": [ + "Fixture data for test_fabric_update_group.py.", + "Keys follow the test__ convention from CLAUDE.md.", + "Fabric scope for all tests: fabric_1.", + "POST returns 207 with per-item status. PUT and DELETE return 204.", + "Switch A: fabricManagementIp=192.168.12.151, switchId=FDO12345ABC.", + "Switch B: fabricManagementIp=192.168.12.152, switchId=FDO12345ABD.", + "Tests that supply IPs in update_group_switches consume one switches-list response first (FabricContext lazy fetch)." + ], + + "test_fabric_update_group_00100a": { + "TEST_NOTES": ["create happy path: POST returns 207 with status:success"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "message": "Update group created successful"} + ] + } + }, + + "test_fabric_update_group_00110a": { + "TEST_NOTES": ["create with per-item error: POST returns 207 with status:error"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "Multi-Status", + "DATA": { + "updateGroups": [ + {"updateGroupName": "leaf_group", "status": "error", "message": "Switch not found"} + ] + } + }, + + "test_fabric_update_group_00120a": { + "TEST_NOTES": ["create transport failure: POST returns 500"], + "RETURN_CODE": 500, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "Internal Server Error", + "DATA": {"error": "boom"} + }, + + "test_fabric_update_group_00200a": { + "TEST_NOTES": ["update happy path: PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00210a": { + "TEST_NOTES": ["update transport failure: PUT returns 500"], + "RETURN_CODE": 500, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "Internal Server Error", + "DATA": {"error": "boom"} + }, + + "test_fabric_update_group_00300a": { + "TEST_NOTES": ["delete happy path: DELETE returns 204"], + "RETURN_CODE": 204, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00310a": { + "TEST_NOTES": ["delete transport failure: DELETE returns 500"], + "RETURN_CODE": 500, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "Internal Server Error", + "DATA": {"error": "boom"} + }, + + "test_fabric_update_group_00400a": { + "TEST_NOTES": [ + "query_one happy path: GET returns single group.", + "updateGroupSwitches intentionally omitted - this test does NOT exercise switchId<->IP denormalization,", + "so the orchestrator should short-circuit without fetching the switches list." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "serial", + "contingency": "continue", + "analysis": "snapshot", + "isMaintenance": true, + "isDisruptiveUpdate": true + } + }, + + "test_fabric_update_group_00410a": { + "TEST_NOTES": ["query_one transport failure: GET returns 500"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "Internal Server Error", + "DATA": {"error": "boom"} + }, + + "test_fabric_update_group_00500a": { + "TEST_NOTES": [ + "query_all happy path: GET returns updateGroups list.", + "updateGroupSwitches intentionally omitted - this test does NOT exercise switchId<->IP denormalization." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + {"updateGroupName": "g1", "execution": "serial", "contingency": "continue", "analysis": "snapshot", "isMaintenance": true, "isDisruptiveUpdate": true}, + {"updateGroupName": "g2", "execution": "parallel", "contingency": "pause", "analysis": "noAnalysis", "isMaintenance": false, "isDisruptiveUpdate": false} + ] + } + }, + + "test_fabric_update_group_00510a": { + "TEST_NOTES": ["query_all 404: returns empty list"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "Not Found", + "DATA": {} + }, + + "test_fabric_update_group_00520a": { + "TEST_NOTES": ["query_all transport failure: GET returns 500"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "Internal Server Error", + "DATA": {"error": "boom"} + }, + + "test_fabric_update_group_00600a": { + "TEST_NOTES": ["create_bulk happy path: POST returns 207 with two successful items"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + {"updateGroupName": "g1", "status": "success", "message": "ok"}, + {"updateGroupName": "g2", "status": "success", "message": "ok"} + ] + } + }, + + "test_fabric_update_group_00610a": { + "TEST_NOTES": ["create_bulk partial failure: POST returns 207 with one error"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "Multi-Status", + "DATA": { + "updateGroups": [ + {"updateGroupName": "g1", "status": "success", "message": "ok"}, + {"updateGroupName": "g2", "status": "error", "message": "Switch missing"} + ] + } + }, + + "test_fabric_update_group_00700a": { + "TEST_NOTES": ["IP-resolved create: switches-list returns both switches"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + {"fabricManagementIp": "192.168.12.151", "switchId": "FDO12345ABC"}, + {"fabricManagementIp": "192.168.12.152", "switchId": "FDO12345ABD"} + ] + } + }, + "test_fabric_update_group_00700b": { + "TEST_NOTES": ["IP-resolved create: POST returns 207 with success"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [{"updateGroupName": "leaf_group", "status": "success", "message": "ok"}] + } + }, + + "test_fabric_update_group_00710a": { + "TEST_NOTES": ["IP-resolved create with unknown IP: switches-list returns a DIFFERENT IP"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + {"fabricManagementIp": "10.0.0.99", "switchId": "FDO99999XYZ"} + ] + } + }, + + "test_fabric_update_group_00720a": { + "TEST_NOTES": ["IP-resolved update: switches-list"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + {"fabricManagementIp": "192.168.12.151", "switchId": "FDO12345ABC"}, + {"fabricManagementIp": "192.168.12.152", "switchId": "FDO12345ABD"} + ] + } + }, + "test_fabric_update_group_00720b": { + "TEST_NOTES": ["IP-resolved update: PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00730a": { + "TEST_NOTES": ["query_one denormalizes switchIds back to IPs: GET single group (consumed first)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "serial", + "contingency": "continue", + "analysis": "snapshot", + "isMaintenance": true, + "isDisruptiveUpdate": true, + "updateGroupSwitches": ["FDO12345ABC", "FDO12345ABD"], + "installationOrderDevices": ["FDO12345ABC", "FDO12345ABD"] + } + }, + "test_fabric_update_group_00730b": { + "TEST_NOTES": ["query_one denormalizes switchIds back to IPs: switches-list (lazy fetch from denormalize)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + {"fabricManagementIp": "192.168.12.151", "switchId": "FDO12345ABC"}, + {"fabricManagementIp": "192.168.12.152", "switchId": "FDO12345ABD"} + ] + } + }, + + "test_fabric_update_group_00740a": { + "TEST_NOTES": ["query_all denormalizes switchIds back to IPs: GET list (consumed first)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + {"updateGroupName": "g1", "updateGroupSwitches": ["FDO12345ABC"]}, + {"updateGroupName": "g2", "updateGroupSwitches": ["FDO99999XYZ"]} + ] + } + }, + "test_fabric_update_group_00740b": { + "TEST_NOTES": ["query_all denormalizes switchIds back to IPs: switches-list (lazy fetch from denormalize)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", + "MESSAGE": "OK", + "DATA": { + "switches": [ + {"fabricManagementIp": "192.168.12.151", "switchId": "FDO12345ABC"} + ] + } + }, + + "test_fabric_update_group_00760a": { + "TEST_NOTES": [ + "query_all drops the ND-managed default group named 'None'.", + "Groups have no switch lists, so denormalization short-circuits (no switches-list fetch)." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + {"updateGroupName": "g1", "execution": "serial"}, + {"updateGroupName": "None", "execution": "parallel"}, + {"updateGroupName": "g2", "execution": "parallel"} + ] + } + } +} diff --git a/tests/unit/module_utils/models/test_fabric_update_group.py b/tests/unit/module_utils/models/test_fabric_update_group.py new file mode 100644 index 000000000..a0fc7888c --- /dev/null +++ b/tests/unit/module_utils/models/test_fabric_update_group.py @@ -0,0 +1,653 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for fabric_update_group.py models. + +Tests: +- Identifier configuration +- Field aliasing (snake_case <-> camelCase round-trip) +- to_payload / from_response round-trip +- Nested InstallImageDataModel and UpdateReportCheckModel +- get_argument_spec shape +""" + +# pylint: disable=disallowed-name,protected-access,redefined-outer-name,too-many-lines,line-too-long,invalid-name + +from __future__ import annotations + +from contextlib import contextmanager + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_update_group.fabric_update_group import ( + FabricUpdateGroupModel, + InstallImageDataModel, + UpdateReportCheckModel, +) + + +@contextmanager +def does_not_raise(): + """Context manager that asserts no exception is raised.""" + yield + + +# ============================================================================= +# Test data constants +# ============================================================================= + +SAMPLE_API_RESPONSE = { + "updateGroupName": "leaf_group", + "execution": "serial", + "contingency": "continue", + "analysis": "snapshot", + "isMaintenance": True, + "isDisruptiveUpdate": True, + "updateGroupSwitches": ["FDO24020JNK", "FDO2338083P"], + "installImageData": { + "epldImageName": "n9000-epld.9.3.13.img", + "nosImageName": "nxos.9.3.13.bin", + "installPackageNames": ["nxos.CSCwh77779-n9k_ALL-1.0.0-9.3.13.lib32_n9000.rpm"], + "uninstallPackage": True, + }, + "installationOrderDevices": ["FDO2338083P", "FDO24020JNK"], + "recommendedVersion": "9.3(14)", + "latestRecommendedVersion": "9.3(15)", + "reportSelection": "advanced", + "reports": "usePreExistingReports", + "updateReportChecks": [{"reportCheckName": "sh_version"}], +} + + +SAMPLE_ANSIBLE_CONFIG = { + "update_group_name": "leaf_group", + "execution": "serial", + "contingency": "continue", + "analysis": "snapshot", + "is_maintenance": True, + "is_disruptive_update": True, + "update_group_switches": ["FDO24020JNK", "FDO2338083P"], + "install_image_data": { + "epld_image_name": "n9000-epld.9.3.13.img", + "nos_image_name": "nxos.9.3.13.bin", + "install_package_names": ["nxos.CSCwh77779-n9k_ALL-1.0.0-9.3.13.lib32_n9000.rpm"], + "uninstall_package": True, + }, + "installation_order_devices": ["FDO2338083P", "FDO24020JNK"], + "recommended_version": "9.3(14)", + "latest_recommended_version": "9.3(15)", + "report_selection": "advanced", + "reports": "usePreExistingReports", + "update_report_checks": [{"report_check_name": "sh_version"}], +} + + +# ============================================================================= +# Test: InstallImageDataModel +# ============================================================================= + + +def test_fabric_update_group_00010() -> None: + """ + # Summary + + Verify `InstallImageDataModel` field defaults to None on bare construction. + + ## Test + + - Instantiate with no arguments + - All four user-facing fields default to None + + ## Classes and Methods + + - InstallImageDataModel.__init__() + """ + with does_not_raise(): + instance = InstallImageDataModel() + + assert instance.nos_image_name is None + assert instance.epld_image_name is None + assert instance.install_package_names is None + assert instance.uninstall_package is None + + +def test_fabric_update_group_00020() -> None: + """ + # Summary + + Verify `InstallImageDataModel` field aliases. + + ## Test + + - Construct from wire (camelCase) keys via `model_validate` with `by_alias=True` + - Snake-case attributes hold the values + + ## Classes and Methods + + - InstallImageDataModel.model_validate() + """ + wire = { + "nosImageName": "nxos.9.3.13.bin", + "epldImageName": "n9000-epld.9.3.13.img", + "installPackageNames": ["a.rpm", "b.rpm"], + "uninstallPackage": False, + } + + with does_not_raise(): + instance = InstallImageDataModel.model_validate(wire, by_alias=True) + + assert instance.nos_image_name == "nxos.9.3.13.bin" + assert instance.epld_image_name == "n9000-epld.9.3.13.img" + assert instance.install_package_names == ["a.rpm", "b.rpm"] + assert instance.uninstall_package is False + + +# ============================================================================= +# Test: UpdateReportCheckModel +# ============================================================================= + + +def test_fabric_update_group_00030() -> None: + """ + # Summary + + Verify `UpdateReportCheckModel` accepts wire alias `reportCheckName` and exposes it as `report_check_name`. + + ## Test + + - Construct from wire dict + - `report_check_name` reads the value + + ## Classes and Methods + + - UpdateReportCheckModel.model_validate() + """ + with does_not_raise(): + instance = UpdateReportCheckModel.model_validate({"reportCheckName": "sh_version"}, by_alias=True) + + assert instance.report_check_name == "sh_version" + + +def test_fabric_update_group_00040() -> None: + """ + # Summary + + Verify `UpdateReportCheckModel` requires `report_check_name`. + + ## Test + + - Construct with empty dict raises ValidationError + + ## Classes and Methods + + - UpdateReportCheckModel.model_validate() + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError): + UpdateReportCheckModel.model_validate({}, by_alias=True) + + +# ============================================================================= +# Test: FabricUpdateGroupModel - identifier configuration +# ============================================================================= + + +def test_fabric_update_group_00100() -> None: + """ + # Summary + + Verify identifier configuration on `FabricUpdateGroupModel`. + + ## Test + + - `identifiers == ["update_group_name"]` + - `identifier_strategy == "single"` + - `get_identifier_value` returns the name string + + ## Classes and Methods + + - FabricUpdateGroupModel (identifiers / identifier_strategy) + - FabricUpdateGroupModel.get_identifier_value() + """ + assert FabricUpdateGroupModel.identifiers == ["update_group_name"] + assert FabricUpdateGroupModel.identifier_strategy == "single" + + instance = FabricUpdateGroupModel(update_group_name="leaf_group") + assert instance.get_identifier_value() == "leaf_group" + + +def test_fabric_update_group_00110() -> None: + """ + # Summary + + Verify required-field enforcement: `update_group_name` is mandatory. + + ## Test + + - Constructing without `update_group_name` raises ValidationError + + ## Classes and Methods + + - FabricUpdateGroupModel.__init__() + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError): + FabricUpdateGroupModel() # type: ignore[call-arg] + + +# ============================================================================= +# Test: FabricUpdateGroupModel - from_response (wire -> model) +# ============================================================================= + + +def test_fabric_update_group_00200() -> None: + """ + # Summary + + Verify `from_response` (model_validate with by_alias) builds a model from the full wire shape. + + ## Test + + - Validate `SAMPLE_API_RESPONSE` + - Top-level snake-case attributes carry the wire values + - `install_image_data` is a populated `InstallImageDataModel` + - `update_report_checks` is a list of one `UpdateReportCheckModel` + + ## Classes and Methods + + - FabricUpdateGroupModel.from_response() + """ + with does_not_raise(): + instance = FabricUpdateGroupModel.from_response(SAMPLE_API_RESPONSE) + + assert instance.update_group_name == "leaf_group" + assert instance.execution == "serial" + assert instance.contingency == "continue" + assert instance.analysis == "snapshot" + assert instance.is_maintenance is True + assert instance.is_disruptive_update is True + assert instance.update_group_switches == ["FDO24020JNK", "FDO2338083P"] + assert isinstance(instance.install_image_data, InstallImageDataModel) + assert instance.install_image_data.nos_image_name == "nxos.9.3.13.bin" + assert instance.install_image_data.epld_image_name == "n9000-epld.9.3.13.img" + assert instance.install_image_data.install_package_names == ["nxos.CSCwh77779-n9k_ALL-1.0.0-9.3.13.lib32_n9000.rpm"] + assert instance.install_image_data.uninstall_package is True + assert instance.installation_order_devices == ["FDO2338083P", "FDO24020JNK"] + assert instance.recommended_version == "9.3(14)" + assert instance.latest_recommended_version == "9.3(15)" + assert instance.report_selection == "advanced" + assert instance.reports == "usePreExistingReports" + assert isinstance(instance.update_report_checks, list) + assert len(instance.update_report_checks) == 1 + assert instance.update_report_checks[0].report_check_name == "sh_version" + + +def test_fabric_update_group_00210() -> None: + """ + # Summary + + Verify `from_response` strips noisy top-level keys ND may echo (e.g. `fabricName`, `createTime`). + + ## Test + + - Build a response with extra keys + - Model still validates cleanly + - The extra keys are not present on the model + + ## Classes and Methods + + - FabricUpdateGroupModel._drop_unwanted_top_level_keys() + """ + data = dict(SAMPLE_API_RESPONSE) + data["fabricName"] = "SITE1" + data["createTime"] = "2026-05-19T10:00:00Z" + data["modifyTime"] = "2026-05-19T10:05:00Z" + + with does_not_raise(): + instance = FabricUpdateGroupModel.from_response(data) + + assert instance.update_group_name == "leaf_group" + + +# ============================================================================= +# Test: FabricUpdateGroupModel - from_config (ansible -> model) +# ============================================================================= + + +def test_fabric_update_group_00300() -> None: + """ + # Summary + + Verify `from_config` accepts Ansible snake-case keys. + + ## Test + + - Validate `SAMPLE_ANSIBLE_CONFIG` + - Top-level attributes populated correctly + - Nested install_image_data and update_report_checks built + + ## Classes and Methods + + - FabricUpdateGroupModel.from_config() + """ + with does_not_raise(): + instance = FabricUpdateGroupModel.from_config(SAMPLE_ANSIBLE_CONFIG) + + assert instance.update_group_name == "leaf_group" + assert instance.execution == "serial" + assert instance.install_image_data is not None + assert instance.install_image_data.nos_image_name == "nxos.9.3.13.bin" + assert instance.update_report_checks is not None + assert instance.update_report_checks[0].report_check_name == "sh_version" + + +# ============================================================================= +# Test: FabricUpdateGroupModel - to_payload (model -> wire) +# ============================================================================= + + +def test_fabric_update_group_00400() -> None: + """ + # Summary + + Verify `to_payload` round-trips the full wire shape (camelCase keys, nested objects). + + ## Test + + - Build model from Ansible config + - Serialize via to_payload + - Resulting dict contains all camelCase wire keys with original values + - `installImageData` is present as a nested object + + ## Classes and Methods + + - FabricUpdateGroupModel.to_payload() + """ + instance = FabricUpdateGroupModel.from_config(SAMPLE_ANSIBLE_CONFIG) + + payload = instance.to_payload() + + assert payload["updateGroupName"] == "leaf_group" + assert payload["execution"] == "serial" + assert payload["contingency"] == "continue" + assert payload["analysis"] == "snapshot" + assert payload["isMaintenance"] is True + assert payload["isDisruptiveUpdate"] is True + assert payload["updateGroupSwitches"] == ["FDO24020JNK", "FDO2338083P"] + assert payload["installImageData"] == { + "epldImageName": "n9000-epld.9.3.13.img", + "nosImageName": "nxos.9.3.13.bin", + "installPackageNames": ["nxos.CSCwh77779-n9k_ALL-1.0.0-9.3.13.lib32_n9000.rpm"], + "uninstallPackage": True, + } + assert payload["installationOrderDevices"] == ["FDO2338083P", "FDO24020JNK"] + assert payload["recommendedVersion"] == "9.3(14)" + assert payload["latestRecommendedVersion"] == "9.3(15)" + assert payload["reportSelection"] == "advanced" + assert payload["reports"] == "usePreExistingReports" + assert payload["updateReportChecks"] == [{"reportCheckName": "sh_version"}] + + +def test_fabric_update_group_00410() -> None: + """ + # Summary + + Verify `to_payload` excludes None fields (sparse payload, no JSON nulls). + + ## Test + + - Build model with only required fields + - Serialize via `to_payload` + - Optional fields are absent from the payload + + ## Classes and Methods + + - FabricUpdateGroupModel.to_payload() + """ + instance = FabricUpdateGroupModel( + update_group_name="g1", + execution="parallel", + contingency="pause", + analysis="noAnalysis", + is_maintenance=False, + is_disruptive_update=False, + update_group_switches=["FDO1"], + ) + + payload = instance.to_payload() + + assert payload == { + "updateGroupName": "g1", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": False, + "isDisruptiveUpdate": False, + "updateGroupSwitches": ["FDO1"], + } + + +def test_fabric_update_group_00420() -> None: + """ + # Summary + + Verify full wire round-trip: from_response -> to_payload -> from_response is stable. + + ## Test + + - Build model from wire shape, serialize back, validate again + - The second model is equal to the first + + ## Classes and Methods + + - FabricUpdateGroupModel.from_response() + - FabricUpdateGroupModel.to_payload() + """ + first = FabricUpdateGroupModel.from_response(SAMPLE_API_RESPONSE) + payload = first.to_payload() + second = FabricUpdateGroupModel.from_response(payload) + + assert first.model_dump() == second.model_dump() + + +# ============================================================================= +# Test: enum validation +# ============================================================================= + + +@pytest.mark.parametrize( + "field,value", + [ + ("execution", "bogus"), + ("contingency", "bogus"), + ("analysis", "bogus"), + ("report_selection", "bogus"), + ("reports", "bogus"), + ], + ids=[ + "execution-bad-value", + "contingency-bad-value", + "analysis-bad-value", + "report_selection-bad-value", + "reports-bad-value", + ], +) +def test_fabric_update_group_00500(field: str, value: str) -> None: + """ + # Summary + + Verify Literal-typed enum fields reject unknown values. + + ## Test + + - Construct model with one bad enum value + - Expect ValidationError + + ## Classes and Methods + + - FabricUpdateGroupModel (Literal field validation) + """ + from pydantic import ValidationError + + kwargs: dict = {"update_group_name": "g1", field: value} + with pytest.raises(ValidationError): + FabricUpdateGroupModel(**kwargs) + + +@pytest.mark.parametrize( + "field,value", + [ + ("execution", "parallel"), + ("execution", "serial"), + ("contingency", "continue"), + ("contingency", "pause"), + ("analysis", "snapshot"), + ("analysis", "noAnalysis"), + ("analysis", "fullAnalysis"), + ("analysis", "usePreExistingAnalysis"), + ("report_selection", "noReport"), + ("report_selection", "basic"), + ("report_selection", "advanced"), + ("reports", "noReport"), + ("reports", "usePreExistingReports"), + ("reports", "useDefaultPreAndPostReports"), + ("reports", "useAdvancePreAndPostReports"), + ], +) +def test_fabric_update_group_00510(field: str, value: str) -> None: + """ + # Summary + + Verify Literal-typed enum fields accept all documented wire values. + + ## Test + + - Construct model with each valid enum value + - Model attribute holds the assigned value + + ## Classes and Methods + + - FabricUpdateGroupModel (Literal field validation) + """ + with does_not_raise(): + instance = FabricUpdateGroupModel(update_group_name="g1", **{field: value}) + + assert getattr(instance, field) == value + + +# ============================================================================= +# Test: get_argument_spec +# ============================================================================= + + +def test_fabric_update_group_00600() -> None: + """ + # Summary + + Verify `get_argument_spec` exposes the expected top-level keys and `config` suboptions. + + ## Test + + - `fabric_name`, `state`, `config` are top-level keys + - `state` choices are merged/replaced/overridden/deleted (no `query` - the ND collection has no + `query` state; `gathered` is the planned read mechanism) + - `config.options.update_group_name` is required + - `install_image_data` and `update_report_checks` are nested dicts/lists + + ## Classes and Methods + + - FabricUpdateGroupModel.get_argument_spec() + """ + spec = FabricUpdateGroupModel.get_argument_spec() + + assert set(spec.keys()) == {"fabric_name", "config", "state"} + assert spec["fabric_name"]["required"] is True + assert spec["state"]["choices"] == ["merged", "replaced", "overridden", "deleted"] + assert spec["config"]["type"] == "list" + assert spec["config"]["elements"] == "dict" + + options = spec["config"]["options"] + assert options["update_group_name"]["required"] is True + assert options["execution"]["choices"] == ["parallel", "serial"] + assert options["analysis"]["choices"] == ["snapshot", "noAnalysis", "fullAnalysis", "usePreExistingAnalysis"] + assert options["install_image_data"]["type"] == "dict" + assert "nos_image_name" in options["install_image_data"]["options"] + assert options["update_report_checks"]["type"] == "list" + assert options["update_report_checks"]["options"]["report_check_name"]["required"] is True + + +# ============================================================================= +# Test: installation_order_devices excluded from diff +# ============================================================================= + + +def test_fabric_update_group_00700() -> None: + """ + # Summary + + Verify `installation_order_devices` is excluded from the diff representation. + + ND 4.2.1 silently drops `installationOrderDevices` - it accepts the field on POST/PUT but never + echoes it on GET. If it were included in the diff, a re-applied unchanged config would always + appear `changed`. `exclude_from_diff` keeps it out of `to_diff_dict()`. + + ## Test + + - `installation_order_devices` is in `FabricUpdateGroupModel.exclude_from_diff` + - `to_diff_dict()` omits the field (both alias and snake_case forms) even when it is set + + ## Classes and Methods + + - FabricUpdateGroupModel (exclude_from_diff) + - FabricUpdateGroupModel.to_diff_dict() + """ + assert "installation_order_devices" in FabricUpdateGroupModel.exclude_from_diff + + instance = FabricUpdateGroupModel( + update_group_name="g1", + update_group_switches=["FDO1", "FDO2"], + installation_order_devices=["FDO1", "FDO2"], + ) + diff_dict = instance.to_diff_dict() + + assert "installationOrderDevices" not in diff_dict + assert "installation_order_devices" not in diff_dict + # A field that is NOT excluded still appears, confirming the exclusion is targeted + assert diff_dict["updateGroupSwitches"] == ["FDO1", "FDO2"] + + +def test_fabric_update_group_00710() -> None: + """ + # Summary + + Verify two models that differ ONLY in `installation_order_devices` are treated as equal by `get_diff`, + so a re-applied config does not produce a false `changed`. + + ## Test + + - `current` has no `installation_order_devices` (mirrors the ND wire state, which drops it) + - `desired` is identical except it sets `installation_order_devices` + - `current.get_diff(desired, exclude_unset=True)` is True (desired is a subset of current - no change) + + ## Classes and Methods + + - FabricUpdateGroupModel.get_diff() + """ + common = dict( + update_group_name="g1", + execution="serial", + contingency="continue", + analysis="snapshot", + is_maintenance=True, + is_disruptive_update=False, + update_group_switches=["FDO1", "FDO2"], + ) + current = FabricUpdateGroupModel(**common) + desired = FabricUpdateGroupModel(**common, installation_order_devices=["FDO2", "FDO1"]) + + assert current.get_diff(desired, exclude_unset=True) is True diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py new file mode 100644 index 000000000..5ab7213fb --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -0,0 +1,811 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for `FabricUpdateGroupOrchestrator`. + +Verifies that the orchestrator drives `RestSend` correctly for fabric update group CRUD, +wraps create payloads in `{"updateGroups": [...]}` for the bulk POST endpoint, inspects +per-item status in 207 responses, sends a flat PUT body, extracts `updateGroups` from +`query_all`, and resolves `fabric_name` from `RestSend` params. +""" + +# pylint: disable=disallowed-name,protected-access,redefined-outer-name,too-many-lines +# pylint: disable=assignment-from-no-return,use-implicit-booleaness-not-comparison +# pylint: disable=invalid-name,line-too-long + +from __future__ import annotations + +import inspect + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_update_group.fabric_update_group import ( + FabricUpdateGroupModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.fabric_update_group import ( + FabricUpdateGroupOrchestrator, +) +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.tests.unit.module_utils.common_utils import does_not_raise +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender + + +def responses_fabric_update_group(key: str): + """Load fixture data for test_fabric_update_group tests.""" + return load_fixture("test_fabric_update_group")[key] + + +def _build_rest_send(gen_responses: ResponseGenerator, fabric_name: str = "fabric_1") -> RestSend: + """Build a `RestSend` wired to a file-based `Sender` and `ResponseHandler`.""" + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + + rest_send = RestSend({"check_mode": False, "fabric_name": fabric_name}) + rest_send.sender = sender + rest_send.response_handler = response_handler + rest_send.unit_test = True + rest_send.timeout = 1 + return rest_send + + +def _build_model(update_group_name: str = "leaf_group") -> FabricUpdateGroupModel: + """Build a minimal valid `FabricUpdateGroupModel`.""" + return FabricUpdateGroupModel( + update_group_name=update_group_name, + execution="serial", + contingency="continue", + analysis="snapshot", + is_maintenance=True, + is_disruptive_update=True, + update_group_switches=["FDO1", "FDO2"], + ) + + +# ============================================================================= +# Test: initialization +# ============================================================================= + + +def test_fabric_update_group_00010() -> None: + """ + # Summary + + Verify `FabricUpdateGroupOrchestrator` instantiates and exposes expected ClassVars / endpoint fields. + + ## Test + + - `model_class` is `FabricUpdateGroupModel` + - `supports_bulk_create` is True + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.__init__() + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + + with does_not_raise(): + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + assert instance.model_class is FabricUpdateGroupModel + assert instance.supports_bulk_create is True + + +def test_fabric_update_group_00020() -> None: + """ + # Summary + + Verify `fabric_name` is read from `rest_send.params`. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.fabric_name + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses, fabric_name="SITE1") + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + assert instance.fabric_name == "SITE1" + + +# ============================================================================= +# Test: create +# ============================================================================= + + +def test_fabric_update_group_00100() -> None: + """ + # Summary + + Verify `create` issues POST against the collection URL with `{"updateGroups": [payload]}` body. + + ## Test + + - POST against `/api/v1/manage/fabrics/fabric_1/updateGroups` + - Body wraps the single payload in the `updateGroups` array + - Payload contains `updateGroupName: leaf_group` + - 207 with `status: success` returns normally + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with does_not_raise(): + instance.create(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups" + assert rest_send.verb == HttpVerbEnum.POST.value + body = rest_send.committed_payload + assert isinstance(body, dict) + assert "updateGroups" in body + assert len(body["updateGroups"]) == 1 + payload_item = body["updateGroups"][0] + assert payload_item["updateGroupName"] == "leaf_group" + assert payload_item["execution"] == "serial" + + +def test_fabric_update_group_00110() -> None: + """ + # Summary + + Verify `create` raises `RuntimeError` when a 207 response contains an item with `status: error`. + + ## Test + + - 207 with `{"updateGroups": [{updateGroupName, status: error}]}` + - `RuntimeError` is raised with the per-item error message + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._raise_on_207_item_errors() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*error.*Switch not found"): + instance.create(model) + + +def test_fabric_update_group_00120() -> None: + """ + # Summary + + Verify `create` wraps a transport failure in `RuntimeError` mentioning the identifier. + + ## Test + + - POST returns 500 + - `RuntimeError` matches `Create failed for .*leaf_group` + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group"): + instance.create(model) + + +# ============================================================================= +# Test: update +# ============================================================================= + + +def test_fabric_update_group_00200() -> None: + """ + # Summary + + Verify `update` issues PUT against per-name URL with a flat group dict (no `updateGroups` wrapper). + + ## Test + + - PUT against `/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group` + - Body is the flat payload (`updateGroupName: leaf_group` at the top level) + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with does_not_raise(): + instance.update(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value + body = rest_send.committed_payload + assert isinstance(body, dict) + assert "updateGroups" not in body + assert body["updateGroupName"] == "leaf_group" + assert body["execution"] == "serial" + + +def test_fabric_update_group_00210() -> None: + """ + # Summary + + Verify `update` wraps a transport failure in `RuntimeError` mentioning the identifier. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Update failed for .*leaf_group"): + instance.update(model) + + +# ============================================================================= +# Test: delete +# ============================================================================= + + +def test_fabric_update_group_00300() -> None: + """ + # Summary + + Verify `delete` issues DELETE against per-name URL with no body. + + ## Test + + - DELETE against `/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group` + - 204 returns normally + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.delete() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with does_not_raise(): + instance.delete(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.DELETE.value + + +def test_fabric_update_group_00310() -> None: + """ + # Summary + + Verify `delete` wraps a transport failure in `RuntimeError` mentioning the identifier. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.delete() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Delete failed for .*leaf_group"): + instance.delete(model) + + +# ============================================================================= +# Test: query_one +# ============================================================================= + + +def test_fabric_update_group_00400() -> None: + """ + # Summary + + Verify `query_one` issues GET against per-name URL and returns the flat dict. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_one() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with does_not_raise(): + result = instance.query_one(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.GET.value + assert isinstance(result, dict) + assert result["updateGroupName"] == "leaf_group" + assert result["execution"] == "serial" + + +def test_fabric_update_group_00410() -> None: + """ + # Summary + + Verify `query_one` wraps a transport failure in `RuntimeError` mentioning the identifier. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_one() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Query failed for .*leaf_group"): + instance.query_one(model) + + +# ============================================================================= +# Test: query_all +# ============================================================================= + + +def test_fabric_update_group_00500() -> None: + """ + # Summary + + Verify `query_all` extracts the `updateGroups` list from the GET response. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_all() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with does_not_raise(): + result = instance.query_all() + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups" + assert rest_send.verb == HttpVerbEnum.GET.value + assert isinstance(result, list) + assert len(result) == 2 + assert {g["updateGroupName"] for g in result} == {"g1", "g2"} + + +def test_fabric_update_group_00510() -> None: + """ + # Summary + + Verify `query_all` returns an empty list on 404. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_all() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with does_not_raise(): + result = instance.query_all() + + assert result == [] + + +def test_fabric_update_group_00520() -> None: + """ + # Summary + + Verify `query_all` wraps a transport failure in `RuntimeError`. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_all() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Query all failed"): + instance.query_all() + + +# ============================================================================= +# Test: create_bulk +# ============================================================================= + + +def test_fabric_update_group_00600() -> None: + """ + # Summary + + Verify `create_bulk` sends a single POST with all groups in the `updateGroups` array. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create_bulk() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + models = [_build_model("g1"), _build_model("g2")] + + with does_not_raise(): + instance.create_bulk(models) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups" + body = rest_send.committed_payload + assert isinstance(body, dict) + assert len(body["updateGroups"]) == 2 + assert [g["updateGroupName"] for g in body["updateGroups"]] == ["g1", "g2"] + + +def test_fabric_update_group_00610() -> None: + """ + # Summary + + Verify `create_bulk` raises `RuntimeError` when any item in the 207 response has status:error. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create_bulk() + - FabricUpdateGroupOrchestrator._raise_on_207_item_errors() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + models = [_build_model("g1"), _build_model("g2")] + + with pytest.raises(RuntimeError, match=r"Bulk create failed.*g2.*error.*Switch missing"): + instance.create_bulk(models) + + +# ============================================================================= +# Test: switch IP <-> switchId resolution +# ============================================================================= + + +def test_fabric_update_group_00700() -> None: + """ + # Summary + + Verify `create` resolves switch IPs in `update_group_switches` and `installation_order_devices` + to switchIds via `FabricContext` before sending. + + ## Test + + - Switches-list returns two switches (IP -> switchId map) + - POST is issued with payload containing switchIds, not IPs + - The serial-form entry in `update_group_switches` is passed through unchanged + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._resolve_switches_in_payload() + - FabricUpdateGroupOrchestrator._resolve_switch_id() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + model = FabricUpdateGroupModel( + update_group_name="leaf_group", + execution="serial", + contingency="continue", + analysis="snapshot", + is_maintenance=True, + is_disruptive_update=True, + # First two entries are IPs, third is a switchId pass-through + update_group_switches=["192.168.12.151", "192.168.12.152", "FDO_PASSTHROUGH"], + installation_order_devices=["192.168.12.152", "192.168.12.151"], + ) + + with does_not_raise(): + instance.create(model) + + body = rest_send.committed_payload + payload_item = body["updateGroups"][0] + assert payload_item["updateGroupSwitches"] == ["FDO12345ABC", "FDO12345ABD", "FDO_PASSTHROUGH"] + assert payload_item["installationOrderDevices"] == ["FDO12345ABD", "FDO12345ABC"] + + +def test_fabric_update_group_00710() -> None: + """ + # Summary + + Verify `create` raises `RuntimeError` if a user-supplied switch IP cannot be resolved in the fabric. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._resolve_switch_id() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + model = FabricUpdateGroupModel( + update_group_name="leaf_group", + execution="serial", + contingency="continue", + analysis="snapshot", + is_maintenance=True, + is_disruptive_update=True, + update_group_switches=["192.168.12.151"], + ) + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*No switch found with fabricManagementIp '192\.168\.12\.151'"): + instance.create(model) + + +def test_fabric_update_group_00720() -> None: + """ + # Summary + + Verify `update` resolves switch IPs to switchIds in the PUT body. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._resolve_switches_in_payload() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + model = FabricUpdateGroupModel( + update_group_name="leaf_group", + execution="serial", + contingency="continue", + analysis="snapshot", + is_maintenance=True, + is_disruptive_update=True, + update_group_switches=["192.168.12.151", "192.168.12.152"], + ) + + with does_not_raise(): + instance.update(model) + + body = rest_send.committed_payload + assert body["updateGroupSwitches"] == ["FDO12345ABC", "FDO12345ABD"] + + +def test_fabric_update_group_00730() -> None: + """ + # Summary + + Verify `query_one` denormalizes switchIds back to IPs in the response. + + ## Test + + - GET returns updateGroupSwitches / installationOrderDevices as switchIds + - Result has those lists rewritten to fabricManagementIp values + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_one() + - FabricUpdateGroupOrchestrator._denormalize_switches_in_response() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with does_not_raise(): + result = instance.query_one(model) + + assert result["updateGroupSwitches"] == ["192.168.12.151", "192.168.12.152"] + assert result["installationOrderDevices"] == ["192.168.12.151", "192.168.12.152"] + + +def test_fabric_update_group_00740() -> None: + """ + # Summary + + Verify `query_all` denormalizes switchIds back to IPs in every list item, leaving unresolvable + switchIds (those not present in the fabric switch map) unchanged. + + ## Test + + - GET list returns two groups: g1 has a known switchId, g2 has an unknown one + - g1.updateGroupSwitches resolves to ["192.168.12.151"] + - g2.updateGroupSwitches stays as ["FDO99999XYZ"] (unresolvable) + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_all() + - FabricUpdateGroupOrchestrator._denormalize_switches_in_response() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with does_not_raise(): + result = instance.query_all() + + assert len(result) == 2 + by_name = {g["updateGroupName"]: g for g in result} + assert by_name["g1"]["updateGroupSwitches"] == ["192.168.12.151"] + assert by_name["g2"]["updateGroupSwitches"] == ["FDO99999XYZ"] + + +def test_fabric_update_group_00750() -> None: + """ + # Summary + + Verify `_looks_like_ip` heuristic: strings with dots are IPs, others are switchIds. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator._looks_like_ip() + """ + assert FabricUpdateGroupOrchestrator._looks_like_ip("192.168.12.151") is True + assert FabricUpdateGroupOrchestrator._looks_like_ip("FDO12345ABC") is False + assert FabricUpdateGroupOrchestrator._looks_like_ip("") is False + + +def test_fabric_update_group_00760() -> None: + """ + # Summary + + Verify `query_all` drops the ND-managed default update group named "None". + + ND returns a system-managed default group (the literal name "None") holding switches not assigned + to any user-defined group. It must not appear in query results, otherwise `state: overridden` would + attempt to delete a group ND manages itself. + + ## Test + + - GET list returns three groups: g1, "None", g2 + - `query_all` returns only g1 and g2 + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.query_all() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with does_not_raise(): + result = instance.query_all() + + names = {g["updateGroupName"] for g in result} + assert names == {"g1", "g2"} From 7af9fe0c2e5452919d2e915b33c8b72141c2abbd Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 20 May 2026 10:17:07 -1000 Subject: [PATCH 02/12] Redesign nd_fabric_update_group on the switch-centric action API The group-centric updateGroups CRUD endpoints have no minimum-switch guard and cannot represent a zero-switch group, so reusing a switch between groups silently left an unreadable "ghost" group behind. Rebuild the write path on the ghost-safe softwareUpdatePlan/actions API: attachGroup creates groups and assigns switches, detachGroup removes switches (ND auto-deletes an emptied group), and group settings are applied via PUT built from a GET of the current group so it never moves membership. Add a force_created option so a group whose membership trips an ND pre-flight warning can be applied. Reads stay on the group-centric GET endpoints; the now-unused group-centric POST endpoint is removed. Verified live on ND 4.2.1: detachGroup last-switch auto-delete, PUT membership no-op, and attachGroup forceCreated semantics (a warning status means ND applied nothing, so it always fails the task). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/manage/fabric_update_group.py | 37 -- .../v1/manage/software_update_plan_actions.py | 133 ++++++ .../fabric_update_group.py | 9 +- .../orchestrators/fabric_update_group.py | 319 ++++++++++--- plugins/modules/nd_fabric_update_group.py | 10 +- .../nd_fabric_update_group/tasks/main.yaml | 1 + .../nd_fabric_update_group/vars/main.yaml | 17 +- .../test_software_update_plan_actions.py | 286 ++++++++++++ .../test_fabric_update_group.json | 392 +++++++++++++--- .../models/test_fabric_update_group.py | 105 +++++ .../orchestrators/test_fabric_update_group.py | 425 +++++++++++++----- 11 files changed, 1470 insertions(+), 264 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py create mode 100644 tests/unit/module_utils/endpoints/test_software_update_plan_actions.py diff --git a/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py index fd4763127..f02acbe22 100644 --- a/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py +++ b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py @@ -11,8 +11,6 @@ - `EpFabricUpdateGroupListGet` - List update groups in a fabric (GET /api/v1/manage/fabrics/{fabric_name}/updateGroups) -- `EpFabricUpdateGroupPost` - Create one or more update groups in a fabric - (POST /api/v1/manage/fabrics/{fabric_name}/updateGroups) - `EpFabricUpdateGroupGet` - Get a specific update group by name (GET /api/v1/manage/fabrics/{fabric_name}/updateGroups/{update_group_name}) - `EpFabricUpdateGroupPut` - Update an existing update group @@ -110,41 +108,6 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.GET -class EpFabricUpdateGroupPost(_EpFabricUpdateGroupBase): - """ - # Summary - - ND Manage Fabric Update Group POST endpoint. - - ## Path - - - `/api/v1/manage/fabrics/{fabric_name}/updateGroups` - - ## Verb - - - POST - - ## Usage - - ```python - ep = EpFabricUpdateGroupPost() - ep.fabric_name = "SITE1" - rest_send.path = ep.path - rest_send.verb = ep.verb - rest_send.payload = {...} - ``` - """ - - _require_update_group_name: ClassVar[bool] = False - - class_name: Literal["EpFabricUpdateGroupPost"] = Field(default="EpFabricUpdateGroupPost", frozen=True, description="Class name for backward compatibility") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST - - class EpFabricUpdateGroupGet(_EpFabricUpdateGroupBase): """ # Summary diff --git a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py new file mode 100644 index 000000000..79d97ee06 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py @@ -0,0 +1,133 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Software Management switch-centric action endpoint models. + +These endpoints back the GUI's switch-centric update-group flow. Unlike the group-centric +`updateGroups` CRUD endpoints, they are ghost-safe by construction: `attachGroup` requires at least +one switch, and `detachGroup` auto-deletes a group server-side once its last switch is removed. + +## Endpoints + +- `EpFabricSoftwareUpdatePlanAttachGroup` - Create an update group and assign switches to it + (POST /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/attachGroup) +- `EpFabricSoftwareUpdatePlanDetachGroup` - Detach switches from an update group + (POST /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/detachGroup) +""" + +from __future__ import annotations + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import FabricNameMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class EpFabricSoftwareUpdatePlanAttachGroup(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Create an update group and assign switches to it (switch-centric, ghost-safe). + + - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/attachGroup` + - Verb: POST + - Body: `{"attachUpdateGroups": [{"updateGroupName": "...", "switchIds": ["..."], "forceCreated": false}]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpFabricSoftwareUpdatePlanAttachGroup"] = Field( + default="EpFabricSoftwareUpdatePlanAttachGroup", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the attachGroup action endpoint path. `fabric_name` is percent-encoded with `safe=""`. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + """ + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", "attachGroup") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST + + +class EpFabricSoftwareUpdatePlanDetachGroup(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Detach switches from an update group (switch-centric). Removing a group's last switch + auto-deletes the group server-side. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/detachGroup` + - Verb: POST + - Body: `{"detachUpdateGroups": [{"updateGroupName": "...", "switchIds": ["..."]}]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpFabricSoftwareUpdatePlanDetachGroup"] = Field( + default="EpFabricSoftwareUpdatePlanDetachGroup", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the detachGroup action endpoint path. `fabric_name` is percent-encoded with `safe=""`. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + """ + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", "detachGroup") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py index 55f6cdc6e..ec1986aa4 100644 --- a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py +++ b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py @@ -96,7 +96,12 @@ class FabricUpdateGroupModel(NDBaseModel): # send it (ND may consume it during the actual upgrade run), but it must be excluded from the idempotency # diff - otherwise a re-applied, unchanged config is perpetually reported as `changed` because the wire # state never carries the field. Observed on ND 4.2.1, fabric SITE1. - exclude_from_diff: ClassVar[set] = {"installation_order_devices"} + # + # `force_created` is an attachGroup-only operational flag (whether ND forces past pre-flight switch + # warnings), not a field of the `updateGroup` resource. It is excluded from the diff so it never + # produces a false `changed`, and from the payload so it never leaks into the settings PUT body. + exclude_from_diff: ClassVar[set] = {"installation_order_devices", "force_created"} + payload_exclude_fields: ClassVar[set] = {"force_created"} # --- Fields --- @@ -107,6 +112,7 @@ class FabricUpdateGroupModel(NDBaseModel): is_maintenance: Optional[bool] = Field(default=None, alias="isMaintenance") is_disruptive_update: Optional[bool] = Field(default=None, alias="isDisruptiveUpdate") update_group_switches: Optional[List[str]] = Field(default=None, alias="updateGroupSwitches") + force_created: bool = Field(default=False) install_image_data: Optional[InstallImageDataModel] = Field(default=None, alias="installImageData") installation_order_devices: Optional[List[str]] = Field(default=None, alias="installationOrderDevices") recommended_version: Optional[str] = Field(default=None, alias="recommendedVersion") @@ -154,6 +160,7 @@ def get_argument_spec(cls) -> Dict: is_maintenance=dict(type="bool"), is_disruptive_update=dict(type="bool"), update_group_switches=dict(type="list", elements="str"), + force_created=dict(type="bool", default=False), install_image_data=dict( type="dict", options=dict( diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py index 453e6942d..c914af3dc 100644 --- a/plugins/module_utils/orchestrators/fabric_update_group.py +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -5,20 +5,32 @@ """ Fabric update group orchestrator for Nexus Dashboard. -Implements CRUD operations for fabric update groups via the ND Manage Fabric Software Management API. -Fabric name is supplied by the module's top-level `fabric_name` option and propagated to every endpoint -instance prior to path generation; per-config identifier is `update_group_name`. - -`update_group_switches` and `installation_order_devices` accept either switch IP addresses or switch -serial numbers (switchIds). IPs are resolved to switchIds via `FabricContext` before being sent on the -wire; switchIds in GET responses are converted back to IPs so playbook authors see consistent IP-based -output even though the wire stores serials. - -POST is bulk-only: the wire shape is `{"updateGroups": [...]}`, returning HTTP 207 with per-item -`status` ("success" or "error"). `create()` wraps a single payload in the bulk shape and inspects -the per-item status before returning. `create_bulk()` sends N groups in a single POST. - -PUT, DELETE, and per-name GET take the single update group as a flat dict / path-only. +Drives fabric update group lifecycle via the ND Manage Fabric Software Management API. The write +path uses the switch-centric "action" API, which is ghost-safe by construction: `attachGroup` +requires at least one switch, and `detachGroup` auto-deletes a group server-side once its last +switch is removed. + +- `create` / `create_bulk` create groups and assign switches via `attachGroup`, then apply group + settings via `PUT /updateGroups/{name}` (the action API carries membership only). +- `update` reconciles membership - `attachGroup` for added switches, `detachGroup` for removed + switches - then applies settings via PUT. +- `delete` detaches every switch via `detachGroup`; ND deletes the emptied group. If the group is + a zero-switch ghost that the single GET cannot read, `delete` falls back to the group-centric + `DELETE /updateGroups/{name}` to free the reserved name. +- `query_one` / `query_all` read via the group-centric GET endpoints. + +Fabric name is supplied by the module's top-level `fabric_name` option and propagated to every +endpoint instance prior to path generation; per-config identifier is `update_group_name`. + +`update_group_switches` and `installation_order_devices` accept either switch IP addresses or +switch serial numbers (switchIds). IPs are resolved to switchIds via `FabricContext` before being +sent on the wire; switchIds in GET responses are converted back to IPs so playbook authors see +consistent IP-based output even though the wire stores serials. + +`attachGroup` and `detachGroup` return HTTP 207 with per-item `status`. Any status other than +`success` fails the task. ND returns `attachGroup` `status: warning` when it declines to apply a +change pending confirmation (and leaves nothing attached); setting the `force_created` option makes +ND apply the change and return `success` instead. """ from __future__ import annotations @@ -30,9 +42,12 @@ EpFabricUpdateGroupDelete, EpFabricUpdateGroupGet, EpFabricUpdateGroupListGet, - EpFabricUpdateGroupPost, EpFabricUpdateGroupPut, ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_actions import ( + EpFabricSoftwareUpdatePlanAttachGroup, + EpFabricSoftwareUpdatePlanDetachGroup, +) from ansible_collections.cisco.nd.plugins.module_utils.fabric_context import FabricContext from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_update_group.fabric_update_group import FabricUpdateGroupModel @@ -42,39 +57,42 @@ # Wire payload keys whose list-valued contents are switch identifiers (IP or switchId) _SWITCH_LIST_PAYLOAD_KEYS = ("updateGroupSwitches", "installationOrderDevices") +# Wire payload keys that identify a group / its membership rather than its settings +_NON_SETTINGS_PAYLOAD_KEYS = ("updateGroupName", "updateGroupSwitches") + class FabricUpdateGroupOrchestrator(NDBaseOrchestrator[FabricUpdateGroupModel]): """ # Summary - Orchestrator for fabric update group CRUD on Nexus Dashboard. + Orchestrator for fabric update group lifecycle on Nexus Dashboard. Reads `fabric_name` from module params and applies it to every endpoint instance before path - generation. Resolves switch IPs to switchIds (and back) via `FabricContext` so users can specify - either form in `update_group_switches` / `installation_order_devices`. POST is bulk-only - single - create calls are wrapped in `{"updateGroups": [payload]}`. + generation. Resolves switch IPs to switchIds (and back) via `FabricContext`. The write path + uses the switch-centric `attachGroup` / `detachGroup` action endpoints for membership and + `PUT /updateGroups/{name}` for group settings. ## Raises ### RuntimeError - - Via `create` if the create API request fails or any per-item status is "error". - - Via `update` if the update API request fails. - - Via `delete` if the delete API request fails. - - Via `query_one` if the query API request fails. - - Via `query_all` if the query API request fails. + - Via `create` / `create_bulk` if a request fails or any per-item `attachGroup` status is not `success`. + - Via `update` if a request fails or `update_group_switches` resolves to an empty set. + - Via `delete` if a request fails or any per-item `detachGroup` status is not `success`. + - Via `query_one` / `query_all` if the query API request fails. - Via `_resolve_switch_id` if the user-provided switch IP cannot be matched in the fabric. """ model_class: ClassVar[Type[NDBaseModel]] = FabricUpdateGroupModel supports_bulk_create: ClassVar[bool] = True - create_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupPost + create_endpoint: Type[NDEndpointBaseModel] = EpFabricSoftwareUpdatePlanAttachGroup update_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupPut delete_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupDelete query_one_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupGet query_all_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupListGet - create_bulk_endpoint: Type[NDEndpointBaseModel] | None = EpFabricUpdateGroupPost + create_bulk_endpoint: Type[NDEndpointBaseModel] | None = EpFabricSoftwareUpdatePlanAttachGroup + detach_group_endpoint: Type[NDEndpointBaseModel] = EpFabricSoftwareUpdatePlanDetachGroup _fabric_context: FabricContext | None = None @@ -178,7 +196,7 @@ def _denormalize_switches_in_response(self, item: Any) -> Any: `installationOrderDevices` with their matching IPs so the user sees consistent IP-based output. Denormalization is best-effort: if the switch map cannot be loaded (e.g. the inventory call - fails), the response is returned unmodified rather than raising — switchIds shown to the user + fails), the response is returned unmodified rather than raising - switchIds shown to the user are still correct, just not user-friendlier IPs. Per-switchId lookups that miss the map (stale serials) are also passed through unchanged. @@ -201,50 +219,198 @@ def _denormalize_switches_in_response(self, item: Any) -> Any: return item @staticmethod - def _raise_on_207_item_errors(result: Any, expected_names: list[str]) -> None: + def _raise_on_207_action_errors(result: Any, response_key: str, message_key: str) -> None: """ # Summary - Inspect a POST 207 response of shape `{"updateGroups": [{"updateGroupName": "X", "status": "...", "message": "..."}]}` - and raise `RuntimeError` if any item reports `status != "success"`. + Inspect a 207 action response of shape `{response_key: [{updateGroupName, status, ...}]}` and + raise `RuntimeError` if any item reports a non-`success` status. A `status: warning` means ND + declined to apply the change (it left nothing attached) - so, like `failed`, it fails the + task. The user opts past warnings by setting `force_created`, which makes ND apply the change + and return `success`; `force_created` therefore governs the request, never response handling. ## Raises ### RuntimeError - - If any item in the response reports a non-success status. + - If any item in the response reports a status other than `success`. """ if not isinstance(result, dict): return - items = result.get("updateGroups") + items = result.get(response_key) if not isinstance(items, list): return - failures = [item for item in items if isinstance(item, dict) and item.get("status") and item.get("status") != "success"] + failures = [item for item in items if isinstance(item, dict) and item.get("status") not in (None, "success")] if failures: - details = ", ".join(f"{item.get('updateGroupName')}: {item.get('status')} - {item.get('message')}" for item in failures) - raise RuntimeError(f"Per-item failures in bulk create response: {details}") + details = ", ".join(f"{item.get('updateGroupName')}: {item.get('status')} - {item.get(message_key)}" for item in failures) + raise RuntimeError(f"Per-item failures in {response_key} response: {details}") + + @staticmethod + def _model_has_settings(model_instance: FabricUpdateGroupModel) -> bool: + """ + # Summary + + Return True if the model carries any group setting (i.e. any payload key beyond the group + name and its switch membership). When False, no settings PUT is needed. + + ## Raises + + None + """ + return any(key not in _NON_SETTINGS_PAYLOAD_KEYS for key in model_instance.to_payload()) + + def _attach_item(self, model_instance: FabricUpdateGroupModel, switch_ids: list[str] | None = None) -> dict: + """ + # Summary + + Build a single `attachUpdateGroups` item. When `switch_ids` is None the model's + `update_group_switches` are resolved (IP -> switchId); otherwise the provided already-resolved + list is used verbatim. + + ## Raises + + ### RuntimeError + + - If a switch IP cannot be resolved (propagated from `_resolve_switch_id`). + """ + if switch_ids is None: + switch_ids = [self._resolve_switch_id(s) for s in (model_instance.update_group_switches or [])] + return { + "updateGroupName": model_instance.update_group_name, + "switchIds": switch_ids, + "forceCreated": model_instance.force_created, + } + + def _attach(self, attach_items: list[dict]) -> ResponseType: + """ + # Summary + + POST `attachGroup` with the supplied `attachUpdateGroups` items and inspect the 207 response. + + ## Raises + + ### RuntimeError + + - If the request fails or any per-item status is not `success`. + """ + api_endpoint = self._configure_endpoint(self.create_endpoint()) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data={"attachUpdateGroups": attach_items}) + self._raise_on_207_action_errors(result, "attachUpdateGroups", "warningMessage") + return result + + def _detach(self, detach_items: list[dict]) -> ResponseType: + """ + # Summary + + POST `detachGroup` with the supplied `detachUpdateGroups` items and inspect the 207 response. + + ## Raises + + ### RuntimeError + + - If the request fails or any per-item status is not `success`. + """ + api_endpoint = self._configure_endpoint(self.detach_group_endpoint()) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data={"detachUpdateGroups": detach_items}) + self._raise_on_207_action_errors(result, "detachUpdateGroups", "message") + return result + + def _get_group_raw(self, update_group_name: str) -> dict: + """ + # Summary + + GET a single update group and return its raw wire dict. + + ## Raises + + ### Exception + + - If the GET request fails (propagated from `_request`). + """ + api_endpoint = self._configure_endpoint(self.query_one_endpoint()) + api_endpoint.set_identifiers(update_group_name) + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb) + return result if isinstance(result, dict) else {} + + def _get_group_raw_or_none(self, update_group_name: str) -> dict | None: + """ + # Summary + + GET a single update group, returning None if the group cannot be read (e.g. a zero-switch + ghost group, which ND returns HTTP 400 for). + + ## Raises + + None + """ + try: + return self._get_group_raw(update_group_name) + except Exception: # pylint: disable=broad-except + return None + + def _delete_group(self, update_group_name: str) -> None: + """ + # Summary + + Issue the group-centric `DELETE /updateGroups/{name}`. Used as a fallback to free the name of + a zero-switch ghost group that `detachGroup` cannot act on. + + ## Raises + + ### Exception + + - If the DELETE request fails (propagated from `_request`). + """ + api_endpoint = self._configure_endpoint(self.delete_endpoint()) + api_endpoint.set_identifiers(update_group_name) + self._request(path=api_endpoint.path, verb=api_endpoint.verb) + + def _apply_settings(self, model_instance: FabricUpdateGroupModel, current_raw: dict | None = None) -> None: + """ + # Summary + + Apply group settings via `PUT /updateGroups/{name}`. The action API carries membership only, + so settings are PUT separately. PUT is a full replace requiring all of `updateGroupName`, + `execution`, `contingency`, `analysis`, `isMaintenance`, `isDisruptiveUpdate`, and + `updateGroupSwitches`; the body is built by overlaying the user's explicitly-set fields onto a + GET of the current group, so every required field is present and `updateGroupSwitches` echoes + the group's actual membership (PUT moves no switches). Skipped entirely when the model carries + no settings. + + ## Raises + + ### RuntimeError + + - If a switch IP cannot be resolved, or the GET / PUT request fails. + """ + if not self._model_has_settings(model_instance): + return + if current_raw is None: + current_raw = self._get_group_raw(model_instance.update_group_name) + merged = FabricUpdateGroupModel.from_response(current_raw) + merged.merge(model_instance) + api_endpoint = self._configure_endpoint(self.update_endpoint()) + api_endpoint.set_identifiers(model_instance.update_group_name) + payload = self._resolve_switches_in_payload(merged.to_payload()) + self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=payload) def create(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: """ # Summary - Create a single fabric update group. Resolves any IPs in `update_group_switches` / - `installation_order_devices` to switchIds, wraps the payload in the bulk `{"updateGroups": [...]}` - shape required by the ND POST endpoint, and inspects per-item status in the 207 response. + Create a fabric update group: `attachGroup` creates the group and assigns its switches, then + any group settings are applied via PUT. ## Raises ### RuntimeError - - If a switch IP cannot be resolved, the POST request fails, or any per-item status is not "success". + - If a switch IP cannot be resolved, a request fails, or `attachGroup` reports a non-success status. """ try: - api_endpoint = self._configure_endpoint(self.create_endpoint()) - payload = self._resolve_switches_in_payload(model_instance.to_payload()) - body = {"updateGroups": [payload]} - result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=body) - self._raise_on_207_item_errors(result, [model_instance.update_group_name]) - return result + self._attach([self._attach_item(model_instance)]) + self._apply_settings(model_instance) + return {} except Exception as e: raise RuntimeError(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e @@ -252,23 +418,21 @@ def create_bulk(self, model_instances: list[FabricUpdateGroupModel], **kwargs) - """ # Summary - Create multiple fabric update groups in a single POST. Resolves switch IPs to switchIds on each - payload before sending. The wire endpoint accepts a list of groups under the `updateGroups` key - and returns per-item status in a 207 response. + Create multiple fabric update groups: a single `attachGroup` POST assigns switches for all + groups, then group settings are applied per group via PUT. ## Raises ### RuntimeError - - If a switch IP cannot be resolved, the POST request fails, or any per-item status is not "success". + - If a switch IP cannot be resolved, a request fails, or `attachGroup` reports a non-success status. """ try: - api_endpoint = self._configure_endpoint(self.create_bulk_endpoint()) # pyright: ignore[reportOptionalCall] - payloads = [self._resolve_switches_in_payload(m.to_payload()) for m in model_instances] - body = {"updateGroups": payloads} - result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=body) - self._raise_on_207_item_errors(result, [m.update_group_name for m in model_instances]) - return result + attach_items = [self._attach_item(m) for m in model_instances] + self._attach(attach_items) + for model_instance in model_instances: + self._apply_settings(model_instance) + return {} except Exception as e: raise RuntimeError(f"Bulk create failed: {e}") from e @@ -276,21 +440,33 @@ def update(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseTy """ # Summary - Update a single fabric update group by name. Resolves any IPs in `update_group_switches` / - `installation_order_devices` to switchIds before sending. PUT body is the flat group dict - (no `updateGroups` wrapper). + Update a fabric update group: reconcile membership against the current wire state + (`attachGroup` for added switches, `detachGroup` for removed switches), then apply settings + via PUT. ## Raises ### RuntimeError - - If a switch IP cannot be resolved or the PUT request fails. + - If `update_group_switches` resolves to an empty set (an empty update group is not permitted - + use `state: deleted`), a switch IP cannot be resolved, a request fails, or an action + endpoint reports a non-success status. """ try: - api_endpoint = self._configure_endpoint(self.update_endpoint()) - api_endpoint.set_identifiers(model_instance.update_group_name) - payload = self._resolve_switches_in_payload(model_instance.to_payload()) - return self._request(path=api_endpoint.path, verb=api_endpoint.verb, data=payload) + update_group_name = model_instance.update_group_name + current_raw = self._get_group_raw(update_group_name) + current_ids = list(current_raw.get("updateGroupSwitches") or []) + desired_ids = [self._resolve_switch_id(s) for s in (model_instance.update_group_switches or [])] + if not desired_ids: + raise RuntimeError("update_group_switches must be non-empty; an empty update group is not permitted (use state: deleted)") + to_add = [s for s in desired_ids if s not in current_ids] + to_remove = [s for s in current_ids if s not in desired_ids] + if to_add: + self._attach([self._attach_item(model_instance, switch_ids=to_add)]) + if to_remove: + self._detach([{"updateGroupName": update_group_name, "switchIds": to_remove}]) + self._apply_settings(model_instance, current_raw=current_raw) + return {} except Exception as e: raise RuntimeError(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e @@ -298,18 +474,25 @@ def delete(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseTy """ # Summary - Delete a single fabric update group by name. + Delete a fabric update group by detaching every switch via `detachGroup` (ND deletes the + emptied group). If the group cannot be read by the single GET (a zero-switch ghost group), + fall back to the group-centric `DELETE /updateGroups/{name}` to free the reserved name. ## Raises ### RuntimeError - - If the DELETE request fails. + - If a request fails or `detachGroup` reports a non-success status. """ try: - api_endpoint = self._configure_endpoint(self.delete_endpoint()) - api_endpoint.set_identifiers(model_instance.update_group_name) - return self._request(path=api_endpoint.path, verb=api_endpoint.verb) + update_group_name = model_instance.update_group_name + current_raw = self._get_group_raw_or_none(update_group_name) + switch_ids = list(current_raw.get("updateGroupSwitches") or []) if current_raw else [] + if switch_ids: + self._detach([{"updateGroupName": update_group_name, "switchIds": switch_ids}]) + else: + self._delete_group(update_group_name) + return {} except Exception as e: raise RuntimeError(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 672ecc7b7..b7bbcd315 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -16,7 +16,7 @@ - Manage fabric update groups under O(fabric_name) on Cisco Nexus Dashboard (ND). - A fabric update group ties together a set of switches with an image / package install plan and orchestration knobs (execution mode, contingency, analysis, maintenance, reports) used by the Fabric Software Management workflow. -- This is the ND 4.2 successor to image policies in ND 3.x / NDFC. +- This is the ND 4.2 successor to image policies in ND 3.x. author: - Allen Robel (@allenrobel) options: @@ -71,8 +71,16 @@ - The list of switches that belong to this update group. - Each entry may be a switch fabric management IP address or a switch serial number (switchId). - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. + - An update group must contain at least one switch; ND does not permit a zero-switch group. type: list elements: str + force_created: + description: + - Whether to force creation of the update group past ND pre-flight switch warnings. + - When V(false), an ND warning (for example, that upgrading the selected switches would impact all + roles in the fabric) fails the task. Set V(true) to acknowledge such warnings and apply anyway. + type: bool + default: false installation_order_devices: description: - The order in which switches are upgraded when O(config.execution=serial). diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml index f19b198c3..58b912aa9 100644 --- a/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml +++ b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml @@ -14,6 +14,7 @@ # nd_test_fabric_name - fabric to use (default: SITE1) # nd_test_update_switch_a - serial of first switch in fabric # nd_test_update_switch_b - serial of second switch in fabric +# nd_test_update_switch_c - serial of a third switch, disjoint from a and b # nd_test_nos_image_name - NXOS image filename in ND software repo # nd_test_epld_image_name - EPLD image filename in ND software repo diff --git a/tests/integration/targets/nd_fabric_update_group/vars/main.yaml b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml index 8a50725c4..5080d0a17 100644 --- a/tests/integration/targets/nd_fabric_update_group/vars/main.yaml +++ b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml @@ -5,16 +5,27 @@ # or switch serial numbers (switchIds). IPs are resolved to switchIds via the fabric # inventory before being sent on the wire. # +# A switch may belong to only one update group at a time. Each group below uses a disjoint +# switch set so that creating one group never moves a switch out of another (which on ND 4.2.1 +# would leave the drained group in an unreadable zero-switch "ghost" state). +# +# Every group sets force_created: true. On a small fabric, attaching a switch set that covers +# all switches of a role makes ND raise a pre-flight warning and decline the attach; force_created +# acknowledges such warnings so the create applies. (force_created has no effect on settings-only +# updates, which issue no attachGroup call.) +# # Override the following in your inventory or extra-vars to match a real ND 4.2 testbed: # nd_test_fabric_name - fabric in which to create update groups # nd_test_update_switch_a - IP or serial of the first switch in the fabric # nd_test_update_switch_b - IP or serial of the second switch in the fabric +# nd_test_update_switch_c - IP or serial of a third switch, disjoint from a and b # nd_test_nos_image_name - filename of an NXOS image present on the ND software repository # nd_test_epld_image_name - filename of an EPLD image present on the ND software repository test_fabric_name: "{{ nd_test_fabric_name | default('SITE1') }}" test_switch_a: "{{ nd_test_update_switch_a | default('192.168.7.11') }}" test_switch_b: "{{ nd_test_update_switch_b | default('192.168.7.12') }}" +test_switch_c: "{{ nd_test_update_switch_c | default('192.168.7.13') }}" test_nos_image_name: "{{ nd_test_nos_image_name | default('nxos.9.3.13.bin') }}" test_epld_image_name: "{{ nd_test_epld_image_name | default('n9000-epld.9.3.13.img') }}" @@ -29,6 +40,7 @@ update_group_leaf: update_group_switches: - "{{ test_switch_a }}" - "{{ test_switch_b }}" + force_created: true installation_order_devices: - "{{ test_switch_a }}" - "{{ test_switch_b }}" @@ -45,12 +57,14 @@ update_group_leaf_updated: update_group_switches: - "{{ test_switch_a }}" - "{{ test_switch_b }}" + force_created: true installation_order_devices: - "{{ test_switch_a }}" - "{{ test_switch_b }}" report_selection: advanced reports: noReport +# Disjoint from update_group_leaf (uses switch_c only). update_group_spine: update_group_name: ansible_spine_group execution: parallel @@ -59,6 +73,7 @@ update_group_spine: is_maintenance: false is_disruptive_update: false update_group_switches: - - "{{ test_switch_a }}" + - "{{ test_switch_c }}" + force_created: true report_selection: noReport reports: noReport diff --git a/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py b/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py new file mode 100644 index 000000000..cfc242d8f --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py @@ -0,0 +1,286 @@ +# -*- 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 software_update_plan_actions.py + +Tests the ND Manage Fabric Software Management switch-centric action endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from contextlib import contextmanager + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_actions import ( + EpFabricSoftwareUpdatePlanAttachGroup, + EpFabricSoftwareUpdatePlanDetachGroup, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@contextmanager +def does_not_raise(): + """A context manager that does not raise an exception.""" + yield + + +# ============================================================================= +# Test: EpFabricSoftwareUpdatePlanAttachGroup +# ============================================================================= + + +def test_ep_software_update_plan_actions_00010(): + """ + # Summary + + Verify EpFabricSoftwareUpdatePlanAttachGroup basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + - fabric_name defaults to None + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanAttachGroup.__init__() + - EpFabricSoftwareUpdatePlanAttachGroup.verb + - EpFabricSoftwareUpdatePlanAttachGroup.class_name + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanAttachGroup() + assert instance.class_name == "EpFabricSoftwareUpdatePlanAttachGroup" + assert instance.verb == HttpVerbEnum.POST + assert instance.fabric_name is None + + +def test_ep_software_update_plan_actions_00020(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name is not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanAttachGroup.path + """ + instance = EpFabricSoftwareUpdatePlanAttachGroup() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_software_update_plan_actions_00030(): + """ + # Summary + + Verify path returns the correct attachGroup action URL. + + ## Test + + - fabric_name is set + - path returns /api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/attachGroup + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanAttachGroup.path + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanAttachGroup() + instance.fabric_name = "SITE1" + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/attachGroup" + + +def test_ep_software_update_plan_actions_00040(): + """ + # Summary + + Verify fabric_name is percent-encoded in the attachGroup path. + + ## Test + + - fabric_name = "fab/odd" + - path encodes the slash + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanAttachGroup.path + """ + instance = EpFabricSoftwareUpdatePlanAttachGroup() + instance.fabric_name = "fab/odd" + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/softwareUpdatePlan/actions/attachGroup" + + +def test_ep_software_update_plan_actions_00050(): + """ + # Summary + + Verify fabric_name="" raises ValueError (Pydantic min_length=1). + + ## Test + + - Setting fabric_name to empty string raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanAttachGroup.__init__() + """ + with pytest.raises(ValueError): + EpFabricSoftwareUpdatePlanAttachGroup(fabric_name="") + + +# ============================================================================= +# Test: EpFabricSoftwareUpdatePlanDetachGroup +# ============================================================================= + + +def test_ep_software_update_plan_actions_00100(): + """ + # Summary + + Verify EpFabricSoftwareUpdatePlanDetachGroup basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + - fabric_name defaults to None + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanDetachGroup.__init__() + - EpFabricSoftwareUpdatePlanDetachGroup.verb + - EpFabricSoftwareUpdatePlanDetachGroup.class_name + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanDetachGroup() + assert instance.class_name == "EpFabricSoftwareUpdatePlanDetachGroup" + assert instance.verb == HttpVerbEnum.POST + assert instance.fabric_name is None + + +def test_ep_software_update_plan_actions_00110(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name is not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanDetachGroup.path + """ + instance = EpFabricSoftwareUpdatePlanDetachGroup() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_software_update_plan_actions_00120(): + """ + # Summary + + Verify path returns the correct detachGroup action URL. + + ## Test + + - fabric_name is set + - path returns /api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/detachGroup + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanDetachGroup.path + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanDetachGroup() + instance.fabric_name = "SITE1" + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/detachGroup" + + +def test_ep_software_update_plan_actions_00130(): + """ + # Summary + + Verify fabric_name is percent-encoded in the detachGroup path. + + ## Test + + - fabric_name = "fab/odd" + - path encodes the slash + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanDetachGroup.path + """ + instance = EpFabricSoftwareUpdatePlanDetachGroup() + instance.fabric_name = "fab/odd" + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/softwareUpdatePlan/actions/detachGroup" + + +def test_ep_software_update_plan_actions_00140(): + """ + # Summary + + Verify fabric_name="" raises ValueError (Pydantic min_length=1). + + ## Test + + - Setting fabric_name to empty string raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanDetachGroup.__init__() + """ + with pytest.raises(ValueError): + EpFabricSoftwareUpdatePlanDetachGroup(fabric_name="") + + +# ============================================================================= +# Test: Cross-class +# ============================================================================= + + +def test_ep_software_update_plan_actions_00200(): + """ + # Summary + + Verify attach/detach endpoints are both POST with distinct action paths. + + ## Test + + - Both classes with the same fabric_name produce distinct paths + - Both have verb POST + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanAttachGroup.path + - EpFabricSoftwareUpdatePlanDetachGroup.path + """ + with does_not_raise(): + attach = EpFabricSoftwareUpdatePlanAttachGroup(fabric_name="SITE1") + detach = EpFabricSoftwareUpdatePlanDetachGroup(fabric_name="SITE1") + + assert attach.path == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/attachGroup" + assert detach.path == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/detachGroup" + assert attach.path != detach.path + assert attach.verb == HttpVerbEnum.POST + assert detach.verb == HttpVerbEnum.POST diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json index 30fbd56dc..db7b204cd 100644 --- a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -1,51 +1,171 @@ { "TEST_NOTES": [ - "Fixture data for test_fabric_update_group.py.", + "Fixture data for test_fabric_update_group.py (orchestrator).", "Keys follow the test__ convention from CLAUDE.md.", "Fabric scope for all tests: fabric_1.", - "POST returns 207 with per-item status. PUT and DELETE return 204.", + "The orchestrator writes via the switch-centric action API:", + " attachGroup POST -> 207 {attachUpdateGroups: [{updateGroupName, status, warningMessage}]}", + " detachGroup POST -> 207 {detachUpdateGroups: [{updateGroupName, status, message}]}", + "attachGroup status enum: success|failed|warning. detachGroup status enum: success|failed.", + "Settings are applied via PUT /updateGroups/{name} (204). Reads use GET (200).", "Switch A: fabricManagementIp=192.168.12.151, switchId=FDO12345ABC.", "Switch B: fabricManagementIp=192.168.12.152, switchId=FDO12345ABD.", - "Tests that supply IPs in update_group_switches consume one switches-list response first (FabricContext lazy fetch)." + "Tests that supply IPs consume one switches-list response (FabricContext lazy fetch)." ], "test_fabric_update_group_00100a": { - "TEST_NOTES": ["create happy path: POST returns 207 with status:success"], + "TEST_NOTES": ["create happy path: attachGroup POST returns 207 status:success"], "RETURN_CODE": 207, "METHOD": "POST", - "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", "MESSAGE": "OK", "DATA": { - "updateGroups": [ - {"updateGroupName": "leaf_group", "status": "success", "message": "Update group created successful"} + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "Update group attached successful"} ] } }, + "test_fabric_update_group_00100b": { + "TEST_NOTES": ["create happy path: GET the just-created group (ND-default settings)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00100c": { + "TEST_NOTES": ["create happy path: settings PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, "test_fabric_update_group_00110a": { - "TEST_NOTES": ["create with per-item error: POST returns 207 with status:error"], + "TEST_NOTES": ["create per-item failure: attachGroup POST returns 207 status:failed"], "RETURN_CODE": 207, "METHOD": "POST", - "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", "MESSAGE": "Multi-Status", "DATA": { - "updateGroups": [ - {"updateGroupName": "leaf_group", "status": "error", "message": "Switch not found"} + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "failed", "warningMessage": "Switch not found"} ] } }, "test_fabric_update_group_00120a": { - "TEST_NOTES": ["create transport failure: POST returns 500"], + "TEST_NOTES": ["create transport failure: attachGroup POST returns 500"], "RETURN_CODE": 500, "METHOD": "POST", - "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", "MESSAGE": "Internal Server Error", "DATA": {"error": "boom"} }, + "test_fabric_update_group_00130a": { + "TEST_NOTES": ["create with attachGroup warning, force_created=False: 207 status:warning -> task fails"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "Multi-Status", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "warning", "warningMessage": "Including the selected switches would result in all responsibilities for [leaf] being included in the same upgrade group. Would you like to proceed?"} + ] + } + }, + + "test_fabric_update_group_00135a": { + "TEST_NOTES": ["create with attachGroup warning, force_created=True: 207 status:warning still fails (warning = not applied)"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "Multi-Status", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "warning", "warningMessage": "Including the selected switches would result in all responsibilities for [leaf] being included in the same upgrade group. Would you like to proceed?"} + ] + } + }, + + "test_fabric_update_group_00140a": { + "TEST_NOTES": ["create with force_created=True (no settings): attachGroup POST returns 207 status:success"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "Update group attached successful"} + ] + } + }, + + "test_fabric_update_group_00150a": { + "TEST_NOTES": ["create with no settings: attachGroup POST only, no follow-up PUT"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "ok"} + ] + } + }, + "test_fabric_update_group_00200a": { - "TEST_NOTES": ["update happy path: PUT returns 204"], + "TEST_NOTES": ["update happy path: GET current group (membership FDO1,FDO9; pre-change settings)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "updateGroupSwitches": ["FDO1", "FDO9"] + } + }, + "test_fabric_update_group_00200b": { + "TEST_NOTES": ["update happy path: attachGroup POST adds FDO2"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "ok"} + ] + } + }, + "test_fabric_update_group_00200c": { + "TEST_NOTES": ["update happy path: detachGroup POST removes FDO9"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/detachGroup", + "MESSAGE": "OK", + "DATA": { + "detachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "message": "ok"} + ] + } + }, + "test_fabric_update_group_00200d": { + "TEST_NOTES": ["update happy path: settings PUT returns 204"], "RETURN_CODE": 204, "METHOD": "PUT", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", @@ -54,37 +174,131 @@ }, "test_fabric_update_group_00210a": { - "TEST_NOTES": ["update transport failure: PUT returns 500"], + "TEST_NOTES": ["update transport failure: initial GET returns 500"], "RETURN_CODE": 500, - "METHOD": "PUT", + "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", "MESSAGE": "Internal Server Error", "DATA": {"error": "boom"} }, + "test_fabric_update_group_00220a": { + "TEST_NOTES": ["update membership add-only: GET current group (membership FDO1,FDO2)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00220b": { + "TEST_NOTES": ["update membership add-only: attachGroup POST adds FDO3"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "ok"} + ] + } + }, + + "test_fabric_update_group_00230a": { + "TEST_NOTES": ["update membership remove-only: GET current group (membership FDO1,FDO2)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00230b": { + "TEST_NOTES": ["update membership remove-only: detachGroup POST removes FDO2"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/detachGroup", + "MESSAGE": "OK", + "DATA": { + "detachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "message": "ok"} + ] + } + }, + "test_fabric_update_group_00300a": { - "TEST_NOTES": ["delete happy path: DELETE returns 204"], - "RETURN_CODE": 204, - "METHOD": "DELETE", + "TEST_NOTES": ["delete happy path: GET current group to learn its switch membership"], + "RETURN_CODE": 200, + "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", - "MESSAGE": "No Content", - "DATA": {} + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00300b": { + "TEST_NOTES": ["delete happy path: detachGroup POST removes all switches (ND auto-deletes the group)"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/detachGroup", + "MESSAGE": "OK", + "DATA": { + "detachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "message": "Update group detached successful"} + ] + } }, "test_fabric_update_group_00310a": { - "TEST_NOTES": ["delete transport failure: DELETE returns 500"], - "RETURN_CODE": 500, + "TEST_NOTES": ["delete failure: GET current group"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00310b": { + "TEST_NOTES": ["delete failure: detachGroup POST returns 207 status:failed"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/detachGroup", + "MESSAGE": "Multi-Status", + "DATA": { + "detachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "failed", "message": "Update group detached failed"} + ] + } + }, + + "test_fabric_update_group_00320a": { + "TEST_NOTES": ["delete ghost fallback: GET single returns 400 (zero-switch ghost group not readable)"], + "RETURN_CODE": 400, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "Bad Request", + "DATA": {"message": "Update group is not associated with any device"} + }, + "test_fabric_update_group_00320b": { + "TEST_NOTES": ["delete ghost fallback: group-centric DELETE frees the name (204)"], + "RETURN_CODE": 204, "METHOD": "DELETE", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", - "MESSAGE": "Internal Server Error", - "DATA": {"error": "boom"} + "MESSAGE": "No Content", + "DATA": {} }, "test_fabric_update_group_00400a": { "TEST_NOTES": [ "query_one happy path: GET returns single group.", - "updateGroupSwitches intentionally omitted - this test does NOT exercise switchId<->IP denormalization,", - "so the orchestrator should short-circuit without fetching the switches list." + "updateGroupSwitches intentionally omitted - denormalization short-circuits." ], "RETURN_CODE": 200, "METHOD": "GET", @@ -112,7 +326,7 @@ "test_fabric_update_group_00500a": { "TEST_NOTES": [ "query_all happy path: GET returns updateGroups list.", - "updateGroupSwitches intentionally omitted - this test does NOT exercise switchId<->IP denormalization." + "updateGroupSwitches intentionally omitted - denormalization short-circuits." ], "RETURN_CODE": 200, "METHOD": "GET", @@ -145,35 +359,83 @@ }, "test_fabric_update_group_00600a": { - "TEST_NOTES": ["create_bulk happy path: POST returns 207 with two successful items"], + "TEST_NOTES": ["create_bulk happy path: attachGroup POST returns 207 with two successful items"], "RETURN_CODE": 207, "METHOD": "POST", - "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", "MESSAGE": "OK", "DATA": { - "updateGroups": [ - {"updateGroupName": "g1", "status": "success", "message": "ok"}, - {"updateGroupName": "g2", "status": "success", "message": "ok"} + "attachUpdateGroups": [ + {"updateGroupName": "g1", "status": "success", "warningMessage": "ok"}, + {"updateGroupName": "g2", "status": "success", "warningMessage": "ok"} ] } }, + "test_fabric_update_group_00600b": { + "TEST_NOTES": ["create_bulk happy path: GET g1"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/g1", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "g1", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00600c": { + "TEST_NOTES": ["create_bulk happy path: PUT g1 settings (204)"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/g1", + "MESSAGE": "No Content", + "DATA": {} + }, + "test_fabric_update_group_00600d": { + "TEST_NOTES": ["create_bulk happy path: GET g2"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/g2", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "g2", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00600e": { + "TEST_NOTES": ["create_bulk happy path: PUT g2 settings (204)"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/g2", + "MESSAGE": "No Content", + "DATA": {} + }, "test_fabric_update_group_00610a": { - "TEST_NOTES": ["create_bulk partial failure: POST returns 207 with one error"], + "TEST_NOTES": ["create_bulk partial failure: attachGroup POST returns 207 with one failed item"], "RETURN_CODE": 207, "METHOD": "POST", - "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", "MESSAGE": "Multi-Status", "DATA": { - "updateGroups": [ - {"updateGroupName": "g1", "status": "success", "message": "ok"}, - {"updateGroupName": "g2", "status": "error", "message": "Switch missing"} + "attachUpdateGroups": [ + {"updateGroupName": "g1", "status": "success", "warningMessage": "ok"}, + {"updateGroupName": "g2", "status": "failed", "warningMessage": "Switch missing"} ] } }, "test_fabric_update_group_00700a": { - "TEST_NOTES": ["IP-resolved create: switches-list returns both switches"], + "TEST_NOTES": ["IP-resolved create: switches-list returns both switches (FabricContext lazy fetch)"], "RETURN_CODE": 200, "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", @@ -186,13 +448,15 @@ } }, "test_fabric_update_group_00700b": { - "TEST_NOTES": ["IP-resolved create: POST returns 207 with success"], + "TEST_NOTES": ["IP-resolved create (no settings): attachGroup POST returns 207 success"], "RETURN_CODE": 207, "METHOD": "POST", - "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", "MESSAGE": "OK", "DATA": { - "updateGroups": [{"updateGroupName": "leaf_group", "status": "success", "message": "ok"}] + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "ok"} + ] } }, @@ -210,7 +474,23 @@ }, "test_fabric_update_group_00720a": { - "TEST_NOTES": ["IP-resolved update: switches-list"], + "TEST_NOTES": ["IP-resolved update: GET current group (membership FDO12345ABC)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "updateGroupSwitches": ["FDO12345ABC"] + } + }, + "test_fabric_update_group_00720b": { + "TEST_NOTES": ["IP-resolved update: switches-list (FabricContext lazy fetch)"], "RETURN_CODE": 200, "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", @@ -222,8 +502,20 @@ ] } }, - "test_fabric_update_group_00720b": { - "TEST_NOTES": ["IP-resolved update: PUT returns 204"], + "test_fabric_update_group_00720c": { + "TEST_NOTES": ["IP-resolved update: attachGroup POST adds FDO12345ABD"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "attachUpdateGroups": [ + {"updateGroupName": "leaf_group", "status": "success", "warningMessage": "ok"} + ] + } + }, + "test_fabric_update_group_00720d": { + "TEST_NOTES": ["IP-resolved update: settings PUT returns 204"], "RETURN_CODE": 204, "METHOD": "PUT", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", @@ -232,7 +524,7 @@ }, "test_fabric_update_group_00730a": { - "TEST_NOTES": ["query_one denormalizes switchIds back to IPs: GET single group (consumed first)"], + "TEST_NOTES": ["query_one denormalizes switchIds back to IPs: GET single group"], "RETURN_CODE": 200, "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", @@ -249,7 +541,7 @@ } }, "test_fabric_update_group_00730b": { - "TEST_NOTES": ["query_one denormalizes switchIds back to IPs: switches-list (lazy fetch from denormalize)"], + "TEST_NOTES": ["query_one denormalizes switchIds back to IPs: switches-list (lazy fetch)"], "RETURN_CODE": 200, "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", @@ -263,7 +555,7 @@ }, "test_fabric_update_group_00740a": { - "TEST_NOTES": ["query_all denormalizes switchIds back to IPs: GET list (consumed first)"], + "TEST_NOTES": ["query_all denormalizes switchIds back to IPs: GET list"], "RETURN_CODE": 200, "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups", @@ -276,7 +568,7 @@ } }, "test_fabric_update_group_00740b": { - "TEST_NOTES": ["query_all denormalizes switchIds back to IPs: switches-list (lazy fetch from denormalize)"], + "TEST_NOTES": ["query_all denormalizes switchIds back to IPs: switches-list (lazy fetch)"], "RETURN_CODE": 200, "METHOD": "GET", "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/switches", @@ -291,7 +583,7 @@ "test_fabric_update_group_00760a": { "TEST_NOTES": [ "query_all drops the ND-managed default group named 'None'.", - "Groups have no switch lists, so denormalization short-circuits (no switches-list fetch)." + "Groups have no switch lists, so denormalization short-circuits." ], "RETURN_CODE": 200, "METHOD": "GET", diff --git a/tests/unit/module_utils/models/test_fabric_update_group.py b/tests/unit/module_utils/models/test_fabric_update_group.py index a0fc7888c..5061167d5 100644 --- a/tests/unit/module_utils/models/test_fabric_update_group.py +++ b/tests/unit/module_utils/models/test_fabric_update_group.py @@ -651,3 +651,108 @@ def test_fabric_update_group_00710() -> None: desired = FabricUpdateGroupModel(**common, installation_order_devices=["FDO2", "FDO1"]) assert current.get_diff(desired, exclude_unset=True) is True + + +# ============================================================================= +# Test: force_created flag +# ============================================================================= + + +def test_fabric_update_group_00800() -> None: + """ + # Summary + + Verify `force_created` defaults to False on bare construction. + + ## Test + + - Construct model with only the required field + - `force_created` is False + + ## Classes and Methods + + - FabricUpdateGroupModel.__init__() + """ + with does_not_raise(): + instance = FabricUpdateGroupModel(update_group_name="g1") + + assert instance.force_created is False + + +def test_fabric_update_group_00810() -> None: + """ + # Summary + + Verify `force_created` is excluded from both the diff and the PUT payload. + + `force_created` is an `attachGroup`-only operational flag, not part of the `updateGroup` resource + schema. It must never reach the settings PUT body and must never trigger a false `changed`. + + ## Test + + - `force_created` is in `exclude_from_diff` and `payload_exclude_fields` + - `to_payload()` omits it even when set True + - `to_diff_dict()` omits it even when set True + + ## Classes and Methods + + - FabricUpdateGroupModel (exclude_from_diff / payload_exclude_fields) + - FabricUpdateGroupModel.to_payload() + - FabricUpdateGroupModel.to_diff_dict() + """ + assert "force_created" in FabricUpdateGroupModel.exclude_from_diff + assert "force_created" in FabricUpdateGroupModel.payload_exclude_fields + + instance = FabricUpdateGroupModel( + update_group_name="g1", + update_group_switches=["FDO1"], + force_created=True, + ) + + assert "force_created" not in instance.to_payload() + assert "forceCreated" not in instance.to_payload() + assert "force_created" not in instance.to_diff_dict() + assert "forceCreated" not in instance.to_diff_dict() + + +def test_fabric_update_group_00820() -> None: + """ + # Summary + + Verify `from_config` reads `force_created` from an Ansible config dict. + + ## Test + + - Config dict sets `force_created: True` + - The model attribute holds True + + ## Classes and Methods + + - FabricUpdateGroupModel.from_config() + """ + config = dict(SAMPLE_ANSIBLE_CONFIG, force_created=True) + + with does_not_raise(): + instance = FabricUpdateGroupModel.from_config(config) + + assert instance.force_created is True + + +def test_fabric_update_group_00830() -> None: + """ + # Summary + + Verify `get_argument_spec` exposes `force_created` as a bool option defaulting to False. + + ## Test + + - `config.options.force_created` is type bool with default False + + ## Classes and Methods + + - FabricUpdateGroupModel.get_argument_spec() + """ + options = FabricUpdateGroupModel.get_argument_spec()["config"]["options"] + + assert options["force_created"]["type"] == "bool" + assert options["force_created"]["default"] is False diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 5ab7213fb..8bf17bba4 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -7,10 +7,11 @@ """ Unit tests for `FabricUpdateGroupOrchestrator`. -Verifies that the orchestrator drives `RestSend` correctly for fabric update group CRUD, -wraps create payloads in `{"updateGroups": [...]}` for the bulk POST endpoint, inspects -per-item status in 207 responses, sends a flat PUT body, extracts `updateGroups` from -`query_all`, and resolves `fabric_name` from `RestSend` params. +Verifies the orchestrator drives `RestSend` against the switch-centric action API: `create` / +`create_bulk` attach switches via `attachGroup`, `update` reconciles membership via +`attachGroup` / `detachGroup`, `delete` detaches all switches (ND auto-deletes the empty group), +and group settings are applied via `PUT /updateGroups/{name}`. Reads (`query_one` / `query_all`) +stay on the group-centric GET endpoints. """ # pylint: disable=disallowed-name,protected-access,redefined-outer-name,too-many-lines @@ -62,8 +63,8 @@ def _build_rest_send(gen_responses: ResponseGenerator, fabric_name: str = "fabri return rest_send -def _build_model(update_group_name: str = "leaf_group") -> FabricUpdateGroupModel: - """Build a minimal valid `FabricUpdateGroupModel`.""" +def _build_model(update_group_name: str = "leaf_group", force_created: bool = False) -> FabricUpdateGroupModel: + """Build a `FabricUpdateGroupModel` with full settings and two switchId-form switches.""" return FabricUpdateGroupModel( update_group_name=update_group_name, execution="serial", @@ -72,6 +73,7 @@ def _build_model(update_group_name: str = "leaf_group") -> FabricUpdateGroupMode is_maintenance=True, is_disruptive_update=True, update_group_switches=["FDO1", "FDO2"], + force_created=force_created, ) @@ -84,7 +86,7 @@ def test_fabric_update_group_00010() -> None: """ # Summary - Verify `FabricUpdateGroupOrchestrator` instantiates and exposes expected ClassVars / endpoint fields. + Verify `FabricUpdateGroupOrchestrator` instantiates and exposes the expected ClassVars. ## Test @@ -139,23 +141,26 @@ def test_fabric_update_group_00100() -> None: """ # Summary - Verify `create` issues POST against the collection URL with `{"updateGroups": [payload]}` body. + Verify `create` attaches switches via `attachGroup` then applies settings via PUT. ## Test - - POST against `/api/v1/manage/fabrics/fabric_1/updateGroups` - - Body wraps the single payload in the `updateGroups` array - - Payload contains `updateGroupName: leaf_group` - - 207 with `status: success` returns normally + - attachGroup POST (207 success), GET the created group, PUT merged settings + - The final call is the PUT against the per-name URL + - The PUT body carries the user's settings overlaid on the GET defaults ## Classes and Methods - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._attach() + - FabricUpdateGroupOrchestrator._apply_settings() """ method_name = inspect.stack()[0][3] def responses(): yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + yield responses_fabric_update_group(f"{method_name}c") gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) @@ -165,32 +170,30 @@ def responses(): with does_not_raise(): instance.create(model) - assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups" - assert rest_send.verb == HttpVerbEnum.POST.value + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value body = rest_send.committed_payload assert isinstance(body, dict) - assert "updateGroups" in body - assert len(body["updateGroups"]) == 1 - payload_item = body["updateGroups"][0] - assert payload_item["updateGroupName"] == "leaf_group" - assert payload_item["execution"] == "serial" + assert "attachUpdateGroups" not in body + assert body["updateGroupName"] == "leaf_group" + assert body["execution"] == "serial" + assert body["contingency"] == "continue" + assert body["analysis"] == "snapshot" + assert body["isMaintenance"] is True + assert body["isDisruptiveUpdate"] is True + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] def test_fabric_update_group_00110() -> None: """ # Summary - Verify `create` raises `RuntimeError` when a 207 response contains an item with `status: error`. - - ## Test - - - 207 with `{"updateGroups": [{updateGroupName, status: error}]}` - - `RuntimeError` is raised with the per-item error message + Verify `create` raises `RuntimeError` when `attachGroup` returns a 207 item with `status: failed`. ## Classes and Methods - FabricUpdateGroupOrchestrator.create() - - FabricUpdateGroupOrchestrator._raise_on_207_item_errors() + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() """ method_name = inspect.stack()[0][3] @@ -202,7 +205,7 @@ def responses(): instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) model = _build_model() - with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*error.*Switch not found"): + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*failed.*Switch not found"): instance.create(model) @@ -212,14 +215,40 @@ def test_fabric_update_group_00120() -> None: Verify `create` wraps a transport failure in `RuntimeError` mentioning the identifier. + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group"): + instance.create(model) + + +def test_fabric_update_group_00130() -> None: + """ + # Summary + + Verify `create` raises when `attachGroup` returns `status: warning` and `force_created` is False. + ## Test - - POST returns 500 - - `RuntimeError` matches `Create failed for .*leaf_group` + - Model has `force_created=False` + - attachGroup 207 with `status: warning` + - `RuntimeError` is raised so the user must explicitly opt in to force ## Classes and Methods - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() """ method_name = inspect.stack()[0][3] @@ -229,11 +258,116 @@ def responses(): gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = _build_model() + model = _build_model(force_created=False) - with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group"): + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*warning"): + instance.create(model) + + +def test_fabric_update_group_00135() -> None: + """ + # Summary + + Verify `create` raises on an `attachGroup` `status: warning` even when `force_created` is True. + + ND returns `status: warning` only when it did NOT apply the attach (verified live: a non-forced + warning leaves a zero-switch ghost group). `force_created` governs the request `forceCreated` + value, not response interpretation - a `warning` always means nothing was attached, so it always + fails the task. + + ## Test + + - Model has `force_created=True` + - attachGroup 207 with `status: warning` still raises `RuntimeError` + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model(force_created=True) + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*warning"): + instance.create(model) + + +def test_fabric_update_group_00140() -> None: + """ + # Summary + + Verify `create` sends `forceCreated: true` in the `attachGroup` body when `force_created` is True. + + ## Test + + - Model has `force_created=True` and no settings + - The attachGroup body item carries `forceCreated: true` + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._attach_item() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1", "FDO2"], force_created=True) + + with does_not_raise(): + instance.create(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup" + body = rest_send.committed_payload + assert body == {"attachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO1", "FDO2"], "forceCreated": True}]} + + +def test_fabric_update_group_00150() -> None: + """ + # Summary + + Verify `create` with no settings issues only the `attachGroup` POST (no follow-up PUT). + + ## Test + + - Model carries only name + switches + - The single call is the attachGroup POST + - The body is `{"attachUpdateGroups": [{updateGroupName, switchIds, forceCreated}]}` + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._attach() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1", "FDO2"]) + + with does_not_raise(): instance.create(model) + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup" + assert rest_send.verb == HttpVerbEnum.POST.value + body = rest_send.committed_payload + assert body == {"attachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO1", "FDO2"], "forceCreated": False}]} + # ============================================================================= # Test: update @@ -244,12 +378,12 @@ def test_fabric_update_group_00200() -> None: """ # Summary - Verify `update` issues PUT against per-name URL with a flat group dict (no `updateGroups` wrapper). + Verify `update` reconciles membership (attach added, detach removed) and applies settings. ## Test - - PUT against `/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group` - - Body is the flat payload (`updateGroupName: leaf_group` at the top level) + - GET current group (membership FDO1, FDO9), attach FDO2, detach FDO9, PUT settings + - The final call is the PUT carrying the desired membership and settings ## Classes and Methods @@ -259,6 +393,9 @@ def test_fabric_update_group_00200() -> None: def responses(): yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + yield responses_fabric_update_group(f"{method_name}c") + yield responses_fabric_update_group(f"{method_name}d") gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) @@ -271,10 +408,9 @@ def responses(): assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" assert rest_send.verb == HttpVerbEnum.PUT.value body = rest_send.committed_payload - assert isinstance(body, dict) - assert "updateGroups" not in body assert body["updateGroupName"] == "leaf_group" assert body["execution"] == "serial" + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] def test_fabric_update_group_00210() -> None: @@ -301,6 +437,76 @@ def responses(): instance.update(model) +def test_fabric_update_group_00220() -> None: + """ + # Summary + + Verify `update` with an added switch and no settings issues only GET + `attachGroup`. + + ## Test + + - Current membership FDO1, FDO2; desired adds FDO3; no settings on the model + - The final call is the attachGroup POST carrying only the added switch + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._attach() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1", "FDO2", "FDO3"]) + + with does_not_raise(): + instance.update(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup" + body = rest_send.committed_payload + assert body == {"attachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO3"], "forceCreated": False}]} + + +def test_fabric_update_group_00230() -> None: + """ + # Summary + + Verify `update` with a removed switch and no settings issues only GET + `detachGroup`. + + ## Test + + - Current membership FDO1, FDO2; desired keeps only FDO1; no settings on the model + - The final call is the detachGroup POST carrying only the removed switch + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._detach() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1"]) + + with does_not_raise(): + instance.update(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/detachGroup" + body = rest_send.committed_payload + assert body == {"detachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO2"]}]} + + # ============================================================================= # Test: delete # ============================================================================= @@ -310,21 +516,22 @@ def test_fabric_update_group_00300() -> None: """ # Summary - Verify `delete` issues DELETE against per-name URL with no body. + Verify `delete` detaches all of the group's switches (ND auto-deletes the emptied group). ## Test - - DELETE against `/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group` - - 204 returns normally + - GET current group to learn membership, then detachGroup POST with all switchIds ## Classes and Methods - FabricUpdateGroupOrchestrator.delete() + - FabricUpdateGroupOrchestrator._detach() """ method_name = inspect.stack()[0][3] def responses(): yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) @@ -334,15 +541,50 @@ def responses(): with does_not_raise(): instance.delete(model) - assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" - assert rest_send.verb == HttpVerbEnum.DELETE.value + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/detachGroup" + assert rest_send.verb == HttpVerbEnum.POST.value + body = rest_send.committed_payload + assert body == {"detachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO1", "FDO2"]}]} def test_fabric_update_group_00310() -> None: """ # Summary - Verify `delete` wraps a transport failure in `RuntimeError` mentioning the identifier. + Verify `delete` raises `RuntimeError` when `detachGroup` returns a 207 item with `status: failed`. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.delete() + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Delete failed for .*leaf_group.*failed"): + instance.delete(model) + + +def test_fabric_update_group_00320() -> None: + """ + # Summary + + Verify `delete` falls back to the group-centric DELETE when GET-single cannot read the group. + + A zero-switch ghost group returns HTTP 400 on the single GET; `delete` then issues the + group-centric `DELETE /updateGroups/{name}` to free the reserved name. + + ## Test + + - GET single returns 400, `delete` issues DELETE against the per-name URL ## Classes and Methods @@ -352,15 +594,19 @@ def test_fabric_update_group_00310() -> None: def responses(): yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) model = _build_model() - with pytest.raises(RuntimeError, match=r"Delete failed for .*leaf_group"): + with does_not_raise(): instance.delete(model) + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.DELETE.value + # ============================================================================= # Test: query_one @@ -371,7 +617,7 @@ def test_fabric_update_group_00400() -> None: """ # Summary - Verify `query_one` issues GET against per-name URL and returns the flat dict. + Verify `query_one` issues GET against the per-name URL and returns the flat dict. ## Classes and Methods @@ -512,7 +758,12 @@ def test_fabric_update_group_00600() -> None: """ # Summary - Verify `create_bulk` sends a single POST with all groups in the `updateGroups` array. + Verify `create_bulk` sends one `attachGroup` POST for all groups, then per-group settings PUTs. + + ## Test + + - One attachGroup POST with both groups in `attachUpdateGroups` + - A GET + PUT for each group's settings ## Classes and Methods @@ -522,6 +773,10 @@ def test_fabric_update_group_00600() -> None: def responses(): yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + yield responses_fabric_update_group(f"{method_name}c") + yield responses_fabric_update_group(f"{method_name}d") + yield responses_fabric_update_group(f"{method_name}e") gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) @@ -531,23 +786,20 @@ def responses(): with does_not_raise(): instance.create_bulk(models) - assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups" - body = rest_send.committed_payload - assert isinstance(body, dict) - assert len(body["updateGroups"]) == 2 - assert [g["updateGroupName"] for g in body["updateGroups"]] == ["g1", "g2"] + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/g2" + assert rest_send.verb == HttpVerbEnum.PUT.value def test_fabric_update_group_00610() -> None: """ # Summary - Verify `create_bulk` raises `RuntimeError` when any item in the 207 response has status:error. + Verify `create_bulk` raises `RuntimeError` when any `attachGroup` 207 item has `status: failed`. ## Classes and Methods - FabricUpdateGroupOrchestrator.create_bulk() - - FabricUpdateGroupOrchestrator._raise_on_207_item_errors() + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() """ method_name = inspect.stack()[0][3] @@ -559,7 +811,7 @@ def responses(): instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) models = [_build_model("g1"), _build_model("g2")] - with pytest.raises(RuntimeError, match=r"Bulk create failed.*g2.*error.*Switch missing"): + with pytest.raises(RuntimeError, match=r"Bulk create failed.*g2.*failed.*Switch missing"): instance.create_bulk(models) @@ -572,19 +824,17 @@ def test_fabric_update_group_00700() -> None: """ # Summary - Verify `create` resolves switch IPs in `update_group_switches` and `installation_order_devices` - to switchIds via `FabricContext` before sending. + Verify `create` resolves switch IPs to switchIds in the `attachGroup` body. ## Test - - Switches-list returns two switches (IP -> switchId map) - - POST is issued with payload containing switchIds, not IPs - - The serial-form entry in `update_group_switches` is passed through unchanged + - Switches-list returns the IP -> switchId map + - The attachGroup body carries switchIds; the serial-form entry passes through unchanged ## Classes and Methods - FabricUpdateGroupOrchestrator.create() - - FabricUpdateGroupOrchestrator._resolve_switches_in_payload() + - FabricUpdateGroupOrchestrator._attach_item() - FabricUpdateGroupOrchestrator._resolve_switch_id() """ method_name = inspect.stack()[0][3] @@ -596,33 +846,23 @@ def responses(): gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = FabricUpdateGroupModel( update_group_name="leaf_group", - execution="serial", - contingency="continue", - analysis="snapshot", - is_maintenance=True, - is_disruptive_update=True, - # First two entries are IPs, third is a switchId pass-through update_group_switches=["192.168.12.151", "192.168.12.152", "FDO_PASSTHROUGH"], - installation_order_devices=["192.168.12.152", "192.168.12.151"], ) with does_not_raise(): instance.create(model) body = rest_send.committed_payload - payload_item = body["updateGroups"][0] - assert payload_item["updateGroupSwitches"] == ["FDO12345ABC", "FDO12345ABD", "FDO_PASSTHROUGH"] - assert payload_item["installationOrderDevices"] == ["FDO12345ABD", "FDO12345ABC"] + assert body["attachUpdateGroups"][0]["switchIds"] == ["FDO12345ABC", "FDO12345ABD", "FDO_PASSTHROUGH"] def test_fabric_update_group_00710() -> None: """ # Summary - Verify `create` raises `RuntimeError` if a user-supplied switch IP cannot be resolved in the fabric. + Verify `create` raises `RuntimeError` if a user-supplied switch IP cannot be resolved. ## Classes and Methods @@ -637,16 +877,7 @@ def responses(): gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - - model = FabricUpdateGroupModel( - update_group_name="leaf_group", - execution="serial", - contingency="continue", - analysis="snapshot", - is_maintenance=True, - is_disruptive_update=True, - update_group_switches=["192.168.12.151"], - ) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["192.168.12.151"]) with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*No switch found with fabricManagementIp '192\.168\.12\.151'"): instance.create(model) @@ -656,23 +887,24 @@ def test_fabric_update_group_00720() -> None: """ # Summary - Verify `update` resolves switch IPs to switchIds in the PUT body. + Verify `update` resolves switch IPs to switchIds for both membership reconciliation and the PUT body. ## Classes and Methods - FabricUpdateGroupOrchestrator.update() - - FabricUpdateGroupOrchestrator._resolve_switches_in_payload() + - FabricUpdateGroupOrchestrator._resolve_switch_id() """ method_name = inspect.stack()[0][3] def responses(): yield responses_fabric_update_group(f"{method_name}a") yield responses_fabric_update_group(f"{method_name}b") + yield responses_fabric_update_group(f"{method_name}c") + yield responses_fabric_update_group(f"{method_name}d") gen_responses = ResponseGenerator(responses()) rest_send = _build_rest_send(gen_responses) instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = FabricUpdateGroupModel( update_group_name="leaf_group", execution="serial", @@ -686,6 +918,8 @@ def responses(): with does_not_raise(): instance.update(model) + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value body = rest_send.committed_payload assert body["updateGroupSwitches"] == ["FDO12345ABC", "FDO12345ABD"] @@ -696,11 +930,6 @@ def test_fabric_update_group_00730() -> None: Verify `query_one` denormalizes switchIds back to IPs in the response. - ## Test - - - GET returns updateGroupSwitches / installationOrderDevices as switchIds - - Result has those lists rewritten to fabricManagementIp values - ## Classes and Methods - FabricUpdateGroupOrchestrator.query_one() @@ -728,14 +957,7 @@ def test_fabric_update_group_00740() -> None: """ # Summary - Verify `query_all` denormalizes switchIds back to IPs in every list item, leaving unresolvable - switchIds (those not present in the fabric switch map) unchanged. - - ## Test - - - GET list returns two groups: g1 has a known switchId, g2 has an unknown one - - g1.updateGroupSwitches resolves to ["192.168.12.151"] - - g2.updateGroupSwitches stays as ["FDO99999XYZ"] (unresolvable) + Verify `query_all` denormalizes switchIds back to IPs, leaving unresolvable switchIds unchanged. ## Classes and Methods @@ -782,15 +1004,6 @@ def test_fabric_update_group_00760() -> None: Verify `query_all` drops the ND-managed default update group named "None". - ND returns a system-managed default group (the literal name "None") holding switches not assigned - to any user-defined group. It must not appear in query results, otherwise `state: overridden` would - attempt to delete a group ND manages itself. - - ## Test - - - GET list returns three groups: g1, "None", g2 - - `query_all` returns only g1 and g2 - ## Classes and Methods - FabricUpdateGroupOrchestrator.query_all() From 38596af8f2a648fab5389510a1fced6554c5ed2c Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 20 May 2026 10:43:56 -1000 Subject: [PATCH 03/12] Modernize fabric_update_group model type annotations Convert the FabricUpdateGroupModel annotations from typing.Optional / List / Dict to the PEP 604 / PEP 585 forms (X | None, list[X], dict) per the collection's type-annotation standard, and trim the now-unused typing imports. Annotation-only change; verified clean against ansible-test sanity (compile / import / pep8) on Python 3.8-3.13. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fabric_update_group.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py index ec1986aa4..189cbca55 100644 --- a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py +++ b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Any, ClassVar, Dict, List, Literal, Optional +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 @@ -40,10 +40,10 @@ class InstallImageDataModel(NDNestedModel): None """ - nos_image_name: Optional[str] = Field(default=None, alias="nosImageName") - epld_image_name: Optional[str] = Field(default=None, alias="epldImageName") - install_package_names: Optional[List[str]] = Field(default=None, alias="installPackageNames") - uninstall_package: Optional[bool] = Field(default=None, alias="uninstallPackage") + nos_image_name: str | None = Field(default=None, alias="nosImageName") + epld_image_name: str | None = Field(default=None, alias="epldImageName") + install_package_names: list[str] | None = Field(default=None, alias="installPackageNames") + uninstall_package: bool | None = Field(default=None, alias="uninstallPackage") class UpdateReportCheckModel(NDNestedModel): @@ -88,8 +88,8 @@ class FabricUpdateGroupModel(NDBaseModel): None """ - identifiers: ClassVar[Optional[List[str]]] = ["update_group_name"] - identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + identifiers: ClassVar[list[str] | None] = ["update_group_name"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical", "singleton"] | None] = "single" # TODO(4.2.1) ND silently drops `installationOrderDevices` on the updateGroups create/update endpoints. # The POST/PUT accept the field without error, but GET (single and list) never echoes it back. We still @@ -106,20 +106,20 @@ class FabricUpdateGroupModel(NDBaseModel): # --- Fields --- update_group_name: str = Field(alias="updateGroupName") - execution: Optional[ExecutionLiteral] = Field(default=None, alias="execution") - contingency: Optional[ContingencyLiteral] = Field(default=None, alias="contingency") - analysis: Optional[AnalysisLiteral] = Field(default=None, alias="analysis") - is_maintenance: Optional[bool] = Field(default=None, alias="isMaintenance") - is_disruptive_update: Optional[bool] = Field(default=None, alias="isDisruptiveUpdate") - update_group_switches: Optional[List[str]] = Field(default=None, alias="updateGroupSwitches") + execution: ExecutionLiteral | None = Field(default=None, alias="execution") + contingency: ContingencyLiteral | None = Field(default=None, alias="contingency") + analysis: AnalysisLiteral | None = Field(default=None, alias="analysis") + is_maintenance: bool | None = Field(default=None, alias="isMaintenance") + is_disruptive_update: bool | None = Field(default=None, alias="isDisruptiveUpdate") + update_group_switches: list[str] | None = Field(default=None, alias="updateGroupSwitches") force_created: bool = Field(default=False) - install_image_data: Optional[InstallImageDataModel] = Field(default=None, alias="installImageData") - installation_order_devices: Optional[List[str]] = Field(default=None, alias="installationOrderDevices") - recommended_version: Optional[str] = Field(default=None, alias="recommendedVersion") - latest_recommended_version: Optional[str] = Field(default=None, alias="latestRecommendedVersion") - report_selection: Optional[ReportSelectionLiteral] = Field(default=None, alias="reportSelection") - reports: Optional[ReportsLiteral] = Field(default=None, alias="reports") - update_report_checks: Optional[List[UpdateReportCheckModel]] = Field(default=None, alias="updateReportChecks") + install_image_data: InstallImageDataModel | None = Field(default=None, alias="installImageData") + installation_order_devices: list[str] | None = Field(default=None, alias="installationOrderDevices") + recommended_version: str | None = Field(default=None, alias="recommendedVersion") + latest_recommended_version: str | None = Field(default=None, alias="latestRecommendedVersion") + report_selection: ReportSelectionLiteral | None = Field(default=None, alias="reportSelection") + reports: ReportsLiteral | None = Field(default=None, alias="reports") + update_report_checks: list[UpdateReportCheckModel] | None = Field(default=None, alias="updateReportChecks") # --- Validators (Deserialization) --- @@ -143,7 +143,7 @@ def _drop_unwanted_top_level_keys(cls, data: Any) -> Any: # --- Argument Spec --- @classmethod - def get_argument_spec(cls) -> Dict: + def get_argument_spec(cls) -> dict: return dict( fabric_name=dict(type="str", required=True), config=dict( From 1edc9d2914061afae7c7a9757374542d49f9cf2a Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 20 May 2026 14:18:05 -1000 Subject: [PATCH 04/12] Add auto_assign option to nd_fabric_update_group Expose ND's fabric-wide softwareUpdatePlan "propose" action as a new top-level auto_assign option (roleBased / evenOdd), letting ND auto-generate update groups instead of defining them explicitly in config. The auto-assign path bypasses NDStateMachine (its per-group config-diff state model does not fit a single fabric-level action) and derives changed from a before/after query_all snapshot. auto_assign is mutually exclusive with config and valid only with state merged or overridden; check mode reports no change since the propose action cannot be previewed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/manage/software_update_plan_actions.py | 57 +++++++++++++ .../fabric_update_group.py | 1 + .../orchestrators/fabric_update_group.py | 23 ++++++ plugins/modules/nd_fabric_update_group.py | 75 +++++++++++++++++ .../tasks/auto_assign.yaml | 82 +++++++++++++++++++ .../nd_fabric_update_group/tasks/main.yaml | 3 + .../test_fabric_update_group.json | 27 ++++++ .../models/test_fabric_update_group.py | 31 ++++++- .../orchestrators/test_fabric_update_group.py | 65 +++++++++++++++ 9 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 tests/integration/targets/nd_fabric_update_group/tasks/auto_assign.yaml diff --git a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py index 79d97ee06..c3a50bbaf 100644 --- a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py @@ -14,6 +14,8 @@ (POST /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/attachGroup) - `EpFabricSoftwareUpdatePlanDetachGroup` - Detach switches from an update group (POST /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/detachGroup) +- `EpFabricSoftwareUpdatePlanPropose` - Auto-assign update groups fabric-wide by algorithm + (POST /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/propose) """ from __future__ import annotations @@ -131,3 +133,58 @@ def verb(self) -> HttpVerbEnum: None """ return HttpVerbEnum.POST + + +class EpFabricSoftwareUpdatePlanPropose(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Auto-assign update groups fabric-wide (the GUI "Auto-generate groups" action). + + ND generates the update groups itself based on the requested algorithm and applies the result + immediately - it is not a preview. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/propose` + - Verb: POST + - Body: `{"algorithm": "roleBased"}` # or "evenOdd" + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpFabricSoftwareUpdatePlanPropose"] = Field( + default="EpFabricSoftwareUpdatePlanPropose", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the propose action endpoint path. `fabric_name` is percent-encoded with `safe=""`. + + ## Raises + + ### ValueError + + - If `fabric_name` is not set before accessing `path`. + """ + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", "propose") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py index 189cbca55..82ffa2b23 100644 --- a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py +++ b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py @@ -192,6 +192,7 @@ def get_argument_spec(cls) -> dict: ), ), ), + auto_assign=dict(type="str", choices=["roleBased", "evenOdd"]), state=dict( type="str", default="merged", diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py index c914af3dc..e7cb7e4b2 100644 --- a/plugins/module_utils/orchestrators/fabric_update_group.py +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -47,6 +47,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_actions import ( EpFabricSoftwareUpdatePlanAttachGroup, EpFabricSoftwareUpdatePlanDetachGroup, + EpFabricSoftwareUpdatePlanPropose, ) from ansible_collections.cisco.nd.plugins.module_utils.fabric_context import FabricContext from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel @@ -80,6 +81,7 @@ class FabricUpdateGroupOrchestrator(NDBaseOrchestrator[FabricUpdateGroupModel]): - Via `update` if a request fails or `update_group_switches` resolves to an empty set. - Via `delete` if a request fails or any per-item `detachGroup` status is not `success`. - Via `query_one` / `query_all` if the query API request fails. + - Via `propose` if the auto-assign request fails. - Via `_resolve_switch_id` if the user-provided switch IP cannot be matched in the fabric. """ @@ -93,6 +95,7 @@ class FabricUpdateGroupOrchestrator(NDBaseOrchestrator[FabricUpdateGroupModel]): query_all_endpoint: Type[NDEndpointBaseModel] = EpFabricUpdateGroupListGet create_bulk_endpoint: Type[NDEndpointBaseModel] | None = EpFabricSoftwareUpdatePlanAttachGroup detach_group_endpoint: Type[NDEndpointBaseModel] = EpFabricSoftwareUpdatePlanDetachGroup + propose_endpoint: Type[NDEndpointBaseModel] = EpFabricSoftwareUpdatePlanPropose _fabric_context: FabricContext | None = None @@ -547,3 +550,23 @@ def query_all(self, model_instance: FabricUpdateGroupModel | None = None, **kwar return [self._denormalize_switches_in_response(item) for item in items] except Exception as e: raise RuntimeError(f"Query all failed: {e}") from e + + def propose(self, algorithm: str) -> ResponseType: + """ + # Summary + + Auto-assign update groups fabric-wide via the `propose` action endpoint. ND generates the + update groups itself from `algorithm` (`roleBased` or `evenOdd`) and applies the result + immediately; the action returns HTTP 200 with the resulting plan. + + ## Raises + + ### RuntimeError + + - If the request fails. + """ + try: + api_endpoint = self._configure_endpoint(self.propose_endpoint()) + return self._request(path=api_endpoint.path, verb=api_endpoint.verb, data={"algorithm": algorithm}) + except Exception as e: + raise RuntimeError(f"Auto-assign (propose) failed for fabric '{self.fabric_name}': {e}") from e diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index b7bbcd315..474126002 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -139,6 +139,18 @@ - The name of the report check to run. type: str required: true + auto_assign: + description: + - Auto-generate fabric update groups by algorithm instead of listing them explicitly in O(config). + - V(roleBased) groups switches by their role. V(evenOdd) splits switches into an odd and an even group. + - This triggers the Nexus Dashboard fabric-wide auto-assign action, which generates the update + groups and applies them immediately. Nexus Dashboard names the generated groups itself, in the + form C(fabric_platform_role) for V(roleBased) or C(fabric_platform_OddGroup) / + C(fabric_platform_EvenGroup) for V(evenOdd). + - O(auto_assign) is mutually exclusive with O(config), and is only valid with O(state=merged) or O(state=overridden). + - In check mode no change is reported, because the auto-assign action cannot be previewed. + type: str + choices: [ roleBased, evenOdd ] state: description: - The desired state of the fabric update groups on the Cisco Nexus Dashboard. @@ -191,6 +203,12 @@ config: - update_group_name: leaf_group state: deleted + +- name: Auto-assign update groups by switch role + cisco.nd.nd_fabric_update_group: + fabric_name: SITE1 + auto_assign: roleBased + state: merged """ RETURN = r""" @@ -200,8 +218,50 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_update_group.fabric_update_group import FabricUpdateGroupModel from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.fabric_update_group import FabricUpdateGroupOrchestrator +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.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender + + +def _run_auto_assign(module: AnsibleModule) -> NDOutput: + """ + # Summary + + Run the fabric-wide auto-assign (`propose`) action, bypassing `NDStateMachine` whose per-group + config-diff state model does not apply to a single fabric-level action. The update groups are + snapshotted before and after so `changed` reflects whether the regrouping altered the fabric. + In check mode the `propose` action is skipped (it cannot be previewed) and no change is reported. + + ## Raises + + ### Exception + + - Propagated from the orchestrator if a query or the `propose` request fails. + """ + output = NDOutput(output_level=module.params.get("output_level", "normal")) + + sender = Sender() + sender.ansible_module = module + rest_send_params = dict(module.params) + rest_send_params["check_mode"] = module.check_mode + rest_send = RestSend(rest_send_params) + rest_send.sender = sender + rest_send.response_handler = ResponseHandler() + + orchestrator = FabricUpdateGroupOrchestrator(rest_send=rest_send, results=Results()) + + before = NDConfigCollection.from_api_response(response_data=orchestrator.query_all(), model_class=FabricUpdateGroupModel) + if not module.check_mode: + orchestrator.propose(module.params["auto_assign"]) + after = NDConfigCollection.from_api_response(response_data=orchestrator.query_all(), model_class=FabricUpdateGroupModel) + + output.assign(before=before, after=after) + return output def main(): @@ -211,9 +271,24 @@ def main(): module = AnsibleModule( argument_spec=argument_spec, supports_check_mode=True, + mutually_exclusive=[["config", "auto_assign"]], ) require_pydantic(module) + auto_assign = module.params.get("auto_assign") + if auto_assign is not None: + state = module.params["state"] + if state not in ("merged", "overridden"): + module.fail_json(msg=f"auto_assign is only valid with state 'merged' or 'overridden', got '{state}'.") + + output = NDOutput(output_level=module.params.get("output_level", "normal")) + try: + output = _run_auto_assign(module) + module.exit_json(**output.format()) + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}", **output.format()) + return + nd_state_machine = NDStateMachine( module=module, model_orchestrator=FabricUpdateGroupOrchestrator, diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/auto_assign.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/auto_assign.yaml new file mode 100644 index 000000000..e053b94d2 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/auto_assign.yaml @@ -0,0 +1,82 @@ +--- +# Auto-assign (propose) tests for nd_fabric_update_group +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# +# These tests run last: the propose action regroups every switch in the fabric, which would +# disturb the disjoint switch sets the merged/replaced/overridden/deleted tests depend on. + +# --- AUTO-ASSIGN NEGATIVE --- + +- name: "AUTO-ASSIGN ERROR: config and auto_assign are mutually exclusive" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + auto_assign: roleBased + config: + - update_group_name: ansible_leaf_group + state: merged + register: err_auto_assign_with_config + ignore_errors: true + +- name: "AUTO-ASSIGN ERROR: auto_assign with state deleted is rejected" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + auto_assign: roleBased + state: deleted + register: err_auto_assign_bad_state + ignore_errors: true + +- name: "AUTO-ASSIGN ERROR: Verify both invalid invocations failed" + ansible.builtin.assert: + that: + - err_auto_assign_with_config is failed + - "'mutually exclusive' in err_auto_assign_with_config.msg" + - err_auto_assign_bad_state is failed + - "'auto_assign is only valid with state' in err_auto_assign_bad_state.msg" + +# --- AUTO-ASSIGN APPLY --- + +- name: "AUTO-ASSIGN APPLY: Auto-assign update groups by role (check mode)" + cisco.nd.nd_fabric_update_group: &auto_assign_role + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + auto_assign: roleBased + state: merged + check_mode: true + register: cm_auto_assign_apply + +- name: "AUTO-ASSIGN APPLY: Auto-assign update groups by role (normal mode)" + cisco.nd.nd_fabric_update_group: *auto_assign_role + register: nm_auto_assign_apply + +- name: "AUTO-ASSIGN APPLY: Verify groups were generated" + ansible.builtin.assert: + that: + # Check mode cannot preview the propose action, so it reports no change. + - cm_auto_assign_apply is not changed + - nm_auto_assign_apply is changed + - nm_auto_assign_apply.after | length > 0 + +# --- AUTO-ASSIGN IDEMPOTENCY --- + +- name: "AUTO-ASSIGN IDEMPOTENT: Re-run auto-assign by role (normal mode)" + cisco.nd.nd_fabric_update_group: *auto_assign_role + register: nm_auto_assign_idem + +- name: "AUTO-ASSIGN IDEMPOTENT: Verify no change on second run" + ansible.builtin.assert: + that: + - nm_auto_assign_idem is not changed + +# --- AUTO-ASSIGN CLEANUP --- + +- name: "AUTO-ASSIGN CLEANUP: Remove the auto-generated update groups" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: "{{ item }}" + state: deleted + loop: "{{ nm_auto_assign_apply.after | map(attribute='update_group_name') | list }}" + failed_when: false diff --git a/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml index 58b912aa9..0cab82b0a 100644 --- a/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml +++ b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml @@ -44,6 +44,9 @@ - name: Run deleted state tests ansible.builtin.include_tasks: deleted.yaml + + - name: Run auto-assign (propose) tests + ansible.builtin.include_tasks: auto_assign.yaml module_defaults: cisco.nd.nd_fabric_update_group: timeout: 300 diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json index db7b204cd..fba7934b0 100644 --- a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -596,5 +596,32 @@ {"updateGroupName": "g2", "execution": "parallel"} ] } + }, + + "test_fabric_update_group_00800a": { + "TEST_NOTES": [ + "propose (auto-assign): POST returns 200 with the resulting plan.", + "Shared by the roleBased and evenOdd parametrized cases - the response is algorithm-agnostic." + ], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/propose", + "MESSAGE": "OK", + "DATA": { + "softwareUpdateSummary": {"fabricName": "fabric_1", "totalSwitches": 2, "switchesToBeUpdated": 0, "updatePlanStatus": "none"}, + "tableHeaders": {"autoAssignGroups": ["roleBased", "evenOdd"]}, + "updateGroups": [ + {"updateGroupName": "fabric_1_N9K_leaf", "updateGroupPlatform": "n9k", "switchCount": 2, "updateGroupSwitches": ["FDO1", "FDO2"]} + ] + } + }, + + "test_fabric_update_group_00810a": { + "TEST_NOTES": ["propose transport failure: POST returns 500"], + "RETURN_CODE": 500, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/propose", + "MESSAGE": "Internal Server Error", + "DATA": {"error": "boom"} } } diff --git a/tests/unit/module_utils/models/test_fabric_update_group.py b/tests/unit/module_utils/models/test_fabric_update_group.py index 5061167d5..e3650b5bf 100644 --- a/tests/unit/module_utils/models/test_fabric_update_group.py +++ b/tests/unit/module_utils/models/test_fabric_update_group.py @@ -553,7 +553,7 @@ def test_fabric_update_group_00600() -> None: ## Test - - `fabric_name`, `state`, `config` are top-level keys + - `fabric_name`, `state`, `config`, `auto_assign` are top-level keys - `state` choices are merged/replaced/overridden/deleted (no `query` - the ND collection has no `query` state; `gathered` is the planned read mechanism) - `config.options.update_group_name` is required @@ -565,7 +565,7 @@ def test_fabric_update_group_00600() -> None: """ spec = FabricUpdateGroupModel.get_argument_spec() - assert set(spec.keys()) == {"fabric_name", "config", "state"} + assert set(spec.keys()) == {"fabric_name", "config", "state", "auto_assign"} assert spec["fabric_name"]["required"] is True assert spec["state"]["choices"] == ["merged", "replaced", "overridden", "deleted"] assert spec["config"]["type"] == "list" @@ -581,6 +581,33 @@ def test_fabric_update_group_00600() -> None: assert options["update_report_checks"]["options"]["report_check_name"]["required"] is True +def test_fabric_update_group_00610() -> None: + """ + # Summary + + Verify `get_argument_spec` exposes `auto_assign` as a string option with the propose algorithm choices. + + `auto_assign` triggers ND's fabric-wide `propose` action, which auto-generates update groups by + algorithm. It is a top-level option (the action is fabric-scoped, not per-group) and has no + default - its absence means "do not auto-assign". + + ## Test + + - `auto_assign` is a top-level key of type `str` + - Its `choices` are `roleBased` and `evenOdd` (ND wire enum values, kept verbatim) + - It has no `default` key + + ## Classes and Methods + + - FabricUpdateGroupModel.get_argument_spec() + """ + spec = FabricUpdateGroupModel.get_argument_spec() + + assert spec["auto_assign"]["type"] == "str" + assert spec["auto_assign"]["choices"] == ["roleBased", "evenOdd"] + assert "default" not in spec["auto_assign"] + + # ============================================================================= # Test: installation_order_devices excluded from diff # ============================================================================= diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 8bf17bba4..15d2b8ea1 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -1022,3 +1022,68 @@ def responses(): names = {g["updateGroupName"] for g in result} assert names == {"g1", "g2"} + + +# ============================================================================= +# Test: propose (auto-assign) +# ============================================================================= + + +@pytest.mark.parametrize("algorithm", ["roleBased", "evenOdd"], ids=["roleBased", "evenOdd"]) +def test_fabric_update_group_00800(algorithm: str) -> None: + """ + # Summary + + Verify `propose` POSTs the chosen algorithm to the `softwareUpdatePlan` propose action endpoint. + + ## Test + + - `propose` is called with `roleBased` / `evenOdd` + - The request is a POST to `.../softwareUpdatePlan/actions/propose` + - The request body is `{"algorithm": }` + - The propose plan dict is returned + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.propose() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with does_not_raise(): + result = instance.propose(algorithm) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/propose" + assert rest_send.verb == HttpVerbEnum.POST.value + assert rest_send.committed_payload == {"algorithm": algorithm} + assert isinstance(result, dict) + assert "updateGroups" in result + + +def test_fabric_update_group_00810() -> None: + """ + # Summary + + Verify `propose` wraps a transport failure in `RuntimeError` mentioning the fabric. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.propose() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Auto-assign \(propose\) failed for fabric 'fabric_1'"): + instance.propose("roleBased") From a9575ad629fcb7d2f1646368f1447432afa67b2e Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 28 May 2026 08:08:34 -1000 Subject: [PATCH 05/12] Bump version_added to 2.0.0 for next ND collection release --- plugins/modules/nd_fabric_update_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 474126002..5a5a47714 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -10,7 +10,7 @@ DOCUMENTATION = r""" --- module: nd_fabric_update_group -version_added: "1.4.0" +version_added: "2.0.0" short_description: Manage fabric update groups (Fabric Software Management) on Cisco Nexus Dashboard description: - Manage fabric update groups under O(fabric_name) on Cisco Nexus Dashboard (ND). From e6d5723ad7b7be62c07bb82765e5b9436d9b59f8 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 1 Jun 2026 13:19:48 -1000 Subject: [PATCH 06/12] Fix replaced reset semantics and enforce IP-only switches in nd_fabric_update_group Two correctness fixes from code review of the nd_fabric_update_group module: - state: replaced/overridden now build a true full-replace PUT body. _apply_settings is state-aware: for replaced/overridden it seeds only the PUT-required fields from the current group and overlays the user's explicitly-set fields, so optional fields the user omits are absent from the body and ND (full replace) resets them. merged still overlays onto the full current group (omitted fields preserved). Previously replaced merged onto current and silently behaved like merged while misreporting changed. PUT full-replace semantics verified live on ND 4.2.1 (SITE1). - Switches are now IP-only input. _resolve_switch_id rejects a non-IP value with a clear error instead of passing a serial through to the wire; DOCUMENTATION, the example, and docstrings drop the serial-number option. IP-only input is inherently idempotent against the IP-denormalized read state, so no proposed-side canonicalization is needed. _switch_ids_to_ips is retained only for wire->output denormalization in GET responses. Tests: add 00240/00250 (replaced-resets vs merged-retains), 00715 (non-IP rejected); create/update/bulk tests use IP-form input with an in-memory FabricContext stand-in. Full unit suite green (625). Framework limitation tracked in #305: the shared subset diff cannot detect a reset-only replaced change (omitted field at a non-default value), so a reset-only run still reports no_diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../orchestrators/fabric_update_group.py | 111 ++++++-- plugins/modules/nd_fabric_update_group.py | 6 +- .../test_fabric_update_group.json | 60 +++++ .../orchestrators/test_fabric_update_group.py | 254 +++++++++++++++--- 4 files changed, 368 insertions(+), 63 deletions(-) diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py index e7cb7e4b2..58389ee04 100644 --- a/plugins/module_utils/orchestrators/fabric_update_group.py +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -22,10 +22,10 @@ Fabric name is supplied by the module's top-level `fabric_name` option and propagated to every endpoint instance prior to path generation; per-config identifier is `update_group_name`. -`update_group_switches` and `installation_order_devices` accept either switch IP addresses or -switch serial numbers (switchIds). IPs are resolved to switchIds via `FabricContext` before being -sent on the wire; switchIds in GET responses are converted back to IPs so playbook authors see -consistent IP-based output even though the wire stores serials. +`update_group_switches` and `installation_order_devices` accept switch fabric management IP addresses +only. IPs are resolved to switchIds via `FabricContext` before being sent on the wire; switchIds in GET +responses are converted back to IPs so playbook authors see consistent IP-based output even though the +wire stores switchIds. `attachGroup` and `detachGroup` return HTTP 207 with per-item `status`. Any status other than `success` fails the task. ND returns `attachGroup` `status: warning` when it declines to apply a @@ -61,6 +61,20 @@ # Wire payload keys that identify a group / its membership rather than its settings _NON_SETTINGS_PAYLOAD_KEYS = ("updateGroupName", "updateGroupSwitches") +# Wire payload keys that PUT /updateGroups/{name} requires. For state replaced/overridden these are +# seeded from the current group when the user omits them (a required field has no "reset" meaning), +# while every OTHER (optional) field the user omits is left out of the PUT body so ND - whose PUT is a +# full replace - resets it to its default. +_REQUIRED_PUT_WIRE_KEYS = ( + "updateGroupName", + "execution", + "contingency", + "analysis", + "isMaintenance", + "isDisruptiveUpdate", + "updateGroupSwitches", +) + class FabricUpdateGroupOrchestrator(NDBaseOrchestrator[FabricUpdateGroupModel]): """ @@ -112,6 +126,20 @@ def fabric_name(self) -> str: """ return self.rest_send.params.get("fabric_name") + @property + def _state(self) -> str: + """ + # Summary + + Return the module `state` (`merged`, `replaced`, `overridden`, `deleted`) from module params, + defaulting to `merged` when unset. + + ## Raises + + None + """ + return self.rest_send.params.get("state") or "merged" + @property def fabric_context(self) -> FabricContext: """ @@ -146,8 +174,9 @@ def _looks_like_ip(value: str) -> bool: """ # Summary - Heuristic: a string with a dot is an IP address; anything else is treated as a switch serial - number (switchIds in the field, e.g. `FDO12345ABC`, never contain dots). + Heuristic: a switch identifier is a fabric management IP address if it is a string containing a + dot. switchIds (serial numbers, e.g. `FDO12345ABC`) never contain dots. Switches are supplied as + IP addresses only, so a value that fails this check is rejected by `_resolve_switch_id`. ## Raises @@ -159,18 +188,24 @@ def _resolve_switch_id(self, value: str) -> str: """ # Summary - Resolve a user-supplied switch identifier to a switchId. IP addresses are looked up via - `FabricContext`; switchId strings are returned unchanged. + Resolve a user-supplied switch fabric management IP address to its switchId via `FabricContext`. + Switches are accepted as IP addresses only; a non-IP value (e.g. a switch serial number) is + rejected rather than passed through, so an unsupported input fails fast with a clear message + instead of being silently sent on the wire. ## Raises ### RuntimeError - - If the value looks like an IP but no switch with that `fabricManagementIp` exists in the fabric. + - If `value` is not an IP address (switches must be specified as fabric management IP addresses). + - If no switch with that `fabricManagementIp` exists in the fabric (propagated from `FabricContext`). """ - if self._looks_like_ip(value): - return self.fabric_context.get_switch_id(value) - return value + if not self._looks_like_ip(value): + raise RuntimeError( + f"Switch identifier '{value}' is not a fabric management IP address; " + "update_group_switches and installation_order_devices accept IP addresses only." + ) + return self.fabric_context.get_switch_id(value) def _resolve_switches_in_payload(self, payload: dict) -> dict: """ @@ -191,6 +226,24 @@ def _resolve_switches_in_payload(self, payload: dict) -> dict: payload[key] = [self._resolve_switch_id(v) for v in values] return payload + def _switch_ids_to_ips(self, values: list) -> list: + """ + # Summary + + Map wire `switchId`s to their `fabricManagementIp`s via `FabricContext` (best-effort). switchIds + absent from the fabric (e.g. stale serials) are passed through unchanged. Used to denormalize GET + responses so the user sees IP-based output that matches the IP-based input they supplied. + + ## Raises + + None + """ + try: + switch_map_by_id = self.fabric_context.switch_map_by_id + except Exception: # pylint: disable=broad-except + return list(values) + return [switch_map_by_id.get(v, v) for v in values] + def _denormalize_switches_in_response(self, item: Any) -> Any: """ # Summary @@ -199,7 +252,7 @@ def _denormalize_switches_in_response(self, item: Any) -> Any: `installationOrderDevices` with their matching IPs so the user sees consistent IP-based output. Denormalization is best-effort: if the switch map cannot be loaded (e.g. the inventory call - fails), the response is returned unmodified rather than raising - switchIds shown to the user + fails), the lists are returned unmodified rather than raising - switchIds shown to the user are still correct, just not user-friendlier IPs. Per-switchId lookups that miss the map (stale serials) are also passed through unchanged. @@ -211,14 +264,10 @@ def _denormalize_switches_in_response(self, item: Any) -> Any: return item if not any(isinstance(item.get(key), list) for key in _SWITCH_LIST_PAYLOAD_KEYS): return item - try: - switch_map_by_id = self.fabric_context.switch_map_by_id - except Exception: # pylint: disable=broad-except - return item for key in _SWITCH_LIST_PAYLOAD_KEYS: values = item.get(key) if isinstance(values, list): - item[key] = [switch_map_by_id.get(v, v) for v in values] + item[key] = self._switch_ids_to_ips(values) return item @staticmethod @@ -375,10 +424,19 @@ def _apply_settings(self, model_instance: FabricUpdateGroupModel, current_raw: d Apply group settings via `PUT /updateGroups/{name}`. The action API carries membership only, so settings are PUT separately. PUT is a full replace requiring all of `updateGroupName`, `execution`, `contingency`, `analysis`, `isMaintenance`, `isDisruptiveUpdate`, and - `updateGroupSwitches`; the body is built by overlaying the user's explicitly-set fields onto a - GET of the current group, so every required field is present and `updateGroupSwitches` echoes - the group's actual membership (PUT moves no switches). Skipped entirely when the model carries - no settings. + `updateGroupSwitches`, and `updateGroupSwitches` echoes the group's actual membership (PUT + moves no switches). + + The PUT body is built differently per state: + + - `merged`: overlay the user's explicitly-set fields onto a GET of the current group, so every + required field is present and fields the user omitted keep their current values. Skipped + entirely when the model carries no settings. + - `replaced` / `overridden`: seed only the PUT-required fields from the current group, then + overlay the user's explicitly-set fields. Optional fields the user omitted are absent from + the body, so ND (full replace) resets them to their defaults. Issued whenever this method is + reached (the state machine has already determined the group changed), because the PUT is the + mechanism that performs the reset. ## Raises @@ -386,11 +444,16 @@ def _apply_settings(self, model_instance: FabricUpdateGroupModel, current_raw: d - If a switch IP cannot be resolved, or the GET / PUT request fails. """ - if not self._model_has_settings(model_instance): + is_replace = self._state in ("replaced", "overridden") + if not is_replace and not self._model_has_settings(model_instance): return if current_raw is None: current_raw = self._get_group_raw(model_instance.update_group_name) - merged = FabricUpdateGroupModel.from_response(current_raw) + if is_replace: + base_raw = {key: current_raw[key] for key in _REQUIRED_PUT_WIRE_KEYS if key in current_raw} + else: + base_raw = current_raw + merged = FabricUpdateGroupModel.from_response(base_raw) merged.merge(model_instance) api_endpoint = self._configure_endpoint(self.update_endpoint()) api_endpoint.set_identifiers(model_instance.update_group_name) diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 5a5a47714..93fbf485a 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -69,7 +69,7 @@ update_group_switches: description: - The list of switches that belong to this update group. - - Each entry may be a switch fabric management IP address or a switch serial number (switchId). + - Each entry is a switch fabric management IP address. - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. - An update group must contain at least one switch; ND does not permit a zero-switch group. type: list @@ -84,7 +84,7 @@ installation_order_devices: description: - The order in which switches are upgraded when O(config.execution=serial). - - Each entry may be a switch fabric management IP address or a switch serial number (switchId). + - Each entry is a switch fabric management IP address. - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. type: list elements: str @@ -182,7 +182,7 @@ is_maintenance: true is_disruptive_update: true update_group_switches: - # Either IP addresses or switch serial numbers may be used. + # Switches are specified as fabric management IP addresses. - 192.168.7.11 - 192.168.7.12 install_image_data: diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json index fba7934b0..e063c27d0 100644 --- a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -623,5 +623,65 @@ "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/propose", "MESSAGE": "Internal Server Error", "DATA": {"error": "boom"} + }, + + "test_fabric_update_group_00240a": { + "TEST_NOTES": [ + "state replaced: GET current group. reportSelection=advanced is an optional field currently on", + "the group that the user omits; a replaced PUT must reset it. Membership FDO1,FDO2 is unchanged." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "reportSelection": "advanced", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + + "test_fabric_update_group_00240b": { + "TEST_NOTES": ["state replaced: settings PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00250a": { + "TEST_NOTES": [ + "state merged: GET current group with reportSelection=advanced. The user omits reportSelection;", + "a merged PUT must preserve it (contrast with the replaced behavior in 00240)." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "reportSelection": "advanced", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + + "test_fabric_update_group_00250b": { + "TEST_NOTES": ["state merged: settings PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} } } diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 15d2b8ea1..67ffd1a05 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -63,8 +63,56 @@ def _build_rest_send(gen_responses: ResponseGenerator, fabric_name: str = "fabri return rest_send +# Switches are supplied as fabric management IP addresses only. The orchestrator resolves them to +# switchIds via FabricContext; in unit tests a fake context resolves these IPs in-memory so no +# switches-list response is consumed (the create/update fixtures carry only the group operations). +_TEST_IP_TO_ID = { + "192.168.0.1": "FDO1", + "192.168.0.2": "FDO2", + "192.168.0.3": "FDO3", + "192.168.0.9": "FDO9", +} + + +class _FakeFabricContext: + """In-memory `FabricContext` stand-in: resolves IPs to switchIds and back, no API calls.""" + + def __init__(self, ip_to_id: dict[str, str] | None = None, id_to_ip: dict[str, str] | None = None) -> None: + self._ip_to_id = ip_to_id or {} + self._id_to_ip = id_to_ip if id_to_ip is not None else {sid: ip for ip, sid in self._ip_to_id.items()} + + def get_switch_id(self, switch_ip: str) -> str: + """Resolve a fabricManagementIp to its switchId, raising if unknown (mirrors FabricContext).""" + try: + return self._ip_to_id[switch_ip] + except KeyError as error: + raise RuntimeError(f"No switch found with fabricManagementIp '{switch_ip}'.") from error + + @property + def switch_map_by_id(self) -> dict[str, str]: + """Return the switchId -> fabricManagementIp mapping.""" + return self._id_to_ip + + +class _RaisingFabricContext: + """`FabricContext` stand-in whose `switch_map_by_id` raises (inventory unavailable).""" + + @property + def switch_map_by_id(self) -> dict[str, str]: + """Raise to simulate a failed switch-inventory fetch.""" + raise RuntimeError("switch inventory unavailable") + + +def _resolving_instance(gen_responses: ResponseGenerator, fabric_name: str = "fabric_1") -> tuple[RestSend, FabricUpdateGroupOrchestrator]: + """Build a `RestSend` + orchestrator with an in-memory `FabricContext` that resolves the test IPs.""" + rest_send = _build_rest_send(gen_responses, fabric_name) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + instance._fabric_context = _FakeFabricContext(ip_to_id=_TEST_IP_TO_ID) + return rest_send, instance + + def _build_model(update_group_name: str = "leaf_group", force_created: bool = False) -> FabricUpdateGroupModel: - """Build a `FabricUpdateGroupModel` with full settings and two switchId-form switches.""" + """Build a `FabricUpdateGroupModel` with full settings and two IP-form switches.""" return FabricUpdateGroupModel( update_group_name=update_group_name, execution="serial", @@ -72,7 +120,7 @@ def _build_model(update_group_name: str = "leaf_group", force_created: bool = Fa analysis="snapshot", is_maintenance=True, is_disruptive_update=True, - update_group_switches=["FDO1", "FDO2"], + update_group_switches=["192.168.0.1", "192.168.0.2"], force_created=force_created, ) @@ -163,8 +211,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}c") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model() with does_not_raise(): @@ -201,8 +248,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model() with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*failed.*Switch not found"): @@ -225,8 +271,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model() with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group"): @@ -256,8 +301,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model(force_created=False) with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*warning"): @@ -291,8 +335,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model(force_created=True) with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*warning"): @@ -321,9 +364,8 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1", "FDO2"], force_created=True) + rest_send, instance = _resolving_instance(gen_responses) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["192.168.0.1", "192.168.0.2"], force_created=True) with does_not_raise(): instance.create(model) @@ -356,9 +398,8 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1", "FDO2"]) + rest_send, instance = _resolving_instance(gen_responses) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["192.168.0.1", "192.168.0.2"]) with does_not_raise(): instance.create(model) @@ -398,8 +439,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}d") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model() with does_not_raise(): @@ -429,8 +469,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) model = _build_model() with pytest.raises(RuntimeError, match=r"Update failed for .*leaf_group"): @@ -460,9 +499,8 @@ def responses(): yield responses_fabric_update_group(f"{method_name}b") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1", "FDO2", "FDO3"]) + rest_send, instance = _resolving_instance(gen_responses) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["192.168.0.1", "192.168.0.2", "192.168.0.3"]) with does_not_raise(): instance.update(model) @@ -495,9 +533,8 @@ def responses(): yield responses_fabric_update_group(f"{method_name}b") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) - model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["FDO1"]) + rest_send, instance = _resolving_instance(gen_responses) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=["192.168.0.1"]) with does_not_raise(): instance.update(model) @@ -507,6 +544,85 @@ def responses(): assert body == {"detachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO2"]}]} +def test_fabric_update_group_00240() -> None: + """ + # Summary + + Verify `update` with `state: replaced` builds a full-replace PUT body that resets an optional + field the user omitted. + + ## Test + + - Current group carries `reportSelection: advanced`; the user's model omits it + - `state` is `replaced`, so the PUT body seeds only the required fields from the current group and + overlays the user's set fields - `reportSelection` is therefore absent and ND resets it + - The user's `analysis: snapshot` still overrides the current `noAnalysis` + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._apply_settings() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + rest_send.params["state"] = "replaced" + model = _build_model() + + with does_not_raise(): + instance.update(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value + body = rest_send.committed_payload + assert "reportSelection" not in body + assert body["analysis"] == "snapshot" + assert body["execution"] == "serial" + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] + + +def test_fabric_update_group_00250() -> None: + """ + # Summary + + Verify `update` with `state: merged` preserves an optional field the user omitted (contrast with + the replaced behavior in 00240). + + ## Test + + - Current group carries `reportSelection: advanced`; the user's model omits it + - `state` is `merged`, so the PUT body overlays the user's set fields onto the full current group + and `reportSelection: advanced` is retained + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._apply_settings() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + model = _build_model() + + with does_not_raise(): + instance.update(model) + + assert rest_send.verb == HttpVerbEnum.PUT.value + body = rest_send.committed_payload + assert body["reportSelection"] == "advanced" + assert body["analysis"] == "snapshot" + + # ============================================================================= # Test: delete # ============================================================================= @@ -779,8 +895,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}e") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) models = [_build_model("g1"), _build_model("g2")] with does_not_raise(): @@ -807,8 +922,7 @@ def responses(): yield responses_fabric_update_group(f"{method_name}a") gen_responses = ResponseGenerator(responses()) - rest_send = _build_rest_send(gen_responses) - instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + rest_send, instance = _resolving_instance(gen_responses) models = [_build_model("g1"), _build_model("g2")] with pytest.raises(RuntimeError, match=r"Bulk create failed.*g2.*failed.*Switch missing"): @@ -824,12 +938,12 @@ def test_fabric_update_group_00700() -> None: """ # Summary - Verify `create` resolves switch IPs to switchIds in the `attachGroup` body. + Verify `create` resolves switch IPs to switchIds in the `attachGroup` body via `FabricContext`. ## Test - Switches-list returns the IP -> switchId map - - The attachGroup body carries switchIds; the serial-form entry passes through unchanged + - The attachGroup body carries the resolved switchIds ## Classes and Methods @@ -848,14 +962,32 @@ def responses(): instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) model = FabricUpdateGroupModel( update_group_name="leaf_group", - update_group_switches=["192.168.12.151", "192.168.12.152", "FDO_PASSTHROUGH"], + update_group_switches=["192.168.12.151", "192.168.12.152"], ) with does_not_raise(): instance.create(model) body = rest_send.committed_payload - assert body["attachUpdateGroups"][0]["switchIds"] == ["FDO12345ABC", "FDO12345ABD", "FDO_PASSTHROUGH"] + assert body["attachUpdateGroups"][0]["switchIds"] == ["FDO12345ABC", "FDO12345ABD"] + + +def test_fabric_update_group_00715() -> None: + """ + # Summary + + Verify `_resolve_switch_id` rejects a non-IP switch identifier (switches are accepted as fabric + management IP addresses only), failing fast before any wire request. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator._resolve_switch_id() + """ + rest_send = RestSend({"check_mode": False, "fabric_name": "fabric_1"}) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"is not a fabric management IP address.*IP addresses only"): + instance._resolve_switch_id("FDO12345ABC") def test_fabric_update_group_00710() -> None: @@ -1024,6 +1156,56 @@ def responses(): assert names == {"g1", "g2"} +# ============================================================================= +# Test: switchId -> IP denormalization helper +# ============================================================================= + + +def _bare_instance(context) -> FabricUpdateGroupOrchestrator: + """Build an orchestrator with `fabric_context` pre-seeded to a fake (no API calls).""" + rest_send = RestSend({"check_mode": False, "fabric_name": "fabric_1"}) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + instance._fabric_context = context + return instance + + +def test_fabric_update_group_00770() -> None: + """ + # Summary + + Verify `_switch_ids_to_ips` maps wire switchIds to IPs and passes through switchIds absent from + the fabric (stale serials). + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator._switch_ids_to_ips() + """ + instance = _bare_instance(_FakeFabricContext(id_to_ip={"FDO1": "192.168.1.1", "FDO2": "192.168.1.2"})) + + result = instance._switch_ids_to_ips(["FDO1", "FDO2", "FDO_STALE"]) + + assert result == ["192.168.1.1", "192.168.1.2", "FDO_STALE"] + + +def test_fabric_update_group_00790() -> None: + """ + # Summary + + Verify `_switch_ids_to_ips` is best-effort: when the switch map cannot be loaded, the switchIds are + returned unchanged rather than raising. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator._switch_ids_to_ips() + """ + instance = _bare_instance(_RaisingFabricContext()) + + with does_not_raise(): + result = instance._switch_ids_to_ips(["FDO1", "FDO2"]) + + assert result == ["FDO1", "FDO2"] + + # ============================================================================= # Test: propose (auto-assign) # ============================================================================= From 7103991b2105c5a9e6e8d6480d3723b4821ce377 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 1 Jun 2026 14:05:13 -1000 Subject: [PATCH 07/12] Address nd_fabric_update_group review findings #3-#10 Follow-up to e6d5723a (review findings #1/#2). Resolves the remaining clear code fixes plus test gaps from the PR #285 review: - #3: _switch_ids_to_ips no longer swallows exceptions; a failed switch inventory load now propagates instead of returning unconverted switchIds (which produced a silent perpetual `changed`). - #4: reject state: overridden with auto_assign (merged-only); the propose action already regroups the whole fabric, so overridden has no well-defined per-group delete semantics. Doc updated. - #5: _raise_on_207_action_errors treats a missing per-item status as a failure (status != "success") rather than assuming success. - #7: _looks_like_ip renamed to _is_ip_address and now validates via ipaddress.ip_address(), accepting IPv6 and rejecting CIDR-prefixed values; no reusable boolean IP predicate existed in module_utils. - #8: _run_auto_assign populates a passed-in NDOutput and seeds before/after right after the snapshot, so a propose failure still surfaces before/after context. - #9: collapse the attach/detach/propose action endpoints onto _EpFabricSoftwareUpdatePlanActionBase (shared path + _action ClassVar; verb kept abstract per subclass, mirroring _EpFabricUpdateGroupBase). - analysis/reports mutual exclusion: new _validate_report_analysis_exclusion turns ND's raw 400 into a clear validation error (module-level so it cannot fire during from_response/merge/replace). - #10: new tests for auto_assign check-mode guard, the Propose endpoint, update() empty-switches rejection, and every behavior change above. 645 unit tests pass; black/isort/pylint/mypy clean (module's 4 mypy errors are pre-existing on unchanged lines). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v1/manage/software_update_plan_actions.py | 124 +++++----- .../orchestrators/fabric_update_group.py | 60 +++-- plugins/modules/nd_fabric_update_group.py | 62 ++++- .../test_software_update_plan_actions.py | 124 +++++++++- .../test_fabric_update_group.json | 13 ++ .../orchestrators/test_fabric_update_group.py | 111 ++++++++- .../modules/test_nd_fabric_update_group.py | 212 ++++++++++++++++++ 7 files changed, 589 insertions(+), 117 deletions(-) create mode 100644 tests/unit/modules/test_nd_fabric_update_group.py diff --git a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py index c3a50bbaf..b29064330 100644 --- a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py @@ -8,6 +8,11 @@ `updateGroups` CRUD endpoints, they are ghost-safe by construction: `attachGroup` requires at least one switch, and `detachGroup` auto-deletes a group server-side once its last switch is removed. +All three endpoints share the same shape - a POST to +`/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/{action}` - so the path and verb +live on a common `_EpFabricSoftwareUpdatePlanActionBase`; each concrete endpoint just sets its +`_action` segment. + ## Endpoints - `EpFabricSoftwareUpdatePlanAttachGroup` - Create an update group and assign switches to it @@ -20,7 +25,7 @@ from __future__ import annotations -from typing import Literal +from typing import ClassVar, Literal from urllib.parse import quote from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field @@ -30,15 +35,17 @@ from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -class EpFabricSoftwareUpdatePlanAttachGroup(FabricNameMixin, NDEndpointBaseModel): +class _EpFabricSoftwareUpdatePlanActionBase(FabricNameMixin, NDEndpointBaseModel): """ # Summary - Create an update group and assign switches to it (switch-centric, ghost-safe). + Base class for the switch-centric Fabric Software Management action endpoints. Every action is a + POST to `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/{_action}`; subclasses + set the `_action` segment, the `verb`, and their own `class_name`. - - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/attachGroup` - - Verb: POST - - Body: `{"attachUpdateGroups": [{"updateGroupName": "...", "switchIds": ["..."], "forceCreated": false}]}` + `verb` is intentionally left abstract here (rather than defined as POST on the base) so the + endpoint metaclass keeps treating this base as abstract and does not require it to carry a + `class_name` field - mirroring the `_EpFabricUpdateGroupBase` pattern in this package. ## Raises @@ -47,16 +54,17 @@ class EpFabricSoftwareUpdatePlanAttachGroup(FabricNameMixin, NDEndpointBaseModel - Via `path` property if `fabric_name` is not set. """ - class_name: Literal["EpFabricSoftwareUpdatePlanAttachGroup"] = Field( - default="EpFabricSoftwareUpdatePlanAttachGroup", frozen=True, description="Class name for backward compatibility" - ) + # Action path segment (e.g. "attachGroup"). Overridden per subclass. Accessed via `self._action` + # (instance access) - the leading-underscore ClassVar trap that bites Pydantic v2 on Python 3.10 + # only fires on CLASS-level access, which this never does. + _action: ClassVar[str] = "" @property def path(self) -> str: """ # Summary - Build the attachGroup action endpoint path. `fabric_name` is percent-encoded with `safe=""`. + Build the action endpoint path. `fabric_name` is percent-encoded with `safe=""`. ## Raises @@ -66,23 +74,39 @@ def path(self) -> str: """ if self.fabric_name is None: raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") - return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", "attachGroup") + return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", self._action) - @property - def verb(self) -> HttpVerbEnum: - """ - # Summary - Return `HttpVerbEnum.POST`. +class EpFabricSoftwareUpdatePlanAttachGroup(_EpFabricSoftwareUpdatePlanActionBase): + """ + # Summary - ## Raises + Create an update group and assign switches to it (switch-centric, ghost-safe). - None - """ + - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/attachGroup` + - Verb: POST + - Body: `{"attachUpdateGroups": [{"updateGroupName": "...", "switchIds": ["..."], "forceCreated": false}]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + _action: ClassVar[str] = "attachGroup" + + class_name: Literal["EpFabricSoftwareUpdatePlanAttachGroup"] = Field( + default="EpFabricSoftwareUpdatePlanAttachGroup", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" return HttpVerbEnum.POST -class EpFabricSoftwareUpdatePlanDetachGroup(FabricNameMixin, NDEndpointBaseModel): +class EpFabricSoftwareUpdatePlanDetachGroup(_EpFabricSoftwareUpdatePlanActionBase): """ # Summary @@ -100,42 +124,19 @@ class EpFabricSoftwareUpdatePlanDetachGroup(FabricNameMixin, NDEndpointBaseModel - Via `path` property if `fabric_name` is not set. """ + _action: ClassVar[str] = "detachGroup" + class_name: Literal["EpFabricSoftwareUpdatePlanDetachGroup"] = Field( default="EpFabricSoftwareUpdatePlanDetachGroup", frozen=True, description="Class name for backward compatibility" ) - @property - def path(self) -> str: - """ - # Summary - - Build the detachGroup action endpoint path. `fabric_name` is percent-encoded with `safe=""`. - - ## Raises - - ### ValueError - - - If `fabric_name` is not set before accessing `path`. - """ - if self.fabric_name is None: - raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") - return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", "detachGroup") - @property def verb(self) -> HttpVerbEnum: - """ - # Summary - - Return `HttpVerbEnum.POST`. - - ## Raises - - None - """ + """Return the HTTP verb for this endpoint.""" return HttpVerbEnum.POST -class EpFabricSoftwareUpdatePlanPropose(FabricNameMixin, NDEndpointBaseModel): +class EpFabricSoftwareUpdatePlanPropose(_EpFabricSoftwareUpdatePlanActionBase): """ # Summary @@ -155,36 +156,13 @@ class EpFabricSoftwareUpdatePlanPropose(FabricNameMixin, NDEndpointBaseModel): - Via `path` property if `fabric_name` is not set. """ + _action: ClassVar[str] = "propose" + class_name: Literal["EpFabricSoftwareUpdatePlanPropose"] = Field( default="EpFabricSoftwareUpdatePlanPropose", frozen=True, description="Class name for backward compatibility" ) - @property - def path(self) -> str: - """ - # Summary - - Build the propose action endpoint path. `fabric_name` is percent-encoded with `safe=""`. - - ## Raises - - ### ValueError - - - If `fabric_name` is not set before accessing `path`. - """ - if self.fabric_name is None: - raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") - return BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "actions", "propose") - @property def verb(self) -> HttpVerbEnum: - """ - # Summary - - Return `HttpVerbEnum.POST`. - - ## Raises - - None - """ + """Return the HTTP verb for this endpoint.""" return HttpVerbEnum.POST diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py index 58389ee04..8f2f33c38 100644 --- a/plugins/module_utils/orchestrators/fabric_update_group.py +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -35,6 +35,7 @@ from __future__ import annotations +import ipaddress from typing import Any, ClassVar, Type from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel @@ -170,19 +171,27 @@ def _configure_endpoint(self, api_endpoint): return api_endpoint @staticmethod - def _looks_like_ip(value: str) -> bool: + def _is_ip_address(value: str) -> bool: """ # Summary - Heuristic: a switch identifier is a fabric management IP address if it is a string containing a - dot. switchIds (serial numbers, e.g. `FDO12345ABC`) never contain dots. Switches are supplied as - IP addresses only, so a value that fails this check is rejected by `_resolve_switch_id`. + Return True if `value` is a valid IPv4 or IPv6 address. Switches are supplied as fabric + management IP addresses only; switchIds (serial numbers, e.g. `FDO12345ABC`) are not valid IP + addresses, so a value that fails this check is rejected by `_resolve_switch_id`. Parsing with + `ipaddress.ip_address` (rather than a dot heuristic) accepts IPv6 management addresses, which + contain no dot, and rejects prefixed (CIDR) values, which a switch identity never carries. ## Raises None """ - return isinstance(value, str) and "." in value + if not isinstance(value, str): + return False + try: + ipaddress.ip_address(value) + return True + except ValueError: + return False def _resolve_switch_id(self, value: str) -> str: """ @@ -200,7 +209,7 @@ def _resolve_switch_id(self, value: str) -> str: - If `value` is not an IP address (switches must be specified as fabric management IP addresses). - If no switch with that `fabricManagementIp` exists in the fabric (propagated from `FabricContext`). """ - if not self._looks_like_ip(value): + if not self._is_ip_address(value): raise RuntimeError( f"Switch identifier '{value}' is not a fabric management IP address; " "update_group_switches and installation_order_devices accept IP addresses only." @@ -230,18 +239,23 @@ def _switch_ids_to_ips(self, values: list) -> list: """ # Summary - Map wire `switchId`s to their `fabricManagementIp`s via `FabricContext` (best-effort). switchIds - absent from the fabric (e.g. stale serials) are passed through unchanged. Used to denormalize GET - responses so the user sees IP-based output that matches the IP-based input they supplied. + Map wire `switchId`s to their `fabricManagementIp`s via `FabricContext`. switchIds absent from + the fabric (e.g. stale serials) are passed through unchanged. Used to denormalize GET responses + so the user sees IP-based output that matches the IP-based input they supplied. + + A failure to load the switch inventory is NOT swallowed: it propagates so the caller fails + loudly. Returning unconverted switchIds here would leave `before` carrying switchIds while the + user's `proposed` carries IPs, so the `issubset` idempotency diff would never match and the + module would report `changed` and re-PUT on every run with no error surfaced. A genuine fault + (auth, network) must not masquerade as a perpetual no-op diff. ## Raises - None + ### RuntimeError + + - If the switch inventory cannot be loaded (propagated from `FabricContext.switch_map_by_id`). """ - try: - switch_map_by_id = self.fabric_context.switch_map_by_id - except Exception: # pylint: disable=broad-except - return list(values) + switch_map_by_id = self.fabric_context.switch_map_by_id return [switch_map_by_id.get(v, v) for v in values] def _denormalize_switches_in_response(self, item: Any) -> Any: @@ -251,14 +265,15 @@ def _denormalize_switches_in_response(self, item: Any) -> Any: For a single GET response dict, replace switchIds in `updateGroupSwitches` and `installationOrderDevices` with their matching IPs so the user sees consistent IP-based output. - Denormalization is best-effort: if the switch map cannot be loaded (e.g. the inventory call - fails), the lists are returned unmodified rather than raising - switchIds shown to the user - are still correct, just not user-friendlier IPs. Per-switchId lookups that miss the map (stale - serials) are also passed through unchanged. + Per-switchId lookups that miss the map (stale serials) are passed through unchanged. A failure + to load the switch inventory is NOT swallowed - it propagates (see `_switch_ids_to_ips`) so an + inventory fault surfaces as an error rather than as a silent perpetual `changed`. ## Raises - None + ### RuntimeError + + - If the switch inventory cannot be loaded (propagated from `_switch_ids_to_ips`). """ if not isinstance(item, dict): return item @@ -281,18 +296,21 @@ def _raise_on_207_action_errors(result: Any, response_key: str, message_key: str task. The user opts past warnings by setting `force_created`, which makes ND apply the change and return `success`; `force_created` therefore governs the request, never response handling. + A missing `status` key is treated as a failure: ND reports success explicitly, so the absence + of `status` means the per-item outcome is unknown and must not be assumed successful. + ## Raises ### RuntimeError - - If any item in the response reports a status other than `success`. + - If any item in the response reports a status other than `success` (including a missing status). """ if not isinstance(result, dict): return items = result.get(response_key) if not isinstance(items, list): return - failures = [item for item in items if isinstance(item, dict) and item.get("status") not in (None, "success")] + failures = [item for item in items if isinstance(item, dict) and item.get("status") != "success"] if failures: details = ", ".join(f"{item.get('updateGroupName')}: {item.get('status')} - {item.get(message_key)}" for item in failures) raise RuntimeError(f"Per-item failures in {response_key} response: {details}") diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 93fbf485a..549a37f78 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -147,7 +147,10 @@ groups and applies them immediately. Nexus Dashboard names the generated groups itself, in the form C(fabric_platform_role) for V(roleBased) or C(fabric_platform_OddGroup) / C(fabric_platform_EvenGroup) for V(evenOdd). - - O(auto_assign) is mutually exclusive with O(config), and is only valid with O(state=merged) or O(state=overridden). + - O(auto_assign) is mutually exclusive with O(config), and is only valid with O(state=merged). + - O(state=overridden) is not supported with O(auto_assign) - the auto-assign action already regroups every + switch in the fabric, so it inherently enforces a complete state, and the per-group delete semantics of + O(state=overridden) do not apply to a single fabric-wide action. - In check mode no change is reported, because the auto-assign action cannot be previewed. type: str choices: [ roleBased, evenOdd ] @@ -228,7 +231,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender -def _run_auto_assign(module: AnsibleModule) -> NDOutput: +def _run_auto_assign(module: AnsibleModule, output: NDOutput) -> None: """ # Summary @@ -237,14 +240,16 @@ def _run_auto_assign(module: AnsibleModule) -> NDOutput: snapshotted before and after so `changed` reflects whether the regrouping altered the fabric. In check mode the `propose` action is skipped (it cannot be previewed) and no change is reported. + The supplied `output` is populated in place. The `before` snapshot is assigned as soon as it is + taken (with `after` seeded to it) so that if `propose` or the post-snapshot query fails, the + caller's error path still surfaces the captured `before`/`after` context instead of an empty result. + ## Raises ### Exception - Propagated from the orchestrator if a query or the `propose` request fails. """ - output = NDOutput(output_level=module.params.get("output_level", "normal")) - sender = Sender() sender.ansible_module = module rest_send_params = dict(module.params) @@ -256,12 +261,48 @@ def _run_auto_assign(module: AnsibleModule) -> NDOutput: orchestrator = FabricUpdateGroupOrchestrator(rest_send=rest_send, results=Results()) before = NDConfigCollection.from_api_response(response_data=orchestrator.query_all(), model_class=FabricUpdateGroupModel) + # Seed before/after now: if propose or the after-snapshot raises, the caller's except path still + # has the before context (after == before reflects "nothing applied yet"). + output.assign(before=before, after=before) if not module.check_mode: orchestrator.propose(module.params["auto_assign"]) after = NDConfigCollection.from_api_response(response_data=orchestrator.query_all(), model_class=FabricUpdateGroupModel) output.assign(before=before, after=after) - return output + + +def _validate_report_analysis_exclusion(module: AnsibleModule) -> None: + """ + # Summary + + Fail with a clear message if any config item selects both `analysis` and a report type. Nexus + Dashboard rejects `analysis` set together with `reports` / `report_selection` at a value other than + `noReport` (400 "Both Analysis and Report type can not be selected togather"), and even + `analysis: noAnalysis` counts as analysis being selected. Enforcing it here turns a raw ND 400 into + an actionable validation error. Skipped for O(state=deleted), where settings fields are ignored. + + ## Raises + + None + + Calls `module.fail_json` (which raises) on a conflicting config item. + """ + if module.params.get("state") == "deleted": + return + for item in module.params.get("config") or []: + if not isinstance(item, dict): + continue + analysis_selected = item.get("analysis") is not None + report_selected = item.get("reports") not in (None, "noReport") or item.get("report_selection") not in (None, "noReport") + if analysis_selected and report_selected: + module.fail_json( + msg=( + f"update_group_name '{item.get('update_group_name')}': analysis cannot be combined with a report type. " + "Nexus Dashboard rejects 'analysis' set together with 'reports' or 'report_selection' at any value other " + "than 'noReport' (even analysis: noAnalysis counts as analysis being selected). " + "Specify analysis or a report type, but not both." + ) + ) def main(): @@ -275,15 +316,20 @@ def main(): ) require_pydantic(module) + _validate_report_analysis_exclusion(module) + auto_assign = module.params.get("auto_assign") if auto_assign is not None: state = module.params["state"] - if state not in ("merged", "overridden"): - module.fail_json(msg=f"auto_assign is only valid with state 'merged' or 'overridden', got '{state}'.") + if state != "merged": + module.fail_json( + msg=f"auto_assign is only valid with state 'merged', got '{state}'. " + "The auto-assign action already regroups every switch in the fabric, so state 'overridden' is not supported with it." + ) output = NDOutput(output_level=module.params.get("output_level", "normal")) try: - output = _run_auto_assign(module) + _run_auto_assign(module, output) module.exit_json(**output.format()) except Exception as e: module.fail_json(msg=f"Module execution failed: {str(e)}", **output.format()) diff --git a/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py b/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py index cfc242d8f..5c9f97520 100644 --- a/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py +++ b/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py @@ -22,6 +22,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_actions import ( EpFabricSoftwareUpdatePlanAttachGroup, EpFabricSoftwareUpdatePlanDetachGroup, + EpFabricSoftwareUpdatePlanPropose, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -254,6 +255,117 @@ def test_ep_software_update_plan_actions_00140(): EpFabricSoftwareUpdatePlanDetachGroup(fabric_name="") +# ============================================================================= +# Test: EpFabricSoftwareUpdatePlanPropose +# ============================================================================= + + +def test_ep_software_update_plan_actions_00150(): + """ + # Summary + + Verify EpFabricSoftwareUpdatePlanPropose basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + - fabric_name defaults to None + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanPropose.__init__() + - EpFabricSoftwareUpdatePlanPropose.verb + - EpFabricSoftwareUpdatePlanPropose.class_name + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanPropose() + assert instance.class_name == "EpFabricSoftwareUpdatePlanPropose" + assert instance.verb == HttpVerbEnum.POST + assert instance.fabric_name is None + + +def test_ep_software_update_plan_actions_00160(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name is not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanPropose.path + """ + instance = EpFabricSoftwareUpdatePlanPropose() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_software_update_plan_actions_00170(): + """ + # Summary + + Verify path returns the correct propose action URL. + + ## Test + + - fabric_name is set + - path returns /api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/propose + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanPropose.path + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanPropose() + instance.fabric_name = "SITE1" + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/propose" + + +def test_ep_software_update_plan_actions_00180(): + """ + # Summary + + Verify fabric_name is percent-encoded in the propose path. + + ## Test + + - fabric_name = "fab/odd" + - path encodes the slash + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanPropose.path + """ + instance = EpFabricSoftwareUpdatePlanPropose() + instance.fabric_name = "fab/odd" + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/softwareUpdatePlan/actions/propose" + + +def test_ep_software_update_plan_actions_00190(): + """ + # Summary + + Verify fabric_name="" raises ValueError (Pydantic min_length=1). + + ## Test + + - Setting fabric_name to empty string raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanPropose.__init__() + """ + with pytest.raises(ValueError): + EpFabricSoftwareUpdatePlanPropose(fabric_name="") + + # ============================================================================= # Test: Cross-class # ============================================================================= @@ -263,24 +375,28 @@ def test_ep_software_update_plan_actions_00200(): """ # Summary - Verify attach/detach endpoints are both POST with distinct action paths. + Verify attach/detach/propose endpoints are all POST with distinct action paths. ## Test - - Both classes with the same fabric_name produce distinct paths - - Both have verb POST + - All three classes with the same fabric_name produce distinct paths + - All have verb POST ## Classes and Methods - EpFabricSoftwareUpdatePlanAttachGroup.path - EpFabricSoftwareUpdatePlanDetachGroup.path + - EpFabricSoftwareUpdatePlanPropose.path """ with does_not_raise(): attach = EpFabricSoftwareUpdatePlanAttachGroup(fabric_name="SITE1") detach = EpFabricSoftwareUpdatePlanDetachGroup(fabric_name="SITE1") + propose = EpFabricSoftwareUpdatePlanPropose(fabric_name="SITE1") assert attach.path == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/attachGroup" assert detach.path == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/detachGroup" - assert attach.path != detach.path + assert propose.path == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/actions/propose" + assert len({attach.path, detach.path, propose.path}) == 3 assert attach.verb == HttpVerbEnum.POST assert detach.verb == HttpVerbEnum.POST + assert propose.verb == HttpVerbEnum.POST diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json index e063c27d0..f2b48e843 100644 --- a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -683,5 +683,18 @@ "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", "MESSAGE": "No Content", "DATA": {} + }, + "test_fabric_update_group_00260a": { + "TEST_NOTES": ["update with empty update_group_switches: GET current group; update() must reject before any write"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "updateGroupSwitches": [ + "FDO1" + ] + } } } diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 67ffd1a05..0a488264e 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -623,6 +623,39 @@ def responses(): assert body["analysis"] == "snapshot" +def test_fabric_update_group_00260() -> None: + """ + # Summary + + Verify `update` rejects an empty `update_group_switches` (an empty update group is not permitted; + `state: deleted` is the way to remove a group) before issuing any membership or settings write. + + ## Test + + - The model carries an empty `update_group_switches` + - `update` GETs the current group, then raises `RuntimeError` (wrapped) without an attach/detach/PUT + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=[]) + + with pytest.raises(RuntimeError, match=r"Update failed for .*leaf_group.*must be non-empty.*state: deleted"): + instance.update(model) + + # The only request issued was the initial GET; no attach/detach/PUT followed. + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.GET.value + + # ============================================================================= # Test: delete # ============================================================================= @@ -1119,15 +1152,28 @@ def test_fabric_update_group_00750() -> None: """ # Summary - Verify `_looks_like_ip` heuristic: strings with dots are IPs, others are switchIds. + Verify `_is_ip_address` accepts valid IPv4 and IPv6 addresses and rejects switchIds, malformed + dotted strings, CIDR-prefixed values, and non-strings. + + ## Test + + - A valid IPv4 address is an IP + - A valid IPv6 address is an IP (no dot - the old dot heuristic would have rejected it) + - A switchId serial, a malformed dotted string, a CIDR-prefixed value, an empty string, and a + non-string are not IPs ## Classes and Methods - - FabricUpdateGroupOrchestrator._looks_like_ip() + - FabricUpdateGroupOrchestrator._is_ip_address() """ - assert FabricUpdateGroupOrchestrator._looks_like_ip("192.168.12.151") is True - assert FabricUpdateGroupOrchestrator._looks_like_ip("FDO12345ABC") is False - assert FabricUpdateGroupOrchestrator._looks_like_ip("") is False + assert FabricUpdateGroupOrchestrator._is_ip_address("192.168.12.151") is True + assert FabricUpdateGroupOrchestrator._is_ip_address("2001:db8::1") is True + assert FabricUpdateGroupOrchestrator._is_ip_address("fe80::200:5aee:feaa:20a2") is True + assert FabricUpdateGroupOrchestrator._is_ip_address("FDO12345ABC") is False + assert FabricUpdateGroupOrchestrator._is_ip_address("1.2.3") is False + assert FabricUpdateGroupOrchestrator._is_ip_address("192.168.12.151/32") is False + assert FabricUpdateGroupOrchestrator._is_ip_address("") is False + assert FabricUpdateGroupOrchestrator._is_ip_address(None) is False def test_fabric_update_group_00760() -> None: @@ -1191,8 +1237,9 @@ def test_fabric_update_group_00790() -> None: """ # Summary - Verify `_switch_ids_to_ips` is best-effort: when the switch map cannot be loaded, the switchIds are - returned unchanged rather than raising. + Verify `_switch_ids_to_ips` does NOT swallow a switch-inventory load failure: it propagates so a + real fault surfaces as an error rather than masquerading as unconverted switchIds (which would + diff against the user's IPs and report a silent perpetual `changed`). ## Classes and Methods @@ -1200,10 +1247,8 @@ def test_fabric_update_group_00790() -> None: """ instance = _bare_instance(_RaisingFabricContext()) - with does_not_raise(): - result = instance._switch_ids_to_ips(["FDO1", "FDO2"]) - - assert result == ["FDO1", "FDO2"] + with pytest.raises(RuntimeError, match=r"switch inventory unavailable"): + instance._switch_ids_to_ips(["FDO1", "FDO2"]) # ============================================================================= @@ -1269,3 +1314,47 @@ def responses(): with pytest.raises(RuntimeError, match=r"Auto-assign \(propose\) failed for fabric 'fabric_1'"): instance.propose("roleBased") + + +# ============================================================================= +# Test: _raise_on_207_action_errors +# ============================================================================= + + +def test_fabric_update_group_00900() -> None: + """ + # Summary + + Verify `_raise_on_207_action_errors` treats a 207 item with a MISSING `status` as a failure. ND + reports success explicitly, so an absent status means the per-item outcome is unknown and must not + be assumed successful. + + ## Test + + - A 207 item with no `status` key raises `RuntimeError` + - The raised message names the offending group + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() + """ + result = {"attachUpdateGroups": [{"updateGroupName": "g1", "warningMessage": "no status echoed"}]} + + with pytest.raises(RuntimeError, match=r"Per-item failures in attachUpdateGroups.*g1.*None"): + FabricUpdateGroupOrchestrator._raise_on_207_action_errors(result, "attachUpdateGroups", "warningMessage") + + +def test_fabric_update_group_00910() -> None: + """ + # Summary + + Verify `_raise_on_207_action_errors` does not raise when every item reports `status: success`. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator._raise_on_207_action_errors() + """ + result = {"attachUpdateGroups": [{"updateGroupName": "g1", "status": "success"}, {"updateGroupName": "g2", "status": "success"}]} + + with does_not_raise(): + FabricUpdateGroupOrchestrator._raise_on_207_action_errors(result, "attachUpdateGroups", "warningMessage") diff --git a/tests/unit/modules/test_nd_fabric_update_group.py b/tests/unit/modules/test_nd_fabric_update_group.py new file mode 100644 index 000000000..ddb8a572b --- /dev/null +++ b/tests/unit/modules/test_nd_fabric_update_group.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for the `nd_fabric_update_group` module helpers. + +Covers the auto-assign path (`_run_auto_assign`) check-mode guard and failure-output behavior, and +the `analysis` / report-type mutual-exclusion guard (`_validate_report_analysis_exclusion`). The +auto-assign tests monkeypatch the orchestrator's `query_all` / `propose` so no controller I/O occurs; +`RestSend` and `Sender` are only constructed (never committed). +""" + +# pylint: disable=disallowed-name,protected-access,redefined-outer-name +# pylint: disable=invalid-name,line-too-long,unused-variable + +from __future__ import annotations + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput +from ansible_collections.cisco.nd.plugins.modules import nd_fabric_update_group as mod +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + + +class _FailJson(Exception): + """Raised by `_FakeModule.fail_json` to mimic AnsibleModule.fail_json aborting execution.""" + + +class _FakeModule: + """Minimal AnsibleModule stand-in exposing `params`, `check_mode`, and a raising `fail_json`.""" + + def __init__(self, params: dict, check_mode: bool = False) -> None: + self.params = params + self.check_mode = check_mode + self.fail_json_calls: list[dict] = [] + + def fail_json(self, **kwargs) -> None: + """Record the call and raise, mirroring AnsibleModule.fail_json halting the module.""" + self.fail_json_calls.append(kwargs) + raise _FailJson(kwargs.get("msg", "")) + + +# ============================================================================= +# Test: _run_auto_assign check-mode guard +# ============================================================================= + + +@pytest.mark.parametrize( + "check_mode,expected_propose_calls", + [(True, 0), (False, 1)], + ids=["check_mode_skips_propose", "normal_runs_propose"], +) +def test_nd_fabric_update_group_00100(monkeypatch, check_mode: bool, expected_propose_calls: int) -> None: + """ + # Summary + + Verify `_run_auto_assign` runs the `propose` action only when NOT in check mode. A regression that + called `propose` under `--check` would mutate the fabric, so this guards the most important gap. + + ## Test + + - `query_all` is stubbed (before / after snapshots) + - In check mode `propose` is never called; in normal mode it is called exactly once + + ## Classes and Methods + + - nd_fabric_update_group._run_auto_assign() + """ + propose_calls: list[str] = [] + monkeypatch.setattr(mod.FabricUpdateGroupOrchestrator, "query_all", lambda self, *a, **k: [{"updateGroupName": "g1"}]) + monkeypatch.setattr(mod.FabricUpdateGroupOrchestrator, "propose", lambda self, algorithm: propose_calls.append(algorithm)) + + module = _FakeModule( + params={"auto_assign": "roleBased", "fabric_name": "SITE1", "state": "merged", "check_mode": check_mode}, + check_mode=check_mode, + ) + output = NDOutput(output_level="normal") + + with does_not_raise(): + mod._run_auto_assign(module, output) + + assert len(propose_calls) == expected_propose_calls + + +def test_nd_fabric_update_group_00110(monkeypatch) -> None: + """ + # Summary + + Verify `_run_auto_assign` surfaces the `before` snapshot in the supplied output even when `propose` + fails after the snapshot was taken (the output is populated in place, not returned). + + ## Test + + - `query_all` returns one group; `propose` raises + - `_run_auto_assign` propagates the error, but `output` already carries the `before` context + + ## Classes and Methods + + - nd_fabric_update_group._run_auto_assign() + """ + monkeypatch.setattr(mod.FabricUpdateGroupOrchestrator, "query_all", lambda self, *a, **k: [{"updateGroupName": "g1"}]) + + def _boom(self, algorithm): + raise RuntimeError("propose blew up") + + monkeypatch.setattr(mod.FabricUpdateGroupOrchestrator, "propose", _boom) + + module = _FakeModule( + params={"auto_assign": "roleBased", "fabric_name": "SITE1", "state": "merged", "check_mode": False}, + check_mode=False, + ) + output = NDOutput(output_level="normal") + + with pytest.raises(RuntimeError, match=r"propose blew up"): + mod._run_auto_assign(module, output) + + formatted = output.format() + assert len(formatted["before"]) == 1 + assert len(formatted["after"]) == 1 + + +# ============================================================================= +# Test: _validate_report_analysis_exclusion +# ============================================================================= + + +def test_nd_fabric_update_group_00200() -> None: + """ + # Summary + + Verify a config item selecting both `analysis` and a non-`noReport` report type fails with a clear + message (Nexus Dashboard rejects the combination with a raw 400). + + ## Classes and Methods + + - nd_fabric_update_group._validate_report_analysis_exclusion() + """ + module = _FakeModule(params={"state": "merged", "config": [{"update_group_name": "g1", "analysis": "snapshot", "reports": "useDefaultPreAndPostReports"}]}) + + with pytest.raises(_FailJson, match=r"analysis cannot be combined with a report type"): + mod._validate_report_analysis_exclusion(module) + + assert module.fail_json_calls + + +def test_nd_fabric_update_group_00210() -> None: + """ + # Summary + + Verify `analysis` combined with a non-`noReport` `report_selection` also fails. + + ## Classes and Methods + + - nd_fabric_update_group._validate_report_analysis_exclusion() + """ + module = _FakeModule(params={"state": "merged", "config": [{"update_group_name": "g1", "analysis": "noAnalysis", "report_selection": "basic"}]}) + + with pytest.raises(_FailJson, match=r"analysis cannot be combined with a report type"): + mod._validate_report_analysis_exclusion(module) + + +@pytest.mark.parametrize( + "item", + [ + {"update_group_name": "g1", "reports": "useDefaultPreAndPostReports"}, + {"update_group_name": "g1", "report_selection": "advanced"}, + {"update_group_name": "g1", "analysis": "snapshot"}, + {"update_group_name": "g1", "analysis": "snapshot", "reports": "noReport"}, + {"update_group_name": "g1", "analysis": "snapshot", "report_selection": "noReport"}, + {"update_group_name": "g1"}, + ], + ids=["reports_only", "report_selection_only", "analysis_only", "analysis_plus_noReport", "analysis_plus_noReport_selection", "neither"], +) +def test_nd_fabric_update_group_00220(item: dict) -> None: + """ + # Summary + + Verify non-conflicting config items pass validation: a report type alone, analysis alone, and + analysis combined with an explicit `noReport` (which does not count as a selected report type). + + ## Classes and Methods + + - nd_fabric_update_group._validate_report_analysis_exclusion() + """ + module = _FakeModule(params={"state": "merged", "config": [item]}) + + with does_not_raise(): + mod._validate_report_analysis_exclusion(module) + + assert not module.fail_json_calls + + +def test_nd_fabric_update_group_00230() -> None: + """ + # Summary + + Verify the mutual-exclusion check is skipped for `state: deleted`, where settings fields are ignored. + + ## Classes and Methods + + - nd_fabric_update_group._validate_report_analysis_exclusion() + """ + module = _FakeModule( + params={"state": "deleted", "config": [{"update_group_name": "g1", "analysis": "snapshot", "reports": "useDefaultPreAndPostReports"}]} + ) + + with does_not_raise(): + mod._validate_report_analysis_exclusion(module) + + assert not module.fail_json_calls From 71bb8f7a71edc1653c742dda8315f97abb32b799 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 1 Jun 2026 14:11:37 -1000 Subject: [PATCH 08/12] Document write-only fields and IP input; guard create-path detach race Doc-only review items plus a regression test for a lower-confidence finding: - #6: document that installation_order_devices (and recommended_version / latest_recommended_version) are accepted on write but never returned on read, so a change to one of them alone is not detected as changed and is not sent. Set alongside another changed field to ensure it is applied. - #7: clarify update_group_switches / installation_order_devices accept an IPv4 or IPv6 fabric management IP address; switch serial numbers are not accepted. - Lower-confidence "create-path PUT detaches just-attached switches" finding verified NOT a bug: the create-path settings PUT derives updateGroupSwitches from the user's model (re-applied by merge), not from the post-attach GET, so an eventually-consistent empty GET cannot produce a detaching PUT. Added regression test 00160 (GET returns empty membership; PUT still carries the user's switches) to lock in the guarantee. 646 unit tests pass; black/isort/pylint/mypy clean (module's 4 mypy errors are pre-existing on unchanged lines). Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/modules/nd_fabric_update_group.py | 11 ++++- .../test_fabric_update_group.json | 44 +++++++++++++++++++ .../orchestrators/test_fabric_update_group.py | 42 ++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 549a37f78..556f0061b 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -69,7 +69,7 @@ update_group_switches: description: - The list of switches that belong to this update group. - - Each entry is a switch fabric management IP address. + - Each entry is a switch fabric management IP address (IPv4 or IPv6). Switch serial numbers are not accepted. - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. - An update group must contain at least one switch; ND does not permit a zero-switch group. type: list @@ -84,17 +84,24 @@ installation_order_devices: description: - The order in which switches are upgraded when O(config.execution=serial). - - Each entry is a switch fabric management IP address. + - Each entry is a switch fabric management IP address (IPv4 or IPv6). Switch serial numbers are not accepted. - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. + - Nexus Dashboard accepts this field on write but never returns it on read, so the module cannot detect + drift on it. A change to O(config.installation_order_devices) alone is therefore not reported as changed + and is not sent; set it alongside another changed field to ensure it is applied. type: list elements: str recommended_version: description: - The recommended target software version for this group. + - Nexus Dashboard accepts this field on write but never returns it on read, so, like + O(config.installation_order_devices), a change to it alone is not reported as changed and is not sent. type: str latest_recommended_version: description: - The latest available recommended software version for this group. + - Nexus Dashboard accepts this field on write but never returns it on read, so, like + O(config.installation_order_devices), a change to it alone is not reported as changed and is not sent. type: str report_selection: description: diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json index f2b48e843..417bb002a 100644 --- a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -696,5 +696,49 @@ "FDO1" ] } + }, + "test_fabric_update_group_00160a": { + "TEST_NOTES": ["create with eventually-consistent GET: attachGroup POST returns 207 status:success"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "attachUpdateGroups": [ + { + "updateGroupName": "leaf_group", + "status": "success", + "warningMessage": "Update group attached successful" + } + ] + } + }, + "test_fabric_update_group_00160b": { + "TEST_NOTES": [ + "create with eventually-consistent GET: the GET right after attachGroup returns the group with EMPTY", + "membership (ND read lag). The PUT body must still carry the user's switches (from the model via merge),", + "NOT the stale empty membership - otherwise the settings PUT would detach the just-attached switches." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "updateGroupSwitches": [] + } + }, + "test_fabric_update_group_00160c": { + "TEST_NOTES": ["create with eventually-consistent GET: settings PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} } } diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 0a488264e..3d1bb5c9a 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -410,6 +410,48 @@ def responses(): assert body == {"attachUpdateGroups": [{"updateGroupName": "leaf_group", "switchIds": ["FDO1", "FDO2"], "forceCreated": False}]} +def test_fabric_update_group_00160() -> None: + """ + # Summary + + Verify the create-path settings PUT carries the user's switches even when the GET issued right + after `attachGroup` returns EMPTY membership (ND read-after-write lag). The PUT body's + `updateGroupSwitches` must come from the user's model (re-applied by `merge`), never from the + stale GET - otherwise the full-replace PUT would detach the switches that were just attached. + + This locks in the guarantee that an eventually-consistent GET cannot turn the create-path settings + PUT into an accidental detach. + + ## Test + + - attachGroup 207 success, GET returns the group with `updateGroupSwitches: []`, then PUT + - The PUT body still carries the user's resolved switchIds + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._apply_settings() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + yield responses_fabric_update_group(f"{method_name}c") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + model = _build_model() + + with does_not_raise(): + instance.create(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value + body = rest_send.committed_payload + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] + + # ============================================================================= # Test: update # ============================================================================= From d033199a90ec6b0ffa6ac9c45697190d99c89a61 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 1 Jun 2026 14:23:47 -1000 Subject: [PATCH 09/12] Relax UpdateReportCheckModel.report_check_name for read-tolerance Lab-verified (ND 4.2.1, 192.168.7.7): the embedded updateGroup.updateReportChecks item schema does NOT mark reportCheckName required, so ND is permitted to return an item without it. Our model marked report_check_name required, which is stricter than ND. Because NDStateMachine validates every existing group via from_response, one such item on any pre-existing group would abort the whole module run -- even for an unrelated target group. Make report_check_name optional on the model (read-tolerance) while the argument spec keeps required=True, so user input is still enforced (the argspec validates before the model is built) but a wire item lacking the name can no longer abort the run. No user-facing behavior change. Tests: 00040 flipped to expect read-tolerance (empty item -> None), 00045 asserts the argspec still requires report_check_name, 00046 proves from_response tolerates an updateReportChecks item with no reportCheckName. The broader framework fragility (from_api_response rejects the whole collection if any existing item fails to parse) is unchanged and remains a separate cross-cutting concern. 648 unit tests pass; black/isort/mypy clean (pylint identical to HEAD). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fabric_update_group.py | 8 ++- .../models/test_fabric_update_group.py | 67 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py index 82ffa2b23..cbb2f758b 100644 --- a/plugins/module_utils/models/fabric_update_group/fabric_update_group.py +++ b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py @@ -63,7 +63,13 @@ class UpdateReportCheckModel(NDNestedModel): None """ - report_check_name: str = Field(alias="reportCheckName") + # `report_check_name` is optional on the MODEL so a GET that returns an item lacking it cannot abort + # the whole module run (NDStateMachine validates every existing group via from_response). ND's own + # embedded `updateGroup.updateReportChecks` item schema does NOT mark reportCheckName required + # (verified live, ND 4.2.1), so we must not be stricter than ND on read. User INPUT is still + # required: the argument spec sets `report_check_name` required=True, which validates before the + # model is built, so a user must always supply a name. + report_check_name: str | None = Field(default=None, alias="reportCheckName") # Enum literal aliases for readability diff --git a/tests/unit/module_utils/models/test_fabric_update_group.py b/tests/unit/module_utils/models/test_fabric_update_group.py index e3650b5bf..173eea423 100644 --- a/tests/unit/module_utils/models/test_fabric_update_group.py +++ b/tests/unit/module_utils/models/test_fabric_update_group.py @@ -175,20 +175,77 @@ def test_fabric_update_group_00040() -> None: """ # Summary - Verify `UpdateReportCheckModel` requires `report_check_name`. + Verify `UpdateReportCheckModel` tolerates a wire item lacking `reportCheckName` (it parses with + `report_check_name = None`) rather than raising. ND's embedded `updateReportChecks` item schema does + not mark `reportCheckName` required, so the model must not be stricter than ND on read - a missing + name on a single existing group must not abort the whole module run. User input is still enforced + as required by the argument spec, which validates before the model is built. ## Test - - Construct with empty dict raises ValidationError + - Construct from an empty wire dict + - No exception is raised; `report_check_name` is None ## Classes and Methods - UpdateReportCheckModel.model_validate() """ - from pydantic import ValidationError + with does_not_raise(): + instance = UpdateReportCheckModel.model_validate({}, by_alias=True) - with pytest.raises(ValidationError): - UpdateReportCheckModel.model_validate({}, by_alias=True) + assert instance.report_check_name is None + + +def test_fabric_update_group_00045() -> None: + """ + # Summary + + Verify the argument spec still REQUIRES `report_check_name` for user input, even though the model + field is optional for read-tolerance. The strictness lives in the argspec (validated before the + model is built); the optional model field only governs wire deserialization. + + ## Test + + - `get_argument_spec()` marks `config.update_report_checks.report_check_name` required + + ## Classes and Methods + + - FabricUpdateGroupModel.get_argument_spec() + """ + spec = FabricUpdateGroupModel.get_argument_spec() + report_check = spec["config"]["options"]["update_report_checks"]["options"]["report_check_name"] + + assert report_check["required"] is True + + +def test_fabric_update_group_00046() -> None: + """ + # Summary + + Verify `FabricUpdateGroupModel.from_response` tolerates an `updateReportChecks` item that lacks + `reportCheckName` - this is the exact wire shape that, if rejected, would abort the whole module run + when `NDStateMachine` validates every existing group. It must parse to `report_check_name = None`. + + ## Test + + - A wire group whose `updateReportChecks` item has no `reportCheckName` builds without raising + + ## Classes and Methods + + - FabricUpdateGroupModel.from_response() + - UpdateReportCheckModel + """ + wire = { + "updateGroupName": "leaf_group", + "updateGroupSwitches": ["FDO1"], + "updateReportChecks": [{}], + } + + with does_not_raise(): + instance = FabricUpdateGroupModel.from_response(wire) + + assert instance.update_report_checks is not None + assert instance.update_report_checks[0].report_check_name is None # ============================================================================= From ab139ea71e88c3b05e2d5a8ff6959059f2d2abb1 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 1 Jun 2026 14:57:14 -1000 Subject: [PATCH 10/12] Address Copilot review threads on nd_fabric_update_group Fixes the three unresolved copilot-pull-request-reviewer threads on PR #285, none of which were covered by the earlier human-reviewer finding commits. - update(): distinguish an omitted update_group_switches (None -> membership not managed, keep current members and apply settings only) from an explicit empty list (still rejected; use state: deleted). This unblocks settings-only updates (e.g. execution/reports) on an existing group across all states. _resolve_switches_in_payload now passes through values that are already known switchIds so the membership seeded from the current-group GET is not run through the IP-only resolver. - _get_group_raw_or_none(): narrow the ghost-group fallback to HTTP 400/404 and re-raise everything else, so a transient/auth/server failure on the preliminary GET is no longer silently turned into a destructive DELETE. Marked with TODO(4.2.1) for the 400-means-ghost ND quirk. - fabric_update_group endpoint: percent-encode fabric_name and update_group_name path segments (matching software_update_plan_actions) so a reserved character such as '/' cannot split the path. Adds orchestrator tests (settings-only update for merged/replaced; delete re-raise on 500 and fallback on 404) and a new endpoint test module asserting segment encoding. Full unit suite: 661 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v1/manage/fabric_update_group.py | 5 +- .../orchestrators/fabric_update_group.py | 42 ++- .../endpoints/test_fabric_update_group.py | 247 ++++++++++++++++++ .../test_fabric_update_group.json | 86 ++++++ .../orchestrators/test_fabric_update_group.py | 152 +++++++++++ 5 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 tests/unit/module_utils/endpoints/test_fabric_update_group.py diff --git a/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py index f02acbe22..cb1298a38 100644 --- a/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py +++ b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py @@ -22,6 +22,7 @@ from __future__ import annotations from typing import ClassVar, Literal +from urllib.parse import quote 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 @@ -66,9 +67,9 @@ def path(self) -> str: raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") if self._require_update_group_name and self.update_group_name is None: raise ValueError(f"{type(self).__name__}.path: update_group_name must be set before accessing path.") - segments = ["fabrics", self.fabric_name, "updateGroups"] + segments = ["fabrics", quote(self.fabric_name, safe=""), "updateGroups"] if self.update_group_name is not None: - segments.append(self.update_group_name) + segments.append(quote(self.update_group_name, safe="")) return BasePath.path(*segments) diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py index 8f2f33c38..63811503b 100644 --- a/plugins/module_utils/orchestrators/fabric_update_group.py +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -223,16 +223,21 @@ def _resolve_switches_in_payload(self, payload: dict) -> dict: Replace IP entries in `updateGroupSwitches` and `installationOrderDevices` with the matching switchIds before sending. Mutates the payload dict in place and returns it. + A value that is already a known switchId is passed through unchanged. This is what makes a + settings-only update (membership omitted) work: the membership seeded from the current-group GET + is already switchIds, so it must not be run through `_resolve_switch_id` (which accepts IPs only). + ## Raises ### RuntimeError - - If any IP in either list cannot be resolved in the fabric (propagated from `_resolve_switch_id`). + - If any value in either list is neither a known switchId nor a resolvable IP (propagated from `_resolve_switch_id`). """ + known_ids = self.fabric_context.switch_map_by_id for key in _SWITCH_LIST_PAYLOAD_KEYS: values = payload.get(key) if isinstance(values, list): - payload[key] = [self._resolve_switch_id(v) for v in values] + payload[key] = [v if v in known_ids else self._resolve_switch_id(v) for v in values] return payload def _switch_ids_to_ips(self, values: list) -> list: @@ -407,16 +412,24 @@ def _get_group_raw_or_none(self, update_group_name: str) -> dict | None: # Summary GET a single update group, returning None if the group cannot be read (e.g. a zero-switch - ghost group, which ND returns HTTP 400 for). + ghost group, which ND returns HTTP 400 for) or does not exist (HTTP 404). Any other failure + (auth, server, transient) is propagated so a real error is not silently treated as a ghost. ## Raises - None + ### Exception + + - If the GET fails with a status code other than 400 (ghost) or 404 (not found). """ try: return self._get_group_raw(update_group_name) except Exception: # pylint: disable=broad-except - return None + # TODO(4.2.1) ND returns HTTP 400 (not 404/empty) for a zero-switch ghost group that cannot be read. + # Only treat the documented ghost/not-found codes as "unreadable"; re-raise anything else so a + # transient/auth/server failure is not silently turned into a destructive DELETE. + if self.rest_send.return_code in (400, 404): + return None + raise def _delete_group(self, update_group_name: str) -> None: """ @@ -532,15 +545,26 @@ def update(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseTy ### RuntimeError - - If `update_group_switches` resolves to an empty set (an empty update group is not permitted - - use `state: deleted`), a switch IP cannot be resolved, a request fails, or an action - endpoint reports a non-success status. + - If `update_group_switches` is given as an explicit empty list (an empty update group is not + permitted - use `state: deleted`), a switch IP cannot be resolved, a request fails, or an + action endpoint reports a non-success status. + + ## Notes + + - An omitted `update_group_switches` (`None`) means membership is not being managed: the current + membership is kept and only group settings are applied. This is what makes a settings-only + update (e.g. changing `execution` or `reports`) possible without re-listing every switch. """ try: update_group_name = model_instance.update_group_name current_raw = self._get_group_raw(update_group_name) + desired = model_instance.update_group_switches + if desired is None: + # Membership not being managed; keep current members, apply settings only. + self._apply_settings(model_instance, current_raw=current_raw) + return {} current_ids = list(current_raw.get("updateGroupSwitches") or []) - desired_ids = [self._resolve_switch_id(s) for s in (model_instance.update_group_switches or [])] + desired_ids = [self._resolve_switch_id(s) for s in desired] if not desired_ids: raise RuntimeError("update_group_switches must be non-empty; an empty update group is not permitted (use state: deleted)") to_add = [s for s in desired_ids if s not in current_ids] diff --git a/tests/unit/module_utils/endpoints/test_fabric_update_group.py b/tests/unit/module_utils/endpoints/test_fabric_update_group.py new file mode 100644 index 000000000..7946f4984 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_fabric_update_group.py @@ -0,0 +1,247 @@ +# -*- 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 endpoints/v1/manage/fabric_update_group.py + +Tests the ND Manage Fabric Update Group endpoint classes, focusing on path construction and the +percent-encoding of the dynamic `fabric_name` / `update_group_name` path segments. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from contextlib import contextmanager + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.fabric_update_group import ( + EpFabricUpdateGroupDelete, + EpFabricUpdateGroupGet, + EpFabricUpdateGroupListGet, + EpFabricUpdateGroupPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@contextmanager +def does_not_raise(): + """A context manager that does not raise an exception.""" + yield + + +# ============================================================================= +# Test: EpFabricUpdateGroupListGet (collection-level, no update_group_name) +# ============================================================================= + + +def test_ep_fabric_update_group_00010(): + """ + # Summary + + Verify EpFabricUpdateGroupListGet basic instantiation and verb. + + ## Test + + - Instance can be created + - verb is GET + - fabric_name defaults to None + + ## Classes and Methods + + - EpFabricUpdateGroupListGet.__init__() + - EpFabricUpdateGroupListGet.verb + """ + with does_not_raise(): + instance = EpFabricUpdateGroupListGet() + assert instance.verb == HttpVerbEnum.GET + assert instance.fabric_name is None + + +def test_ep_fabric_update_group_00020(): + """ + # Summary + + Verify the list path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name is not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpFabricUpdateGroupListGet.path + """ + instance = EpFabricUpdateGroupListGet() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_fabric_update_group_00030(): + """ + # Summary + + Verify the list path returns the collection URL (no update_group_name segment). + + ## Test + + - fabric_name is set + - path returns /api/v1/manage/fabrics/SITE1/updateGroups + + ## Classes and Methods + + - EpFabricUpdateGroupListGet.path + """ + with does_not_raise(): + instance = EpFabricUpdateGroupListGet() + instance.fabric_name = "SITE1" + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/updateGroups" + + +def test_ep_fabric_update_group_00040(): + """ + # Summary + + Verify fabric_name is percent-encoded in the list path. + + ## Test + + - fabric_name = "fab/odd" + - path encodes the slash + + ## Classes and Methods + + - EpFabricUpdateGroupListGet.path + """ + instance = EpFabricUpdateGroupListGet() + instance.fabric_name = "fab/odd" + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/updateGroups" + + +# ============================================================================= +# Test: EpFabricUpdateGroupGet (per-name) +# ============================================================================= + + +def test_ep_fabric_update_group_00100(): + """ + # Summary + + Verify the per-name GET path raises ValueError when update_group_name is required but unset. + + ## Test + + - fabric_name is set, update_group_name is not + - Accessing path raises ValueError + + ## Classes and Methods + + - EpFabricUpdateGroupGet.path + """ + instance = EpFabricUpdateGroupGet() + instance.fabric_name = "SITE1" + with pytest.raises(ValueError, match="update_group_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_fabric_update_group_00110(): + """ + # Summary + + Verify the per-name GET path returns the correct URL. + + ## Test + + - fabric_name and update_group_name are set + - path returns /api/v1/manage/fabrics/SITE1/updateGroups/leaf_group + + ## Classes and Methods + + - EpFabricUpdateGroupGet.path + - EpFabricUpdateGroupGet.set_identifiers() + """ + with does_not_raise(): + instance = EpFabricUpdateGroupGet() + instance.fabric_name = "SITE1" + instance.set_identifiers("leaf_group") + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/updateGroups/leaf_group" + + +def test_ep_fabric_update_group_00120(): + """ + # Summary + + Verify both fabric_name and update_group_name are percent-encoded in the per-name GET path. + + ## Test + + - fabric_name = "fab/odd", update_group_name = "grp/one" + - path encodes the slash in both segments + + ## Classes and Methods + + - EpFabricUpdateGroupGet.path + """ + instance = EpFabricUpdateGroupGet() + instance.fabric_name = "fab/odd" + instance.set_identifiers("grp/one") + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/updateGroups/grp%2Fone" + + +# ============================================================================= +# Test: EpFabricUpdateGroupPut / EpFabricUpdateGroupDelete encoding parity +# ============================================================================= + + +def test_ep_fabric_update_group_00200(): + """ + # Summary + + Verify the PUT path percent-encodes both dynamic segments and uses the PUT verb. + + ## Test + + - fabric_name = "fab/odd", update_group_name = "grp/one" + - path encodes both, verb is PUT + + ## Classes and Methods + + - EpFabricUpdateGroupPut.path + - EpFabricUpdateGroupPut.verb + """ + instance = EpFabricUpdateGroupPut() + instance.fabric_name = "fab/odd" + instance.set_identifiers("grp/one") + assert instance.verb == HttpVerbEnum.PUT + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/updateGroups/grp%2Fone" + + +def test_ep_fabric_update_group_00210(): + """ + # Summary + + Verify the DELETE path percent-encodes both dynamic segments and uses the DELETE verb. + + ## Test + + - fabric_name = "fab/odd", update_group_name = "grp/one" + - path encodes both, verb is DELETE + + ## Classes and Methods + + - EpFabricUpdateGroupDelete.path + - EpFabricUpdateGroupDelete.verb + """ + instance = EpFabricUpdateGroupDelete() + instance.fabric_name = "fab/odd" + instance.set_identifiers("grp/one") + assert instance.verb == HttpVerbEnum.DELETE + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/updateGroups/grp%2Fone" diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json index 417bb002a..f0ba57a93 100644 --- a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -740,5 +740,91 @@ "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", "MESSAGE": "No Content", "DATA": {} + }, + "test_fabric_update_group_00270a": { + "TEST_NOTES": [ + "state merged, settings-only: update_group_switches omitted (None). GET current group; membership", + "FDO1,FDO2 must be kept and only settings PUT issued (no attach/detach)." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "reportSelection": "advanced", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00270b": { + "TEST_NOTES": ["state merged, settings-only: settings PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, + "test_fabric_update_group_00280a": { + "TEST_NOTES": [ + "state replaced, settings-only: update_group_switches omitted (None). GET current group; membership", + "FDO1,FDO2 is kept (required PUT key) while omitted optional reportSelection is reset." + ], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "OK", + "DATA": { + "updateGroupName": "leaf_group", + "execution": "parallel", + "contingency": "pause", + "analysis": "noAnalysis", + "isMaintenance": false, + "isDisruptiveUpdate": false, + "reportSelection": "advanced", + "updateGroupSwitches": ["FDO1", "FDO2"] + } + }, + "test_fabric_update_group_00280b": { + "TEST_NOTES": ["state replaced, settings-only: settings PUT returns 204"], + "RETURN_CODE": 204, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} + }, + "test_fabric_update_group_00330a": { + "TEST_NOTES": [ + "delete with non-ghost GET failure: the preliminary GET returns 500. _get_group_raw_or_none must", + "re-raise (not treat as a ghost group) so delete() fails without issuing a destructive DELETE." + ], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "backend unavailable"} + }, + "test_fabric_update_group_00340a": { + "TEST_NOTES": [ + "delete with not-found GET: the preliminary GET returns 404 (group already gone). _get_group_raw_or_none", + "returns None and delete() falls back to the group-centric DELETE to free the name." + ], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "Not Found", + "DATA": {"message": "Update group not found"} + }, + "test_fabric_update_group_00340b": { + "TEST_NOTES": ["delete not-found fallback: group-centric DELETE frees the name (204)"], + "RETURN_CODE": 204, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group", + "MESSAGE": "No Content", + "DATA": {} } } diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 3d1bb5c9a..7f5f7877e 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -698,6 +698,86 @@ def responses(): assert rest_send.verb == HttpVerbEnum.GET.value +def test_fabric_update_group_00270() -> None: + """ + # Summary + + Verify `update` with `state: merged` and an omitted `update_group_switches` (settings-only update) + keeps the current membership and issues only GET + settings PUT (no attach/detach). + + ## Test + + - The model carries settings but omits `update_group_switches` (None) + - Current membership is FDO1, FDO2; the PUT body preserves it and overlays the user's settings + - No attachGroup / detachGroup request is issued + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._apply_settings() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + model = FabricUpdateGroupModel(update_group_name="leaf_group", analysis="snapshot") + + with does_not_raise(): + instance.update(model) + + # The last request is the settings PUT; no attach/detach intervened. + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value + body = rest_send.committed_payload + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] + assert body["analysis"] == "snapshot" + assert body["reportSelection"] == "advanced" + + +def test_fabric_update_group_00280() -> None: + """ + # Summary + + Verify `update` with `state: replaced` and an omitted `update_group_switches` keeps the current + membership (a required PUT key) while still resetting an omitted optional field. + + ## Test + + - The model carries settings but omits `update_group_switches` (None) + - `state` is `replaced`: the PUT seeds only required fields from the current group, so membership + FDO1, FDO2 is preserved while the omitted optional `reportSelection` is reset (absent) + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - FabricUpdateGroupOrchestrator._apply_settings() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + rest_send.params["state"] = "replaced" + model = FabricUpdateGroupModel(update_group_name="leaf_group", analysis="snapshot") + + with does_not_raise(): + instance.update(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.PUT.value + body = rest_send.committed_payload + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] + assert body["analysis"] == "snapshot" + assert "reportSelection" not in body + + # ============================================================================= # Test: delete # ============================================================================= @@ -799,6 +879,78 @@ def responses(): assert rest_send.verb == HttpVerbEnum.DELETE.value +def test_fabric_update_group_00330() -> None: + """ + # Summary + + Verify `delete` propagates a non-ghost GET failure instead of falling back to a destructive DELETE. + + A zero-switch ghost group returns HTTP 400; any other failure (here 500) must not be mistaken for a + ghost. `delete` re-raises so a transient/auth/server error is never turned into a DELETE. + + ## Test + + - GET single returns 500 + - `delete` raises `RuntimeError` (wrapped) and issues no DELETE; the last request is the GET + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.delete() + - FabricUpdateGroupOrchestrator._get_group_raw_or_none() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Delete failed for .*leaf_group"): + instance.delete(model) + + # Only the GET was issued; no DELETE followed the unexpected failure. + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.GET.value + + +def test_fabric_update_group_00340() -> None: + """ + # Summary + + Verify `delete` treats a 404 on the single GET as "group already gone" and falls back to the + group-centric DELETE to free the reserved name. + + ## Test + + - GET single returns 404 + - `delete` issues DELETE against the per-name URL + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.delete() + - FabricUpdateGroupOrchestrator._get_group_raw_or_none() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + yield responses_fabric_update_group(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = _build_model() + + with does_not_raise(): + instance.delete(model) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/updateGroups/leaf_group" + assert rest_send.verb == HttpVerbEnum.DELETE.value + + # ============================================================================= # Test: query_one # ============================================================================= From cd85389cfbe0d041e0d74efa7349501704c9476d Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 2 Jun 2026 13:54:22 -1000 Subject: [PATCH 11/12] Generalize auto_assign state error in nd_fabric_update_group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard is `state != "merged"`, so it rejects replaced, overridden, deleted, and query — but the message singled out 'overridden', which was misleading. Reword the rationale to not name a single state. Addresses samiib review comment on PR #285. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/modules/nd_fabric_update_group.py | 2 +- .../targets/nd_fabric_update_group/vars/main.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 556f0061b..8b87455c9 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -331,7 +331,7 @@ def main(): if state != "merged": module.fail_json( msg=f"auto_assign is only valid with state 'merged', got '{state}'. " - "The auto-assign action already regroups every switch in the fabric, so state 'overridden' is not supported with it." + "The auto-assign action already regroups every switch in the fabric, so it cannot be combined with any other state." ) output = NDOutput(output_level=module.params.get("output_level", "normal")) diff --git a/tests/integration/targets/nd_fabric_update_group/vars/main.yaml b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml index 5080d0a17..7abcbc3a0 100644 --- a/tests/integration/targets/nd_fabric_update_group/vars/main.yaml +++ b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml @@ -1,9 +1,9 @@ --- # Variables for nd_fabric_update_group integration tests. # -# `update_group_switches` and `installation_order_devices` accept either switch IP addresses -# or switch serial numbers (switchIds). IPs are resolved to switchIds via the fabric -# inventory before being sent on the wire. +# `update_group_switches` and `installation_order_devices` accept a switch fabric management IP +# address (IPv4 or IPv6) only; switch serial numbers (switchIds) are not accepted. IPs are resolved +# to switchIds via the fabric inventory before being sent on the wire. # # A switch may belong to only one update group at a time. Each group below uses a disjoint # switch set so that creating one group never moves a switch out of another (which on ND 4.2.1 From 7f25daad24422936f394d78fd8b16493e78ff9dd Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 2 Jun 2026 14:12:34 -1000 Subject: [PATCH 12/12] Guard create path against empty update_group_switches; document omit-vs-empty samiib review: the update path already rejects an empty update_group_switches (use state: deleted), but the create path silently turned an omitted/empty switch list into switchIds: [] and sent it to attachGroup, where ND rejects it or leaves a zero-switch ghost group with no clear message. - _attach_item now raises a clear RuntimeError when switch_ids is None (the create path) and the model carries no switches. The guard sits only on the create path; update passes an explicit switch_ids and keeps its own empty guard, and its None-membership case ("not managed, keep current") is unaffected. - DOCUMENTATION: update_group_switches now spells out the create-vs-update semantics - required and non-empty on create; on update, omitting it keeps current membership while providing it reconciles to exactly the listed switches, and an explicit empty list is rejected in both cases. - Test 00170 (parametrized: omitted None and explicit empty list) asserts create fails fast with no wire request issued. Full unit suite green (663). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../orchestrators/fabric_update_group.py | 13 ++++++- plugins/modules/nd_fabric_update_group.py | 5 +++ .../orchestrators/test_fabric_update_group.py | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/orchestrators/fabric_update_group.py b/plugins/module_utils/orchestrators/fabric_update_group.py index 63811503b..37a4b1dc4 100644 --- a/plugins/module_utils/orchestrators/fabric_update_group.py +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -339,17 +339,26 @@ def _attach_item(self, model_instance: FabricUpdateGroupModel, switch_ids: list[ # Summary Build a single `attachUpdateGroups` item. When `switch_ids` is None the model's - `update_group_switches` are resolved (IP -> switchId); otherwise the provided already-resolved - list is used verbatim. + `update_group_switches` are resolved (IP -> switchId) and must be non-empty (this is the create + path - a new group needs at least one switch); otherwise the provided already-resolved list is + used verbatim (the update path, which has already validated membership). ## Raises ### RuntimeError - If a switch IP cannot be resolved (propagated from `_resolve_switch_id`). + - If `switch_ids` is None and the model carries no switches: a new update group must list at least + one switch (ND does not permit a zero-switch group). Reached only on the create path; the update + path passes an explicit `switch_ids` and guards the empty case itself. """ if switch_ids is None: switch_ids = [self._resolve_switch_id(s) for s in (model_instance.update_group_switches or [])] + if not switch_ids: + raise RuntimeError( + f"update_group_name '{model_instance.update_group_name}': update_group_switches must be non-empty when " + "creating an update group; ND does not permit a zero-switch group." + ) return { "updateGroupName": model_instance.update_group_name, "switchIds": switch_ids, diff --git a/plugins/modules/nd_fabric_update_group.py b/plugins/modules/nd_fabric_update_group.py index 8b87455c9..9db07684d 100644 --- a/plugins/modules/nd_fabric_update_group.py +++ b/plugins/modules/nd_fabric_update_group.py @@ -72,6 +72,11 @@ - Each entry is a switch fabric management IP address (IPv4 or IPv6). Switch serial numbers are not accepted. - Switch IP addresses are resolved to switchIds via the fabric inventory before the request is sent. - An update group must contain at least one switch; ND does not permit a zero-switch group. + - When creating a new update group, O(config.update_group_switches) is required and must be non-empty. + - When updating an existing group, omitting O(config.update_group_switches) leaves the current membership + unchanged (membership is not managed) and applies only the other provided settings, whereas providing it + reconciles membership to exactly the listed switches. An explicit empty list is rejected in both cases; + use O(state=deleted) to remove a group. type: list elements: str force_created: diff --git a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py index 7f5f7877e..c93c406cf 100644 --- a/tests/unit/module_utils/orchestrators/test_fabric_update_group.py +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -452,6 +452,44 @@ def responses(): assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] +@pytest.mark.parametrize( + "switches", + [ + pytest.param(None, id="omitted"), + pytest.param([], id="explicit_empty_list"), + ], +) +def test_fabric_update_group_00170(switches: list[str] | None) -> None: + """ + # Summary + + Verify `create` rejects a group with no switches - both an omitted `update_group_switches` (`None`) + and an explicit empty list - failing fast with a clear message before any wire request. On the + create path there is no existing membership to preserve, so (unlike `update`, where `None` means + "membership not managed") a missing switch list is simply invalid: ND does not permit a zero-switch + group. + + ## Test + + - The model carries no switches (omitted, then explicit empty list) + - `create` raises `RuntimeError` (wrapped) naming `update_group_switches` before issuing any request + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._attach_item() + """ + rest_send = RestSend({"check_mode": False, "fabric_name": "fabric_1"}) + instance = FabricUpdateGroupOrchestrator(rest_send=rest_send) + model = FabricUpdateGroupModel(update_group_name="leaf_group", update_group_switches=switches) + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*update_group_switches must be non-empty.*zero-switch group"): + instance.create(model) + + # No wire request was issued; the guard fired while building the attach item. + assert rest_send._path is None + + # ============================================================================= # Test: update # =============================================================================