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..cb1298a38 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/fabric_update_group.py @@ -0,0 +1,180 @@ +# 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) +- `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 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, 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", quote(self.fabric_name, safe=""), "updateGroups"] + if self.update_group_name is not None: + segments.append(quote(self.update_group_name, safe="")) + 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 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/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..b29064330 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py @@ -0,0 +1,168 @@ +# 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. + +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 + (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 + +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 +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 _EpFabricSoftwareUpdatePlanActionBase(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + 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`. + + `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 + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + # 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 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", self._action) + + +class EpFabricSoftwareUpdatePlanAttachGroup(_EpFabricSoftwareUpdatePlanActionBase): + """ + # 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. + """ + + _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(_EpFabricSoftwareUpdatePlanActionBase): + """ + # 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. + """ + + _action: ClassVar[str] = "detachGroup" + + class_name: Literal["EpFabricSoftwareUpdatePlanDetachGroup"] = Field( + default="EpFabricSoftwareUpdatePlanDetachGroup", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpFabricSoftwareUpdatePlanPropose(_EpFabricSoftwareUpdatePlanActionBase): + """ + # 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. + """ + + _action: ClassVar[str] = "propose" + + class_name: Literal["EpFabricSoftwareUpdatePlanPropose"] = Field( + default="EpFabricSoftwareUpdatePlanPropose", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/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..cbb2f758b --- /dev/null +++ b/plugins/module_utils/models/fabric_update_group/fabric_update_group.py @@ -0,0 +1,207 @@ +# 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, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field, model_validator +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.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: 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): + """ + # 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` 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 +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[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 + # 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. + # + # `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 --- + + update_group_name: str = Field(alias="updateGroupName") + 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: 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) --- + + @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"), + force_created=dict(type="bool", default=False), + 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), + ), + ), + ), + ), + auto_assign=dict(type="str", choices=["roleBased", "evenOdd"]), + 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..37a4b1dc4 --- /dev/null +++ b/plugins/module_utils/orchestrators/fabric_update_group.py @@ -0,0 +1,686 @@ +# 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. + +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 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 +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 + +import ipaddress +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, + EpFabricUpdateGroupPut, +) +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 +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") + +# 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]): + """ + # Summary + + 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`. The write path + uses the switch-centric `attachGroup` / `detachGroup` action endpoints for membership and + `PUT /updateGroups/{name}` for group settings. + + ## Raises + + ### RuntimeError + + - 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 `propose` if the auto-assign 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] = 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 = EpFabricSoftwareUpdatePlanAttachGroup + detach_group_endpoint: Type[NDEndpointBaseModel] = EpFabricSoftwareUpdatePlanDetachGroup + propose_endpoint: Type[NDEndpointBaseModel] = EpFabricSoftwareUpdatePlanPropose + + _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 _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: + """ + # 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 _is_ip_address(value: str) -> bool: + """ + # Summary + + 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 + """ + 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: + """ + # Summary + + 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 `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._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." + ) + return self.fabric_context.get_switch_id(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. + + 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 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] = [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: + """ + # Summary + + 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 + + ### RuntimeError + + - If the switch inventory cannot be loaded (propagated from `FabricContext.switch_map_by_id`). + """ + 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: + """ + # 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. + + 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 + + ### RuntimeError + + - If the switch inventory cannot be loaded (propagated from `_switch_ids_to_ips`). + """ + if not isinstance(item, dict): + return item + if not any(isinstance(item.get(key), list) for key in _SWITCH_LIST_PAYLOAD_KEYS): + return item + for key in _SWITCH_LIST_PAYLOAD_KEYS: + values = item.get(key) + if isinstance(values, list): + item[key] = self._switch_ids_to_ips(values) + return item + + @staticmethod + def _raise_on_207_action_errors(result: Any, response_key: str, message_key: str) -> None: + """ + # Summary + + 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. + + 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` (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") != "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}") + + @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) 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, + "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) 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 + + ### 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 + # 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: + """ + # 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`, 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 + + ### RuntimeError + + - If a switch IP cannot be resolved, or the GET / PUT request fails. + """ + 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) + 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) + 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 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, a request fails, or `attachGroup` reports a non-success status. + """ + try: + 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 + + def create_bulk(self, model_instances: list[FabricUpdateGroupModel], **kwargs) -> ResponseType: + """ + # Summary + + 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, a request fails, or `attachGroup` reports a non-success status. + """ + try: + 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 + + def update(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: + """ + # Summary + + 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 `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 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] + 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 + + def delete(self, model_instance: FabricUpdateGroupModel, **kwargs) -> ResponseType: + """ + # Summary + + 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 a request fails or `detachGroup` reports a non-success status. + """ + try: + 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 + + 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 + + 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 new file mode 100644 index 000000000..9db07684d --- /dev/null +++ b/plugins/modules/nd_fabric_update_group.py @@ -0,0 +1,363 @@ +#!/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: "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). +- 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. +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 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: + 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). + - 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: + - 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 + 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). + - 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 ] + 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: + # Switches are specified as fabric management IP addresses. + - 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 + +- name: Auto-assign update groups by switch role + cisco.nd.nd_fabric_update_group: + fabric_name: SITE1 + auto_assign: roleBased + state: merged +""" + +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_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, output: NDOutput) -> None: + """ + # 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. + + 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. + """ + 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) + # 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) + + +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(): + argument_spec = nd_argument_spec() + argument_spec.update(FabricUpdateGroupModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[["config", "auto_assign"]], + ) + 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 != "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 it cannot be combined with any other state." + ) + + output = NDOutput(output_level=module.params.get("output_level", "normal")) + try: + _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()) + return + + 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/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/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..0cab82b0a --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/tasks/main.yaml @@ -0,0 +1,52 @@ +--- +# 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_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 + +- 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 + + - 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/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..7abcbc3a0 --- /dev/null +++ b/tests/integration/targets/nd_fabric_update_group/vars/main.yaml @@ -0,0 +1,79 @@ +--- +# Variables for nd_fabric_update_group integration tests. +# +# `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 +# 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') }}" + +# 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 }}" + force_created: true + 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 }}" + 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 + contingency: continue + analysis: noAnalysis + is_maintenance: false + is_disruptive_update: false + update_group_switches: + - "{{ test_switch_c }}" + force_created: true + report_selection: noReport + reports: noReport 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/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..5c9f97520 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_software_update_plan_actions.py @@ -0,0 +1,402 @@ +# -*- 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, + EpFabricSoftwareUpdatePlanPropose, +) +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: 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 +# ============================================================================= + + +def test_ep_software_update_plan_actions_00200(): + """ + # Summary + + Verify attach/detach/propose endpoints are all POST with distinct action paths. + + ## Test + + - 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 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 new file mode 100644 index 000000000..f0ba57a93 --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_update_group.json @@ -0,0 +1,830 @@ +{ + "TEST_NOTES": [ + "Fixture data for test_fabric_update_group.py (orchestrator).", + "Keys follow the test__ convention from CLAUDE.md.", + "Fabric scope for all tests: fabric_1.", + "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 consume one switches-list response (FabricContext lazy fetch)." + ], + + "test_fabric_update_group_00100a": { + "TEST_NOTES": ["create happy path: 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_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 per-item failure: attachGroup POST returns 207 status:failed"], + "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": "failed", "warningMessage": "Switch not found"} + ] + } + }, + + "test_fabric_update_group_00120a": { + "TEST_NOTES": ["create transport failure: attachGroup POST returns 500"], + "RETURN_CODE": 500, + "METHOD": "POST", + "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: 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", + "MESSAGE": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00210a": { + "TEST_NOTES": ["update transport failure: initial 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_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: 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": "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 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": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00400a": { + "TEST_NOTES": [ + "query_one happy path: GET returns single group.", + "updateGroupSwitches intentionally omitted - denormalization short-circuits." + ], + "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 - denormalization short-circuits." + ], + "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: attachGroup POST returns 207 with two successful items"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "OK", + "DATA": { + "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: attachGroup POST returns 207 with one failed item"], + "RETURN_CODE": 207, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/attachGroup", + "MESSAGE": "Multi-Status", + "DATA": { + "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 (FabricContext lazy fetch)"], + "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 (no settings): attachGroup POST returns 207 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": "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: 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", + "MESSAGE": "OK", + "DATA": { + "switches": [ + {"fabricManagementIp": "192.168.12.151", "switchId": "FDO12345ABC"}, + {"fabricManagementIp": "192.168.12.152", "switchId": "FDO12345ABD"} + ] + } + }, + "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", + "MESSAGE": "No Content", + "DATA": {} + }, + + "test_fabric_update_group_00730a": { + "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", + "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)"], + "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"], + "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)"], + "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." + ], + "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"} + ] + } + }, + + "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"} + }, + + "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": {} + }, + "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" + ] + } + }, + "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": {} + }, + "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/models/test_fabric_update_group.py b/tests/unit/module_utils/models/test_fabric_update_group.py new file mode 100644 index 000000000..173eea423 --- /dev/null +++ b/tests/unit/module_utils/models/test_fabric_update_group.py @@ -0,0 +1,842 @@ +# -*- 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` 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 from an empty wire dict + - No exception is raised; `report_check_name` is None + + ## Classes and Methods + + - UpdateReportCheckModel.model_validate() + """ + with does_not_raise(): + instance = 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 + + +# ============================================================================= +# 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`, `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 + - `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", "auto_assign"} + 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 + + +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 +# ============================================================================= + + +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 + + +# ============================================================================= +# 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 new file mode 100644 index 000000000..c93c406cf --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_fabric_update_group.py @@ -0,0 +1,1592 @@ +# -*- 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 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 +# 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 + + +# 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 IP-form switches.""" + return FabricUpdateGroupModel( + update_group_name=update_group_name, + execution="serial", + contingency="continue", + analysis="snapshot", + is_maintenance=True, + is_disruptive_update=True, + update_group_switches=["192.168.0.1", "192.168.0.2"], + force_created=force_created, + ) + + +# ============================================================================= +# Test: initialization +# ============================================================================= + + +def test_fabric_update_group_00010() -> None: + """ + # Summary + + Verify `FabricUpdateGroupOrchestrator` instantiates and exposes the expected ClassVars. + + ## 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` attaches switches via `attachGroup` then applies settings via PUT. + + ## Test + + - 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, 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 isinstance(body, dict) + 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 `attachGroup` returns a 207 item with `status: failed`. + + ## 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, instance = _resolving_instance(gen_responses) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Create failed for .*leaf_group.*failed.*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. + + ## 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, instance = _resolving_instance(gen_responses) + 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 + + - 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] + + def responses(): + yield responses_fabric_update_group(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + 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"): + 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, instance = _resolving_instance(gen_responses) + 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, 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) + + 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, 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) + + 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}]} + + +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"] + + +@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 +# ============================================================================= + + +def test_fabric_update_group_00200() -> None: + """ + # Summary + + Verify `update` reconciles membership (attach added, detach removed) and applies settings. + + ## Test + + - 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 + + - FabricUpdateGroupOrchestrator.update() + """ + 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, instance = _resolving_instance(gen_responses) + 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 body["updateGroupName"] == "leaf_group" + assert body["execution"] == "serial" + assert body["updateGroupSwitches"] == ["FDO1", "FDO2"] + + +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, instance = _resolving_instance(gen_responses) + model = _build_model() + + with pytest.raises(RuntimeError, match=r"Update failed for .*leaf_group"): + 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, 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) + + 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, 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) + + 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"]}]} + + +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" + + +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 + + +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 +# ============================================================================= + + +def test_fabric_update_group_00300() -> None: + """ + # Summary + + Verify `delete` detaches all of the group's switches (ND auto-deletes the emptied group). + + ## Test + + - 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) + 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/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` 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 + + - FabricUpdateGroupOrchestrator.delete() + """ + 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 + + +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 +# ============================================================================= + + +def test_fabric_update_group_00400() -> None: + """ + # Summary + + Verify `query_one` issues GET against the 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 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 + + - FabricUpdateGroupOrchestrator.create_bulk() + """ + 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") + yield responses_fabric_update_group(f"{method_name}e") + + gen_responses = ResponseGenerator(responses()) + rest_send, instance = _resolving_instance(gen_responses) + 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/g2" + assert rest_send.verb == HttpVerbEnum.PUT.value + + +def test_fabric_update_group_00610() -> None: + """ + # Summary + + Verify `create_bulk` raises `RuntimeError` when any `attachGroup` 207 item has `status: failed`. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create_bulk() + - 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, 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"): + instance.create_bulk(models) + + +# ============================================================================= +# Test: switch IP <-> switchId resolution +# ============================================================================= + + +def test_fabric_update_group_00700() -> None: + """ + # Summary + + 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 the resolved switchIds + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.create() + - FabricUpdateGroupOrchestrator._attach_item() + - 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", + 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"] + + +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: + """ + # Summary + + Verify `create` raises `RuntimeError` if a user-supplied switch IP cannot be resolved. + + ## 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", 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 for both membership reconciliation and the PUT body. + + ## Classes and Methods + + - FabricUpdateGroupOrchestrator.update() + - 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", + 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) + + 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"] + + +def test_fabric_update_group_00730() -> None: + """ + # Summary + + Verify `query_one` denormalizes switchIds back to IPs in the response. + + ## 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, leaving unresolvable switchIds unchanged. + + ## 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 `_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._is_ip_address() + """ + 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: + """ + # Summary + + Verify `query_all` drops the ND-managed default update group named "None". + + ## 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"} + + +# ============================================================================= +# 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` 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 + + - FabricUpdateGroupOrchestrator._switch_ids_to_ips() + """ + instance = _bare_instance(_RaisingFabricContext()) + + with pytest.raises(RuntimeError, match=r"switch inventory unavailable"): + instance._switch_ids_to_ips(["FDO1", "FDO2"]) + + +# ============================================================================= +# 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") + + +# ============================================================================= +# 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