From 950499fdaa8652b420d4e6af09d4e0d6ec0c3d81 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Wed, 1 Jul 2026 20:31:19 +0100 Subject: [PATCH 1/2] Add cloudera.cloud.compute and cloudera.cloud.compute_info Signed-off-by: Jim Enright --- plugins/module_utils/cdp_compute.py | 318 +++++++++++ plugins/modules/compute.py | 475 ++++++++++++++++ plugins/modules/compute_info.py | 341 ++++++++++++ .../cdp_compute/test_cdp_compute.py | 521 ++++++++++++++++++ .../plugins/modules/compute/test_compute.py | 372 +++++++++++++ .../modules/compute/test_compute_info.py | 340 ++++++++++++ 6 files changed, 2367 insertions(+) create mode 100644 plugins/module_utils/cdp_compute.py create mode 100644 plugins/modules/compute.py create mode 100644 plugins/modules/compute_info.py create mode 100644 tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py create mode 100644 tests/unit/plugins/modules/compute/test_compute.py create mode 100644 tests/unit/plugins/modules/compute/test_compute_info.py diff --git a/plugins/module_utils/cdp_compute.py b/plugins/module_utils/cdp_compute.py new file mode 100644 index 00000000..d4ff0537 --- /dev/null +++ b/plugins/module_utils/cdp_compute.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- + +# Copyright 2026 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +A REST client for the Cloudera on Cloud Platform (CDP) Compute API +""" + +import time +from typing import Any, Dict, List, Optional + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + CdpClient, + CdpError, +) + + +class CdpComputeClient: + """CDP Compute API client.""" + + # Cluster lifecycle state constants + ACTIVE_STATES = ["RUNNING"] + REMOVABLE_STATES = ["RUNNING", "FAILED", "CREATE_FAILED"] + TERMINATION_STATES = ["DELETING"] + DELETED_STATES = ["DELETED"] + FAILED_STATES = ["FAILED", "CREATE_FAILED", "DELETE_FAILED"] + + def __init__(self, api_client: CdpClient): + """ + Initialize CDP Compute client. + + Args: + api_client: CdpClient instance for managing HTTP method calls + """ + self.api_client = api_client + + # ======================================================================== + # Cluster Management Methods + # ======================================================================== + + @CdpClient.paginated() + def list_clusters( + self, + env_name_or_crn: Optional[str] = None, + cluster_shape: Optional[str] = None, + default: Optional[bool] = None, + include_deleted: Optional[bool] = None, + status: Optional[str] = None, + workloads: Optional[str] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List all compute clusters, optionally filtered by environment. + + Args: + env_name_or_crn: Environment name or CRN to filter clusters by + cluster_shape: Filter between Externalized and Embedded cluster shapes + default: Only show default clusters + include_deleted: Include deleted clusters in the response + status: Cluster status for status filtering + workloads: Workloads for workload filtering + pageToken: Pagination token for getting the next page + pageSize: Number of results per page (1-500, default 100) + + Returns: + Dictionary containing: + - clusters: List of ListClusterItem objects + - nextToken: Token for next page (if available) + - totalClusters: Total number of clusters + - totalPages: Total number of pages + """ + data: Dict[str, Any] = {} + if env_name_or_crn is not None: + data["envNameOrCrn"] = env_name_or_crn + if cluster_shape is not None: + data["clusterShape"] = cluster_shape + if default is not None: + data["default"] = default + if include_deleted is not None: + data["includeDeleted"] = include_deleted + if status is not None: + data["status"] = status + if workloads is not None: + data["workloads"] = workloads + if pageToken is not None: + data["startingToken"] = pageToken + if pageSize is not None: + data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/compute/listClusters", + data=data, + squelch={404: {"clusters": []}}, + ) + + def describe_cluster(self, cluster_crn: str) -> Dict[str, Any]: + """ + Describe a compute cluster by CRN. + + Args: + cluster_crn: The CRN of the compute cluster + + Returns: + Dictionary containing DescribeClusterResponse fields, or empty dict if not found + """ + data = {"clusterCrn": cluster_crn} + return self.api_client.post( + "/api/v1/compute/describeCluster", + data=data, + squelch={404: {}}, + ) + + def get_cluster_by_crn(self, cluster_crn: str) -> Optional[Dict[str, Any]]: + """ + Get detailed cluster information by CRN. + + Args: + cluster_crn: The CRN of the compute cluster + + Returns: + Cluster details dict (DescribeClusterResponse), or None if not found + """ + response = self.describe_cluster(cluster_crn) + if not response: + return None + return response + + def get_clusters_by_env( + self, + env_name_or_crn: str, + **kwargs: Any, + ) -> List[Dict[str, Any]]: + """ + List clusters filtered by environment name or CRN. + + Args: + env_name_or_crn: Environment name or CRN + **kwargs: Additional filter arguments passed to list_clusters + + Returns: + List of ListClusterItem objects + """ + response = self.list_clusters(env_name_or_crn=env_name_or_crn, **kwargs) + return response.get("clusters", []) + + def get_all_clusters(self, **kwargs: Any) -> List[Dict[str, Any]]: + """ + List all compute clusters. + + Args: + **kwargs: Optional filter arguments passed to list_clusters + + Returns: + List of ListClusterItem objects + """ + response = self.list_clusters(**kwargs) + return response.get("clusters", []) + + def create_cluster( + self, + name: str, + environment: str, + description: Optional[str] = None, + network: Optional[Dict[str, Any]] = None, + tags: Optional[Dict[str, str]] = None, + skip_validation: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Create an externalized compute cluster. + + Args: + name: Name for the new cluster + environment: CRN of the CDP environment + description: Optional description + network: Optional CommonNetwork dict (subnets, podCidr, serviceCidr, outboundType) + tags: Optional map of string tags + skip_validation: If True, skip pre-flight validation + + Returns: + Dictionary containing CreateClusterResponse fields: + - clusterCrn, clusterId, clusterStatus, uri, validationResponse + """ + data: Dict[str, Any] = { + "clusterName": name, + "environmentCrn": environment, + } + if description is not None: + data["description"] = description + if network is not None: + data["network"] = network + if tags is not None: + data["tags"] = tags + if skip_validation is not None: + data["skipValidation"] = skip_validation + + return self.api_client.post( + "/api/v1/compute/createCluster", + data=data, + ) + + def delete_cluster( + self, + cluster_crn: str, + force: Optional[bool] = None, + skip_validation: Optional[bool] = None, + skip_workloads_validation: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Delete an externalized compute cluster. + + Args: + cluster_crn: CRN of the cluster to delete + force: If True, force delete even if workloads are running + skip_validation: If True, skip pre-flight validation + skip_workloads_validation: If True, skip workload validation checks + + Returns: + Dictionary containing DeleteClusterResponse fields: + - clusterStatus, validationResponse + """ + data: Dict[str, Any] = {"clusterCrn": cluster_crn} + if force is not None: + data["force"] = force + if skip_validation is not None: + data["skipValidation"] = skip_validation + if skip_workloads_validation is not None: + data["skipWorkloadsValidation"] = skip_workloads_validation + + return self.api_client.post( + "/api/v1/compute/deleteCluster", + data=data, + ) + + def get_cluster_by_name_and_env( + self, + name: str, + env_name_or_crn: str, + ) -> Optional[Dict[str, Any]]: + """ + Find an externalized cluster by name within an environment. + + Calls list_clusters filtered by environment and scans for a matching name. + + Args: + name: Cluster name to search for + env_name_or_crn: Environment name or CRN to filter by + + Returns: + First matching cluster dict, or None if not found + """ + clusters = self.get_clusters_by_env(env_name_or_crn) + for cluster in clusters: + if cluster.get("clusterName") == name: + return cluster + return None + + def wait_for_cluster_state( + self, + cluster_crn: str, + target_states: List[str], + timeout: int = 3600, + delay: int = 15, + ) -> Optional[Dict[str, Any]]: + """ + Wait for a compute cluster to reach one of the target states. + + Args: + cluster_crn: CRN of the cluster to monitor + target_states: List of acceptable target state strings + timeout: Maximum seconds to wait before raising CdpError + delay: Polling interval in seconds + + Returns: + Cluster details dict when target state is reached, or None if cluster is gone + + Raises: + CdpError: If timeout is reached or cluster enters a failed state + """ + start_time = time.time() + + while True: + elapsed = time.time() - start_time + if elapsed > timeout: + raise CdpError( + f"Timeout waiting for compute cluster to reach {target_states} " + f"after {timeout} seconds", + ) + + cluster = self.get_cluster_by_crn(cluster_crn) + + if cluster is None: + return None + + current_state = cluster.get("status") + + if current_state in target_states: + return cluster + + if current_state in self.FAILED_STATES: + msg = cluster.get("statusMessage", "Unknown error") + raise CdpError( + f"Compute cluster entered failed state '{current_state}': {msg}", + ) + + time.sleep(delay) diff --git a/plugins/modules/compute.py b/plugins/modules/compute.py new file mode 100644 index 00000000..424f1e99 --- /dev/null +++ b/plugins/modules/compute.py @@ -0,0 +1,475 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2026 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +DOCUMENTATION = r""" +module: compute +short_description: Manage the lifecycle of CDP Externalized Compute Clusters +description: + - Create and delete CDP Externalized Compute Clusters. + - Use O(state=present) to create a cluster; the module is idempotent if the + cluster already exists. + - Use O(state=absent) to delete a cluster; the module is idempotent if the + cluster does not exist. + - Default compute clusters (embedded, environment-scoped) are managed through + environment creation and are out of scope for this module. + - The module supports check_mode. +author: + - "Jim Enright (@jimright)" +version_added: "3.6.0" +options: + name: + description: + - Name of the compute cluster. + - Required when O(state=present). + - Used together with O(environment) to resolve an existing cluster when + O(crn) is not provided. + type: str + required: false + aliases: + - cluster_name + environment: + description: + - CRN of the CDP environment in which to create the cluster. + - Required when O(state=present). + - Used together with O(name) to resolve an existing cluster when + O(crn) is not provided. + type: str + required: false + aliases: + - env + crn: + description: + - CRN of an existing compute cluster. + - When provided, used directly to look up or delete the cluster. + - Required when O(state=absent) and O(name)/O(environment) are not set. + type: str + required: false + aliases: + - cluster_crn + description: + description: + - Human-readable description for the cluster. + - Only used when O(state=present) and the cluster does not yet exist. + type: str + required: false + network: + description: + - Network configuration for the cluster. + - Only used when O(state=present) and the cluster does not yet exist. + type: dict + required: false + suboptions: + subnets: + description: List of subnet IDs for the cluster nodes. + type: list + elements: str + required: false + pod_cidr: + description: CIDR block for pod IP addresses. + type: str + required: false + service_cidr: + description: CIDR block for service IP addresses. + type: str + required: false + outbound_type: + description: Outbound connectivity type (e.g. C(loadBalancer), C(userDefinedRouting)). + type: str + required: false + tags: + description: + - Map of string key/value tags to apply to the cluster. + - Only used when O(state=present) and the cluster does not yet exist. + type: dict + required: false + skip_validation: + description: + - If C(true), skip pre-flight validation during create or delete. + type: bool + required: false + default: false + force: + description: + - If C(true), force-delete the cluster even if workloads are running. + - Only relevant when O(state=absent). + type: bool + required: false + default: false + aliases: + - force_delete + skip_workloads_validation: + description: + - If C(true), skip workload validation checks during delete. + - Only relevant when O(state=absent). + type: bool + required: false + default: false + wait: + description: + - If C(true), wait for the cluster to reach a stable state before returning. + - For O(state=present), waits until the cluster is C(Running). + - For O(state=absent), waits until the cluster is C(Deleted) or no longer exists. + type: bool + required: false + default: true + delay: + description: + - Polling interval in seconds when O(wait=true). + type: int + required: false + default: 15 + aliases: + - polling_delay + timeout: + description: + - Maximum time in seconds to wait for the cluster to reach a stable state. + - Only relevant when O(wait=true). + type: int + required: false + default: 3600 + aliases: + - polling_timeout + state: + description: + - Desired lifecycle state of the compute cluster. + type: str + required: false + choices: + - present + - absent + default: present +extends_documentation_fragment: + - cloudera.cloud.cdp_client +attributes: + check_mode: + support: full + platform: + platforms: all +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details. + +# Create an externalized compute cluster +- cloudera.cloud.compute: + name: my-compute-cluster + environment: "crn:cdp:environments:us-west-1:tenant-uuid:environment:env-uuid" + state: present + +# Create a cluster with custom network settings and tags +- cloudera.cloud.compute: + name: my-compute-cluster + environment: "crn:cdp:environments:us-west-1:tenant-uuid:environment:env-uuid" + description: "Production compute cluster" + network: + pod_cidr: "10.0.0.0/16" + service_cidr: "10.1.0.0/16" + tags: + team: platform + env: production + state: present + +# Delete a cluster by name and environment (waits for deletion to complete) +- cloudera.cloud.compute: + name: my-compute-cluster + environment: "crn:cdp:environments:us-west-1:tenant-uuid:environment:env-uuid" + state: absent + +# Delete a cluster directly by CRN, force-removing active workloads +- cloudera.cloud.compute: + crn: "crn:cdp:compute:us-west-1:tenant-uuid:cluster:cluster-uuid" + force: true + state: absent +""" + +RETURN = r""" +cluster: + description: Details of the compute cluster after the operation. + returned: when O(state=present) or when the cluster still exists after delete + type: dict + contains: + cluster_crn: + description: Compute cluster CRN. + returned: always + type: str + cluster_id: + description: Compute cluster ID. + returned: always + type: str + cluster_name: + description: Compute cluster name. + returned: always + type: str + status: + description: Current cluster status. + returned: always + type: str + status_message: + description: Additional message about the cluster status. + returned: when available + type: str + env_crn: + description: CRN of the CDP environment. + returned: when available + type: str + env_name: + description: Name of the CDP environment. + returned: when available + type: str + compute_platform: + description: Underlying compute platform (e.g. C(EKS), C(AKS)). + returned: when available + type: str + kubernetes_version: + description: Kubernetes version. + returned: when available + type: str + cluster_shape: + description: Shape of the cluster (C(Externalized) or C(Embedded)). + returned: when available + type: str + is_default: + description: Whether this is the default cluster for its environment. + returned: when available + type: bool + creation_time: + description: Cluster creation time in ISO format. + returned: when available + type: str + region: + description: Cloud region. + returned: when available + type: str +sdk_out: + description: Returns the captured CDP SDK log. + returned: when supported + type: str +sdk_out_lines: + description: Returns a list of each line of the captured CDP SDK log. + returned: when supported + type: list + elements: str +""" + +from typing import Any, Dict, Optional + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.cloudera.cloud.plugins.module_utils.common import ( + ServicesModule, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_compute import ( + CdpComputeClient, +) + + +def _build_network( + network_params: Optional[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """Convert snake_case network suboptions to the API camelCase CommonNetwork dict.""" + if not network_params: + return None + + result: Dict[str, Any] = {} + if network_params.get("subnets") is not None: + result["subnets"] = network_params["subnets"] + if network_params.get("pod_cidr") is not None: + result["podCidr"] = network_params["pod_cidr"] + if network_params.get("service_cidr") is not None: + result["serviceCidr"] = network_params["service_cidr"] + if network_params.get("outbound_type") is not None: + result["outboundType"] = network_params["outbound_type"] + + return result if result else None + + +class ComputeCluster(ServicesModule): + def __init__(self): + super().__init__( + argument_spec=dict( + name=dict(required=False, type="str", aliases=["cluster_name"]), + environment=dict(required=False, type="str", aliases=["env"]), + crn=dict(required=False, type="str", aliases=["cluster_crn"]), + description=dict(required=False, type="str"), + network=dict( + required=False, + type="dict", + options=dict( + subnets=dict(required=False, type="list", elements="str"), + pod_cidr=dict(required=False, type="str"), + service_cidr=dict(required=False, type="str"), + outbound_type=dict(required=False, type="str"), + ), + ), + tags=dict(required=False, type="dict"), + skip_validation=dict(required=False, type="bool", default=False), + force=dict( + required=False, + type="bool", + default=False, + aliases=["force_delete"], + ), + skip_workloads_validation=dict( + required=False, + type="bool", + default=False, + ), + wait=dict(required=False, type="bool", default=True), + delay=dict( + required=False, + type="int", + default=15, + aliases=["polling_delay"], + ), + timeout=dict( + required=False, + type="int", + default=3600, + aliases=["polling_timeout"], + ), + state=dict( + required=False, + type="str", + choices=["present", "absent"], + default="present", + ), + ), + supports_check_mode=True, + required_if=[ + ("state", "present", ("name", "environment")), + ("state", "absent", ("crn", "name"), False), + ], + ) + + # Set parameters + self.name = self.get_param("name") + self.environment = self.get_param("environment") + self.cluster_crn = self.get_param("crn") + self.description = self.get_param("description") + self.network = self.get_param("network") + self.tags = self.get_param("tags") + self.skip_validation = self.get_param("skip_validation") + self.force = self.get_param("force") + self.skip_workloads_validation = self.get_param("skip_workloads_validation") + self.wait = self.get_param("wait") + self.delay = self.get_param("delay") + self.timeout = self.get_param("timeout") + self.state = self.get_param("state") + + # Initialize return values + self.cluster: Dict[str, Any] = {} + self.changed = False + + def process(self): + compute_client = CdpComputeClient(self.api_client) + + # Resolve existing cluster + existing: Optional[Dict[str, Any]] = None + if self.cluster_crn: + existing = compute_client.get_cluster_by_crn(self.cluster_crn) + elif self.name and self.environment: + existing = compute_client.get_cluster_by_name_and_env( + self.name, + self.environment, + ) + + if self.state == "present": + if existing: + # Cluster already exists — idempotent + self.cluster = camel_dict_to_snake_dict(existing) + else: + self.changed = True + if not self.module.check_mode: + result = compute_client.create_cluster( + name=self.name, + environment=self.environment, + description=self.description, + network=_build_network(self.network), + tags=self.tags, + skip_validation=( + self.skip_validation if self.skip_validation else None + ), + ) + # After create, result contains clusterCrn — describe for full details + created_crn = result.get("clusterCrn") + + if self.wait and created_crn: + final = compute_client.wait_for_cluster_state( + cluster_crn=created_crn, + target_states=CdpComputeClient.ACTIVE_STATES, + timeout=self.timeout, + delay=self.delay, + ) + self.cluster = ( + camel_dict_to_snake_dict(final) + if final + else camel_dict_to_snake_dict(result) + ) + else: + self.cluster = camel_dict_to_snake_dict(result) + + elif self.state == "absent": + if not existing: + # Cluster doesn't exist — idempotent + pass + else: + self.changed = True + resolved_crn = self.cluster_crn or existing.get("clusterCrn") + + if not self.module.check_mode: + compute_client.delete_cluster( + cluster_crn=resolved_crn, + force=self.force if self.force else None, + skip_validation=( + self.skip_validation if self.skip_validation else None + ), + skip_workloads_validation=( + self.skip_workloads_validation + if self.skip_workloads_validation + else None + ), + ) + + if self.wait: + final = compute_client.wait_for_cluster_state( + cluster_crn=resolved_crn, + target_states=CdpComputeClient.DELETED_STATES, + timeout=self.timeout, + delay=self.delay, + ) + self.cluster = camel_dict_to_snake_dict(final) if final else {} + + +def main(): + result = ComputeCluster() + + output: Dict[str, Any] = dict( + changed=result.changed, + cluster=result.cluster, + ) + + if result.debug_log: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines, + ) + + result.module.exit_json(**output) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/compute_info.py b/plugins/modules/compute_info.py new file mode 100644 index 00000000..7a58f5cc --- /dev/null +++ b/plugins/modules/compute_info.py @@ -0,0 +1,341 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2026 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +DOCUMENTATION = r""" +module: compute_info +short_description: Gather information about CDP Compute Clusters +description: + - Gather information about CDP Compute Clusters. + - When O(cluster_crn) is provided, describes that single cluster in detail. + - When O(environment) is provided, lists all clusters in that environment. + - When neither is provided, lists all compute clusters. + - The module supports check_mode. +author: + - "Jim Enright (@jimright)" +version_added: "3.6.0" +options: + crn: + description: + - The CRN of the compute cluster to describe. + - Mutually exclusive with O(environment). + type: str + required: false + aliases: + - cluster_crn + environment: + description: + - The name or CRN of the CDP Environment used to filter clusters. + - Mutually exclusive with O(crn). + type: str + required: false + aliases: + - env + cluster_shape: + description: + - Filter clusters by shape. + - Only used when O(crn) is not specified. + type: str + required: false + choices: + - Externalized + - Embedded + aliases: + - shape + default: + description: + - If C(true), only return the default cluster(s). + - Only used when O(crn) is not specified. + type: bool + required: false + aliases: + - default_cluster + include_deleted: + description: + - If C(true), include deleted clusters in the results. + - Only used when O(crn) is not specified. + type: bool + required: false + default: false + status: + description: + - Filter clusters by status string. + - Only used when O(crn) is not specified. + type: str + required: false +extends_documentation_fragment: + - cloudera.cloud.cdp_client +attributes: + check_mode: + support: full + platform: + platforms: all +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details. + +# List all compute clusters +- cloudera.cloud.compute_info: + +# List all compute clusters in a specific environment +- cloudera.cloud.compute_info: + environment: my-environment + +# Describe a specific compute cluster by CRN +- cloudera.cloud.compute_info: + crn: "crn:cdp:compute:us-west-1:tenant-uuid:cluster:cluster-uuid" + +# List only default clusters +- cloudera.cloud.compute_info: + default: true + +# List clusters including deleted ones +- cloudera.cloud.compute_info: + include_deleted: true +""" + +RETURN = r""" +clusters: + description: The information about the named Cluster or Clusters. + returned: always + type: list + elements: dict + contains: + cluster_crn: + description: Compute cluster CRN. + returned: always + type: str + cluster_id: + description: Compute cluster ID. + returned: always + type: str + cluster_name: + description: Compute cluster name. + returned: always + type: str + status: + description: Compute cluster status. + returned: always + type: str + message: + description: Message with additional details about the cluster status. + returned: when available + type: str + env_crn: + description: CDP environment CRN. + returned: always + type: str + env_name: + description: CDP environment name. + returned: always + type: str + env_cloud_provider: + description: CDP environment cloud provider. + returned: when available + type: str + compute_platform: + description: Compute cluster platform provider. + returned: when available + type: str + compute_platform_version: + description: Compute cluster platform version. + returned: when available + type: str + kubernetes_version: + description: Kubernetes version. + returned: when available + type: str + cluster_type: + description: Compute cluster type. + returned: when available + type: str + cluster_shape: + description: The shape of the cluster (Embedded or Externalized). + returned: when available + type: str + cluster_size: + description: Number of nodes in the cluster. + returned: when available + type: int + is_default: + description: Whether the cluster is the default cluster for its environment. + returned: when available + type: bool + is_cloudera_managed: + description: Whether the cluster is Cloudera managed. + returned: when describe is used + type: bool + creation_time: + description: Compute cluster creation time in ISO format. + returned: when available + type: str + update_time: + description: Compute cluster update time in ISO format. + returned: when available + type: str + deletion_time: + description: Compute cluster deletion time in ISO format. + returned: when available + type: str + region: + description: Region. + returned: when available + type: str + labels: + description: Map of labels associated with this cluster. + returned: when available + type: dict + cluster_owner: + description: Cluster owner details. + returned: when available + type: dict + contains: + account_id: + description: Owner's account ID. + type: str + returned: when available + crn: + description: Owner's actor CRN. + type: str + returned: when available + email: + description: Owner's email. + type: str + returned: when available + first_name: + description: Owner's first name. + type: str + returned: when available + last_name: + description: Owner's last name. + type: str + returned: when available + user_id: + description: Owner's user ID. + type: str + returned: when available + available_upgrades: + description: List of available Kubernetes upgrades. + returned: when available + type: list + elements: str +sdk_out: + description: Returns the captured CDP SDK log. + returned: when supported + type: str +sdk_out_lines: + description: Returns a list of each line of the captured CDP SDK log. + returned: when supported + type: list + elements: str +""" + +from typing import Any, Dict + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.cloudera.cloud.plugins.module_utils.common import ( + ServicesModule, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_compute import ( + CdpComputeClient, +) + + +class ComputeClusterInfo(ServicesModule): + def __init__(self): + super().__init__( + argument_spec=dict( + crn=dict(required=False, type="str", aliases=["cluster_crn"]), + environment=dict( + required=False, + type="str", + aliases=["env"], + ), + cluster_shape=dict( + required=False, + type="str", + choices=["Externalized", "Embedded"], + aliases=["shape"], + ), + default=dict(required=False, type="bool", aliases=["default_cluster"]), + include_deleted=dict(required=False, type="bool", default=False), + status=dict(required=False, type="str"), + ), + supports_check_mode=True, + mutually_exclusive=[["crn", "environment"]], + ) + + # Set parameters + self.cluster_crn = self.get_param("crn") + self.env_name_or_crn = self.get_param("environment") + self.cluster_shape = self.get_param("cluster_shape") + self.default_only = self.get_param("default") + self.include_deleted = self.get_param("include_deleted") + self.status = self.get_param("status") + + # Initialize return values + self.clusters = [] + + def process(self): + compute_client = CdpComputeClient(self.api_client) + + if self.cluster_crn: + cluster = compute_client.get_cluster_by_crn(self.cluster_crn) + if cluster: + self.clusters.append(camel_dict_to_snake_dict(cluster)) + else: + filter_kwargs: Dict[str, Any] = {} + if self.cluster_shape is not None: + filter_kwargs["cluster_shape"] = self.cluster_shape + if self.default_only is not None: + filter_kwargs["default"] = self.default_only + if self.include_deleted is not None: + filter_kwargs["include_deleted"] = self.include_deleted + if self.status is not None: + filter_kwargs["status"] = self.status + + if self.env_name_or_crn: + clusters = compute_client.get_clusters_by_env( + self.env_name_or_crn, + **filter_kwargs, + ) + else: + clusters = compute_client.get_all_clusters(**filter_kwargs) + + self.clusters = [camel_dict_to_snake_dict(c) for c in clusters] + + +def main(): + result = ComputeClusterInfo() + + output: Dict[str, Any] = dict( + changed=False, + clusters=result.clusters, + ) + + if result.debug_log: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines, + ) + + result.module.exit_json(**output) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py b/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py new file mode 100644 index 00000000..9851d2ab --- /dev/null +++ b/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py @@ -0,0 +1,521 @@ +# -*- coding: utf-8 -*- + +# Copyright 2026 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from unittest.mock import MagicMock + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_compute import ( + CdpComputeClient, +) + + +CLUSTER_CRN = "crn:cdp:compute:us-west-1:tenant-uuid:cluster:cluster-uuid" +ENV_CRN = "crn:cdp:environments:us-west-1:tenant-uuid:environment:env-uuid" +ENV_NAME = "test-environment" + + +def make_client(): + """Create a CdpComputeClient with a mocked api_client.""" + api_client = MagicMock() + return CdpComputeClient(api_client), api_client + + +# ============================================================================ +# list_clusters tests +# ============================================================================ + + +def test_list_clusters_all(mocker): + """list_clusters returns all clusters when called with no filters.""" + client, api_client = make_client() + + expected = { + "clusters": [ + {"clusterCrn": CLUSTER_CRN, "clusterName": "cluster-1"}, + ], + "totalClusters": 1, + "totalPages": 1, + } + api_client.post.return_value = expected + + result = client.list_clusters() + + api_client.post.assert_called_once_with( + "/api/v1/compute/listClusters", + data={"pageSize": 100}, + squelch={404: {"clusters": []}}, + ) + assert result == expected + + +def test_list_clusters_with_env_filter(mocker): + """list_clusters passes envNameOrCrn when env_name_or_crn is provided.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + client.list_clusters(env_name_or_crn=ENV_CRN) + + call_kwargs = api_client.post.call_args + assert call_kwargs[1]["data"]["envNameOrCrn"] == ENV_CRN + + +def test_list_clusters_with_include_deleted(mocker): + """list_clusters passes includeDeleted when include_deleted is set.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + client.list_clusters(include_deleted=True) + + call_kwargs = api_client.post.call_args + assert call_kwargs[1]["data"]["includeDeleted"] is True + + +def test_list_clusters_with_status_filter(mocker): + """list_clusters passes status when status filter is provided.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + client.list_clusters(status="RUNNING") + + call_kwargs = api_client.post.call_args + assert call_kwargs[1]["data"]["status"] == "RUNNING" + + +def test_list_clusters_with_default_only(mocker): + """list_clusters passes default=True when default_only is requested.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + client.list_clusters(default=True) + + call_kwargs = api_client.post.call_args + assert call_kwargs[1]["data"]["default"] is True + + +# ============================================================================ +# describe_cluster tests +# ============================================================================ + + +def test_describe_cluster_found(mocker): + """describe_cluster returns cluster details when found.""" + client, api_client = make_client() + + expected = { + "clusterCrn": CLUSTER_CRN, + "clusterName": "cluster-1", + "status": "RUNNING", + "envCrn": ENV_CRN, + } + api_client.post.return_value = expected + + result = client.describe_cluster(CLUSTER_CRN) + + api_client.post.assert_called_once_with( + "/api/v1/compute/describeCluster", + data={"clusterCrn": CLUSTER_CRN}, + squelch={404: {}}, + ) + assert result == expected + + +def test_describe_cluster_not_found(mocker): + """describe_cluster returns empty dict when cluster is not found (404 squelched).""" + client, api_client = make_client() + + api_client.post.return_value = {} + + result = client.describe_cluster(CLUSTER_CRN) + + assert result == {} + + +# ============================================================================ +# get_cluster_by_crn tests +# ============================================================================ + + +def test_get_cluster_by_crn_found(mocker): + """get_cluster_by_crn returns cluster details when the cluster exists.""" + client, api_client = make_client() + + cluster_detail = { + "clusterCrn": CLUSTER_CRN, + "clusterName": "cluster-1", + "status": "RUNNING", + } + api_client.post.return_value = cluster_detail + + result = client.get_cluster_by_crn(CLUSTER_CRN) + + assert result == cluster_detail + + +def test_get_cluster_by_crn_not_found(mocker): + """get_cluster_by_crn returns None when the cluster does not exist.""" + client, api_client = make_client() + + api_client.post.return_value = {} + + result = client.get_cluster_by_crn(CLUSTER_CRN) + + assert result is None + + +# ============================================================================ +# get_clusters_by_env tests +# ============================================================================ + + +def test_get_clusters_by_env_returns_list(mocker): + """get_clusters_by_env returns the clusters list from the API response.""" + client, api_client = make_client() + + cluster_list = [ + {"clusterCrn": CLUSTER_CRN, "clusterName": "cluster-1"}, + ] + api_client.post.return_value = { + "clusters": cluster_list, + "totalClusters": 1, + "totalPages": 1, + } + + result = client.get_clusters_by_env(ENV_CRN) + + assert result == cluster_list + call_kwargs = api_client.post.call_args + assert call_kwargs[1]["data"]["envNameOrCrn"] == ENV_CRN + + +def test_get_clusters_by_env_empty(mocker): + """get_clusters_by_env returns empty list when no clusters exist.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + result = client.get_clusters_by_env(ENV_NAME) + + assert result == [] + + +# ============================================================================ +# get_all_clusters tests +# ============================================================================ + + +def test_get_all_clusters_returns_list(mocker): + """get_all_clusters returns the full clusters list from the API response.""" + client, api_client = make_client() + + cluster_list = [ + {"clusterCrn": CLUSTER_CRN, "clusterName": "cluster-1"}, + { + "clusterCrn": "crn:cdp:compute:us-west-1:tenant:cluster:c2", + "clusterName": "cluster-2", + }, + ] + api_client.post.return_value = { + "clusters": cluster_list, + "totalClusters": 2, + "totalPages": 1, + } + + result = client.get_all_clusters() + + assert result == cluster_list + + +def test_get_all_clusters_empty(mocker): + """get_all_clusters returns an empty list when no clusters exist.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + result = client.get_all_clusters() + + assert result == [] + + +# ============================================================================ +# create_cluster tests +# ============================================================================ + + +def test_create_cluster(mocker): + """create_cluster sends the correct POST body and returns the response.""" + client, api_client = make_client() + + expected = { + "clusterCrn": CLUSTER_CRN, + "clusterId": "cluster-uuid", + "clusterStatus": {"status": "Creating"}, + } + api_client.post.return_value = expected + + result = client.create_cluster(name="my-cluster", environment=ENV_CRN) + + api_client.post.assert_called_once_with( + "/api/v1/compute/createCluster", + data={"clusterName": "my-cluster", "environmentCrn": ENV_CRN}, + ) + assert result == expected + assert result["clusterCrn"] == CLUSTER_CRN + + +def test_create_cluster_with_all_options(mocker): + """create_cluster includes optional fields when provided.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusterCrn": CLUSTER_CRN} + network = {"podCidr": "10.0.0.0/16", "serviceCidr": "10.1.0.0/16"} + tags = {"env": "test"} + + client.create_cluster( + name="my-cluster", + environment=ENV_CRN, + description="A test cluster", + network=network, + tags=tags, + skip_validation=True, + ) + + call_data = api_client.post.call_args[1]["data"] + assert call_data["description"] == "A test cluster" + assert call_data["network"] == network + assert call_data["tags"] == tags + assert call_data["skipValidation"] is True + + +def test_create_cluster_omits_none_options(mocker): + """create_cluster does not include None optional fields in the request body.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusterCrn": CLUSTER_CRN} + + client.create_cluster(name="my-cluster", environment=ENV_CRN) + + call_data = api_client.post.call_args[1]["data"] + assert "description" not in call_data + assert "network" not in call_data + assert "tags" not in call_data + assert "skipValidation" not in call_data + + +# ============================================================================ +# delete_cluster tests +# ============================================================================ + + +def test_delete_cluster(mocker): + """delete_cluster sends the correct POST body with only clusterCrn.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusterStatus": {"status": "Deleting"}} + + client.delete_cluster(CLUSTER_CRN) + + api_client.post.assert_called_once_with( + "/api/v1/compute/deleteCluster", + data={"clusterCrn": CLUSTER_CRN}, + ) + + +def test_delete_cluster_force(mocker): + """delete_cluster includes force=True when requested.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusterStatus": {"status": "Deleting"}} + + client.delete_cluster(CLUSTER_CRN, force=True) + + call_data = api_client.post.call_args[1]["data"] + assert call_data["force"] is True + + +def test_delete_cluster_with_all_options(mocker): + """delete_cluster includes all optional flags when provided.""" + client, api_client = make_client() + + api_client.post.return_value = {} + + client.delete_cluster( + CLUSTER_CRN, + force=True, + skip_validation=True, + skip_workloads_validation=True, + ) + + call_data = api_client.post.call_args[1]["data"] + assert call_data["clusterCrn"] == CLUSTER_CRN + assert call_data["force"] is True + assert call_data["skipValidation"] is True + assert call_data["skipWorkloadsValidation"] is True + + +# ============================================================================ +# get_cluster_by_name_and_env tests +# ============================================================================ + + +def test_get_cluster_by_name_and_env_found(mocker): + """get_cluster_by_name_and_env returns the matching cluster when found.""" + client, api_client = make_client() + + cluster = { + "clusterCrn": CLUSTER_CRN, + "clusterName": "my-cluster", + "status": "Running", + } + api_client.post.return_value = { + "clusters": [ + cluster, + {"clusterCrn": "other-crn", "clusterName": "other-cluster"}, + ], + "totalClusters": 2, + "totalPages": 1, + } + + result = client.get_cluster_by_name_and_env("my-cluster", ENV_CRN) + + assert result == cluster + + +def test_get_cluster_by_name_and_env_not_found(mocker): + """get_cluster_by_name_and_env returns None when no cluster matches the name.""" + client, api_client = make_client() + + api_client.post.return_value = { + "clusters": [{"clusterCrn": "other-crn", "clusterName": "other-cluster"}], + "totalClusters": 1, + "totalPages": 1, + } + + result = client.get_cluster_by_name_and_env("missing-cluster", ENV_CRN) + + assert result is None + + +def test_get_cluster_by_name_and_env_empty_env(mocker): + """get_cluster_by_name_and_env returns None when environment has no clusters.""" + client, api_client = make_client() + + api_client.post.return_value = {"clusters": [], "totalClusters": 0, "totalPages": 0} + + result = client.get_cluster_by_name_and_env("my-cluster", ENV_CRN) + + assert result is None + + +# ============================================================================ +# wait_for_cluster_state tests +# ============================================================================ + + +def test_wait_for_cluster_state_already_in_target(mocker): + """wait_for_cluster_state returns immediately when cluster is already in the target state.""" + client, api_client = make_client() + + cluster = { + "clusterCrn": CLUSTER_CRN, + "clusterName": "my-cluster", + "status": "Running", + } + api_client.post.return_value = cluster + + result = client.wait_for_cluster_state(CLUSTER_CRN, ["Running"]) + + assert result == cluster + api_client.post.assert_called_once() + + +def test_wait_for_cluster_state_polls_until_ready(mocker): + """wait_for_cluster_state polls until the cluster reaches the target state.""" + client, api_client = make_client() + + mocker.patch("time.sleep") + api_client.post.side_effect = [ + {"clusterCrn": CLUSTER_CRN, "status": "Creating"}, + {"clusterCrn": CLUSTER_CRN, "status": "Creating"}, + {"clusterCrn": CLUSTER_CRN, "status": "Running"}, + ] + + result = client.wait_for_cluster_state(CLUSTER_CRN, ["Running"]) + + assert result["status"] == "Running" + assert api_client.post.call_count == 3 + + +def test_wait_for_cluster_state_returns_none_when_deleted(mocker): + """wait_for_cluster_state returns None when cluster no longer exists (deleted).""" + client, api_client = make_client() + + mocker.patch("time.sleep") + api_client.post.side_effect = [ + {"clusterCrn": CLUSTER_CRN, "status": "Deleting"}, + {}, # 404 squelched → empty dict → get_cluster_by_crn returns None + ] + + result = client.wait_for_cluster_state(CLUSTER_CRN, ["Deleted"]) + + assert result is None + + +def test_wait_for_cluster_state_raises_on_failed_state(mocker): + """wait_for_cluster_state raises CdpError when cluster enters a failed state.""" + from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + CdpError, + ) + + client, api_client = make_client() + + mocker.patch("time.sleep") + api_client.post.return_value = { + "clusterCrn": CLUSTER_CRN, + "status": "Failed", + "statusMessage": "Cluster creation failed", + } + + with pytest.raises(CdpError, match="Failed"): + client.wait_for_cluster_state(CLUSTER_CRN, ["Running"]) + + +def test_wait_for_cluster_state_timeout(mocker): + """wait_for_cluster_state raises CdpError after the timeout is exceeded.""" + from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + CdpError, + ) + + client, api_client = make_client() + + mocker.patch("time.sleep") + # Mock time.time to simulate elapsed time exceeding the timeout + mocker.patch( + "ansible_collections.cloudera.cloud.plugins.module_utils.cdp_compute.time.time", + side_effect=[0, 0, 9999], + ) + api_client.post.return_value = {"clusterCrn": CLUSTER_CRN, "status": "Creating"} + + with pytest.raises(CdpError, match="Timeout"): + client.wait_for_cluster_state(CLUSTER_CRN, ["Running"], timeout=60) diff --git a/tests/unit/plugins/modules/compute/test_compute.py b/tests/unit/plugins/modules/compute/test_compute.py new file mode 100644 index 00000000..cba3ec1d --- /dev/null +++ b/tests/unit/plugins/modules/compute/test_compute.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- + +# Copyright 2026 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.modules import compute +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_compute import ( + CdpComputeClient, +) + + +BASE_URL = "https://cloudera.internal/api" +ACCESS_KEY = "test-access-key" +PRIVATE_KEY = "test-private-key" +FILE_ACCESS_KEY = "file-access-key" +FILE_PRIVATE_KEY = "file-private-key" +FILE_REGION = "default" + +CLUSTER_CRN = "crn:cdp:compute:us-west-1:tenant-uuid:cluster:cluster-uuid" +ENV_CRN = "crn:cdp:environments:us-west-1:tenant-uuid:environment:env-uuid" +CLUSTER_NAME = "my-compute-cluster" + +SAMPLE_CLUSTER_RUNNING = { + "clusterCrn": CLUSTER_CRN, + "clusterId": "cluster-uuid", + "clusterName": CLUSTER_NAME, + "status": "Running", + "envCrn": ENV_CRN, + "envName": "test-env", + "computePlatform": "EKS", + "isDefault": False, +} + +SAMPLE_CREATE_RESPONSE = { + "clusterCrn": CLUSTER_CRN, + "clusterId": "cluster-uuid", + "clusterStatus": {"status": "Creating"}, +} + + +def _patch_config(mocker): + """Patch CDP config loading.""" + config = mocker.patch( + "ansible_collections.cloudera.cloud.plugins.module_utils.common.load_cdp_config", + ) + config.return_value = (FILE_ACCESS_KEY, FILE_PRIVATE_KEY, FILE_REGION) + return config + + +def _patch_client(mocker): + """Patch CdpComputeClient and return the mock instance.""" + return mocker.patch( + "ansible_collections.cloudera.cloud.plugins.modules.compute.CdpComputeClient", + autospec=True, + ).return_value + + +# ============================================================================ +# state=present — create +# ============================================================================ + + +def test_compute_create(module_args, mocker): + """state=present with no existing cluster creates the cluster.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": CLUSTER_NAME, + "environment": ENV_CRN, + "state": "present", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_name_and_env.return_value = None + client.create_cluster.return_value = SAMPLE_CREATE_RESPONSE + client.wait_for_cluster_state.return_value = SAMPLE_CLUSTER_RUNNING + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + assert result.value.cluster["cluster_crn"] == CLUSTER_CRN + assert result.value.cluster["status"] == "Running" + + client.get_cluster_by_name_and_env.assert_called_once_with(CLUSTER_NAME, ENV_CRN) + client.create_cluster.assert_called_once() + client.wait_for_cluster_state.assert_called_once() + + +def test_compute_create_idempotent(module_args, mocker): + """state=present with an existing cluster is idempotent (no create call).""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": CLUSTER_NAME, + "environment": ENV_CRN, + "state": "present", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_name_and_env.return_value = SAMPLE_CLUSTER_RUNNING + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is False + assert result.value.cluster["cluster_crn"] == CLUSTER_CRN + + client.create_cluster.assert_not_called() + client.wait_for_cluster_state.assert_not_called() + + +def test_compute_create_no_wait(module_args, mocker): + """state=present with wait=false returns immediately after create.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": CLUSTER_NAME, + "environment": ENV_CRN, + "wait": False, + "state": "present", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_name_and_env.return_value = None + client.create_cluster.return_value = SAMPLE_CREATE_RESPONSE + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + client.create_cluster.assert_called_once() + client.wait_for_cluster_state.assert_not_called() + + +# ============================================================================ +# state=present — check mode +# ============================================================================ + + +def test_compute_create_check_mode(module_args, mocker): + """state=present in check mode does not call create_cluster.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": CLUSTER_NAME, + "environment": ENV_CRN, + "_ansible_check_mode": True, + "state": "present", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_name_and_env.return_value = None + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + client.create_cluster.assert_not_called() + + +# ============================================================================ +# state=absent — delete +# ============================================================================ + + +def test_compute_absent(module_args, mocker): + """state=absent with an existing cluster deletes it.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": CLUSTER_NAME, + "environment": ENV_CRN, + "state": "absent", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_name_and_env.return_value = SAMPLE_CLUSTER_RUNNING + client.delete_cluster.return_value = {"clusterStatus": {"status": "Deleting"}} + client.wait_for_cluster_state.return_value = None # cluster gone + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + assert result.value.cluster == {} + + client.delete_cluster.assert_called_once_with( + cluster_crn=CLUSTER_CRN, + force=None, + skip_validation=None, + skip_workloads_validation=None, + ) + client.wait_for_cluster_state.assert_called_once() + + +def test_compute_absent_idempotent(module_args, mocker): + """state=absent when cluster does not exist is idempotent.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": CLUSTER_NAME, + "environment": ENV_CRN, + "state": "absent", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_name_and_env.return_value = None + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is False + assert result.value.cluster == {} + + client.delete_cluster.assert_not_called() + + +def test_compute_absent_with_crn(module_args, mocker): + """state=absent using crn directly looks up via get_cluster_by_crn.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + "state": "absent", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = SAMPLE_CLUSTER_RUNNING + client.delete_cluster.return_value = {} + client.wait_for_cluster_state.return_value = None + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + client.get_cluster_by_crn.assert_called_once_with(CLUSTER_CRN) + client.get_cluster_by_name_and_env.assert_not_called() + client.delete_cluster.assert_called_once() + + +def test_compute_absent_force(module_args, mocker): + """state=absent with force=true passes force to delete_cluster.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + "force": True, + "state": "absent", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = SAMPLE_CLUSTER_RUNNING + client.delete_cluster.return_value = {} + client.wait_for_cluster_state.return_value = None + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + call_kwargs = client.delete_cluster.call_args[1] + assert call_kwargs["force"] is True + + +def test_compute_absent_no_wait(module_args, mocker): + """state=absent with wait=false issues delete but does not poll.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + "wait": False, + "state": "absent", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = SAMPLE_CLUSTER_RUNNING + client.delete_cluster.return_value = {} + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + client.delete_cluster.assert_called_once() + client.wait_for_cluster_state.assert_not_called() + + +# ============================================================================ +# state=absent — check mode +# ============================================================================ + + +def test_compute_absent_check_mode(module_args, mocker): + """state=absent in check mode does not call delete_cluster.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + "_ansible_check_mode": True, + "state": "absent", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = SAMPLE_CLUSTER_RUNNING + + with pytest.raises(AnsibleExitJson) as result: + compute.main() + + assert result.value.changed is True + client.delete_cluster.assert_not_called() + client.wait_for_cluster_state.assert_not_called() diff --git a/tests/unit/plugins/modules/compute/test_compute_info.py b/tests/unit/plugins/modules/compute/test_compute_info.py new file mode 100644 index 00000000..bd1af79b --- /dev/null +++ b/tests/unit/plugins/modules/compute/test_compute_info.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- + +# Copyright 2026 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.modules import compute_info +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_compute import ( + CdpComputeClient, +) + + +BASE_URL = "https://cloudera.internal/api" +ACCESS_KEY = "test-access-key" +PRIVATE_KEY = "test-private-key" +FILE_ACCESS_KEY = "file-access-key" +FILE_PRIVATE_KEY = "file-private-key" +FILE_REGION = "default" + +CLUSTER_CRN = "crn:cdp:compute:us-west-1:tenant-uuid:cluster:cluster-uuid" +ENV_CRN = "crn:cdp:environments:us-west-1:tenant-uuid:environment:env-uuid" +ENV_NAME = "test-environment" + +SAMPLE_CLUSTER_LIST_ITEM = { + "clusterCrn": CLUSTER_CRN, + "clusterId": "cluster-uuid", + "clusterName": "my-cluster", + "status": "RUNNING", + "envCrn": ENV_CRN, + "envName": ENV_NAME, + "computePlatform": "EKS", + "isDefault": False, +} + +SAMPLE_CLUSTER_DESCRIBE = { + "clusterCrn": CLUSTER_CRN, + "clusterId": "cluster-uuid", + "clusterName": "my-cluster", + "status": "RUNNING", + "envCrn": ENV_CRN, + "envName": ENV_NAME, + "computePlatform": "EKS", + "kubernetesVersion": "1.28", + "isClouderaManaged": True, + "isDefault": False, + "clusterSize": 3, + "region": "us-west-1", +} + + +def _patch_config(mocker): + """Helper to patch CDP config loading.""" + config = mocker.patch( + "ansible_collections.cloudera.cloud.plugins.module_utils.common.load_cdp_config", + ) + config.return_value = (FILE_ACCESS_KEY, FILE_PRIVATE_KEY, FILE_REGION) + return config + + +def _patch_client(mocker): + """Helper to patch CdpComputeClient.""" + return mocker.patch( + "ansible_collections.cloudera.cloud.plugins.modules.compute_info.CdpComputeClient", + autospec=True, + ).return_value + + +# ============================================================================ +# List all clusters (no filters) +# ============================================================================ + + +def test_compute_info_list_all(module_args, mocker): + """compute_info with no parameters returns all clusters.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_all_clusters.return_value = [SAMPLE_CLUSTER_LIST_ITEM] + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.changed is False + assert len(result.value.clusters) == 1 + assert result.value.clusters[0]["cluster_crn"] == CLUSTER_CRN + + client.get_all_clusters.assert_called_once() + + +def test_compute_info_list_empty(module_args, mocker): + """compute_info returns an empty list when no clusters exist.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_all_clusters.return_value = [] + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.changed is False + assert result.value.clusters == [] + + +# ============================================================================ +# Filter by environment +# ============================================================================ + + +def test_compute_info_by_env_crn(module_args, mocker): + """compute_info with environment (CRN) calls get_clusters_by_env.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "environment": ENV_CRN, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_clusters_by_env.return_value = [SAMPLE_CLUSTER_LIST_ITEM] + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.changed is False + assert len(result.value.clusters) == 1 + assert result.value.clusters[0]["env_crn"] == ENV_CRN + + client.get_clusters_by_env.assert_called_once() + call_args = client.get_clusters_by_env.call_args + assert call_args[0][0] == ENV_CRN + + +def test_compute_info_by_env_name(module_args, mocker): + """compute_info with environment name resolves correctly.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "environment": ENV_NAME, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_clusters_by_env.return_value = [] + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.clusters == [] + client.get_clusters_by_env.assert_called_once() + call_args = client.get_clusters_by_env.call_args + assert call_args[0][0] == ENV_NAME + + +# ============================================================================ +# Describe by cluster CRN +# ============================================================================ + + +def test_compute_info_by_crn(module_args, mocker): + """compute_info with crn calls get_cluster_by_crn.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = SAMPLE_CLUSTER_DESCRIBE + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.changed is False + assert len(result.value.clusters) == 1 + assert result.value.clusters[0]["cluster_crn"] == CLUSTER_CRN + assert result.value.clusters[0]["kubernetes_version"] == "1.28" + + client.get_cluster_by_crn.assert_called_once_with(CLUSTER_CRN) + + +def test_compute_info_cluster_crn_alias(module_args, mocker): + """compute_info accepts 'cluster_crn' as an alias for crn.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "cluster_crn": CLUSTER_CRN, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = SAMPLE_CLUSTER_DESCRIBE + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert len(result.value.clusters) == 1 + client.get_cluster_by_crn.assert_called_once_with(CLUSTER_CRN) + + +def test_compute_info_crn_not_found(module_args, mocker): + """compute_info returns empty list when crn resolves to nothing.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_cluster_by_crn.return_value = None + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.clusters == [] + + +# ============================================================================ +# Mutual exclusion +# ============================================================================ + + +def test_compute_info_mutual_exclusion(module_args, mocker): + """compute_info fails when both crn and environment are specified.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "crn": CLUSTER_CRN, + "environment": ENV_CRN, + }, + ) + + _patch_config(mocker) + + with pytest.raises(AnsibleFailJson): + compute_info.main() + + +# ============================================================================ +# Optional filters passed through to list +# ============================================================================ + + +def test_compute_info_with_include_deleted(module_args, mocker): + """compute_info passes include_deleted filter to the client.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "include_deleted": True, + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_all_clusters.return_value = [] + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert result.value.clusters == [] + call_kwargs = client.get_all_clusters.call_args[1] + assert call_kwargs.get("include_deleted") is True + + +def test_compute_info_with_status_filter(module_args, mocker): + """compute_info passes status filter to the client.""" + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "status": "RUNNING", + }, + ) + + _patch_config(mocker) + client = _patch_client(mocker) + client.get_all_clusters.return_value = [SAMPLE_CLUSTER_LIST_ITEM] + + with pytest.raises(AnsibleExitJson) as result: + compute_info.main() + + assert len(result.value.clusters) == 1 + call_kwargs = client.get_all_clusters.call_args[1] + assert call_kwargs.get("status") == "RUNNING" From 6bd154cf74d8e9ff5a31c7eea3e5b3c2213dc972 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Fri, 3 Jul 2026 09:47:22 +0100 Subject: [PATCH 2/2] Refactor cluster attributes in cdp_compute and compute modules for consistency Signed-off-by: Jim Enright --- plugins/module_utils/cdp_compute.py | 4 ++-- plugins/modules/compute.py | 5 ++++- plugins/modules/compute_info.py | 5 ----- .../plugins/module_utils/cdp_compute/test_cdp_compute.py | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/plugins/module_utils/cdp_compute.py b/plugins/module_utils/cdp_compute.py index d4ff0537..0daf4928 100644 --- a/plugins/module_utils/cdp_compute.py +++ b/plugins/module_utils/cdp_compute.py @@ -194,8 +194,8 @@ def create_cluster( - clusterCrn, clusterId, clusterStatus, uri, validationResponse """ data: Dict[str, Any] = { - "clusterName": name, - "environmentCrn": environment, + "name": name, + "environment": environment, } if description is not None: data["description"] = description diff --git a/plugins/modules/compute.py b/plugins/modules/compute.py index 424f1e99..c86deb75 100644 --- a/plugins/modules/compute.py +++ b/plugins/modules/compute.py @@ -153,10 +153,13 @@ - absent default: present extends_documentation_fragment: + - ansible.builtin.action_common_attributes - cloudera.cloud.cdp_client attributes: check_mode: support: full + diff_mode: + support: none platform: platforms: all """ @@ -351,7 +354,7 @@ def __init__(self): supports_check_mode=True, required_if=[ ("state", "present", ("name", "environment")), - ("state", "absent", ("crn", "name"), False), + ("state", "absent", ("crn", "name"), True), ], ) diff --git a/plugins/modules/compute_info.py b/plugins/modules/compute_info.py index 7a58f5cc..e243f1f3 100644 --- a/plugins/modules/compute_info.py +++ b/plugins/modules/compute_info.py @@ -78,11 +78,6 @@ required: false extends_documentation_fragment: - cloudera.cloud.cdp_client -attributes: - check_mode: - support: full - platform: - platforms: all """ EXAMPLES = r""" diff --git a/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py b/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py index 9851d2ab..18ef39d2 100644 --- a/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py +++ b/tests/unit/plugins/module_utils/cdp_compute/test_cdp_compute.py @@ -278,7 +278,7 @@ def test_create_cluster(mocker): api_client.post.assert_called_once_with( "/api/v1/compute/createCluster", - data={"clusterName": "my-cluster", "environmentCrn": ENV_CRN}, + data={"name": "my-cluster", "environment": ENV_CRN}, ) assert result == expected assert result["clusterCrn"] == CLUSTER_CRN