diff --git a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py index b29064330..ca03237fe 100644 --- a/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/software_update_plan_actions.py @@ -21,6 +21,8 @@ (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) +- `EpFabricSoftwareUpdatePlanStage` - Stage and validate images for update groups + (POST /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/stage) """ from __future__ import annotations @@ -166,3 +168,59 @@ class EpFabricSoftwareUpdatePlanPropose(_EpFabricSoftwareUpdatePlanActionBase): def verb(self) -> HttpVerbEnum: """Return the HTTP verb for this endpoint.""" return HttpVerbEnum.POST + + +class EpFabricSoftwareUpdatePlanStage(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Stage and validate images for one or more update groups (the GUI "Prepare" action). + + ND copies the configured image to each switch's bootflash, runs `show install all impact`, and + generates pre-reports. The action is asynchronous: it returns HTTP 202 with an empty body, and + progress is observed via the `softwareUpdatePlan/summary` endpoint. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/actions/stage` + - Verb: POST + - Body: `{"updateGroupNames": ["...", "..."]}` + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpFabricSoftwareUpdatePlanStage"] = Field( + default="EpFabricSoftwareUpdatePlanStage", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the stage 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", "stage") + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.POST`. + + ## Raises + + None + """ + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/software_update_plan_summary.py b/plugins/module_utils/endpoints/v1/manage/software_update_plan_summary.py new file mode 100644 index 000000000..9a05c3c01 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/software_update_plan_summary.py @@ -0,0 +1,107 @@ +# 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 software update plan summary endpoint model. + +## Endpoints + +- `EpFabricSoftwareUpdatePlanSummary` - Software update plan summary for a fabric + (GET /api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/summary) +""" + +from __future__ import annotations + +from typing import Literal +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import FabricNameMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import EndpointQueryParams +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 SoftwareUpdatePlanSummaryEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for the software update plan summary endpoint. + + `update_group_name` is rendered as the API's `updateGroupName` query parameter (snake_case -> + camelCase is automatic via `EndpointQueryParams`). When set, Nexus Dashboard scopes the summary + to that single update group instead of returning the fabric-wide plan. + + ## Raises + + None + """ + + update_group_name: str | None = Field(default=None, min_length=1, description="Scope the summary to a single update group") + + +class EpFabricSoftwareUpdatePlanSummary(FabricNameMixin, NDEndpointBaseModel): + """ + # Summary + + Software update plan summary for a fabric. + + Returns the fabric-wide software update plan: every update group with its per-switch stage / + validate / install status. Used to drive the `nd_fabric_prepare_update` pre-flight role check + and to poll for staging completion. + + Set `endpoint_params.update_group_name` to scope the summary to a single update group via the + API's optional `updateGroupName` query parameter; leave it unset for the fabric-wide summary. + + - Path: `/api/v1/manage/fabrics/{fabric_name}/softwareUpdatePlan/summary` + - Verb: GET + + ## Raises + + ### ValueError + + - Via `path` property if `fabric_name` is not set. + """ + + class_name: Literal["EpFabricSoftwareUpdatePlanSummary"] = Field( + default="EpFabricSoftwareUpdatePlanSummary", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: SoftwareUpdatePlanSummaryEndpointParams = Field( + default_factory=SoftwareUpdatePlanSummaryEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the software update plan summary endpoint path. `fabric_name` is percent-encoded with `safe=""`. When + `endpoint_params.update_group_name` is set, the `updateGroupName` query string is appended to scope the summary. + + ## 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.") + base_path = BasePath.path("fabrics", quote(self.fabric_name, safe=""), "softwareUpdatePlan", "summary") + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return `HttpVerbEnum.GET`. + + ## Raises + + None + """ + return HttpVerbEnum.GET diff --git a/plugins/module_utils/models/fabric_prepare_update/__init__.py b/plugins/module_utils/models/fabric_prepare_update/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/models/fabric_prepare_update/software_update_plan_summary.py b/plugins/module_utils/models/fabric_prepare_update/software_update_plan_summary.py new file mode 100644 index 000000000..33a3e3367 --- /dev/null +++ b/plugins/module_utils/models/fabric_prepare_update/software_update_plan_summary.py @@ -0,0 +1,115 @@ +# 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 models for parsing the ND `softwareUpdatePlan/summary` response. + +The summary endpoint returns the fabric-wide software update plan: every update group with its +per-switch stage / validate / install status. `nd_fabric_prepare_update` uses these models for the +pre-flight switch-role check and to poll for staging completion. The models are response-only - +they parse the wire shape and are never serialized back to ND. +""" + +from __future__ import annotations + +from typing import Literal, TypeAlias + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + +# Known per-switch stage / validate statuses on ND 4.2.1. The `str` fallback in `ImageStatus` lets +# an unrecognized status from a newer ND release parse rather than raising during a poll; type +# checkers collapse the union to `str`, so the Literal is documentation of the known vocabulary. +KnownImageStatus: TypeAlias = Literal["none", "inProgress", "success", "failed", "skipped"] +ImageStatus: TypeAlias = KnownImageStatus | str + + +class UpdateGroupWarningModel(NDNestedModel): + """ + # Summary + + A single advisory warning ND attaches to an update group in the software update plan summary + (for example, that upgrading the selected switches would impact every switch of a role). + + ## Raises + + None + """ + + message: str | None = Field(default=None, alias="message") + switch_name: str | None = Field(default=None, alias="switchName") + + +class SwitchStageStatusModel(NDNestedModel): + """ + # Summary + + Per-switch stage / validate status within an update group, as reported by the + `softwareUpdatePlan/summary` endpoint. + + `image_staged_status` and `image_validated_status` are typed `ImageStatus` + (`KnownImageStatus | str`): the `Literal` half documents the known vocabulary while the `str` + fallback lets an unrecognized status from a newer ND release parse rather than raising during + a poll. + + ## Raises + + None + """ + + switch_id: str | None = Field(default=None, alias="switchId") + switch_name: str | None = Field(default=None, alias="switchName") + switch_role: str | None = Field(default=None, alias="switchRole") + switch_management_ip: str | None = Field(default=None, alias="switchManagementIP") + switch_version: str | None = Field(default=None, alias="switchVersion") + selected_version: str | None = Field(default=None, alias="selectedVersion") + image_staged_status: ImageStatus | None = Field(default=None, alias="imageStagedStatus") + image_validated_status: ImageStatus | None = Field(default=None, alias="imageValidatedStatus") + switch_stage_validate_percentage: int | None = Field(default=None, alias="switchStageValidatePercentage") + + +class UpdateGroupStatusModel(NDNestedModel): + """ + # Summary + + A single update group's status within the software update plan summary, including its member + switches and any advisory warnings ND raises for the group. + + The `warnings` and `update_group_switches` lists default to empty: a key absent from the + response body parses the same as an explicitly empty list, since every consumer treats the two + identically. + + `update_group_status` is kept as a free-form string (not a `Literal`) for the same reason as + the per-switch statuses in `SwitchStageStatusModel`: an unrecognized status from a newer ND + release must parse rather than raise during a poll. Its vocabulary is not lab-confirmed, so no + known-values alias is provided. + + ## Raises + + None + """ + + update_group_name: str | None = Field(default=None, alias="updateGroupName") + update_group_status: str | None = Field(default=None, alias="updateGroupStatus") + update_type: str | None = Field(default=None, alias="updateType") + stage_validate_percentage: int | None = Field(default=None, alias="stageValidatePercentage") + switch_count: int | None = Field(default=None, alias="switchCount") + warnings: list[UpdateGroupWarningModel] = Field(default_factory=list, alias="warnings") + update_group_switches: list[SwitchStageStatusModel] = Field(default_factory=list, alias="updateGroupSwitches") + + +class SoftwareUpdatePlanSummaryModel(NDNestedModel): + """ + # Summary + + Top-level parse of the `softwareUpdatePlan/summary` response. Only the `updateGroups` list is + modeled; the `softwareUpdateSummary` and `tableHeaders` blocks are ignored (`extra="ignore"`). + `update_groups` defaults to empty, so a response missing the key parses the same as an + explicitly empty plan. + + ## Raises + + None + """ + + update_groups: list[UpdateGroupStatusModel] = Field(default_factory=list, alias="updateGroups") diff --git a/plugins/module_utils/orchestrators/fabric_prepare_update.py b/plugins/module_utils/orchestrators/fabric_prepare_update.py new file mode 100644 index 000000000..325bb25d8 --- /dev/null +++ b/plugins/module_utils/orchestrators/fabric_prepare_update.py @@ -0,0 +1,472 @@ +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Orchestrator for the fabric "prepare update" (stage) action on Nexus Dashboard. + +The ND 4.2 GUI "Prepare" step is the `softwareUpdatePlan/actions/stage` action: ND copies each +update group's configured image to the member switches, runs `show install all impact`, and +generates pre-reports. The action is asynchronous - the POST returns HTTP 202 with an empty body +and progress is observed by polling `softwareUpdatePlan/summary`. + +This orchestrator is intentionally *not* an `NDBaseOrchestrator` subclass: there is no CRUD +resource here, so the base's five required create/update/delete/query endpoint fields do not +apply. It is a standalone Pydantic model that drives `RestSend` directly via a private `_request` +helper adapted from `NDBaseOrchestrator._request`. + +Responsibilities: + +- `preflight_role_check` - fail before staging if an update group spans more than one switch role + (ND will not prepare a mixed-role group). +- `status_snapshot` - per-group / per-switch stage-validate status, used for module `before` / + `after` output and for the idempotency decision. +- `stage` - POST the stage action for the named update groups. +- `wait_for_completion` - poll the summary until every target switch has staged and validated, or + raise on a per-switch failure or timeout. +""" + +from __future__ import annotations + +import time +from typing import Any, cast + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_actions import EpFabricSoftwareUpdatePlanStage +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_summary import EpFabricSoftwareUpdatePlanSummary +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum, OperationType +from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_prepare_update.software_update_plan_summary import ( + SoftwareUpdatePlanSummaryModel, + SwitchStageStatusModel, + UpdateGroupStatusModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +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 + +# Per-switch staged / validated statuses that count as terminal-OK for a prepare operation. +# `skipped` is terminal-OK: ND skips staging a switch whose image is already in place. +_TERMINAL_OK: frozenset[str] = frozenset({"success", "skipped"}) + +# Per-switch staged / validated status that counts as a terminal failure. +_FAILED_STATUS: str = "failed" + +# Consecutive `get_summary()` failures tolerated while polling before `wait_for_completion` gives +# up. A staging poll runs for many minutes, so a single transient transport error (a token +# refresh, a brief controller hiccup) is expected; only a sustained run of failures is fatal. +_MAX_CONSECUTIVE_POLL_FAILURES: int = 3 + + +def _switch_is_prepared(switch: SwitchStageStatusModel) -> bool: + """ + # Summary + + Return True if `switch` has reached a terminal-OK state for both the stage and validate phases. + + ## Raises + + None + """ + return switch.image_staged_status in _TERMINAL_OK and switch.image_validated_status in _TERMINAL_OK + + +def _switch_has_failed(switch: SwitchStageStatusModel) -> bool: + """ + # Summary + + Return True if `switch` reports a terminal failure for either the stage or validate phase. + + ## Raises + + None + """ + return _FAILED_STATUS in (switch.image_staged_status, switch.image_validated_status) + + +class FabricPrepareUpdateOrchestrator(BaseModel): + """ + # Summary + + Orchestrator for the fabric "prepare update" (stage) action on Nexus Dashboard. + + Reads `fabric_name` from module params and drives the `softwareUpdatePlan/actions/stage` and + `softwareUpdatePlan/summary` endpoints via an injected `RestSend`. + + ## Raises + + ### RuntimeError + + - Via `_request` if a REST request fails. + - Via `preflight_role_check` if an update group is missing or spans more than one switch role. + - Via `status_snapshot` / `wait_for_completion` if an update group is missing from the summary. + - Via `stage` if the stage action request fails. + - Via `wait_for_completion` if a switch reports a staging failure or the wait times out. + """ + + model_config = ConfigDict( + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + arbitrary_types_allowed=True, + ) + + rest_send: RestSend + results: Results | 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") + + def _register_api_call(self, path: str, verb: HttpVerbEnum, operation_type: OperationType, payload: dict[str, Any] | None = None) -> None: + """ + # Summary + + Register the most recent REST call with `Results` for verbosity-gated observability. No-op + when no `Results` instance is attached. + + ## Raises + + None + """ + if self.results is None: + return + self.results.action = operation_type.value + self.results.operation_type = operation_type + self.results.path_current = path + self.results.verb_current = verb + self.results.payload_current = payload + self.results.response_current = self.rest_send.response_current + self.results.result_current = self.rest_send.result_current + self.results.diff_current = {} + # Write actions are shown at -vv (verbosity 2); reads at -vvv (verbosity 3). + self.results.verbosity_level_current = 3 if operation_type == OperationType.QUERY else 2 + self.results.register_api_call() + + def _request(self, path: str, verb: HttpVerbEnum, data: dict[str, Any] | None = None, operation_type: OperationType = OperationType.QUERY) -> ResponseType: + """ + # Summary + + Send a REST request via `RestSend`, register it with `Results`, and return the response + `DATA`. + + ## Raises + + ### RuntimeError + + - If the controller returns a non-success result. + """ + self.rest_send.path = path + self.rest_send.verb = verb + if data is not None: + self.rest_send.payload = data + self.rest_send.commit() + + # Register before the success check so failed calls are also captured for troubleshooting. + self._register_api_call(path, verb, operation_type, self.rest_send.committed_payload) + + if not self.rest_send.success: + raise RuntimeError(f"Request failed: {self.rest_send.error_summary}") + + return self.rest_send.response_current.get("DATA", {}) + + @staticmethod + def scope_for(update_group_names: list[str]) -> str | None: + """ + # Summary + + Return the single update group name to scope a summary read to, or `None` when the read must + stay fabric-wide. The `updateGroupName` query parameter accepts one group at a time, so only + a single-group request can be scoped; any other count falls back to the fabric-wide summary. + + ## Raises + + None + """ + return update_group_names[0] if len(update_group_names) == 1 else None + + def get_summary(self, update_group_name: str | None = None) -> SoftwareUpdatePlanSummaryModel: + """ + # Summary + + GET the fabric's software update plan summary and parse it into a + `SoftwareUpdatePlanSummaryModel`. + + Pass `update_group_name` to scope the read to a single update group via the API's + `updateGroupName` query parameter; this avoids transferring and parsing the entire fabric + plan on every poll for the common single-group prepare. The parameter accepts only one group + at a time, so multi-group prepares fetch the fabric-wide summary once and filter in memory + (a single GET beats one scoped GET per group). + + ## Raises + + ### RuntimeError + + - If the summary GET request fails. + """ + api_endpoint = EpFabricSoftwareUpdatePlanSummary() + api_endpoint.fabric_name = self.fabric_name + if update_group_name is not None: + api_endpoint.endpoint_params.update_group_name = update_group_name + result = self._request(path=api_endpoint.path, verb=api_endpoint.verb, operation_type=OperationType.QUERY) + # NDBaseModel.from_response is annotated to return the base type; narrow it to the concrete model. + return cast(SoftwareUpdatePlanSummaryModel, SoftwareUpdatePlanSummaryModel.from_response(result if isinstance(result, dict) else {})) + + def _resolve_groups(self, summary: SoftwareUpdatePlanSummaryModel, update_group_names: list[str]) -> list[UpdateGroupStatusModel]: + """ + # Summary + + Return the `UpdateGroupStatusModel` for each requested name, in request order, raising if + any requested update group is absent from the summary. + + ## Raises + + ### RuntimeError + + - If one or more requested update groups are not present in the software update plan. + """ + groups_by_name = {g.update_group_name: g for g in summary.update_groups} + resolved: list[UpdateGroupStatusModel] = [] + missing: list[str] = [] + for name in update_group_names: + group = groups_by_name.get(name) + if group is None: + missing.append(name) + else: + resolved.append(group) + if missing: + available = sorted(n for n in groups_by_name if n) + raise RuntimeError( + f"Update group(s) {missing} not found in the software update plan for fabric '{self.fabric_name}'. " + f"Available update groups: {available or 'none'}. Create them first with cisco.nd.nd_fabric_update_group." + ) + return resolved + + def preflight_role_check(self, update_group_names: list[str], summary: SoftwareUpdatePlanSummaryModel | None = None) -> None: + """ + # Summary + + Fail before staging if any requested update group spans more than one switch role. Nexus + Dashboard will not prepare a mixed-role update group (for example, leaf + spine together), + because simultaneous reloads across roles can disrupt fabric functionality. + + Pass an already-fetched `summary` to reuse it; when omitted, the summary is fetched. + + ## Raises + + ### RuntimeError + + - If a requested update group is missing from the summary. + - If a requested update group contains switches of more than one role. + """ + if summary is None: + summary = self.get_summary() + for group in self._resolve_groups(summary, update_group_names): + roles = sorted({s.switch_role for s in group.update_group_switches if s.switch_role}) + if len(roles) > 1: + raise RuntimeError( + f"Update group '{group.update_group_name}' contains a mix of switch roles ({', '.join(roles)}); " + f"Nexus Dashboard will not prepare a mixed-role update group. Split the switches into " + f"single-role update groups with cisco.nd.nd_fabric_update_group." + ) + + @staticmethod + def _group_to_snapshot(group: UpdateGroupStatusModel) -> dict[str, Any]: + """ + # Summary + + Build a stable, user-facing status dict for one update group. Member switches are sorted by + name so `before` / `after` output is deterministic. + + ## Raises + + None + """ + switches = sorted(group.update_group_switches, key=lambda s: (s.switch_name or s.switch_id or "")) + return { + "update_group_name": group.update_group_name, + "update_group_status": group.update_group_status, + "stage_validate_percentage": group.stage_validate_percentage, + "switches": [ + { + "switch_name": s.switch_name, + "switch_id": s.switch_id, + "switch_role": s.switch_role, + "switch_management_ip": s.switch_management_ip, + "selected_version": s.selected_version, + "image_staged_status": s.image_staged_status, + "image_validated_status": s.image_validated_status, + "switch_stage_validate_percentage": s.switch_stage_validate_percentage, + } + for s in switches + ], + } + + def status_snapshot(self, update_group_names: list[str], summary: SoftwareUpdatePlanSummaryModel | None = None) -> list[dict[str, Any]]: + """ + # Summary + + Return the current stage-validate status of each requested update group as a list of plain + dicts (one per group, in request order). Used for the module's `before` / `after` output + and as the input to the idempotency decision. + + Pass an already-fetched `summary` to reuse it; when omitted, the summary is fetched. + + ## Raises + + ### RuntimeError + + - If a requested update group is missing from the summary. + """ + if summary is None: + summary = self.get_summary() + return [self._group_to_snapshot(group) for group in self._resolve_groups(summary, update_group_names)] + + @staticmethod + def snapshot_fully_prepared(snapshot: list[dict[str, Any]]) -> bool: + """ + # Summary + + Return True if every switch in `snapshot` is already staged and validated. ND resets a + switch's `imageStagedStatus` to `none` whenever the update group's configured image + changes, so a `success` status already means "staged for the currently-configured image" - + no separate version comparison is needed. + + A switch-less snapshot is vacuously prepared: there is nothing to stage, so this returns + True (consistent with `wait_for_completion`, which treats a switch-less group as already + satisfied). Reporting "prepared" avoids a pointless stage POST for a group with no members. + + ## Raises + + None + """ + switches = [sw for group in snapshot for sw in group.get("switches", [])] + return all(sw.get("image_staged_status") in _TERMINAL_OK and sw.get("image_validated_status") in _TERMINAL_OK for sw in switches) + + def stage(self, update_group_names: list[str]) -> ResponseType: + """ + # Summary + + POST the `softwareUpdatePlan/actions/stage` action for the named update groups. The action + is asynchronous: ND returns HTTP 202 with an empty body. + + ## Raises + + ### RuntimeError + + - If the stage action request fails. + """ + api_endpoint = EpFabricSoftwareUpdatePlanStage() + api_endpoint.fabric_name = self.fabric_name + try: + return self._request( + path=api_endpoint.path, + verb=api_endpoint.verb, + data={"updateGroupNames": list(update_group_names)}, + operation_type=OperationType.UPDATE, + ) + except Exception as e: + raise RuntimeError(f"Failed to stage update group(s) {update_group_names} in fabric '{self.fabric_name}': {e}") from e + + @staticmethod + def _format_switch_statuses(switches: list[SwitchStageStatusModel]) -> str: + """ + # Summary + + Render a compact, human-readable per-switch staged / validated status line for error and + timeout messages. + + ## Raises + + None + """ + return "; ".join(f"{s.switch_name or s.switch_id}=[staged:{s.image_staged_status}, validated:{s.image_validated_status}]" for s in switches) + + def wait_for_completion(self, update_group_names: list[str], timeout: int, interval: int) -> SoftwareUpdatePlanSummaryModel: + """ + # Summary + + Poll the software update plan summary until every switch in the requested update groups has + staged and validated. Returns the final summary once staging is complete, so the caller can + reuse it for the `after` snapshot instead of issuing another GET. + + A staging poll runs for many minutes, so a transient hiccup is tolerated. Both a failed + summary GET and a summary that does not yet resolve the requested groups (for example a + controller returning a partial body during a failover) are treated as retryable: the poll is + retried, and only `_MAX_CONSECUTIVE_POLL_FAILURES` failures in a row abort the wait. A + successful, fully-resolved poll resets the failure count. + + The `timeout` is honored ahead of the retry budget: if the deadline has passed, a timeout is + raised even when the most recent poll failed, so a small `timeout` is never overshot by the + retry loop. Each sleep is capped at the time remaining until the deadline, so the loop takes + one final poll at the deadline instead of overshooting it by up to a full `interval`. + + ## Raises + + ### RuntimeError + + - If any switch reports a staging or validation failure. + - If the summary poll fails (transport error or unresolved groups) more than + `_MAX_CONSECUTIVE_POLL_FAILURES` times in a row before the deadline. + - If staging does not complete within `timeout` seconds. + """ + deadline = time.monotonic() + max(timeout, 0) + interval = max(interval, 0) + # Scope the (repeated) poll to a single update group when possible; multi-group prepares + # fetch the fabric-wide summary once per poll and filter in memory. + scope = self.scope_for(update_group_names) + consecutive_failures = 0 + while True: + try: + summary = self.get_summary(update_group_name=scope) + groups = self._resolve_groups(summary, update_group_names) + switches = [sw for group in groups for sw in group.update_group_switches] + except Exception as e: # pylint: disable=broad-except + # A long poll will occasionally hit a transient transport error, or briefly return a + # body that does not resolve the requested groups; retry rather than aborting the + # whole prepare. Honor the user's timeout first, then the retry budget; only a + # sustained run of failures before the deadline is fatal. + consecutive_failures += 1 + if time.monotonic() >= deadline: + raise RuntimeError( + f"Timed out after {timeout}s waiting for staging of update group(s) {update_group_names} " + f"in fabric '{self.fabric_name}' to complete; last poll error: {e}" + ) from e + if consecutive_failures > _MAX_CONSECUTIVE_POLL_FAILURES: + raise RuntimeError( + f"Polling staging status for update group(s) {update_group_names} in fabric " + f"'{self.fabric_name}' failed {consecutive_failures} times in a row: {e}" + ) from e + time.sleep(min(interval, max(0.0, deadline - time.monotonic()))) + continue + consecutive_failures = 0 + + failed = [sw for sw in switches if _switch_has_failed(sw)] + if failed: + raise RuntimeError( + f"Staging failed for update group(s) {update_group_names} in fabric '{self.fabric_name}': " f"{self._format_switch_statuses(failed)}" + ) + + # No member switches in the requested groups means there is nothing to stage or + # validate, so the wait is already satisfied. Returning here (rather than falling + # through to the deadline check) avoids polling a switch-less group until `timeout`. + if not switches: + return summary + + if all(_switch_is_prepared(sw) for sw in switches): + return summary + + if time.monotonic() >= deadline: + raise RuntimeError( + f"Timed out after {timeout}s waiting for staging of update group(s) {update_group_names} in " + f"fabric '{self.fabric_name}' to complete. Current status: {self._format_switch_statuses(switches)}" + ) + + time.sleep(min(interval, max(0.0, deadline - time.monotonic()))) diff --git a/plugins/modules/nd_fabric_prepare_update.py b/plugins/modules/nd_fabric_prepare_update.py new file mode 100644 index 000000000..1c70147df --- /dev/null +++ b/plugins/modules/nd_fabric_prepare_update.py @@ -0,0 +1,240 @@ +#!/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) + +from __future__ import annotations + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_fabric_prepare_update +version_added: "2.0.0" +short_description: Prepare (stage and validate) fabric update groups on Cisco Nexus Dashboard +description: +- Prepare one or more fabric update groups under O(fabric_name) for a software upgrade on Cisco Nexus Dashboard (ND). +- This runs the Fabric Software Management I(Prepare) step, which Nexus Dashboard implements as the + C(softwareUpdatePlan/actions/stage) action - it stages (copies) each update group's configured image to the + member switches, runs C(show install all impact), and generates pre-upgrade reports. +- Before staging, the module performs a pre-flight check and fails if any update group contains a mix of switch + roles (for example, leaf and spine). Nexus Dashboard does not permit preparing a mixed-role update group. +- Preparing is a long-running, asynchronous operation. By default the module waits for staging to complete on + every switch before returning. +- If every switch in every requested update group is already staged and validated for the update group's + configured image, the module reports no change and does not stage again. +author: +- Allen Robel (@allenrobel) +options: + fabric_name: + description: + - The name of the fabric that contains the update groups to prepare. + - The fabric must already exist on Nexus Dashboard. + type: str + required: true + update_groups: + description: + - The list of update group names to prepare. + - At least one update group name is required; an empty list fails validation. + - Each named update group must already exist in O(fabric_name). Create update groups with M(cisco.nd.nd_fabric_update_group). + type: list + elements: str + required: true + wait: + description: + - Whether to wait for staging to complete on every switch before returning. + - When V(true), the module polls Nexus Dashboard until every switch has staged and validated, or until O(wait_timeout) is reached. + - When V(false), the module does not wait for staging to complete; it records one post-stage status snapshot (returned as the + task's after status) and returns while staging continues on Nexus Dashboard. + type: bool + default: true + wait_timeout: + description: + - The maximum time, in seconds, to wait for staging to complete when O(wait=true). + - The task fails if staging has not completed for every switch within this time. + type: int + default: 1800 + wait_interval: + description: + - The interval, in seconds, between staging-status polls when O(wait=true). + - Keep this comfortably below the persistent connection idle timeout (Ansible's + C(persistent_command_timeout), default V(30)). If the module is idle between polls for + longer than that timeout, the persistent connection is closed and the task fails. + type: int + default: 10 + state: + description: + - The desired state. + - Use O(state=merged) to prepare the requested update groups. + - Only V(merged) is currently supported. A V(gathered) state will be added in a future release. + type: str + default: merged + choices: [ merged ] +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module is only supported on Nexus Dashboard 4.2.1 or higher. +- The target image, whether the upgrade is disruptive or non-disruptive, maintenance mode, and the report checks + are properties of the update group itself, not of this module. Configure them with M(cisco.nd.nd_fabric_update_group) + before preparing. This module only selects which update groups to prepare. +- In check mode, if staging is required, the module reports C(changed=true) but does not invoke the stage action, + because it cannot be previewed. Consequently, the returned after status is unchanged from before. +- When O(wait=true), the module holds the persistent connection for the whole staging wait. Keep O(wait_interval) + below the persistent connection idle timeout (C(persistent_command_timeout)), and for long O(wait_timeout) + values raise C(ansible_command_timeout) for the host so the connection is not reaped mid-wait. +""" + +EXAMPLES = r""" +- name: Prepare a single update group and wait for staging to complete + cisco.nd.nd_fabric_prepare_update: + fabric_name: SITE1 + update_groups: + - SITE1_N9K_leaf + state: merged + +- name: Prepare multiple update groups with a longer timeout + cisco.nd.nd_fabric_prepare_update: + fabric_name: SITE1 + update_groups: + - SITE1_N9K_leaf + - SITE1_N9K_spine + wait: true + wait_timeout: 3600 + wait_interval: 20 + state: merged + +- name: Start preparing an update group without waiting for completion + cisco.nd.nd_fabric_prepare_update: + fabric_name: SITE1 + update_groups: + - SITE1_N9K_leaf + wait: false + 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.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.fabric_prepare_update import FabricPrepareUpdateOrchestrator +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 _validate_update_groups(module: AnsibleModule, output: NDOutput) -> None: + """ + # Summary + + Fail if `update_groups` is empty. Ansible's `required=True` only guarantees the key is present, + not that the list is non-empty; an empty list would silently prepare nothing, so enforce at + least one update group name here. + + ## Raises + + None + """ + if not module.params["update_groups"]: + module.fail_json(msg="update_groups must contain at least one update group name.", **output.format()) + + +def _run_prepare(module: AnsibleModule) -> tuple[Results | None, dict]: + """ + # Summary + + Run the fabric prepare-update (stage) workflow: pre-flight role check, idempotency check, + stage action, and - when `wait` is set - poll until staging completes. `changed` is derived + directly from whether staging was required (the stage action's effect is deterministic, so no + before/after diff is needed). In check mode the stage action is skipped (it cannot be + previewed) but a required change is still reported. + + Returns the orchestrator's `Results` instance (which records every REST call for verbosity-gated + `-vv` / `-vvv` output) together with the config-level `changed` / `before` / `after` fields, so + `main` can render them with `NDOutput.format_with_verbosity`. + + ## Raises + + ### Exception + + - Propagated from the orchestrator if the pre-flight check fails, a request fails, a switch + reports a staging failure, or the wait times out. + """ + 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 = FabricPrepareUpdateOrchestrator(rest_send=rest_send, results=Results()) + update_groups = module.params["update_groups"] + + # Pre-flight: ND will not prepare an update group that spans more than one switch role. + # Fetch the summary once and reuse it for both the role check and the `before` snapshot, + # so startup costs a single GET instead of two. Scope the read to the requested group when a + # single group is prepared (the common case); multi-group prepares fetch the fabric-wide plan. + summary = orchestrator.get_summary(update_group_name=orchestrator.scope_for(update_groups)) + orchestrator.preflight_role_check(update_groups, summary=summary) + + before = orchestrator.status_snapshot(update_groups, summary=summary) + + if FabricPrepareUpdateOrchestrator.snapshot_fully_prepared(before): + # Every switch is already staged and validated for the update group's configured image. + return orchestrator.results, {"changed": False, "before": before, "after": before} + + if module.check_mode: + # The stage action cannot be previewed; report the pending change without acting. + return orchestrator.results, {"changed": True, "before": before, "after": before} + + orchestrator.stage(update_groups) + + # When waiting, reuse the final poll summary for the `after` snapshot so completion does not + # cost an extra GET. When not waiting, no summary is available yet, so status_snapshot fetches + # the just-started staging status fresh. + final_summary = None + if module.params["wait"]: + final_summary = orchestrator.wait_for_completion( + update_groups, + timeout=module.params["wait_timeout"], + interval=module.params["wait_interval"], + ) + + after = orchestrator.status_snapshot(update_groups, summary=final_summary) + return orchestrator.results, {"changed": True, "before": before, "after": after} + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update( + fabric_name=dict(type="str", required=True), + update_groups=dict(type="list", elements="str", required=True), + wait=dict(type="bool", default=True), + wait_timeout=dict(type="int", default=1800), + wait_interval=dict(type="int", default=10), + state=dict(type="str", default="merged", choices=["merged"]), + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + require_pydantic(module) + + output = NDOutput(output_level=module.params.get("output_level", "normal")) + _validate_update_groups(module, output) + + try: + results, output_fields = _run_prepare(module) + verbosity = module._verbosity if hasattr(module, "_verbosity") else 0 + module.exit_json(**output.format_with_verbosity(verbosity, results, **output_fields)) + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}", **output.format()) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_fabric_prepare_update/meta/main.yml b/tests/integration/targets/nd_fabric_prepare_update/meta/main.yml new file mode 100644 index 000000000..23d65c7ef --- /dev/null +++ b/tests/integration/targets/nd_fabric_prepare_update/meta/main.yml @@ -0,0 +1,2 @@ +--- +dependencies: [] diff --git a/tests/integration/targets/nd_fabric_prepare_update/tasks/cleanup.yaml b/tests/integration/targets/nd_fabric_prepare_update/tasks/cleanup.yaml new file mode 100644 index 000000000..354aa5583 --- /dev/null +++ b/tests/integration/targets/nd_fabric_prepare_update/tasks/cleanup.yaml @@ -0,0 +1,16 @@ +--- +# Post-test cleanup for nd_fabric_prepare_update. +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# +# Remove the update groups created by setup.yaml. Runs from an `always` block so the groups are +# cleaned up even when a test fails. + +- name: "CLEANUP: Remove the update groups created by these tests" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: "{{ test_leaf_group_name }}" + - update_group_name: "{{ test_mixed_group_name }}" + state: deleted + failed_when: false diff --git a/tests/integration/targets/nd_fabric_prepare_update/tasks/main.yaml b/tests/integration/targets/nd_fabric_prepare_update/tasks/main.yaml new file mode 100644 index 000000000..f3a753d53 --- /dev/null +++ b/tests/integration/targets/nd_fabric_prepare_update/tasks/main.yaml @@ -0,0 +1,49 @@ +--- +# Test code for the nd_fabric_prepare_update 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_prepare_update +# +# NOTE: the prepare (stage) step runs against real switches and can take 10 or more minutes. +# +# Override test variables in tests/integration/inventory.networking [nd:vars]: +# nd_test_fabric_name - fabric to use (default: SITE1) +# nd_test_prepare_leaf_switch_1 - IP or serial of a leaf switch +# nd_test_prepare_leaf_switch_2 - IP or serial of a second leaf switch +# nd_test_prepare_spine_switch - IP or serial of a spine switch (mixed-role test) +# nd_test_nos_image_name - NXOS image filename in the ND software repository + +- 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_prepare_update tests + block: + - name: Pre-test setup + ansible.builtin.include_tasks: setup.yaml + + - name: Run prepare (stage) tests + ansible.builtin.include_tasks: prepare.yaml + always: + - name: Post-test cleanup + ansible.builtin.include_tasks: cleanup.yaml + module_defaults: + # nd_fabric_prepare_update holds the httpapi connection for the whole staging wait. The ND + # httpapi plugin maps `timeout` to persistent_command_timeout, so it must exceed the module's + # total run time (>= wait_timeout) or the persistent connection is torn down mid-wait. + cisco.nd.nd_fabric_prepare_update: + timeout: 3600 + cisco.nd.nd_fabric_update_group: + timeout: 300 diff --git a/tests/integration/targets/nd_fabric_prepare_update/tasks/prepare.yaml b/tests/integration/targets/nd_fabric_prepare_update/tasks/prepare.yaml new file mode 100644 index 000000000..57e1bd792 --- /dev/null +++ b/tests/integration/targets/nd_fabric_prepare_update/tasks/prepare.yaml @@ -0,0 +1,74 @@ +--- +# Prepare (stage) tests for nd_fabric_prepare_update +# Copyright: (c) 2026, Allen Robel (@allenrobel) + +# --- PRE-FLIGHT: a mixed-role update group is rejected before any staging --- + +- name: "PREFLIGHT: Prepare a mixed-role update group (must fail)" + cisco.nd.nd_fabric_prepare_update: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + update_groups: + - "{{ test_mixed_group_name }}" + state: merged + register: nm_preflight_mixed + ignore_errors: true + +- name: "PREFLIGHT: Verify the mixed-role group was rejected" + ansible.builtin.assert: + that: + - nm_preflight_mixed is failed + - "'mix of switch roles' in nm_preflight_mixed.msg" + +# --- CHECK MODE: a required prepare reports changed without staging --- + +- name: "PREPARE CHECK: Prepare the leaf group (check mode)" + cisco.nd.nd_fabric_prepare_update: &prepare_leaf + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + update_groups: + - "{{ test_leaf_group_name }}" + wait: true + wait_timeout: 1800 + # Poll well inside the persistent-connection idle window (default 30s) so the + # ansible-connection daemon is never idle long enough to reap itself mid-wait. + wait_interval: 10 + state: merged + check_mode: true + register: cm_prepare_leaf + +- name: "PREPARE CHECK: Verify a change is reported in check mode" + ansible.builtin.assert: + that: + - cm_prepare_leaf is changed + +# --- PREPARE: stage and validate the leaf group (this can take 10+ minutes) --- + +- name: "PREPARE: Stage and validate the leaf group (normal mode)" + cisco.nd.nd_fabric_prepare_update: *prepare_leaf + register: nm_prepare_leaf + +- name: "PREPARE: Verify every switch staged and validated" + ansible.builtin.assert: + that: + - nm_prepare_leaf is changed + - nm_prepare_leaf.after | length == 1 + - nm_prepare_leaf.after[0].switches | rejectattr('image_staged_status', 'in', ['success', 'skipped']) | list | length == 0 + - nm_prepare_leaf.after[0].switches | rejectattr('image_validated_status', 'in', ['success', 'skipped']) | list | length == 0 + +# --- IDEMPOTENCY: re-preparing an already-staged group reports no change --- + +- name: "PREPARE IDEMPOTENT: Re-prepare the leaf group (check mode)" + cisco.nd.nd_fabric_prepare_update: *prepare_leaf + check_mode: true + register: cm_prepare_idem + +- name: "PREPARE IDEMPOTENT: Re-prepare the leaf group (normal mode)" + cisco.nd.nd_fabric_prepare_update: *prepare_leaf + register: nm_prepare_idem + +- name: "PREPARE IDEMPOTENT: Verify no change on re-prepare" + ansible.builtin.assert: + that: + - cm_prepare_idem is not changed + - nm_prepare_idem is not changed diff --git a/tests/integration/targets/nd_fabric_prepare_update/tasks/setup.yaml b/tests/integration/targets/nd_fabric_prepare_update/tasks/setup.yaml new file mode 100644 index 000000000..1accd365e --- /dev/null +++ b/tests/integration/targets/nd_fabric_prepare_update/tasks/setup.yaml @@ -0,0 +1,32 @@ +--- +# Pre-test setup for nd_fabric_prepare_update. +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# +# Remove any update groups left over from a prior run, then create the single-role (leaf) and +# mixed-role (leaf + spine) update groups the prepare tests operate on. + +- name: "SETUP: Remove leftover update groups if present" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - update_group_name: "{{ test_leaf_group_name }}" + - update_group_name: "{{ test_mixed_group_name }}" + state: deleted + failed_when: false + +- name: "SETUP: Create the single-role (leaf) update group" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ prepare_leaf_group }}" + state: merged + +- name: "SETUP: Create the mixed-role (leaf + spine) update group" + cisco.nd.nd_fabric_update_group: + output_level: "{{ nd_info.output_level }}" + fabric_name: "{{ test_fabric_name }}" + config: + - "{{ prepare_mixed_group }}" + state: merged diff --git a/tests/integration/targets/nd_fabric_prepare_update/vars/main.yaml b/tests/integration/targets/nd_fabric_prepare_update/vars/main.yaml new file mode 100644 index 000000000..e4cdd5e30 --- /dev/null +++ b/tests/integration/targets/nd_fabric_prepare_update/vars/main.yaml @@ -0,0 +1,61 @@ +--- +# Variables for nd_fabric_prepare_update integration tests. +# +# These tests PREPARE (stage and validate) real switches. Staging copies an image to switch +# bootflash and can take 10 or more minutes to complete. The target update groups are created +# here via cisco.nd.nd_fabric_update_group and removed during cleanup. +# +# A single update group must contain switches of only one role: nd_fabric_prepare_update fails +# the pre-flight check for a mixed-role group, so the tests build one single-role (leaf) group +# and one mixed-role (leaf + spine) group to exercise both paths. +# +# 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 the update groups +# nd_test_prepare_leaf_switch_1 - IP or serial of a leaf switch +# nd_test_prepare_leaf_switch_2 - IP or serial of a second leaf switch +# nd_test_prepare_spine_switch - IP or serial of a spine switch (for the mixed-role test) +# nd_test_nos_image_name - filename of an NXOS image present in the ND software repository + +test_fabric_name: "{{ nd_test_fabric_name | default('SITE1') }}" +test_leaf_switch_1: "{{ nd_test_prepare_leaf_switch_1 | default('192.168.12.151') }}" +test_leaf_switch_2: "{{ nd_test_prepare_leaf_switch_2 | default('192.168.12.155') }}" +test_spine_switch: "{{ nd_test_prepare_spine_switch | default('192.168.12.171') }}" +test_nos_image_name: "{{ nd_test_nos_image_name | default('nxos64-cs.10.3.8.M.bin') }}" + +# Names of the update groups created and prepared by these tests. +test_leaf_group_name: ansible_prepare_leaf +test_mixed_group_name: ansible_prepare_mixed + +# Update group configs, created via cisco.nd.nd_fabric_update_group in setup.yaml. Each carries +# an install image so the group is preparable. +prepare_leaf_group: + update_group_name: "{{ test_leaf_group_name }}" + execution: parallel + contingency: continue + analysis: noAnalysis + is_maintenance: false + is_disruptive_update: false + update_group_switches: + - "{{ test_leaf_switch_1 }}" + - "{{ test_leaf_switch_2 }}" + force_created: true + install_image_data: + nos_image_name: "{{ test_nos_image_name }}" + report_selection: basic + reports: noReport + +prepare_mixed_group: + update_group_name: "{{ test_mixed_group_name }}" + execution: parallel + contingency: continue + analysis: noAnalysis + is_maintenance: false + is_disruptive_update: false + update_group_switches: + - "{{ test_leaf_switch_1 }}" + - "{{ test_spine_switch }}" + force_created: true + install_image_data: + nos_image_name: "{{ test_nos_image_name }}" + report_selection: basic + reports: noReport diff --git a/tests/unit/module_utils/endpoints/test_software_update_plan_summary.py b/tests/unit/module_utils/endpoints/test_software_update_plan_summary.py new file mode 100644 index 000000000..e36f59f5e --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_software_update_plan_summary.py @@ -0,0 +1,165 @@ +# -*- 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 software_update_plan_summary.py + +Tests the ND Manage Fabric Software Management software update plan summary endpoint, including the +optional `updateGroupName` query parameter used to scope a poll to a single update group. +""" + +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 +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.software_update_plan_summary import ( + EpFabricSoftwareUpdatePlanSummary, + SoftwareUpdatePlanSummaryEndpointParams, +) +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 + + +def test_ep_software_update_plan_summary_00010(): + """ + # Summary + + Verify EpFabricSoftwareUpdatePlanSummary basic instantiation. + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + - fabric_name defaults to None + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanSummary.__init__() + - EpFabricSoftwareUpdatePlanSummary.verb + - EpFabricSoftwareUpdatePlanSummary.class_name + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanSummary() + assert instance.class_name == "EpFabricSoftwareUpdatePlanSummary" + assert instance.verb == HttpVerbEnum.GET + assert instance.fabric_name is None + + +def test_ep_software_update_plan_summary_00020(): + """ + # Summary + + Verify path raises ValueError when fabric_name is None. + + ## Test + + - fabric_name is not set + - Accessing path raises ValueError + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanSummary.path + """ + instance = EpFabricSoftwareUpdatePlanSummary() + with pytest.raises(ValueError, match="fabric_name must be set"): + result = instance.path # pylint: disable=unused-variable + + +def test_ep_software_update_plan_summary_00030(): + """ + # Summary + + Verify path returns the fabric-wide summary URL with no query string when no update group is set. + + ## Test + + - fabric_name is set, update_group_name is unset + - path returns /api/v1/manage/fabrics/SITE1/softwareUpdatePlan/summary (no `?`) + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanSummary.path + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanSummary() + instance.fabric_name = "SITE1" + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/summary" + + +def test_ep_software_update_plan_summary_00040(): + """ + # Summary + + Verify fabric_name is percent-encoded in the summary path. + + ## Test + + - fabric_name = "fab/odd" + - path encodes the slash + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanSummary.path + """ + instance = EpFabricSoftwareUpdatePlanSummary() + instance.fabric_name = "fab/odd" + assert instance.path == "/api/v1/manage/fabrics/fab%2Fodd/softwareUpdatePlan/summary" + + +def test_ep_software_update_plan_summary_00050(): + """ + # Summary + + Verify setting `update_group_name` scopes the summary path with the `updateGroupName` query + parameter (snake_case -> camelCase conversion is automatic). + + ## Test + + - fabric_name and endpoint_params.update_group_name are set + - path appends `?updateGroupName=` + + ## Classes and Methods + + - EpFabricSoftwareUpdatePlanSummary.path + - SoftwareUpdatePlanSummaryEndpointParams + """ + with does_not_raise(): + instance = EpFabricSoftwareUpdatePlanSummary() + instance.fabric_name = "SITE1" + instance.endpoint_params.update_group_name = "SITE1_N9K_leaf" + result = instance.path + assert result == "/api/v1/manage/fabrics/SITE1/softwareUpdatePlan/summary?updateGroupName=SITE1_N9K_leaf" + + +def test_ep_software_update_plan_summary_00060(): + """ + # Summary + + Verify an empty `update_group_name` is rejected by the endpoint-params model (`min_length=1`), + so a blank scope cannot silently render an `updateGroupName=` query string. + + ## Test + + - Constructing the params model with update_group_name="" raises ValueError + + ## Classes and Methods + + - SoftwareUpdatePlanSummaryEndpointParams + """ + with pytest.raises(ValueError): + SoftwareUpdatePlanSummaryEndpointParams(update_group_name="") diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_fabric_prepare_update.json b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_prepare_update.json new file mode 100644 index 000000000..a9e0ff961 --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_fabric_prepare_update.json @@ -0,0 +1,359 @@ +{ + "TEST_NOTES": [ + "Fixture data for test_fabric_prepare_update.py (orchestrator).", + "Keys follow the test__ convention from CLAUDE.md.", + "Fabric scope for all tests: fabric_1.", + "FabricPrepareUpdateOrchestrator drives two endpoints:", + " GET /api/v1/manage/fabrics/{fabric}/softwareUpdatePlan/summary -> {updateGroups: [...]}", + " POST /api/v1/manage/fabrics/{fabric}/softwareUpdatePlan/actions/stage -> 202 (empty body)", + "Per-switch imageStagedStatus / imageValidatedStatus enum: none|inProgress|success|failed|skipped.", + "Summary bodies are modeled on a live ND 4.2.1 softwareUpdatePlan/summary capture." + ], + + "test_fabric_prepare_update_00100a": { + "TEST_NOTES": ["preflight_role_check happy path: single-role (leaf) update group"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "none", + "stageValidatePercentage": 0, + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "none", "imageValidatedStatus": "none", "switchStageValidatePercentage": 0, "selectedVersion": "10.3.8"}, + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "imageStagedStatus": "none", "imageValidatedStatus": "none", "switchStageValidatePercentage": 0, "selectedVersion": "10.3.8"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00110a": { + "TEST_NOTES": ["preflight_role_check mixed-role: update group spans leaf + spine"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_mixed", + "updateGroupStatus": "none", + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "none", "imageValidatedStatus": "none"}, + {"switchId": "FDO3", "switchName": "spine-1", "switchRole": "spine", "imageStagedStatus": "none", "imageValidatedStatus": "none"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00120a": { + "TEST_NOTES": ["preflight_role_check group not found: requested name absent from the summary"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "other_group", + "updateGroupSwitches": [ + {"switchId": "FDO9", "switchName": "leaf-9", "switchRole": "leaf", "imageStagedStatus": "none", "imageValidatedStatus": "none"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00130a": { + "TEST_NOTES": ["get_summary transport failure: controller returns 500"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + + "test_fabric_prepare_update_00200a": { + "TEST_NOTES": ["status_snapshot: fully staged + validated leaf group (also exercises snapshot_fully_prepared)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "stageSuccess", + "stageValidatePercentage": 100, + "updateGroupSwitches": [ + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "switchManagementIP": "192.168.12.155", "imageStagedStatus": "success", "imageValidatedStatus": "success", "switchStageValidatePercentage": 100, "selectedVersion": "10.3.8"}, + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "switchManagementIP": "192.168.12.151", "imageStagedStatus": "success", "imageValidatedStatus": "success", "switchStageValidatePercentage": 100, "selectedVersion": "10.3.8"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00210a": { + "TEST_NOTES": ["status_snapshot group not found: requested name absent from the summary"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": {"updateGroups": []} + }, + + "test_fabric_prepare_update_00220a": { + "TEST_NOTES": ["preflight_role_check + status_snapshot summary reuse: single-role (leaf) update group"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "none", + "stageValidatePercentage": 0, + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "none", "imageValidatedStatus": "none", "switchStageValidatePercentage": 0, "selectedVersion": "10.3.8"}, + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "imageStagedStatus": "none", "imageValidatedStatus": "none", "switchStageValidatePercentage": 0, "selectedVersion": "10.3.8"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00400a": { + "TEST_NOTES": ["stage happy path: actions/stage POST returns 202 with an empty body"], + "RETURN_CODE": 202, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/stage", + "MESSAGE": "Accepted", + "DATA": {} + }, + + "test_fabric_prepare_update_00410a": { + "TEST_NOTES": ["stage transport failure: controller returns 500"], + "RETURN_CODE": 500, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/stage", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + + "test_fabric_prepare_update_00500a": { + "TEST_NOTES": ["wait_for_completion poll 1: staging in progress"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "inProgress", + "stageValidatePercentage": 40, + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "inProgress", "imageValidatedStatus": "none"}, + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "imageStagedStatus": "inProgress", "imageValidatedStatus": "none"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00500b": { + "TEST_NOTES": ["wait_for_completion poll 2: staging complete (staged + validated)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "stageSuccess", + "stageValidatePercentage": 100, + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "success", "imageValidatedStatus": "success"}, + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "imageStagedStatus": "success", "imageValidatedStatus": "success"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00510a": { + "TEST_NOTES": ["wait_for_completion: one switch reports a staging failure"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "stageFailed", + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "success", "imageValidatedStatus": "success"}, + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "imageStagedStatus": "failed", "imageValidatedStatus": "none"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00520a": { + "TEST_NOTES": ["wait_for_completion timeout: staging still in progress when the deadline passes"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "inProgress", + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "inProgress", "imageValidatedStatus": "none"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00530a": { + "TEST_NOTES": ["wait_for_completion poll 1: transient transport failure (500) - must be retried"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + "test_fabric_prepare_update_00530b": { + "TEST_NOTES": ["wait_for_completion poll 2: poll recovers, staging in progress"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "inProgress", + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "inProgress", "imageValidatedStatus": "none"} + ] + } + ] + } + }, + "test_fabric_prepare_update_00530c": { + "TEST_NOTES": ["wait_for_completion poll 3: staging complete"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "stageSuccess", + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "success", "imageValidatedStatus": "success"} + ] + } + ] + } + }, + + "test_fabric_prepare_update_00540a": { + "TEST_NOTES": ["wait_for_completion: sustained poll failure 1 of 4 (500)"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + "test_fabric_prepare_update_00540b": { + "TEST_NOTES": ["wait_for_completion: sustained poll failure 2 of 4 (500)"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + "test_fabric_prepare_update_00540c": { + "TEST_NOTES": ["wait_for_completion: sustained poll failure 3 of 4 (500)"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + "test_fabric_prepare_update_00540d": { + "TEST_NOTES": ["wait_for_completion: sustained poll failure 4 of 4 (500) - exceeds the retry budget"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + + "test_fabric_prepare_update_00550a": { + "TEST_NOTES": ["wait_for_completion: requested group resolves but has no member switches"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + {"updateGroupName": "prep_leaf", "updateGroupStatus": "none", "stageValidatePercentage": 0, "updateGroupSwitches": []} + ] + } + }, + + "test_fabric_prepare_update_00560a": { + "TEST_NOTES": ["wait_for_completion: single poll failure (500) with the deadline already passed - timeout wins over the retry budget"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "Internal Server Error", + "DATA": {"message": "Internal Server Error"} + }, + + "test_fabric_prepare_update_00570a": { + "TEST_NOTES": ["wait_for_completion: poll 1 returns a body that does not yet resolve the requested group (retryable)"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": {"updateGroups": []} + }, + "test_fabric_prepare_update_00570b": { + "TEST_NOTES": ["wait_for_completion: poll 2 resolves the group and reports staging complete"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary", + "MESSAGE": "OK", + "DATA": { + "updateGroups": [ + { + "updateGroupName": "prep_leaf", + "updateGroupStatus": "stageSuccess", + "stageValidatePercentage": 100, + "updateGroupSwitches": [ + {"switchId": "FDO1", "switchName": "leaf-1", "switchRole": "leaf", "imageStagedStatus": "success", "imageValidatedStatus": "success"}, + {"switchId": "FDO2", "switchName": "leaf-2", "switchRole": "leaf", "imageStagedStatus": "success", "imageValidatedStatus": "success"} + ] + } + ] + } + } +} diff --git a/tests/unit/module_utils/models/test_fabric_prepare_update.py b/tests/unit/module_utils/models/test_fabric_prepare_update.py new file mode 100644 index 000000000..437a17a9d --- /dev/null +++ b/tests/unit/module_utils/models/test_fabric_prepare_update.py @@ -0,0 +1,195 @@ +# -*- 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 `softwareUpdatePlan/summary` response models. + +Tests: +- Parsing a full summary response (modeled on a live ND 4.2.1 capture) +- Field aliasing (camelCase wire keys -> snake_case attributes) +- Nested `UpdateGroupStatusModel`, `SwitchStageStatusModel`, `UpdateGroupWarningModel` +- Tolerance of missing optional fields and unknown wire keys +""" + +# pylint: disable=disallowed-name,protected-access,redefined-outer-name,line-too-long,invalid-name + +from __future__ import annotations + +from ansible_collections.cisco.nd.plugins.module_utils.models.fabric_prepare_update.software_update_plan_summary import ( + SoftwareUpdatePlanSummaryModel, + SwitchStageStatusModel, + UpdateGroupStatusModel, + UpdateGroupWarningModel, +) + +# ============================================================================= +# Test data - modeled on a live ND 4.2.1 softwareUpdatePlan/summary response +# ============================================================================= + +SAMPLE_SUMMARY_RESPONSE = { + "softwareUpdateSummary": {"fabricName": "SITE1", "updatePlanStatus": "Ready To Install", "totalSwitches": 5}, + "tableHeaders": {"executions": ["parallel", "serial"]}, + "updateGroups": [ + { + "updateGroupName": "SITE1_N9K_leaf", + "updateGroupStatus": "none", + "updateType": "none", + "stageValidatePercentage": 2, + "switchCount": 2, + "warnings": [{"message": "Upgrading the selected switches would impact all [leaf] from the fabric [SITE1].", "switchName": "SITE1_N9K_leaf"}], + "updateGroupSwitches": [ + { + "switchId": "9ASNKH8T9DJ", + "switchName": "S1_LE1", + "switchRole": "leaf", + "switchManagementIP": "192.168.12.151", + "switchVersion": "10.6(2)", + "selectedVersion": "10.3.8", + "imageStagedStatus": "none", + "imageValidatedStatus": "none", + "switchStageValidatePercentage": 0, + }, + { + "switchId": "9SJKCSQND07", + "switchName": "S1_LE2", + "switchRole": "leaf", + "switchManagementIP": "192.168.12.155", + "switchVersion": "10.6(2)", + "selectedVersion": "10.3.8", + "imageStagedStatus": "inProgress", + "imageValidatedStatus": "none", + "switchStageValidatePercentage": 35, + }, + ], + } + ], +} + + +# ============================================================================= +# Test: SoftwareUpdatePlanSummaryModel +# ============================================================================= + + +def test_fabric_prepare_update_00010() -> None: + """ + # Summary + + Verify a full summary response parses into one update group with two member switches, and that + the unmodeled `softwareUpdateSummary` / `tableHeaders` blocks are ignored. + + ## Classes and Methods + + - SoftwareUpdatePlanSummaryModel.from_response() + """ + summary = SoftwareUpdatePlanSummaryModel.from_response(SAMPLE_SUMMARY_RESPONSE) + + assert isinstance(summary, SoftwareUpdatePlanSummaryModel) + assert summary.update_groups is not None + assert len(summary.update_groups) == 1 + + group = summary.update_groups[0] + assert isinstance(group, UpdateGroupStatusModel) + assert group.update_group_name == "SITE1_N9K_leaf" + assert group.update_group_status == "none" + assert group.update_type == "none" + assert group.stage_validate_percentage == 2 + assert group.switch_count == 2 + assert group.update_group_switches is not None + assert len(group.update_group_switches) == 2 + + assert not hasattr(summary, "software_update_summary") + assert not hasattr(summary, "table_headers") + + +def test_fabric_prepare_update_00020() -> None: + """ + # Summary + + Verify per-switch camelCase wire keys map onto the snake_case `SwitchStageStatusModel` fields. + + ## Classes and Methods + + - SwitchStageStatusModel + """ + summary = SoftwareUpdatePlanSummaryModel.from_response(SAMPLE_SUMMARY_RESPONSE) + switch = summary.update_groups[0].update_group_switches[0] + + assert isinstance(switch, SwitchStageStatusModel) + assert switch.switch_id == "9ASNKH8T9DJ" + assert switch.switch_name == "S1_LE1" + assert switch.switch_role == "leaf" + assert switch.switch_management_ip == "192.168.12.151" + assert switch.switch_version == "10.6(2)" + assert switch.selected_version == "10.3.8" + assert switch.image_staged_status == "none" + assert switch.image_validated_status == "none" + assert switch.switch_stage_validate_percentage == 0 + + in_progress = summary.update_groups[0].update_group_switches[1] + assert in_progress.image_staged_status == "inProgress" + assert in_progress.switch_stage_validate_percentage == 35 + + +def test_fabric_prepare_update_00030() -> None: + """ + # Summary + + Verify per-group advisory warnings parse into `UpdateGroupWarningModel` items. + + ## Classes and Methods + + - UpdateGroupWarningModel + """ + summary = SoftwareUpdatePlanSummaryModel.from_response(SAMPLE_SUMMARY_RESPONSE) + warnings = summary.update_groups[0].warnings + + assert warnings is not None + assert len(warnings) == 1 + assert isinstance(warnings[0], UpdateGroupWarningModel) + assert warnings[0].switch_name == "SITE1_N9K_leaf" + assert warnings[0].message is not None + assert "would impact all [leaf]" in warnings[0].message + + +def test_fabric_prepare_update_00040() -> None: + """ + # Summary + + Verify an empty response yields a model with `update_groups` defaulted to an empty list, so a + response missing the key parses the same as an explicitly empty plan. + + ## Classes and Methods + + - SoftwareUpdatePlanSummaryModel.from_response() + """ + summary = SoftwareUpdatePlanSummaryModel.from_response({}) + + assert summary.update_groups == [] + + +def test_fabric_prepare_update_00050() -> None: + """ + # Summary + + Verify a sparse update group (only `updateGroupName`) parses with missing scalar fields as + None and missing list fields as empty lists, and that unknown wire keys are ignored rather + than raising. + + ## Classes and Methods + + - UpdateGroupStatusModel + """ + summary = SoftwareUpdatePlanSummaryModel.from_response({"updateGroups": [{"updateGroupName": "sparse_group", "someFutureKey": "ignored"}]}) + + assert summary.update_groups is not None + group = summary.update_groups[0] + assert group.update_group_name == "sparse_group" + assert group.update_group_status is None + assert group.stage_validate_percentage is None + assert group.update_group_switches == [] + assert group.warnings == [] + assert not hasattr(group, "some_future_key") diff --git a/tests/unit/module_utils/orchestrators/test_fabric_prepare_update.py b/tests/unit/module_utils/orchestrators/test_fabric_prepare_update.py new file mode 100644 index 000000000..81b354908 --- /dev/null +++ b/tests/unit/module_utils/orchestrators/test_fabric_prepare_update.py @@ -0,0 +1,716 @@ +# -*- 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 `FabricPrepareUpdateOrchestrator`. + +Verifies the orchestrator drives `RestSend` against the ND software-update "prepare" workflow: +`preflight_role_check` rejects a mixed-role update group, `status_snapshot` summarizes per-switch +stage / validate status, `stage` POSTs the stage action, and `wait_for_completion` polls the plan +summary until staging succeeds, fails, or times out. +""" + +# pylint: disable=disallowed-name,protected-access,redefined-outer-name,too-many-lines +# 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_prepare_update.software_update_plan_summary import ( + SoftwareUpdatePlanSummaryModel, + SwitchStageStatusModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.fabric_prepare_update import ( + FabricPrepareUpdateOrchestrator, + _switch_has_failed, + _switch_is_prepared, +) +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_prepare_update(key: str): + """Load fixture data for test_fabric_prepare_update tests.""" + return load_fixture("test_fabric_prepare_update")[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 + + +# ============================================================================= +# Test: initialization +# ============================================================================= + + +def test_fabric_prepare_update_00010() -> None: + """ + # Summary + + Verify `FabricPrepareUpdateOrchestrator` instantiates with an injected `RestSend`. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.__init__() + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + + with does_not_raise(): + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + assert instance.results is None + + +def test_fabric_prepare_update_00020() -> None: + """ + # Summary + + Verify `fabric_name` is read from `rest_send.params`. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.fabric_name + """ + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses, fabric_name="SITE1") + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + assert instance.fabric_name == "SITE1" + + +# ============================================================================= +# Test: preflight_role_check +# ============================================================================= + + +def test_fabric_prepare_update_00100() -> None: + """ + # Summary + + Verify `preflight_role_check` passes for a single-role update group. + + ## Test + + - The summary reports `prep_leaf` with two `leaf` switches + - `preflight_role_check` does not raise + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.preflight_role_check() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + instance.preflight_role_check(["prep_leaf"]) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/summary" + assert rest_send.verb == HttpVerbEnum.GET.value + + +def test_fabric_prepare_update_00110() -> None: + """ + # Summary + + Verify `preflight_role_check` raises for an update group that spans more than one switch role. + + ## Test + + - The summary reports `prep_mixed` containing a `leaf` and a `spine` switch + - `preflight_role_check` raises `RuntimeError` naming both roles + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.preflight_role_check() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"prep_mixed.*mix of switch roles.*leaf, spine"): + instance.preflight_role_check(["prep_mixed"]) + + +def test_fabric_prepare_update_00120() -> None: + """ + # Summary + + Verify `preflight_role_check` raises when a requested update group is absent from the summary. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.preflight_role_check() + - FabricPrepareUpdateOrchestrator._resolve_groups() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"missing_group.*not found in the software update plan"): + instance.preflight_role_check(["missing_group"]) + + +def test_fabric_prepare_update_00130() -> None: + """ + # Summary + + Verify `get_summary` raises `RuntimeError` when the summary GET fails. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.get_summary() + - FabricPrepareUpdateOrchestrator._request() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Request failed"): + instance.get_summary() + + +# ============================================================================= +# Test: status_snapshot +# ============================================================================= + + +def test_fabric_prepare_update_00200() -> None: + """ + # Summary + + Verify `status_snapshot` returns a per-group / per-switch status structure with switches sorted + by name, and that `snapshot_fully_prepared` recognizes a fully staged + validated snapshot. + + ## Test + + - The summary reports `prep_leaf` fully staged and validated + - The snapshot carries one group with two switches, name-sorted + - `snapshot_fully_prepared` returns True for the snapshot + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.status_snapshot() + - FabricPrepareUpdateOrchestrator.snapshot_fully_prepared() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + snapshot = instance.status_snapshot(["prep_leaf"]) + + assert len(snapshot) == 1 + group = snapshot[0] + assert group["update_group_name"] == "prep_leaf" + assert group["stage_validate_percentage"] == 100 + assert [s["switch_name"] for s in group["switches"]] == ["leaf-1", "leaf-2"] + assert group["switches"][0]["image_staged_status"] == "success" + assert group["switches"][0]["image_validated_status"] == "success" + assert FabricPrepareUpdateOrchestrator.snapshot_fully_prepared(snapshot) is True + + +def test_fabric_prepare_update_00210() -> None: + """ + # Summary + + Verify `status_snapshot` raises when a requested update group is absent from the summary. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.status_snapshot() + - FabricPrepareUpdateOrchestrator._resolve_groups() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"prep_leaf.*not found in the software update plan"): + instance.status_snapshot(["prep_leaf"]) + + +def test_fabric_prepare_update_00220() -> None: + """ + # Summary + + Verify `preflight_role_check` and `status_snapshot` reuse a caller-supplied `summary` rather + than fetching it again, so the prepare-update startup costs a single summary GET. + + ## Test + + - The summary is fetched once via `get_summary` + - That summary object is passed to both `preflight_role_check` and `status_snapshot` + - Exactly one GET is issued across all three calls + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.get_summary() + - FabricPrepareUpdateOrchestrator.preflight_role_check() + - FabricPrepareUpdateOrchestrator.status_snapshot() + """ + method_name = inspect.stack()[0][3] + get_count = 0 + + def responses(): + nonlocal get_count + # Three identical summary responses are made available so a regression (a stray fetch in + # preflight_role_check or status_snapshot) succeeds instead of raising on an exhausted + # generator - the get_count assertion below is what catches the extra request. + get_count += 1 + yield responses_fabric_prepare_update(f"{method_name}a") + get_count += 1 + yield responses_fabric_prepare_update(f"{method_name}a") + get_count += 1 + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + summary = instance.get_summary() + instance.preflight_role_check(["prep_leaf"], summary=summary) + snapshot = instance.status_snapshot(["prep_leaf"], summary=summary) + + assert get_count == 1 + assert [group["update_group_name"] for group in snapshot] == ["prep_leaf"] + + +# ============================================================================= +# Test: snapshot_fully_prepared (pure) +# ============================================================================= + + +@pytest.mark.parametrize( + "snapshot, expected", + [ + ([{"switches": [{"image_staged_status": "success", "image_validated_status": "success"}]}], True), + ([{"switches": [{"image_staged_status": "skipped", "image_validated_status": "success"}]}], True), + ([{"switches": [{"image_staged_status": "success", "image_validated_status": "inProgress"}]}], False), + ([{"switches": [{"image_staged_status": "none", "image_validated_status": "none"}]}], False), + ( + [ + {"switches": [{"image_staged_status": "success", "image_validated_status": "success"}]}, + {"switches": [{"image_staged_status": "success", "image_validated_status": "failed"}]}, + ], + False, + ), + ([], True), + ([{"switches": []}], True), + ], + ids=["all-success", "skipped-ok", "validate-in-progress", "not-started", "one-group-failed", "empty-snapshot", "no-switches"], +) +def test_fabric_prepare_update_00300(snapshot: list, expected: bool) -> None: + """ + # Summary + + Verify `snapshot_fully_prepared` returns True only when every switch in every group has reached + a terminal-OK state for both the stage and validate phases. A switch-less or empty snapshot is + vacuously prepared (True), consistent with `wait_for_completion`. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.snapshot_fully_prepared() + """ + assert FabricPrepareUpdateOrchestrator.snapshot_fully_prepared(snapshot) is expected + + +@pytest.mark.parametrize( + "staged, validated, prepared, failed", + [ + ("success", "success", True, False), + ("skipped", "success", True, False), + ("inProgress", "none", False, False), + ("failed", "none", False, True), + ("success", "failed", False, True), + ("none", "none", False, False), + ], + ids=["both-success", "skipped-staged", "in-progress", "stage-failed", "validate-failed", "not-started"], +) +def test_fabric_prepare_update_00310(staged: str, validated: str, prepared: bool, failed: bool) -> None: + """ + # Summary + + Verify the `_switch_is_prepared` and `_switch_has_failed` per-switch status predicates. + + ## Classes and Methods + + - _switch_is_prepared() + - _switch_has_failed() + """ + switch = SwitchStageStatusModel(image_staged_status=staged, image_validated_status=validated) + assert _switch_is_prepared(switch) is prepared + assert _switch_has_failed(switch) is failed + + +# ============================================================================= +# Test: stage +# ============================================================================= + + +def test_fabric_prepare_update_00400() -> None: + """ + # Summary + + Verify `stage` POSTs the stage action with the requested update group names. + + ## Test + + - `stage` is called with one update group name + - The request is a POST to `.../softwareUpdatePlan/actions/stage` + - The request body is `{"updateGroupNames": ["prep_leaf"]}` + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.stage() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + instance.stage(["prep_leaf"]) + + assert rest_send.path == "/api/v1/manage/fabrics/fabric_1/softwareUpdatePlan/actions/stage" + assert rest_send.verb == HttpVerbEnum.POST.value + assert rest_send.committed_payload == {"updateGroupNames": ["prep_leaf"]} + + +def test_fabric_prepare_update_00410() -> None: + """ + # Summary + + Verify `stage` wraps a transport failure in `RuntimeError` naming the fabric. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.stage() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Failed to stage update group\(s\).*fabric 'fabric_1'"): + instance.stage(["prep_leaf"]) + + +# ============================================================================= +# Test: wait_for_completion +# ============================================================================= + + +def test_fabric_prepare_update_00500() -> None: + """ + # Summary + + Verify `wait_for_completion` returns once every switch has staged and validated. + + ## Test + + - Poll 1 reports staging in progress; poll 2 reports staging complete + - `wait_for_completion` polls twice and returns the final summary without raising + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + yield responses_fabric_prepare_update(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + result = instance.wait_for_completion(["prep_leaf"], timeout=300, interval=0) + + # The final summary is returned so the caller can reuse it for the `after` snapshot + # instead of issuing another GET. + assert isinstance(result, SoftwareUpdatePlanSummaryModel) + assert [g.update_group_name for g in result.update_groups] == ["prep_leaf"] + + +def test_fabric_prepare_update_00510() -> None: + """ + # Summary + + Verify `wait_for_completion` raises `RuntimeError` when a switch reports a staging failure. + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Staging failed.*leaf-2=\[staged:failed"): + instance.wait_for_completion(["prep_leaf"], timeout=300, interval=0) + + +def test_fabric_prepare_update_00520() -> None: + """ + # Summary + + Verify `wait_for_completion` raises `RuntimeError` when staging does not complete within + `timeout` seconds. + + ## Test + + - The summary reports staging still in progress + - `timeout=0` forces the deadline to pass after the first poll + - `wait_for_completion` raises a timeout `RuntimeError` + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Timed out after 0s waiting for staging"): + instance.wait_for_completion(["prep_leaf"], timeout=0, interval=0) + + +def test_fabric_prepare_update_00530() -> None: + """ + # Summary + + Verify `wait_for_completion` retries a transient summary-poll failure rather than aborting. + + ## Test + + - Poll 1 fails with a transport error (HTTP 500) + - Poll 2 recovers and reports staging in progress + - Poll 3 reports staging complete + - `wait_for_completion` rides through the failure and returns without raising + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + yield responses_fabric_prepare_update(f"{method_name}b") + yield responses_fabric_prepare_update(f"{method_name}c") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + instance.wait_for_completion(["prep_leaf"], timeout=300, interval=0) + + +def test_fabric_prepare_update_00540() -> None: + """ + # Summary + + Verify `wait_for_completion` aborts when the summary poll fails more times in a row than the + retry budget allows. + + ## Test + + - Four consecutive summary polls fail with a transport error (HTTP 500) + - The fourth failure exceeds `_MAX_CONSECUTIVE_POLL_FAILURES` (3) + - `wait_for_completion` raises `RuntimeError` reporting the consecutive-failure count + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + yield responses_fabric_prepare_update(f"{method_name}b") + yield responses_fabric_prepare_update(f"{method_name}c") + yield responses_fabric_prepare_update(f"{method_name}d") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"failed 4 times in a row"): + instance.wait_for_completion(["prep_leaf"], timeout=300, interval=0) + + +def test_fabric_prepare_update_00550() -> None: + """ + # Summary + + Verify `wait_for_completion` returns immediately when a requested update group resolves but has + no member switches, instead of polling it until `timeout`. + + ## Test + + - The summary reports `prep_leaf` with an empty `updateGroupSwitches` list + - `wait_for_completion` returns on the first poll without raising, even with `timeout=0` + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + instance.wait_for_completion(["prep_leaf"], timeout=0, interval=0) + + +def test_fabric_prepare_update_00560() -> None: + """ + # Summary + + Verify `wait_for_completion` honors `timeout` ahead of the retry budget: when a poll fails after + the deadline has passed, it raises a timeout rather than a consecutive-failure error. + + ## Test + + - The first (and only) summary poll fails with a transport error (HTTP 500) + - `timeout=0` forces the deadline to pass before the failure is evaluated + - `wait_for_completion` raises a timeout `RuntimeError`, not a "failed N times in a row" error + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with pytest.raises(RuntimeError, match=r"Timed out after 0s.*last poll error"): + instance.wait_for_completion(["prep_leaf"], timeout=0, interval=0) + + +def test_fabric_prepare_update_00570() -> None: + """ + # Summary + + Verify `wait_for_completion` retries a poll whose summary does not yet resolve the requested + update group (a partial body during a controller hiccup) rather than aborting. + + ## Test + + - Poll 1 returns an empty `updateGroups` list, so the requested group does not resolve + - Poll 2 resolves the group and reports staging complete + - `wait_for_completion` rides through the unresolved poll and returns without raising + + ## Classes and Methods + + - FabricPrepareUpdateOrchestrator.wait_for_completion() + """ + method_name = inspect.stack()[0][3] + + def responses(): + yield responses_fabric_prepare_update(f"{method_name}a") + yield responses_fabric_prepare_update(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + rest_send = _build_rest_send(gen_responses) + instance = FabricPrepareUpdateOrchestrator(rest_send=rest_send) + + with does_not_raise(): + instance.wait_for_completion(["prep_leaf"], timeout=300, interval=0) diff --git a/tests/unit/modules/test_nd_fabric_prepare_update.py b/tests/unit/modules/test_nd_fabric_prepare_update.py new file mode 100644 index 000000000..1b1f132e5 --- /dev/null +++ b/tests/unit/modules/test_nd_fabric_prepare_update.py @@ -0,0 +1,227 @@ +# -*- 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_prepare_update` module wrapper. + +Covers the user-facing Ansible contract that the orchestrator/model tests do not exercise: the +`update_groups` non-empty guard (`_validate_update_groups`), and the `_run_prepare` decision surface +(idempotency short-circuit, check-mode handoff, and the wait / no-wait branches). Every orchestrator +method is monkeypatched 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,unused-argument + +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_prepare_update 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", "")) + + +def _prepare_params(update_groups: list[str], *, wait: bool = True, check_mode: bool = False) -> dict: + """Build a module params dict with the keys `_run_prepare` and the guard read.""" + return { + "fabric_name": "SITE1", + "update_groups": update_groups, + "wait": wait, + "wait_timeout": 1800, + "wait_interval": 10, + "state": "merged", + "check_mode": check_mode, + } + + +def _patch_orchestrator(monkeypatch, *, fully_prepared: bool, calls: dict) -> None: + """ + # Summary + + Monkeypatch every `FabricPrepareUpdateOrchestrator` method `_run_prepare` calls so the wrapper + logic runs without controller I/O. Records `stage` / `wait_for_completion` invocations in `calls`. + + ## Raises + + None + """ + calls.setdefault("stage", []) + calls.setdefault("wait", []) + + monkeypatch.setattr(mod.FabricPrepareUpdateOrchestrator, "get_summary", lambda self, update_group_name=None: {"summary": True}) + monkeypatch.setattr(mod.FabricPrepareUpdateOrchestrator, "preflight_role_check", lambda self, groups, summary=None: None) + monkeypatch.setattr(mod.FabricPrepareUpdateOrchestrator, "status_snapshot", lambda self, groups, summary=None: [{"update_group_name": g} for g in groups]) + monkeypatch.setattr(mod.FabricPrepareUpdateOrchestrator, "snapshot_fully_prepared", staticmethod(lambda snapshot: fully_prepared)) + monkeypatch.setattr(mod.FabricPrepareUpdateOrchestrator, "stage", lambda self, groups: calls["stage"].append(list(groups))) + monkeypatch.setattr( + mod.FabricPrepareUpdateOrchestrator, + "wait_for_completion", + lambda self, groups, timeout, interval: calls["wait"].append((list(groups), timeout, interval)) or {"summary": "final"}, + ) + + +# ============================================================================= +# Test: _validate_update_groups +# ============================================================================= + + +def test_nd_fabric_prepare_update_00100() -> None: + """ + # Summary + + Verify `_validate_update_groups` fails when `update_groups` is empty. Ansible's `required=True` + accepts an empty list, which would silently prepare nothing. + + ## Classes and Methods + + - nd_fabric_prepare_update._validate_update_groups() + """ + module = _FakeModule(params=_prepare_params([])) + output = NDOutput(output_level="normal") + + with pytest.raises(_FailJson, match=r"update_groups must contain at least one update group name"): + mod._validate_update_groups(module, output) + + assert module.fail_json_calls + + +def test_nd_fabric_prepare_update_00110() -> None: + """ + # Summary + + Verify `_validate_update_groups` passes for a non-empty `update_groups` list. + + ## Classes and Methods + + - nd_fabric_prepare_update._validate_update_groups() + """ + module = _FakeModule(params=_prepare_params(["SITE1_N9K_leaf"])) + output = NDOutput(output_level="normal") + + with does_not_raise(): + mod._validate_update_groups(module, output) + + assert not module.fail_json_calls + + +# ============================================================================= +# Test: _run_prepare decision surface +# ============================================================================= + + +def test_nd_fabric_prepare_update_00200(monkeypatch) -> None: + """ + # Summary + + Verify the idempotency short-circuit: when every switch is already staged and validated, + `_run_prepare` reports `changed=False` and never calls `stage` or `wait_for_completion`. + + ## Classes and Methods + + - nd_fabric_prepare_update._run_prepare() + """ + calls: dict = {} + _patch_orchestrator(monkeypatch, fully_prepared=True, calls=calls) + + module = _FakeModule(params=_prepare_params(["SITE1_N9K_leaf"])) + + with does_not_raise(): + _results, fields = mod._run_prepare(module) + + assert fields["changed"] is False + assert calls["stage"] == [] + assert calls["wait"] == [] + + +def test_nd_fabric_prepare_update_00210(monkeypatch) -> None: + """ + # Summary + + Verify check mode reports the pending change without acting: `changed=True`, but the stage + action (which cannot be previewed) is never sent. + + ## Classes and Methods + + - nd_fabric_prepare_update._run_prepare() + """ + calls: dict = {} + _patch_orchestrator(monkeypatch, fully_prepared=False, calls=calls) + + module = _FakeModule(params=_prepare_params(["SITE1_N9K_leaf"], check_mode=True), check_mode=True) + + with does_not_raise(): + _results, fields = mod._run_prepare(module) + + assert fields["changed"] is True + assert calls["stage"] == [] + assert calls["wait"] == [] + + +def test_nd_fabric_prepare_update_00220(monkeypatch) -> None: + """ + # Summary + + Verify the no-wait branch: staging is required, `stage` is sent once, and `wait_for_completion` + is NOT called when `wait=false`. + + ## Classes and Methods + + - nd_fabric_prepare_update._run_prepare() + """ + calls: dict = {} + _patch_orchestrator(monkeypatch, fully_prepared=False, calls=calls) + + module = _FakeModule(params=_prepare_params(["SITE1_N9K_leaf"], wait=False)) + + with does_not_raise(): + _results, fields = mod._run_prepare(module) + + assert fields["changed"] is True + assert calls["stage"] == [["SITE1_N9K_leaf"]] + assert calls["wait"] == [] + + +def test_nd_fabric_prepare_update_00230(monkeypatch) -> None: + """ + # Summary + + Verify the wait branch: staging is required, `stage` is sent, and `wait_for_completion` is + called exactly once with the configured timeout and interval. + + ## Classes and Methods + + - nd_fabric_prepare_update._run_prepare() + """ + calls: dict = {} + _patch_orchestrator(monkeypatch, fully_prepared=False, calls=calls) + + module = _FakeModule(params=_prepare_params(["SITE1_N9K_leaf"], wait=True)) + + with does_not_raise(): + _results, fields = mod._run_prepare(module) + + assert fields["changed"] is True + assert calls["stage"] == [["SITE1_N9K_leaf"]] + assert calls["wait"] == [(["SITE1_N9K_leaf"], 1800, 10)]