From 14bf44f5428e1121aa474427bf6a2f6d1ecd6ef5 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:33 +0100 Subject: [PATCH 01/16] Add control_plane and control_plane_info modules Signed-off-by: Jim Enright --- plugins/module_utils/cluster_utils.py | 10 + plugins/modules/control_plane.py | 539 ++++++++++++++++++ plugins/modules/control_plane_info.py | 174 ++++++ .../control_plane/test_control_plane.py | 88 +++ 4 files changed, 811 insertions(+) create mode 100755 plugins/modules/control_plane.py create mode 100644 plugins/modules/control_plane_info.py create mode 100644 tests/unit/plugins/modules/control_plane/test_control_plane.py diff --git a/plugins/module_utils/cluster_utils.py b/plugins/module_utils/cluster_utils.py index 13243780..eab5d065 100644 --- a/plugins/module_utils/cluster_utils.py +++ b/plugins/module_utils/cluster_utils.py @@ -72,3 +72,13 @@ def get_cluster_hosts(api_client: ApiClient, cluster: ApiCluster) -> list[ApiHos ) return hosts + +def parse_control_plane_result(control_plane): + """Parse a control plane API result into a dictionary format.""" + result = control_plane.to_dict() + + # Convert tags list to a more readable format if present + if result.get('tags'): + result['tags'] = [{'name': tag.name, 'value': tag.value} for tag in control_plane.tags] + + return result diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py new file mode 100755 index 00000000..4d013965 --- /dev/null +++ b/plugins/modules/control_plane.py @@ -0,0 +1,539 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2025 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. + +import yaml +from cm_client import ( + ClustersResourceApi, + ControlPlanesResourceApi, + ApiInstallControlPlaneArgs, + ApiInstallEmbeddedControlPlaneArgs +) + +from ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( + ClouderaManagerModule, +) + +from ansible_collections.cloudera.cluster.plugins.module_utils.cluster_utils import ( + parse_cluster_result, + parse_control_plane_result +) + +from cm_client.rest import ApiException + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = r""" +module: control_plane +short_description: Manage Cloudera control planes +description: + - Manage the lifecycle and state of control planes in Cloudera on-premise deployments. + - Install, uninstall, and manage both normal and K8s embedded control planes. + - Check for existing control planes and handle idempotency. +author: + - "Jim Enright (@jimright)" +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint +attributes: + check_mode: + support: full +requirements: + - cm-client +options: + state: + description: + - The desired state of the control plane. + - If I(state=present), the control plane will be installed if it does not exist. + - If I(state=absent), the control plane will be uninstalled if it exists. + type: str + required: false + default: present + choices: + - present + - absent + type: + description: + - The type of control plane to manage. + - V(external) for external control plane installation. + - V(embedded) for embedded control plane installation. + type: str + required: true + choices: + - external + - embedded + name: + description: + - The name of the Containerized Cluster that will bring up this control plane. + - Required for embedded control planes. + type: str + required: false + aliases: + - containerized_cluster_name + - control_plane_name + datalake_cluster_name: + description: + - The name of the datalake cluster to use for the initial environment in this control plane. + - Required when creating O(state=present) for embedded control plane O(type=embedded). + type: str + required: false + namespace: + description: + - The namespace where the control plane should be installed. + - Required for external control planes, O(type=external). + type: str + required: false + selected_features: + description: + - The list of features to enable in the control plane. + - Only used during creation O(state=present) of embedded control planes O(type=embedded). + type: list + elements: str + required: false + remote_repo_url: + description: + - The URL of the remote repository where the artifacts used to install the control plane are hosted. + - Required when O(state=present) + type: str + required: false + values_yaml: + description: + - The content of the values YAML used to configure the control plane. + - Required when O(state=present). + type: str + required: false + aliases: + - control_plane_config + kubernetes_type: + description: + - The Kubernetes type on which the control plane should run. + - Required for external control planes, O(type=external). + type: str + required: false + kubeconfig: + description: + - The content of the kubeconfig file of the kubernetes environment on which the install will be performed. + - Required for external control planes, O(type=external). + type: str + required: false + is_override_allowed: + description: + - Flag to specify if the control plane installation override existing configurations. + - Only used during creation O(state=present) of external control planes O(type=external). + type: bool + required: false +seealso: + - module: cloudera.cluster.control_plane_info +""" + +EXAMPLES = r""" +- name: Install a external control plane + cloudera.cluster.control_plane: + host: "example.cloudera.host" + username: "admin" + password: "admin_password" + state: present + type: normal + remote_repo_url: "https://archive.cloudera.com/cdp-pvc/7.1.9.0/" + +- name: Install an embedded control plane + cloudera.cluster.control_plane: + host: "example.cloudera.host" + username: "admin" + password: "admin_password" + state: present + type: embedded + namespace: "cdp-pvc" + kubernetes_type: "EKS" + remote_repo_url: "https://archive.cloudera.com/cdp-pvc/7.1.9.0/" + +- name: Uninstall a control plane + cloudera.cluster.control_plane: + host: "example.cloudera.host" + username: "admin" + password: "admin_password" + state: absent +""" + +RETURN = r""" +control_plane: + description: Information about the control plane after the operation. + type: dict + returned: always + contains: + namespace: + description: The namespace where the control plane is installed. + type: str + returned: when available + uuid: + description: The universally unique ID of this control plane. + type: str + returned: when available + remote_repo_url: + description: The URL of the remote repository. + type: str + returned: when available + version: + description: The CDP version of the control plane. + type: str + returned: when available + kubernetes_type: + description: The Kubernetes type on which the control plane is running. + type: str + returned: when available + tags: + description: Tags associated with the control plane. + type: list + elements: dict + returned: when available +msg: + description: A message describing the result of the operation. + type: str + returned: always +""" + + +class ControlPlane(ClouderaManagerModule): + def __init__(self, module): + super(ControlPlane, self).__init__(module) + + self.state = self.get_param('state') + self.type = self.get_param('type') + self.remote_repo_url = self.get_param('remote_repo_url') + self.values_yaml=self.get_param('values_yaml') + + # Embedded Control plane parameters + self.name = self.get_param('name') + self.datalake_cluster_name = self.get_param('datalake_cluster_name') + self.selected_features = self.get_param('selected_features') + + # External Control plane parameters + self.kubernetes_type = self.get_param('kubernetes_type') + self.namespace = self.get_param('namespace') + self.kubeconfig = self.get_param('kubeconfig') + self.is_override_allowed = self.get_param('is_override_allowed') + + self.delay = 15 # Sleep time between wait for control plane install cmd to complete + + # Initialize the output + self.changed = False + self.output = {} + self.msg = "" + + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + """Process the control plane management operation.""" + + # Check parameters that are required depending on control plane type + if self.type == "embedded" and self.state == "present": + if any( + param is None + for param in [ + self.name, + self.remote_repo_url, + self.datalake_cluster_name, + ] + ): + self.module.fail_json( + msg="Parameter 'name', 'remote_repo_url' and 'datalake_cluster_name' are required when creating an embedded control plane." + ) + + try: + self.cp_api_instance = ControlPlanesResourceApi(self.api_client) + current_cps = self.cp_api_instance.get_control_planes() + current_cp_list = [] + + if current_cps and hasattr(current_cps, 'items'): + current_cp_list = current_cps.items + elif current_cps and isinstance(current_cps, list): + current_cp_list = current_cps + elif current_cps: + current_cp_list = [current_cps] + + except ApiException as e: + if e.status == 404: + current_cp_list = [] + else: + raise e + + # Search for experience clusters matching the control plane name + try: + self.cluster_api_instance = ClustersResourceApi(self.api_client) + + if self.name: + existing_experience_cluster = parse_cluster_result( + self.cluster_api_instance.read_cluster(cluster_name=self.name) + ) + + except ApiException as e: + if e.status == 404: + + # TODO: For External control plane, check if Experience cluster needs to pre-exist + self.module.fail_json( + msg=f"Failed to find Experience Cluster {self.name}. Cluster should exist before creating the control plane.", + details=str(e) + ) + else: + raise e + + # Find matching control plane + existing_cp = self._find_matching_control_plane(current_cp_list, existing_experience_cluster) + + print("Existing control plane found:", existing_cp) + + if self.state == 'present': + if existing_cp: + # Control plane already exists + self.changed = False + self.module.warn( + "Control plane matching the required parameters already exists. Reconciliation is not currently supported." + ) + self.output = parse_control_plane_result(existing_cp) + self.msg = "Control plane matching the required parameters already exists. Reconciliation is not currently supported." + else: + # Install new control plane + if not self.module.check_mode: + self._install_control_plane() + self.changed = True + + elif self.state == 'absent': + if existing_cp: + # Uninstall existing control plane + if not self.module.check_mode: + self._uninstall_control_plane(existing_experience_cluster) + self.changed = True + else: + # Control plane doesn't exist + self.changed = False + self.msg = "Control plane does not exist, nothing to uninstall" + + def _find_matching_control_plane(self, control_planes, experience_cluster): + """Find a control plane that matches the target parameters.""" + if not control_planes: + return None + + # Initialize match + matches = True + + for cp in control_planes: + cp_dict = cp.to_dict() + + if self.type == 'embedded': + + # Extract the value of the _cldr_cm_ek8s_control_plane tag from the tags list + # experience_cluster_uuid = experience_cluster.get('tags', []).tag.get('_cldr_cm_ek8s_control_plane') + + experience_cluster_uuid = experience_cluster.get('tags', {}).get('_cldr_cm_ek8s_control_plane') + + # For embedded control planes, we need to check the control plane uuid + # this is accessed via the cluster name in the experience cluster + if experience_cluster_uuid and cp_dict.get('uuid') != experience_cluster_uuid: + matches = False + + if self.type == 'external': + # For external control planes, we need to check the namespace and kubernetes type + + if self.namespace and cp_dict.get('namespace') != self.namespace: + matches = False + + if self.kubernetes_type and cp_dict.get('kubernetes_type') != self.kubernetes_type: + matches = False + + if matches: + return cp + + return None + + def _install_control_plane(self): + """Install a control plane based on the type.""" + + try: + if self.type == 'embedded': + # Install embedded control plane + if self.values_yaml: + values_yaml_data = self.values_yaml + values_yaml_str = yaml.dump(values_yaml_data) + else: + values_yaml_str = None + + body = ApiInstallEmbeddedControlPlaneArgs( + remote_repo_url=self.get_param('remote_repo_url'), + values_yaml=values_yaml_str, + experience_cluster_name=self.name, + containerized_cluster_name=self.name, + datalake_cluster_name=self.datalake_cluster_name, + selected_features=self.selected_features, + ) + + command = self.cp_api_instance.install_embedded_control_plane(body=body) + print(f"Command id: {command.id}") + # Wait for command completion + command_state = self.wait_for_command_state( + command_id=command.id, polling_interval=self.delay + ) + + print(f"Command state: {command_state}") + + # Retry logic if command failed and can be retried + # if isinstance(command_state, dict): + can_retry = command_state.get('can_retry', False) + success = command_state.get('success', True) + command_id = command_state.get('id', None) + # else: + # can_retry = getattr(command_state, 'can_retry', False) + # success = getattr(command_state, 'success', True) + # command_id = getattr(command_state, 'id', None) + + if not success and can_retry and command_id: + self.module.warn(f"Command failed but can be retried. Retrying command {command_id}.") + retry_command = self.command_api_instance.api_instance.retry(command_id) + + # Wait for command completion + command_state = self.wait_for_command_state( + command_id=retry_command.id, polling_interval=self.delay + ) + + else: # TODO: Install external control plane + pass + # # # Install external control plane + # if self.values_yaml: + # values_yaml_data = self.values_yaml + # values_yaml_str = yaml.dump(values_yaml_data) + # else: + # values_yaml_str = None + + # body = ApiInstallControlPlaneArgs( + # kubernetes_type=self.kubernetes_type, + # remote_repo_url=self.get_param('remote_repo_url'), + # values_yaml=values_yaml_str, + # kube_config=self.kubeconfig, + # namespace=self.namespace, + # is_override_allowed=self.is_override_allowed + # ) + + # command = api_instance.install_control_plane(body=body) + + # Get the installed control plane info + updated_cps = self.cp_api_instance.get_control_planes() + if updated_cps and hasattr(updated_cps, 'items') and updated_cps.items: + + if self.name: + existing_experience_cluster = parse_cluster_result( + self.cluster_api_instance.read_cluster(cluster_name=self.name) + ) + else: + existing_experience_cluster = None + + # Find the newly installed control plane + new_cp = self._find_matching_control_plane(updated_cps.items, existing_experience_cluster) + if new_cp: + self.output = parse_control_plane_result(new_cp) + + self.msg = f"Successfully installed {self.type} control plane" + + except ApiException as e: + self.module.fail_json( + msg=f"Failed to install {self.type} control plane: {str(e)}", + details=str(e) + ) + + def _uninstall_control_plane(self, experience_cluster): + """Uninstall a control plane. + For embedded control planes, this will delete the associated experience cluster.""" + + try: + + if self.type == 'embedded': + + # try: + # # Access the experience cluster to check its status + # # print(experience_cluster.get('entity_status', 'STOPPED')) + # print(experience_cluster['entity_status']) + # except e: + # print(f"Error accessing experience cluster dict: {str(e)}") + + if experience_cluster['entity_status'] != "STOPPED": + stop = self.cluster_api_instance.stop_command(cluster_name=self.name) + # self.wait_command(stop, polling=self.timeout, delay=self.delay) + self.wait_for_command_state(command_id=stop.id, polling_interval=self.delay) + + delete = self.cluster_api_instance.delete_cluster(cluster_name=self.name) + self.wait_command(delete, polling=self.timeout, delay=30) + + else: # TODO: Remove External control plane + pass + + except ApiException as e: + self.module.fail_json( + msg=f"Failed to uninstall control plane: {str(e)}", + details=str(e) + ) + +def main(): + module = ClouderaManagerModule.ansible_module( + argument_spec=dict( + state=dict( + type='str', + default='present', + choices=['present', 'absent'] + ), + type=dict( + type='str', + choices=['external', 'embedded'], + required=True + ), + namespace=dict(type='str'), + remote_repo_url=dict(type='str'), + values_yaml=dict(type='dict', aliases=['control_plane_config']), + name=dict(type='str', aliases=['containerized_cluster_name','control_plane_name']), + datalake_cluster_name=dict(type='str'), + selected_features=dict(type="list", elements="str"), + kubernetes_type=dict(type='str'), + kubeconfig=dict(type='str'), + is_override_allowed=dict(type='bool'), + ), + required_if=[ + ('type', 'external', ['namespace', 'kubernetes_type']), + ('type', 'embedded', ['name']), + ], + supports_check_mode=True, + ) + + result = ControlPlane(module) + + output = dict( + changed=result.changed, + control_plane=result.output, + msg=result.msg, + ) + + if result.debug: + log = result.log_capture.getvalue() + output.update(debug=log, debug_lines=log.split("\n")) + + module.exit_json(**output) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/plugins/modules/control_plane_info.py b/plugins/modules/control_plane_info.py new file mode 100644 index 00000000..8b92c3e8 --- /dev/null +++ b/plugins/modules/control_plane_info.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2024 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: control_plane_info +short_description: Retrieve information about control planes +description: + - Gather information about control planes in Cloudera on-premise deployments. + - Returns details about available control planes including their configuration, versions, and metadata. +author: + - "Jim Enright (@jimright)" +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint +attributes: + check_mode: + support: full +requirements: + - cm-client +seealso: + - module: cloudera.cluster.cluster +""" + +EXAMPLES = r""" +- name: Gather information about all control planes + cloudera.cluster.control_plane_info: + host: "example.cloudera.host" + username: "admin" + password: "admin_password" + register: control_planes_output + +""" + +RETURN = r""" +control_planes: + description: List of control planes in the Cloudera Manager deployment. + type: list + elements: dict + returned: always + contains: + namespace: + description: The namespace where the control plane is installed. + type: str + returned: optional + dns_suffix: + description: The domain where the control plane is installed. + type: str + returned: optional + + uuid: + description: The universally unique ID of this control plane in Cloudera Manager. + type: str + returned: optional + remote_repo_url: + description: The URL of the remote repository where the artifacts used to install the control plane are hosted. + type: str + returned: optional + version: + description: The CDP version of the control plane. + type: str + returned: optional + manifest: + description: The content of the manifest JSON of the control plane. + type: str + returned: optional + values_yaml: + description: The content of the values YAML used to configure the control plane. + type: str + returned: optional + tags: + description: Tags associated with the control plane. + type: list + elements: dict + returned: optional + contains: + name: + description: The name of the tag. + type: str + returned: always + value: + description: The value of the tag. + type: str + returned: always + kubernetes_type: + description: The Kubernetes type on which the control plane is running. + type: str + returned: optional +""" + +from cm_client.rest import ApiException +from cm_client import ControlPlanesResourceApi + +from ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( + ClouderaManagerModule +) + +from ansible_collections.cloudera.cluster.plugins.module_utils.cluster_utils import ( + parse_control_plane_result +) + + +class ControlPlaneInfo(ClouderaManagerModule): + def __init__(self, module): + super(ControlPlaneInfo, self).__init__(module) + + # Initialize the return values + self.output = [] + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + """Retrieve control plane information from Cloudera Manager API.""" + try: + api_instance = ControlPlanesResourceApi(self.api_client) + control_planes = api_instance.get_control_planes() + + if control_planes and hasattr(control_planes, 'items'): + self.output = [ + parse_control_plane_result(cp) for cp in control_planes.items + ] + elif control_planes: + # Handle case where response is a list directly + if isinstance(control_planes, list): + self.output = [parse_control_plane_result(cp) for cp in control_planes] + else: + self.output = [parse_control_plane_result(control_planes)] + + except ApiException as e: + if e.status == 404: + # No control planes found, return empty list + self.output = [] + else: + # Re-raise other API exceptions + raise e + + +def main(): + module = ClouderaManagerModule.ansible_module( + argument_spec=dict(), + supports_check_mode=True, + ) + + result = ControlPlaneInfo(module) + + output = dict( + changed=False, + control_planes=result.output, + ) + + if result.debug: + log = result.log_capture.getvalue() + output.update(debug=log, debug_lines=log.split("\n")) + + module.exit_json(**output) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/control_plane/test_control_plane.py b/tests/unit/plugins/modules/control_plane/test_control_plane.py new file mode 100644 index 00000000..ce2035b0 --- /dev/null +++ b/tests/unit/plugins/modules/control_plane/test_control_plane.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 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 + +import yaml + +__metaclass__ = type + +import logging +import os +import pytest +import unittest + +from ansible_collections.cloudera.cluster.plugins.modules import control_plane +from ansible_collections.cloudera.cluster.tests.unit import ( + AnsibleExitJson, + AnsibleFailJson, +) + +LOG = logging.getLogger(__name__) + +def test_create_embedded_control_plane(module_args, conn): + + if os.getenv("CONTROL_PLANE_DATALAKE_NAME", None): + conn.update(datalake_cluster_name=os.getenv("CONTROL_PLANE_DATALAKE_NAME")) + + if os.getenv("CONTROL_PLANE_REMOTE_REPO_URL", None): + conn.update(remote_repo_url=os.getenv("CONTROL_PLANE_REMOTE_REPO_URL")) + else: + conn.update(remote_repo_url="https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1") + + values_yaml_args = """ + values_yaml: + ContainerInfo: + Mode: public + CopyDocker: false + Database: + Mode: embedded + EmbeddedDbStorage: 200 + Vault: + Mode: embedded + EmbeddedDbStorage: 20 + """ + conn.update(yaml.safe_load(values_yaml_args)) + + module_args({**conn, + "state": "present", + "type": "embedded", + # "name": "je-aio-ecs-cluster", + "name": "je-ce-ecs-cluster", + }) + + with pytest.raises(AnsibleExitJson) as e: + control_plane.main() + + # Verify basic response structure + assert e.value.changed == False + assert isinstance(e.value.control_plane, dict) + + +def test_remove_embedded_control_plane(module_args, conn): + + module_args({**conn, + "state": "absent", + "type": "embedded", + # "name": "je-aio-ecs-cluster", + "name": "je-ce-ecs-cluster", + }) + + with pytest.raises(AnsibleExitJson) as e: + control_plane.main() + + # Verify basic response structure + assert e.value.changed == True From 1be11ef2efde03580a6a0d0f4f2f6a135c6e139c Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:35 +0100 Subject: [PATCH 02/16] Fix lint issues and add control_plane_info test Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 80 +++++++++---------- plugins/modules/control_plane_info.py | 27 +++---- .../control_plane/test_control_plane.py | 10 ++- .../test_control_plane_info.py | 59 ++++++++++++++ 4 files changed, 113 insertions(+), 63 deletions(-) create mode 100644 tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index 4d013965..17d3fe3b 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -20,7 +20,8 @@ ClustersResourceApi, ControlPlanesResourceApi, ApiInstallControlPlaneArgs, - ApiInstallEmbeddedControlPlaneArgs + ApiInstallEmbeddedControlPlaneArgs, + CommandsResourceApi ) from ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( @@ -261,19 +262,19 @@ def process(self): try: self.cp_api_instance = ControlPlanesResourceApi(self.api_client) - current_cps = self.cp_api_instance.get_control_planes() - current_cp_list = [] + current_cps = self.cp_api_instance.get_control_planes().items + # current_cp_list = [] - if current_cps and hasattr(current_cps, 'items'): - current_cp_list = current_cps.items - elif current_cps and isinstance(current_cps, list): - current_cp_list = current_cps - elif current_cps: - current_cp_list = [current_cps] + # if current_cps and hasattr(current_cps, 'items'): + # current_cp_list = current_cps.items + # elif current_cps and isinstance(current_cps, list): + # current_cp_list = current_cps + # elif current_cps: + # current_cp_list = [current_cps] except ApiException as e: if e.status == 404: - current_cp_list = [] + current_cps = [] else: raise e @@ -298,10 +299,8 @@ def process(self): raise e # Find matching control plane - existing_cp = self._find_matching_control_plane(current_cp_list, existing_experience_cluster) - - print("Existing control plane found:", existing_cp) - + existing_cp = self._find_matching_control_plane(current_cps, existing_experience_cluster) + if self.state == 'present': if existing_cp: # Control plane already exists @@ -387,19 +386,17 @@ def _install_control_plane(self): ) command = self.cp_api_instance.install_embedded_control_plane(body=body) - print(f"Command id: {command.id}") # Wait for command completion command_state = self.wait_for_command_state( command_id=command.id, polling_interval=self.delay ) - print(f"Command state: {command_state}") - # Retry logic if command failed and can be retried - # if isinstance(command_state, dict): - can_retry = command_state.get('can_retry', False) - success = command_state.get('success', True) - command_id = command_state.get('id', None) + # command_state is a tuple from read_command_with_http_info, where [0] is the ApiCommand object + api_command = command_state[0] + can_retry = getattr(api_command, "can_retry", False) + success = getattr(api_command, "success", True) + command_id = getattr(api_command, "id", None) # else: # can_retry = getattr(command_state, 'can_retry', False) # success = getattr(command_state, 'success', True) @@ -433,22 +430,28 @@ def _install_control_plane(self): # ) # command = api_instance.install_control_plane(body=body) - + # Get the installed control plane info - updated_cps = self.cp_api_instance.get_control_planes() - if updated_cps and hasattr(updated_cps, 'items') and updated_cps.items: + updated_cps = self.cp_api_instance.get_control_planes().items + + # if updated_cps and hasattr(updated_cps, 'items'): + # updated_cps_list = updated_cps.items + # elif updated_cps and isinstance(updated_cps, list): + # updated_cps_list = updated_cps + # elif updated_cps: + # updated_cps_list = [updated_cps] - if self.name: - existing_experience_cluster = parse_cluster_result( - self.cluster_api_instance.read_cluster(cluster_name=self.name) - ) - else: - existing_experience_cluster = None - - # Find the newly installed control plane - new_cp = self._find_matching_control_plane(updated_cps.items, existing_experience_cluster) - if new_cp: - self.output = parse_control_plane_result(new_cp) + if self.name: + existing_experience_cluster = parse_cluster_result( + self.cluster_api_instance.read_cluster(cluster_name=self.name) + ) + else: + existing_experience_cluster = None + + # Find the newly installed control plane + new_cp = self._find_matching_control_plane(updated_cps, existing_experience_cluster) + if new_cp: + self.output = parse_control_plane_result(new_cp) self.msg = f"Successfully installed {self.type} control plane" @@ -466,13 +469,6 @@ def _uninstall_control_plane(self, experience_cluster): if self.type == 'embedded': - # try: - # # Access the experience cluster to check its status - # # print(experience_cluster.get('entity_status', 'STOPPED')) - # print(experience_cluster['entity_status']) - # except e: - # print(f"Error accessing experience cluster dict: {str(e)}") - if experience_cluster['entity_status'] != "STOPPED": stop = self.cluster_api_instance.stop_command(cluster_name=self.name) # self.wait_command(stop, polling=self.timeout, delay=self.delay) diff --git a/plugins/modules/control_plane_info.py b/plugins/modules/control_plane_info.py index 8b92c3e8..c396d0d7 100644 --- a/plugins/modules/control_plane_info.py +++ b/plugins/modules/control_plane_info.py @@ -105,21 +105,21 @@ from cm_client import ControlPlanesResourceApi from ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( - ClouderaManagerModule + ClouderaManagerModule, ) from ansible_collections.cloudera.cluster.plugins.module_utils.cluster_utils import ( - parse_control_plane_result + parse_control_plane_result, ) class ControlPlaneInfo(ClouderaManagerModule): def __init__(self, module): super(ControlPlaneInfo, self).__init__(module) - + # Initialize the return values self.output = [] - + # Execute the logic self.process() @@ -128,19 +128,12 @@ def process(self): """Retrieve control plane information from Cloudera Manager API.""" try: api_instance = ControlPlanesResourceApi(self.api_client) - control_planes = api_instance.get_control_planes() - - if control_planes and hasattr(control_planes, 'items'): - self.output = [ - parse_control_plane_result(cp) for cp in control_planes.items - ] - elif control_planes: - # Handle case where response is a list directly - if isinstance(control_planes, list): - self.output = [parse_control_plane_result(cp) for cp in control_planes] - else: - self.output = [parse_control_plane_result(control_planes)] - + control_planes = api_instance.get_control_planes().items + + self.output = [ + parse_control_plane_result(cp) for cp in control_planes + ] + except ApiException as e: if e.status == 404: # No control planes found, return empty list diff --git a/tests/unit/plugins/modules/control_plane/test_control_plane.py b/tests/unit/plugins/modules/control_plane/test_control_plane.py index ce2035b0..4dd6a773 100644 --- a/tests/unit/plugins/modules/control_plane/test_control_plane.py +++ b/tests/unit/plugins/modules/control_plane/test_control_plane.py @@ -38,6 +38,9 @@ def test_create_embedded_control_plane(module_args, conn): if os.getenv("CONTROL_PLANE_DATALAKE_NAME", None): conn.update(datalake_cluster_name=os.getenv("CONTROL_PLANE_DATALAKE_NAME")) + if os.getenv("CONTROL_PLANE_NAME", None): + conn.update(name=os.getenv("CONTROL_PLANE_NAME")) + if os.getenv("CONTROL_PLANE_REMOTE_REPO_URL", None): conn.update(remote_repo_url=os.getenv("CONTROL_PLANE_REMOTE_REPO_URL")) else: @@ -60,8 +63,6 @@ def test_create_embedded_control_plane(module_args, conn): module_args({**conn, "state": "present", "type": "embedded", - # "name": "je-aio-ecs-cluster", - "name": "je-ce-ecs-cluster", }) with pytest.raises(AnsibleExitJson) as e: @@ -74,11 +75,12 @@ def test_create_embedded_control_plane(module_args, conn): def test_remove_embedded_control_plane(module_args, conn): + if os.getenv("CONTROL_PLANE_NAME", None): + conn.update(name=os.getenv("CONTROL_PLANE_NAME")) + module_args({**conn, "state": "absent", "type": "embedded", - # "name": "je-aio-ecs-cluster", - "name": "je-ce-ecs-cluster", }) with pytest.raises(AnsibleExitJson) as e: diff --git a/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py b/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py new file mode 100644 index 00000000..cf8eb7c5 --- /dev/null +++ b/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 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 logging +import os +import pytest +import unittest + +from ansible_collections.cloudera.cluster.plugins.modules import control_plane_info +from ansible_collections.cloudera.cluster.tests.unit import ( + AnsibleExitJson, + AnsibleFailJson, +) + +LOG = logging.getLogger(__name__) + +def test_list_all_control_planes_simple(module_args, conn): + + module_args({**conn}) + + with pytest.raises(AnsibleExitJson) as e: + control_plane_info.main() + + # Verify basic response structure + assert e.value.changed == False + assert isinstance(e.value.control_planes, list) + + # Log the results for debugging + LOG.info(f"Found {len(e.value.control_planes)} control planes") + +def test_invalid_credentials(module_args, conn): + """Test behavior with invalid credentials""" + + # Update parameters to enable with invalid ssh key + conn.update(username="invalid_user", password="invalid_pass") + module_args({**conn}) + + with pytest.raises(AnsibleFailJson) as e: + control_plane_info.main() + + # Should fail with authentication error + assert e.value.failed == True From 8a211da73b80d9611a0003454618a7ddbe170e95 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:36 +0100 Subject: [PATCH 03/16] Fix lint error on control_plane_info tests Signed-off-by: Jim Enright --- .../modules/control_plane_info/test_control_plane_info.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py b/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py index cf8eb7c5..77566105 100644 --- a/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py +++ b/tests/unit/plugins/modules/control_plane_info/test_control_plane_info.py @@ -31,6 +31,7 @@ LOG = logging.getLogger(__name__) + def test_list_all_control_planes_simple(module_args, conn): module_args({**conn}) @@ -45,6 +46,7 @@ def test_list_all_control_planes_simple(module_args, conn): # Log the results for debugging LOG.info(f"Found {len(e.value.control_planes)} control planes") + def test_invalid_credentials(module_args, conn): """Test behavior with invalid credentials""" @@ -54,6 +56,6 @@ def test_invalid_credentials(module_args, conn): with pytest.raises(AnsibleFailJson) as e: control_plane_info.main() - + # Should fail with authentication error assert e.value.failed == True From 91ea017d11a7a2421bae66e115b1e844398e6c1a Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:37 +0100 Subject: [PATCH 04/16] Fix tflint on control plane tests Signed-off-by: Jim Enright --- .../control_plane/test_control_plane.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/unit/plugins/modules/control_plane/test_control_plane.py b/tests/unit/plugins/modules/control_plane/test_control_plane.py index 4dd6a773..45c04d1c 100644 --- a/tests/unit/plugins/modules/control_plane/test_control_plane.py +++ b/tests/unit/plugins/modules/control_plane/test_control_plane.py @@ -33,6 +33,7 @@ LOG = logging.getLogger(__name__) + def test_create_embedded_control_plane(module_args, conn): if os.getenv("CONTROL_PLANE_DATALAKE_NAME", None): @@ -44,7 +45,9 @@ def test_create_embedded_control_plane(module_args, conn): if os.getenv("CONTROL_PLANE_REMOTE_REPO_URL", None): conn.update(remote_repo_url=os.getenv("CONTROL_PLANE_REMOTE_REPO_URL")) else: - conn.update(remote_repo_url="https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1") + conn.update( + remote_repo_url="https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1" + ) values_yaml_args = """ values_yaml: @@ -60,11 +63,14 @@ def test_create_embedded_control_plane(module_args, conn): """ conn.update(yaml.safe_load(values_yaml_args)) - module_args({**conn, - "state": "present", - "type": "embedded", - }) - + module_args( + { + **conn, + "state": "present", + "type": "embedded", + } + ) + with pytest.raises(AnsibleExitJson) as e: control_plane.main() @@ -78,11 +84,14 @@ def test_remove_embedded_control_plane(module_args, conn): if os.getenv("CONTROL_PLANE_NAME", None): conn.update(name=os.getenv("CONTROL_PLANE_NAME")) - module_args({**conn, - "state": "absent", - "type": "embedded", - }) - + module_args( + { + **conn, + "state": "absent", + "type": "embedded", + } + ) + with pytest.raises(AnsibleExitJson) as e: control_plane.main() From f8be18fba3c8eb639788d72d9b440759c3a95808 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:38 +0100 Subject: [PATCH 05/16] Fix various tflint issues Signed-off-by: Jim Enright --- plugins/module_utils/cluster_utils.py | 11 +- plugins/modules/control_plane.py | 233 ++++++++++-------- plugins/modules/control_plane_info.py | 4 +- .../control_plane/test_control_plane.py | 6 +- 4 files changed, 136 insertions(+), 118 deletions(-) diff --git a/plugins/module_utils/cluster_utils.py b/plugins/module_utils/cluster_utils.py index eab5d065..f1e09601 100644 --- a/plugins/module_utils/cluster_utils.py +++ b/plugins/module_utils/cluster_utils.py @@ -73,12 +73,15 @@ def get_cluster_hosts(api_client: ApiClient, cluster: ApiCluster) -> list[ApiHos return hosts + def parse_control_plane_result(control_plane): """Parse a control plane API result into a dictionary format.""" result = control_plane.to_dict() - + # Convert tags list to a more readable format if present - if result.get('tags'): - result['tags'] = [{'name': tag.name, 'value': tag.value} for tag in control_plane.tags] - + if result.get("tags"): + result["tags"] = [ + {"name": tag.name, "value": tag.value} for tag in control_plane.tags + ] + return result diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index 17d3fe3b..90aa1d2c 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -21,7 +21,7 @@ ControlPlanesResourceApi, ApiInstallControlPlaneArgs, ApiInstallEmbeddedControlPlaneArgs, - CommandsResourceApi + CommandsResourceApi, ) from ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( @@ -30,7 +30,7 @@ from ansible_collections.cloudera.cluster.plugins.module_utils.cluster_utils import ( parse_cluster_result, - parse_control_plane_result + parse_control_plane_result, ) from cm_client.rest import ApiException @@ -107,7 +107,7 @@ - Only used during creation O(state=present) of embedded control planes O(type=embedded). type: list elements: str - required: false + required: false remote_repo_url: description: - The URL of the remote repository where the artifacts used to install the control plane are hosted. @@ -215,30 +215,31 @@ class ControlPlane(ClouderaManagerModule): def __init__(self, module): super(ControlPlane, self).__init__(module) - self.state = self.get_param('state') - self.type = self.get_param('type') - self.remote_repo_url = self.get_param('remote_repo_url') - self.values_yaml=self.get_param('values_yaml') + self.state = self.get_param("state") + self.type = self.get_param("type") + self.remote_repo_url = self.get_param("remote_repo_url") + self.values_yaml = self.get_param("values_yaml") # Embedded Control plane parameters - self.name = self.get_param('name') - self.datalake_cluster_name = self.get_param('datalake_cluster_name') - self.selected_features = self.get_param('selected_features') + self.name = self.get_param("name") + self.datalake_cluster_name = self.get_param("datalake_cluster_name") + self.selected_features = self.get_param("selected_features") # External Control plane parameters - self.kubernetes_type = self.get_param('kubernetes_type') - self.namespace = self.get_param('namespace') - self.kubeconfig = self.get_param('kubeconfig') - self.is_override_allowed = self.get_param('is_override_allowed') + self.kubernetes_type = self.get_param("kubernetes_type") + self.namespace = self.get_param("namespace") + self.kubeconfig = self.get_param("kubeconfig") + self.is_override_allowed = self.get_param("is_override_allowed") - self.delay = 15 # Sleep time between wait for control plane install cmd to complete + self.delay = ( + 15 # Sleep time between wait for control plane install cmd to complete + ) # Initialize the output self.changed = False self.output = {} self.msg = "" - # Execute the logic self.process() @@ -246,7 +247,7 @@ def __init__(self, module): def process(self): """Process the control plane management operation.""" - # Check parameters that are required depending on control plane type + # Check parameters that are required depending on control plane type if self.type == "embedded" and self.state == "present": if any( param is None @@ -259,19 +260,19 @@ def process(self): self.module.fail_json( msg="Parameter 'name', 'remote_repo_url' and 'datalake_cluster_name' are required when creating an embedded control plane." ) - + try: self.cp_api_instance = ControlPlanesResourceApi(self.api_client) current_cps = self.cp_api_instance.get_control_planes().items # current_cp_list = [] - + # if current_cps and hasattr(current_cps, 'items'): # current_cp_list = current_cps.items # elif current_cps and isinstance(current_cps, list): # current_cp_list = current_cps # elif current_cps: # current_cp_list = [current_cps] - + except ApiException as e: if e.status == 404: current_cps = [] @@ -280,28 +281,30 @@ def process(self): # Search for experience clusters matching the control plane name try: - self.cluster_api_instance = ClustersResourceApi(self.api_client) - - if self.name: - existing_experience_cluster = parse_cluster_result( - self.cluster_api_instance.read_cluster(cluster_name=self.name) - ) + self.cluster_api_instance = ClustersResourceApi(self.api_client) + + if self.name: + existing_experience_cluster = parse_cluster_result( + self.cluster_api_instance.read_cluster(cluster_name=self.name) + ) except ApiException as e: if e.status == 404: - + # TODO: For External control plane, check if Experience cluster needs to pre-exist self.module.fail_json( - msg=f"Failed to find Experience Cluster {self.name}. Cluster should exist before creating the control plane.", - details=str(e) + msg=f"Failed to find Experience Cluster {self.name}. Cluster should exist before creating the control plane.", + details=str(e), ) else: raise e # Find matching control plane - existing_cp = self._find_matching_control_plane(current_cps, existing_experience_cluster) - - if self.state == 'present': + existing_cp = self._find_matching_control_plane( + current_cps, existing_experience_cluster + ) + + if self.state == "present": if existing_cp: # Control plane already exists self.changed = False @@ -313,10 +316,10 @@ def process(self): else: # Install new control plane if not self.module.check_mode: - self._install_control_plane() + self._install_control_plane() self.changed = True - - elif self.state == 'absent': + + elif self.state == "absent": if existing_cp: # Uninstall existing control plane if not self.module.check_mode: @@ -331,61 +334,69 @@ def _find_matching_control_plane(self, control_planes, experience_cluster): """Find a control plane that matches the target parameters.""" if not control_planes: return None - + # Initialize match matches = True - + for cp in control_planes: cp_dict = cp.to_dict() - - if self.type == 'embedded': - - # Extract the value of the _cldr_cm_ek8s_control_plane tag from the tags list - # experience_cluster_uuid = experience_cluster.get('tags', []).tag.get('_cldr_cm_ek8s_control_plane') - experience_cluster_uuid = experience_cluster.get('tags', {}).get('_cldr_cm_ek8s_control_plane') + if self.type == "embedded": + + # Extract the value of the _cldr_cm_ek8s_control_plane tag from the tags list + # experience_cluster_uuid = experience_cluster.get('tags', []).tag.get('_cldr_cm_ek8s_control_plane') + + experience_cluster_uuid = experience_cluster.get("tags", {}).get( + "_cldr_cm_ek8s_control_plane" + ) # For embedded control planes, we need to check the control plane uuid # this is accessed via the cluster name in the experience cluster - if experience_cluster_uuid and cp_dict.get('uuid') != experience_cluster_uuid: - matches = False - - if self.type == 'external': + if ( + experience_cluster_uuid + and cp_dict.get("uuid") != experience_cluster_uuid + ): + matches = False + + if self.type == "external": # For external control planes, we need to check the namespace and kubernetes type - if self.namespace and cp_dict.get('namespace') != self.namespace: - matches = False + if self.namespace and cp_dict.get("namespace") != self.namespace: + matches = False + + if ( + self.kubernetes_type + and cp_dict.get("kubernetes_type") != self.kubernetes_type + ): + matches = False - if self.kubernetes_type and cp_dict.get('kubernetes_type') != self.kubernetes_type: - matches = False - if matches: return cp - + return None def _install_control_plane(self): """Install a control plane based on the type.""" try: - if self.type == 'embedded': + if self.type == "embedded": # Install embedded control plane if self.values_yaml: - values_yaml_data = self.values_yaml - values_yaml_str = yaml.dump(values_yaml_data) + values_yaml_data = self.values_yaml + values_yaml_str = yaml.dump(values_yaml_data) else: values_yaml_str = None body = ApiInstallEmbeddedControlPlaneArgs( - remote_repo_url=self.get_param('remote_repo_url'), + remote_repo_url=self.get_param("remote_repo_url"), values_yaml=values_yaml_str, experience_cluster_name=self.name, containerized_cluster_name=self.name, datalake_cluster_name=self.datalake_cluster_name, selected_features=self.selected_features, ) - - command = self.cp_api_instance.install_embedded_control_plane(body=body) + + command = self.cp_api_instance.install_embedded_control_plane(body=body) # Wait for command completion command_state = self.wait_for_command_state( command_id=command.id, polling_interval=self.delay @@ -403,15 +414,19 @@ def _install_control_plane(self): # command_id = getattr(command_state, 'id', None) if not success and can_retry and command_id: - self.module.warn(f"Command failed but can be retried. Retrying command {command_id}.") - retry_command = self.command_api_instance.api_instance.retry(command_id) + self.module.warn( + f"Command failed but can be retried. Retrying command {command_id}." + ) + retry_command = self.command_api_instance.api_instance.retry( + command_id + ) # Wait for command completion command_state = self.wait_for_command_state( command_id=retry_command.id, polling_interval=self.delay - ) + ) - else: # TODO: Install external control plane + else: # TODO: Install external control plane pass # # # Install external control plane # if self.values_yaml: @@ -421,14 +436,14 @@ def _install_control_plane(self): # values_yaml_str = None # body = ApiInstallControlPlaneArgs( - # kubernetes_type=self.kubernetes_type, + # kubernetes_type=self.kubernetes_type, # remote_repo_url=self.get_param('remote_repo_url'), # values_yaml=values_yaml_str, # kube_config=self.kubeconfig, # namespace=self.namespace, # is_override_allowed=self.is_override_allowed # ) - + # command = api_instance.install_control_plane(body=body) # Get the installed control plane info @@ -440,25 +455,27 @@ def _install_control_plane(self): # updated_cps_list = updated_cps # elif updated_cps: # updated_cps_list = [updated_cps] - + if self.name: - existing_experience_cluster = parse_cluster_result( + existing_experience_cluster = parse_cluster_result( self.cluster_api_instance.read_cluster(cluster_name=self.name) - ) + ) else: - existing_experience_cluster = None - + existing_experience_cluster = None + # Find the newly installed control plane - new_cp = self._find_matching_control_plane(updated_cps, existing_experience_cluster) + new_cp = self._find_matching_control_plane( + updated_cps, existing_experience_cluster + ) if new_cp: self.output = parse_control_plane_result(new_cp) - + self.msg = f"Successfully installed {self.type} control plane" - + except ApiException as e: self.module.fail_json( msg=f"Failed to install {self.type} control plane: {str(e)}", - details=str(e) + details=str(e), ) def _uninstall_control_plane(self, experience_cluster): @@ -466,56 +483,56 @@ def _uninstall_control_plane(self, experience_cluster): For embedded control planes, this will delete the associated experience cluster.""" try: - - if self.type == 'embedded': - if experience_cluster['entity_status'] != "STOPPED": - stop = self.cluster_api_instance.stop_command(cluster_name=self.name) - # self.wait_command(stop, polling=self.timeout, delay=self.delay) - self.wait_for_command_state(command_id=stop.id, polling_interval=self.delay) + if self.type == "embedded": + + if experience_cluster["entity_status"] != "STOPPED": + stop = self.cluster_api_instance.stop_command( + cluster_name=self.name + ) + # self.wait_command(stop, polling=self.timeout, delay=self.delay) + self.wait_for_command_state( + command_id=stop.id, polling_interval=self.delay + ) + + delete = self.cluster_api_instance.delete_cluster( + cluster_name=self.name + ) + self.wait_command(delete, polling=self.timeout, delay=30) - delete = self.cluster_api_instance.delete_cluster(cluster_name=self.name) - self.wait_command(delete, polling=self.timeout, delay=30) + else: # TODO: Remove External control plane + pass - else: # TODO: Remove External control plane - pass - except ApiException as e: self.module.fail_json( - msg=f"Failed to uninstall control plane: {str(e)}", - details=str(e) + msg=f"Failed to uninstall control plane: {str(e)}", details=str(e) ) + def main(): module = ClouderaManagerModule.ansible_module( argument_spec=dict( - state=dict( - type='str', - default='present', - choices=['present', 'absent'] + state=dict(type="str", default="present", choices=["present", "absent"]), + type=dict(type="str", choices=["external", "embedded"], required=True), + namespace=dict(type="str"), + remote_repo_url=dict(type="str"), + values_yaml=dict(type="dict", aliases=["control_plane_config"]), + name=dict( + type="str", aliases=["containerized_cluster_name", "control_plane_name"] ), - type=dict( - type='str', - choices=['external', 'embedded'], - required=True - ), - namespace=dict(type='str'), - remote_repo_url=dict(type='str'), - values_yaml=dict(type='dict', aliases=['control_plane_config']), - name=dict(type='str', aliases=['containerized_cluster_name','control_plane_name']), - datalake_cluster_name=dict(type='str'), + datalake_cluster_name=dict(type="str"), selected_features=dict(type="list", elements="str"), - kubernetes_type=dict(type='str'), - kubeconfig=dict(type='str'), - is_override_allowed=dict(type='bool'), + kubernetes_type=dict(type="str"), + kubeconfig=dict(type="str"), + is_override_allowed=dict(type="bool"), ), required_if=[ - ('type', 'external', ['namespace', 'kubernetes_type']), - ('type', 'embedded', ['name']), + ("type", "external", ["namespace", "kubernetes_type"]), + ("type", "embedded", ["name"]), ], supports_check_mode=True, ) - + result = ControlPlane(module) output = dict( @@ -532,4 +549,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/plugins/modules/control_plane_info.py b/plugins/modules/control_plane_info.py index c396d0d7..77ea32b5 100644 --- a/plugins/modules/control_plane_info.py +++ b/plugins/modules/control_plane_info.py @@ -130,9 +130,7 @@ def process(self): api_instance = ControlPlanesResourceApi(self.api_client) control_planes = api_instance.get_control_planes().items - self.output = [ - parse_control_plane_result(cp) for cp in control_planes - ] + self.output = [parse_control_plane_result(cp) for cp in control_planes] except ApiException as e: if e.status == 404: diff --git a/tests/unit/plugins/modules/control_plane/test_control_plane.py b/tests/unit/plugins/modules/control_plane/test_control_plane.py index 45c04d1c..6b45b435 100644 --- a/tests/unit/plugins/modules/control_plane/test_control_plane.py +++ b/tests/unit/plugins/modules/control_plane/test_control_plane.py @@ -46,7 +46,7 @@ def test_create_embedded_control_plane(module_args, conn): conn.update(remote_repo_url=os.getenv("CONTROL_PLANE_REMOTE_REPO_URL")) else: conn.update( - remote_repo_url="https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1" + remote_repo_url="https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1", ) values_yaml_args = """ @@ -68,7 +68,7 @@ def test_create_embedded_control_plane(module_args, conn): **conn, "state": "present", "type": "embedded", - } + }, ) with pytest.raises(AnsibleExitJson) as e: @@ -89,7 +89,7 @@ def test_remove_embedded_control_plane(module_args, conn): **conn, "state": "absent", "type": "embedded", - } + }, ) with pytest.raises(AnsibleExitJson) as e: From b5c00549ed22a9dbde7c4d1ee92c11b62b5b64e1 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:39 +0100 Subject: [PATCH 06/16] Fix tflint after rebase Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index 90aa1d2c..f8d3d495 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -258,7 +258,7 @@ def process(self): ] ): self.module.fail_json( - msg="Parameter 'name', 'remote_repo_url' and 'datalake_cluster_name' are required when creating an embedded control plane." + msg="Parameter 'name', 'remote_repo_url' and 'datalake_cluster_name' are required when creating an embedded control plane.", ) try: @@ -285,7 +285,7 @@ def process(self): if self.name: existing_experience_cluster = parse_cluster_result( - self.cluster_api_instance.read_cluster(cluster_name=self.name) + self.cluster_api_instance.read_cluster(cluster_name=self.name), ) except ApiException as e: @@ -301,7 +301,7 @@ def process(self): # Find matching control plane existing_cp = self._find_matching_control_plane( - current_cps, existing_experience_cluster + current_cps, existing_experience_cluster, ) if self.state == "present": @@ -309,7 +309,7 @@ def process(self): # Control plane already exists self.changed = False self.module.warn( - "Control plane matching the required parameters already exists. Reconciliation is not currently supported." + "Control plane matching the required parameters already exists. Reconciliation is not currently supported.", ) self.output = parse_control_plane_result(existing_cp) self.msg = "Control plane matching the required parameters already exists. Reconciliation is not currently supported." @@ -347,7 +347,7 @@ def _find_matching_control_plane(self, control_planes, experience_cluster): # experience_cluster_uuid = experience_cluster.get('tags', []).tag.get('_cldr_cm_ek8s_control_plane') experience_cluster_uuid = experience_cluster.get("tags", {}).get( - "_cldr_cm_ek8s_control_plane" + "_cldr_cm_ek8s_control_plane", ) # For embedded control planes, we need to check the control plane uuid @@ -399,7 +399,7 @@ def _install_control_plane(self): command = self.cp_api_instance.install_embedded_control_plane(body=body) # Wait for command completion command_state = self.wait_for_command_state( - command_id=command.id, polling_interval=self.delay + command_id=command.id, polling_interval=self.delay, ) # Retry logic if command failed and can be retried @@ -415,15 +415,15 @@ def _install_control_plane(self): if not success and can_retry and command_id: self.module.warn( - f"Command failed but can be retried. Retrying command {command_id}." + f"Command failed but can be retried. Retrying command {command_id}.", ) retry_command = self.command_api_instance.api_instance.retry( - command_id + command_id, ) # Wait for command completion command_state = self.wait_for_command_state( - command_id=retry_command.id, polling_interval=self.delay + command_id=retry_command.id, polling_interval=self.delay, ) else: # TODO: Install external control plane @@ -458,14 +458,14 @@ def _install_control_plane(self): if self.name: existing_experience_cluster = parse_cluster_result( - self.cluster_api_instance.read_cluster(cluster_name=self.name) + self.cluster_api_instance.read_cluster(cluster_name=self.name), ) else: existing_experience_cluster = None # Find the newly installed control plane new_cp = self._find_matching_control_plane( - updated_cps, existing_experience_cluster + updated_cps, existing_experience_cluster, ) if new_cp: self.output = parse_control_plane_result(new_cp) @@ -488,15 +488,15 @@ def _uninstall_control_plane(self, experience_cluster): if experience_cluster["entity_status"] != "STOPPED": stop = self.cluster_api_instance.stop_command( - cluster_name=self.name + cluster_name=self.name, ) # self.wait_command(stop, polling=self.timeout, delay=self.delay) self.wait_for_command_state( - command_id=stop.id, polling_interval=self.delay + command_id=stop.id, polling_interval=self.delay, ) delete = self.cluster_api_instance.delete_cluster( - cluster_name=self.name + cluster_name=self.name, ) self.wait_command(delete, polling=self.timeout, delay=30) @@ -505,7 +505,7 @@ def _uninstall_control_plane(self, experience_cluster): except ApiException as e: self.module.fail_json( - msg=f"Failed to uninstall control plane: {str(e)}", details=str(e) + msg=f"Failed to uninstall control plane: {str(e)}", details=str(e), ) @@ -518,7 +518,7 @@ def main(): remote_repo_url=dict(type="str"), values_yaml=dict(type="dict", aliases=["control_plane_config"]), name=dict( - type="str", aliases=["containerized_cluster_name", "control_plane_name"] + type="str", aliases=["containerized_cluster_name", "control_plane_name"], ), datalake_cluster_name=dict(type="str"), selected_features=dict(type="list", elements="str"), From e8bc0059caeedbd7846f68b4601f52d79f13411e Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 20:59:40 +0100 Subject: [PATCH 07/16] Fix tflint issues Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index f8d3d495..87fa42c9 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -301,7 +301,8 @@ def process(self): # Find matching control plane existing_cp = self._find_matching_control_plane( - current_cps, existing_experience_cluster, + current_cps, + existing_experience_cluster, ) if self.state == "present": @@ -399,7 +400,8 @@ def _install_control_plane(self): command = self.cp_api_instance.install_embedded_control_plane(body=body) # Wait for command completion command_state = self.wait_for_command_state( - command_id=command.id, polling_interval=self.delay, + command_id=command.id, + polling_interval=self.delay, ) # Retry logic if command failed and can be retried @@ -423,7 +425,8 @@ def _install_control_plane(self): # Wait for command completion command_state = self.wait_for_command_state( - command_id=retry_command.id, polling_interval=self.delay, + command_id=retry_command.id, + polling_interval=self.delay, ) else: # TODO: Install external control plane @@ -465,7 +468,8 @@ def _install_control_plane(self): # Find the newly installed control plane new_cp = self._find_matching_control_plane( - updated_cps, existing_experience_cluster, + updated_cps, + existing_experience_cluster, ) if new_cp: self.output = parse_control_plane_result(new_cp) @@ -480,7 +484,8 @@ def _install_control_plane(self): def _uninstall_control_plane(self, experience_cluster): """Uninstall a control plane. - For embedded control planes, this will delete the associated experience cluster.""" + For embedded control planes, this will delete the associated experience cluster. + """ try: @@ -492,7 +497,8 @@ def _uninstall_control_plane(self, experience_cluster): ) # self.wait_command(stop, polling=self.timeout, delay=self.delay) self.wait_for_command_state( - command_id=stop.id, polling_interval=self.delay, + command_id=stop.id, + polling_interval=self.delay, ) delete = self.cluster_api_instance.delete_cluster( @@ -505,7 +511,8 @@ def _uninstall_control_plane(self, experience_cluster): except ApiException as e: self.module.fail_json( - msg=f"Failed to uninstall control plane: {str(e)}", details=str(e), + msg=f"Failed to uninstall control plane: {str(e)}", + details=str(e), ) @@ -518,7 +525,8 @@ def main(): remote_repo_url=dict(type="str"), values_yaml=dict(type="dict", aliases=["control_plane_config"]), name=dict( - type="str", aliases=["containerized_cluster_name", "control_plane_name"], + type="str", + aliases=["containerized_cluster_name", "control_plane_name"], ), datalake_cluster_name=dict(type="str"), selected_features=dict(type="list", elements="str"), From 5aab03173629b73f1c894ec34b0252b010a4bc78 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 21:01:34 +0100 Subject: [PATCH 08/16] Add action group for control plane Signed-off-by: Jim Enright --- meta/runtime.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/meta/runtime.yml b/meta/runtime.yml index 278873f5..c7bf8913 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -76,6 +76,12 @@ action_groups: - cm - parcel_info - parcel + control_plane: + - metadata: + extend_group: + - cm + - control_plane_info + - control_plane deployment: - metadata: extend_group: @@ -86,6 +92,7 @@ action_groups: - host - host_template - parcel + - control_plane plugin_routing: filter: From 54aa079df9452b71b273bdf654013a153da3dad8 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Tue, 8 Jul 2025 21:34:01 +0100 Subject: [PATCH 09/16] Fix tflint issues Signed-off-by: Jim Enright --- plugins/modules/control_plane_info.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/modules/control_plane_info.py b/plugins/modules/control_plane_info.py index 77ea32b5..746efc1b 100644 --- a/plugins/modules/control_plane_info.py +++ b/plugins/modules/control_plane_info.py @@ -42,7 +42,6 @@ username: "admin" password: "admin_password" register: control_planes_output - """ RETURN = r""" From 1bf2bdfb25c49ce2ea84a1d6bb8e60366217840c Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Wed, 9 Jul 2025 13:02:48 +0100 Subject: [PATCH 10/16] Updates based on PR feedback Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 183 +++++++++--------- plugins/modules/control_plane_info.py | 8 +- .../control_plane/test_control_plane.py | 18 +- 3 files changed, 100 insertions(+), 109 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index 87fa42c9..adbc2b49 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -15,6 +15,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import yaml from cm_client import ( ClustersResourceApi, @@ -32,15 +33,10 @@ parse_cluster_result, parse_control_plane_result, ) +from ansible.module_utils.common.text.converters import to_native from cm_client.rest import ApiException -ANSIBLE_METADATA = { - "metadata_version": "1.1", - "status": ["preview"], - "supported_by": "community", -} - DOCUMENTATION = r""" module: control_plane short_description: Manage Cloudera control planes @@ -50,6 +46,7 @@ - Check for existing control planes and handle idempotency. author: - "Jim Enright (@jimright)" +version_added: 5.0.0 extends_documentation_fragment: - cloudera.cluster.cm_options - cloudera.cluster.cm_endpoint @@ -82,8 +79,8 @@ - embedded name: description: - - The name of the Containerized Cluster that will bring up this control plane. - - Required for embedded control planes. + - The name of the Experience Cluster associated with the control plane. + - Required for O(type=embedded) control planes. type: str required: false aliases: @@ -91,57 +88,72 @@ - control_plane_name datalake_cluster_name: description: - - The name of the datalake cluster to use for the initial environment in this control plane. + - The name of the datalake base cluster associated with the control plane. - Required when creating O(state=present) for embedded control plane O(type=embedded). type: str required: false namespace: description: - - The namespace where the control plane should be installed. + - The Kubernetes namespace where the control plane should be installed. - Required for external control planes, O(type=external). type: str required: false - selected_features: + features: description: - The list of features to enable in the control plane. - Only used during creation O(state=present) of embedded control planes O(type=embedded). type: list elements: str required: false + aliases: + - selected_features remote_repo_url: description: - The URL of the remote repository where the artifacts used to install the control plane are hosted. - Required when O(state=present) type: str required: false - values_yaml: + control_plane_config: description: - The content of the values YAML used to configure the control plane. - Required when O(state=present). - type: str + type: dict required: false aliases: - - control_plane_config + - values_yaml kubernetes_type: description: - The Kubernetes type on which the control plane should run. - Required for external control planes, O(type=external). type: str required: false + aliases: + - external_k8s_type kubeconfig: description: - The content of the kubeconfig file of the kubernetes environment on which the install will be performed. - Required for external control planes, O(type=external). type: str required: false - is_override_allowed: + override: description: - Flag to specify if the control plane installation override existing configurations. - Only used during creation O(state=present) of external control planes O(type=external). type: bool required: false + delay: + description: + - Delay (interval), in seconds, between check for control plane commandstatus check. + type: int + default: 15 + aliases: + - polling_interval seealso: - module: cloudera.cluster.control_plane_info + - module: cloudera.cluster.cluster +notes: + - Removing an embedded control plane is not possible with this module. + - Instead use the O(cloudera.cluster.cluster) module to remove embedded control planes. """ EXAMPLES = r""" @@ -151,8 +163,21 @@ username: "admin" password: "admin_password" state: present - type: normal - remote_repo_url: "https://archive.cloudera.com/cdp-pvc/7.1.9.0/" + type: external + namespace: "example-namespace" + remote_repo_url: "https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1" + kubernetes_type: "openshift" + kubeconfig: {{ lookup('ansible.builtin.file', 'kubeconfig.yml') }} + control_plane_config: + ContainerInfo: + Mode: public + CopyDocker: false + Database: + Mode: embedded + EmbeddedDbStorage: 200 + Vault: + Mode: embedded + EmbeddedDbStorage: 20 - name: Install an embedded control plane cloudera.cluster.control_plane: @@ -160,11 +185,21 @@ username: "admin" password: "admin_password" state: present + name: "example-embedded-cp" type: embedded - namespace: "cdp-pvc" - kubernetes_type: "EKS" - remote_repo_url: "https://archive.cloudera.com/cdp-pvc/7.1.9.0/" - + datalake_cluster_name: "example-base-cluster" + remote_repo_url: "https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1" + control_plane_config: + ContainerInfo: + Mode: public + CopyDocker: false + Database: + Mode: embedded + EmbeddedDbStorage: 200 + Vault: + Mode: embedded + EmbeddedDbStorage: 20 + - name: Uninstall a control plane cloudera.cluster.control_plane: host: "example.cloudera.host" @@ -175,12 +210,12 @@ RETURN = r""" control_plane: - description: Information about the control plane after the operation. + description: Details about the control plane. type: dict returned: always contains: namespace: - description: The namespace where the control plane is installed. + description: The Kubernetes namespace where the control plane is installed. type: str returned: when available uuid: @@ -204,12 +239,10 @@ type: list elements: dict returned: when available -msg: - description: A message describing the result of the operation. - type: str - returned: always """ +# Constant for the tag used to identify the control plane in Experience Cluster +CONTROL_PLANE_IDENTIFIER_TAG = "_cldr_cm_ek8s_control_plane" class ControlPlane(ClouderaManagerModule): def __init__(self, module): @@ -218,27 +251,24 @@ def __init__(self, module): self.state = self.get_param("state") self.type = self.get_param("type") self.remote_repo_url = self.get_param("remote_repo_url") - self.values_yaml = self.get_param("values_yaml") + self.control_plane_config = self.get_param("control_plane_config") # Embedded Control plane parameters self.name = self.get_param("name") self.datalake_cluster_name = self.get_param("datalake_cluster_name") - self.selected_features = self.get_param("selected_features") + self.features = self.get_param("features") # External Control plane parameters self.kubernetes_type = self.get_param("kubernetes_type") self.namespace = self.get_param("namespace") self.kubeconfig = self.get_param("kubeconfig") - self.is_override_allowed = self.get_param("is_override_allowed") + self.override = self.get_param("override") - self.delay = ( - 15 # Sleep time between wait for control plane install cmd to complete - ) + self.delay = self.get_param("delay") # Initialize the output self.changed = False self.output = {} - self.msg = "" # Execute the logic self.process() @@ -264,14 +294,6 @@ def process(self): try: self.cp_api_instance = ControlPlanesResourceApi(self.api_client) current_cps = self.cp_api_instance.get_control_planes().items - # current_cp_list = [] - - # if current_cps and hasattr(current_cps, 'items'): - # current_cp_list = current_cps.items - # elif current_cps and isinstance(current_cps, list): - # current_cp_list = current_cps - # elif current_cps: - # current_cp_list = [current_cps] except ApiException as e: if e.status == 404: @@ -294,10 +316,8 @@ def process(self): # TODO: For External control plane, check if Experience cluster needs to pre-exist self.module.fail_json( msg=f"Failed to find Experience Cluster {self.name}. Cluster should exist before creating the control plane.", - details=str(e), + details=to_native(e), ) - else: - raise e # Find matching control plane existing_cp = self._find_matching_control_plane( @@ -313,7 +333,6 @@ def process(self): "Control plane matching the required parameters already exists. Reconciliation is not currently supported.", ) self.output = parse_control_plane_result(existing_cp) - self.msg = "Control plane matching the required parameters already exists. Reconciliation is not currently supported." else: # Install new control plane if not self.module.check_mode: @@ -329,7 +348,9 @@ def process(self): else: # Control plane doesn't exist self.changed = False - self.msg = "Control plane does not exist, nothing to uninstall" + self.module.info( + f"Control plane does not exist, nothing to uninstall.", + ) def _find_matching_control_plane(self, control_planes, experience_cluster): """Find a control plane that matches the target parameters.""" @@ -344,11 +365,9 @@ def _find_matching_control_plane(self, control_planes, experience_cluster): if self.type == "embedded": - # Extract the value of the _cldr_cm_ek8s_control_plane tag from the tags list - # experience_cluster_uuid = experience_cluster.get('tags', []).tag.get('_cldr_cm_ek8s_control_plane') - + # Extract the value of the CONTROL_PLANE_IDENTIFIER_TAG from the tags list experience_cluster_uuid = experience_cluster.get("tags", {}).get( - "_cldr_cm_ek8s_control_plane", + CONTROL_PLANE_IDENTIFIER_TAG, ) # For embedded control planes, we need to check the control plane uuid @@ -382,8 +401,8 @@ def _install_control_plane(self): try: if self.type == "embedded": # Install embedded control plane - if self.values_yaml: - values_yaml_data = self.values_yaml + if self.control_plane_config: + values_yaml_data = self.control_plane_config values_yaml_str = yaml.dump(values_yaml_data) else: values_yaml_str = None @@ -394,7 +413,7 @@ def _install_control_plane(self): experience_cluster_name=self.name, containerized_cluster_name=self.name, datalake_cluster_name=self.datalake_cluster_name, - selected_features=self.selected_features, + selected_features=self.features, ) command = self.cp_api_instance.install_embedded_control_plane(body=body) @@ -410,13 +429,9 @@ def _install_control_plane(self): can_retry = getattr(api_command, "can_retry", False) success = getattr(api_command, "success", True) command_id = getattr(api_command, "id", None) - # else: - # can_retry = getattr(command_state, 'can_retry', False) - # success = getattr(command_state, 'success', True) - # command_id = getattr(command_state, 'id', None) if not success and can_retry and command_id: - self.module.warn( + self.module.info( f"Command failed but can be retried. Retrying command {command_id}.", ) retry_command = self.command_api_instance.api_instance.retry( @@ -432,8 +447,8 @@ def _install_control_plane(self): else: # TODO: Install external control plane pass # # # Install external control plane - # if self.values_yaml: - # values_yaml_data = self.values_yaml + # if self.control_plane_config: + # values_yaml_data = self.control_plane_config # values_yaml_str = yaml.dump(values_yaml_data) # else: # values_yaml_str = None @@ -444,7 +459,7 @@ def _install_control_plane(self): # values_yaml=values_yaml_str, # kube_config=self.kubeconfig, # namespace=self.namespace, - # is_override_allowed=self.is_override_allowed + # is_override_allowed=self.override # ) # command = api_instance.install_control_plane(body=body) @@ -452,13 +467,6 @@ def _install_control_plane(self): # Get the installed control plane info updated_cps = self.cp_api_instance.get_control_planes().items - # if updated_cps and hasattr(updated_cps, 'items'): - # updated_cps_list = updated_cps.items - # elif updated_cps and isinstance(updated_cps, list): - # updated_cps_list = updated_cps - # elif updated_cps: - # updated_cps_list = [updated_cps] - if self.name: existing_experience_cluster = parse_cluster_result( self.cluster_api_instance.read_cluster(cluster_name=self.name), @@ -474,12 +482,11 @@ def _install_control_plane(self): if new_cp: self.output = parse_control_plane_result(new_cp) - self.msg = f"Successfully installed {self.type} control plane" except ApiException as e: self.module.fail_json( - msg=f"Failed to install {self.type} control plane: {str(e)}", - details=str(e), + msg=f"Failed to install {self.type} control plane: {to_native(e)}", + details=to_native(e), ) def _uninstall_control_plane(self, experience_cluster): @@ -491,31 +498,19 @@ def _uninstall_control_plane(self, experience_cluster): if self.type == "embedded": - if experience_cluster["entity_status"] != "STOPPED": - stop = self.cluster_api_instance.stop_command( - cluster_name=self.name, - ) - # self.wait_command(stop, polling=self.timeout, delay=self.delay) - self.wait_for_command_state( - command_id=stop.id, - polling_interval=self.delay, - ) - - delete = self.cluster_api_instance.delete_cluster( - cluster_name=self.name, - ) - self.wait_command(delete, polling=self.timeout, delay=30) + self.module.info( + f"Removing embedded control plane is not possible. Use the cloudera.cluster.cluster module to remove the {self.name} experience cluster.", + ) else: # TODO: Remove External control plane pass except ApiException as e: self.module.fail_json( - msg=f"Failed to uninstall control plane: {str(e)}", - details=str(e), + msg=f"Failed to uninstall control plane: {to_native(e)}", + details=to_native(e), ) - def main(): module = ClouderaManagerModule.ansible_module( argument_spec=dict( @@ -523,16 +518,17 @@ def main(): type=dict(type="str", choices=["external", "embedded"], required=True), namespace=dict(type="str"), remote_repo_url=dict(type="str"), - values_yaml=dict(type="dict", aliases=["control_plane_config"]), + control_plane_config=dict(type="dict", aliases=["values_yaml"]), name=dict( type="str", aliases=["containerized_cluster_name", "control_plane_name"], ), datalake_cluster_name=dict(type="str"), - selected_features=dict(type="list", elements="str"), - kubernetes_type=dict(type="str"), + features=dict(type="list", elements="str", aliases=["selected_features"]), + kubernetes_type=dict(type="str", aliases=["external_k8s_type"]), kubeconfig=dict(type="str"), - is_override_allowed=dict(type="bool"), + override=dict(type="bool"), + delay=dict(type="int", default=15, aliases=["polling_interval"]), ), required_if=[ ("type", "external", ["namespace", "kubernetes_type"]), @@ -546,7 +542,6 @@ def main(): output = dict( changed=result.changed, control_plane=result.output, - msg=result.msg, ) if result.debug: diff --git a/plugins/modules/control_plane_info.py b/plugins/modules/control_plane_info.py index 746efc1b..0c3d80c1 100644 --- a/plugins/modules/control_plane_info.py +++ b/plugins/modules/control_plane_info.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright 2024 Cloudera, Inc. All Rights Reserved. +# Copyright 2025 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. @@ -59,7 +59,6 @@ description: The domain where the control plane is installed. type: str returned: optional - uuid: description: The universally unique ID of this control plane in Cloudera Manager. type: str @@ -135,10 +134,6 @@ def process(self): if e.status == 404: # No control planes found, return empty list self.output = [] - else: - # Re-raise other API exceptions - raise e - def main(): module = ClouderaManagerModule.ansible_module( @@ -159,6 +154,5 @@ def main(): module.exit_json(**output) - if __name__ == "__main__": main() diff --git a/tests/unit/plugins/modules/control_plane/test_control_plane.py b/tests/unit/plugins/modules/control_plane/test_control_plane.py index 6b45b435..6a250185 100644 --- a/tests/unit/plugins/modules/control_plane/test_control_plane.py +++ b/tests/unit/plugins/modules/control_plane/test_control_plane.py @@ -37,17 +37,15 @@ def test_create_embedded_control_plane(module_args, conn): if os.getenv("CONTROL_PLANE_DATALAKE_NAME", None): - conn.update(datalake_cluster_name=os.getenv("CONTROL_PLANE_DATALAKE_NAME")) + datalake_cluster_name = os.getenv("CONTROL_PLANE_DATALAKE_NAME") if os.getenv("CONTROL_PLANE_NAME", None): - conn.update(name=os.getenv("CONTROL_PLANE_NAME")) + control_plane_name = os.getenv("CONTROL_PLANE_NAME") if os.getenv("CONTROL_PLANE_REMOTE_REPO_URL", None): - conn.update(remote_repo_url=os.getenv("CONTROL_PLANE_REMOTE_REPO_URL")) + remote_repo_url = os.getenv("CONTROL_PLANE_REMOTE_REPO_URL") else: - conn.update( - remote_repo_url="https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1", - ) + remote_repo_url = "https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1" values_yaml_args = """ values_yaml: @@ -61,13 +59,16 @@ def test_create_embedded_control_plane(module_args, conn): Mode: embedded EmbeddedDbStorage: 20 """ - conn.update(yaml.safe_load(values_yaml_args)) module_args( { **conn, + "name": control_plane_name, "state": "present", "type": "embedded", + "remote_repo_url": remote_repo_url, + "datalake_cluster_name": datalake_cluster_name, + "control_plane_config": yaml.safe_load(values_yaml_args) }, ) @@ -82,13 +83,14 @@ def test_create_embedded_control_plane(module_args, conn): def test_remove_embedded_control_plane(module_args, conn): if os.getenv("CONTROL_PLANE_NAME", None): - conn.update(name=os.getenv("CONTROL_PLANE_NAME")) + control_plane_name = os.getenv("CONTROL_PLANE_NAME") module_args( { **conn, "state": "absent", "type": "embedded", + "name": control_plane_name }, ) From a6d098441245e7c88481273c3d39bc1440d4a00c Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Wed, 9 Jul 2025 14:21:06 +0100 Subject: [PATCH 11/16] Fix lint issues Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 25 ++++++++++--------- plugins/modules/control_plane_info.py | 2 ++ .../control_plane/test_control_plane.py | 4 +-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index adbc2b49..f07e2d7b 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -147,13 +147,13 @@ type: int default: 15 aliases: - - polling_interval + - polling_interval seealso: - module: cloudera.cluster.control_plane_info - module: cloudera.cluster.cluster notes: - Removing an embedded control plane is not possible with this module. - - Instead use the O(cloudera.cluster.cluster) module to remove embedded control planes. + - Instead use the O(cloudera.cluster.cluster) module to remove embedded control planes. """ EXAMPLES = r""" @@ -167,7 +167,7 @@ namespace: "example-namespace" remote_repo_url: "https://archive.cloudera.com/p/cdp-pvc-ds/1.5.5-h1" kubernetes_type: "openshift" - kubeconfig: {{ lookup('ansible.builtin.file', 'kubeconfig.yml') }} + kubeconfig: "{{ lookup('ansible.builtin.file', 'kubeconfig.yml') }}" control_plane_config: ContainerInfo: Mode: public @@ -176,7 +176,7 @@ Mode: embedded EmbeddedDbStorage: 200 Vault: - Mode: embedded + Mode: embedded EmbeddedDbStorage: 20 - name: Install an embedded control plane @@ -197,9 +197,9 @@ Mode: embedded EmbeddedDbStorage: 200 Vault: - Mode: embedded + Mode: embedded EmbeddedDbStorage: 20 - + - name: Uninstall a control plane cloudera.cluster.control_plane: host: "example.cloudera.host" @@ -244,6 +244,7 @@ # Constant for the tag used to identify the control plane in Experience Cluster CONTROL_PLANE_IDENTIFIER_TAG = "_cldr_cm_ek8s_control_plane" + class ControlPlane(ClouderaManagerModule): def __init__(self, module): super(ControlPlane, self).__init__(module) @@ -349,8 +350,8 @@ def process(self): # Control plane doesn't exist self.changed = False self.module.info( - f"Control plane does not exist, nothing to uninstall.", - ) + f"Control plane does not exist, nothing to uninstall.", + ) def _find_matching_control_plane(self, control_planes, experience_cluster): """Find a control plane that matches the target parameters.""" @@ -482,7 +483,6 @@ def _install_control_plane(self): if new_cp: self.output = parse_control_plane_result(new_cp) - except ApiException as e: self.module.fail_json( msg=f"Failed to install {self.type} control plane: {to_native(e)}", @@ -498,9 +498,9 @@ def _uninstall_control_plane(self, experience_cluster): if self.type == "embedded": - self.module.info( - f"Removing embedded control plane is not possible. Use the cloudera.cluster.cluster module to remove the {self.name} experience cluster.", - ) + self.module.info( + f"Removing embedded control plane is not possible. Use the cloudera.cluster.cluster module to remove the {self.name} experience cluster.", + ) else: # TODO: Remove External control plane pass @@ -511,6 +511,7 @@ def _uninstall_control_plane(self, experience_cluster): details=to_native(e), ) + def main(): module = ClouderaManagerModule.ansible_module( argument_spec=dict( diff --git a/plugins/modules/control_plane_info.py b/plugins/modules/control_plane_info.py index 0c3d80c1..764808cc 100644 --- a/plugins/modules/control_plane_info.py +++ b/plugins/modules/control_plane_info.py @@ -135,6 +135,7 @@ def process(self): # No control planes found, return empty list self.output = [] + def main(): module = ClouderaManagerModule.ansible_module( argument_spec=dict(), @@ -154,5 +155,6 @@ def main(): module.exit_json(**output) + if __name__ == "__main__": main() diff --git a/tests/unit/plugins/modules/control_plane/test_control_plane.py b/tests/unit/plugins/modules/control_plane/test_control_plane.py index 6a250185..db58519d 100644 --- a/tests/unit/plugins/modules/control_plane/test_control_plane.py +++ b/tests/unit/plugins/modules/control_plane/test_control_plane.py @@ -68,7 +68,7 @@ def test_create_embedded_control_plane(module_args, conn): "type": "embedded", "remote_repo_url": remote_repo_url, "datalake_cluster_name": datalake_cluster_name, - "control_plane_config": yaml.safe_load(values_yaml_args) + "control_plane_config": yaml.safe_load(values_yaml_args), }, ) @@ -90,7 +90,7 @@ def test_remove_embedded_control_plane(module_args, conn): **conn, "state": "absent", "type": "embedded", - "name": control_plane_name + "name": control_plane_name, }, ) From acba31b98466f6f5075c7f5b2c4f494bf47b9d5c Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Wed, 9 Jul 2025 16:41:36 +0100 Subject: [PATCH 12/16] Update formatting in module doc notes Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index f07e2d7b..08748fdc 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -153,7 +153,7 @@ - module: cloudera.cluster.cluster notes: - Removing an embedded control plane is not possible with this module. - - Instead use the O(cloudera.cluster.cluster) module to remove embedded control planes. + - Instead use the M(cloudera.cluster.cluster) module to remove embedded control planes. """ EXAMPLES = r""" From d4232d237685684f69d7d7c4d550e493ec114e59 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Thu, 10 Jul 2025 17:25:58 +0100 Subject: [PATCH 13/16] Use ansible function for param validation. Pass api instance via function args. Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 102 +++++++++++++++++-------------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index 08748fdc..ba642e55 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -33,6 +33,7 @@ parse_cluster_result, parse_control_plane_result, ) +from ansible.module_utils.common.validation import check_missing_parameters from ansible.module_utils.common.text.converters import to_native from cm_client.rest import ApiException @@ -280,17 +281,25 @@ def process(self): # Check parameters that are required depending on control plane type if self.type == "embedded" and self.state == "present": - if any( - param is None - for param in [ - self.name, - self.remote_repo_url, - self.datalake_cluster_name, - ] - ): - self.module.fail_json( - msg="Parameter 'name', 'remote_repo_url' and 'datalake_cluster_name' are required when creating an embedded control plane.", - ) + # Define the required parameters for embedded control plane creation + embedded_cp_required_params = { + 'name': {'required': True}, + 'remote_repo_url': {'required': True}, + 'datalake_cluster_name': {'required': True}, + } + + # Get current parameter values + params = { + 'name': self.name, + 'remote_repo_url': self.remote_repo_url, + 'datalake_cluster_name': self.datalake_cluster_name, + } + + # Check for missing parameters + try: + check_missing_parameters(params, embedded_cp_required_params) + except TypeError as e: + self.module.fail_json(msg=to_native(e)) try: self.cp_api_instance = ControlPlanesResourceApi(self.api_client) @@ -299,8 +308,6 @@ def process(self): except ApiException as e: if e.status == 404: current_cps = [] - else: - raise e # Search for experience clusters matching the control plane name try: @@ -337,7 +344,7 @@ def process(self): else: # Install new control plane if not self.module.check_mode: - self._install_control_plane() + self._install_control_plane(self.cp_api_instance, self.cluster_api_instance) self.changed = True elif self.state == "absent": @@ -353,7 +360,7 @@ def process(self): f"Control plane does not exist, nothing to uninstall.", ) - def _find_matching_control_plane(self, control_planes, experience_cluster): + def _find_matching_control_plane(self, control_planes: list, experience_cluster: dict) -> bool | None: """Find a control plane that matches the target parameters.""" if not control_planes: return None @@ -396,7 +403,7 @@ def _find_matching_control_plane(self, control_planes, experience_cluster): return None - def _install_control_plane(self): + def _install_control_plane(self, cp_api_instance: ControlPlanesResourceApi, cluster_api_instance: ClustersResourceApi) -> None: """Install a control plane based on the type.""" try: @@ -409,7 +416,7 @@ def _install_control_plane(self): values_yaml_str = None body = ApiInstallEmbeddedControlPlaneArgs( - remote_repo_url=self.get_param("remote_repo_url"), + remote_repo_url=self.remote_repo_url, values_yaml=values_yaml_str, experience_cluster_name=self.name, containerized_cluster_name=self.name, @@ -417,33 +424,34 @@ def _install_control_plane(self): selected_features=self.features, ) - command = self.cp_api_instance.install_embedded_control_plane(body=body) - # Wait for command completion - command_state = self.wait_for_command_state( - command_id=command.id, - polling_interval=self.delay, - ) + # command = cp_api_instance.install_embedded_control_plane(body=body) + # # Wait for command completion + # command_state = self.wait_for_command_state( + # command_id=command.id, + # polling_interval=self.delay, + # ) - # Retry logic if command failed and can be retried - # command_state is a tuple from read_command_with_http_info, where [0] is the ApiCommand object - api_command = command_state[0] - can_retry = getattr(api_command, "can_retry", False) - success = getattr(api_command, "success", True) - command_id = getattr(api_command, "id", None) - - if not success and can_retry and command_id: - self.module.info( - f"Command failed but can be retried. Retrying command {command_id}.", - ) - retry_command = self.command_api_instance.api_instance.retry( - command_id, - ) - - # Wait for command completion - command_state = self.wait_for_command_state( - command_id=retry_command.id, - polling_interval=self.delay, - ) + # # Retry logic if command failed and can be retried + # # command_state is a tuple from read_command_with_http_info, where [0] is the ApiCommand object + # api_command = command_state[0] + # can_retry = getattr(api_command, "can_retry", False) + # success = getattr(api_command, "success", True) + # command_id = getattr(api_command, "id", None) + + # if not success and can_retry and command_id: + # self.module.info( + # f"Command failed but can be retried. Retrying command {command_id}.", + # ) + # command_api_instance = CommandsResourceApi(self.api_client) + # retry_command = command_api_instance.retry( + # command_id, + # ) + + # # Wait for command completion + # command_state = self.wait_for_command_state( + # command_id=retry_command.id, + # polling_interval=self.delay, + # ) else: # TODO: Install external control plane pass @@ -463,14 +471,14 @@ def _install_control_plane(self): # is_override_allowed=self.override # ) - # command = api_instance.install_control_plane(body=body) + # command = cp_api_instance.install_control_plane(body=body) # Get the installed control plane info - updated_cps = self.cp_api_instance.get_control_planes().items + updated_cps = cp_api_instance.get_control_planes().items if self.name: existing_experience_cluster = parse_cluster_result( - self.cluster_api_instance.read_cluster(cluster_name=self.name), + cluster_api_instance.read_cluster(cluster_name=self.name), ) else: existing_experience_cluster = None @@ -489,7 +497,7 @@ def _install_control_plane(self): details=to_native(e), ) - def _uninstall_control_plane(self, experience_cluster): + def _uninstall_control_plane(self, experience_cluster: dict ) -> None: """Uninstall a control plane. For embedded control planes, this will delete the associated experience cluster. """ From a0189fffd305f285c7a2ce0d5854808a1d3cbe9f Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Thu, 10 Jul 2025 17:27:13 +0100 Subject: [PATCH 14/16] Use ansible function for param validation. Pass api instance via function args. Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 54 ++++++++++++++++---------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index ba642e55..a337d5e1 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -424,34 +424,34 @@ def _install_control_plane(self, cp_api_instance: ControlPlanesResourceApi, clus selected_features=self.features, ) - # command = cp_api_instance.install_embedded_control_plane(body=body) - # # Wait for command completion - # command_state = self.wait_for_command_state( - # command_id=command.id, - # polling_interval=self.delay, - # ) + command = cp_api_instance.install_embedded_control_plane(body=body) + # Wait for command completion + command_state = self.wait_for_command_state( + command_id=command.id, + polling_interval=self.delay, + ) - # # Retry logic if command failed and can be retried - # # command_state is a tuple from read_command_with_http_info, where [0] is the ApiCommand object - # api_command = command_state[0] - # can_retry = getattr(api_command, "can_retry", False) - # success = getattr(api_command, "success", True) - # command_id = getattr(api_command, "id", None) - - # if not success and can_retry and command_id: - # self.module.info( - # f"Command failed but can be retried. Retrying command {command_id}.", - # ) - # command_api_instance = CommandsResourceApi(self.api_client) - # retry_command = command_api_instance.retry( - # command_id, - # ) - - # # Wait for command completion - # command_state = self.wait_for_command_state( - # command_id=retry_command.id, - # polling_interval=self.delay, - # ) + # Retry logic if command failed and can be retried + # command_state is a tuple from read_command_with_http_info, where [0] is the ApiCommand object + api_command = command_state[0] + can_retry = getattr(api_command, "can_retry", False) + success = getattr(api_command, "success", True) + command_id = getattr(api_command, "id", None) + + if not success and can_retry and command_id: + self.module.info( + f"Command failed but can be retried. Retrying command {command_id}.", + ) + command_api_instance = CommandsResourceApi(self.api_client) + retry_command = command_api_instance.retry( + command_id, + ) + + # Wait for command completion + command_state = self.wait_for_command_state( + command_id=retry_command.id, + polling_interval=self.delay, + ) else: # TODO: Install external control plane pass From 868da65a0890664ac48844289ca4ab16b2e95835 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Thu, 10 Jul 2025 17:51:29 +0100 Subject: [PATCH 15/16] Add diff mode support Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index a337d5e1..70d4e35e 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -54,6 +54,8 @@ attributes: check_mode: support: full + diff_mode: + support: full requirements: - cm-client options: @@ -271,6 +273,7 @@ def __init__(self, module): # Initialize the output self.changed = False self.output = {} + self.diff = dict(before={}, after={}) # Execute the logic self.process() @@ -341,12 +344,21 @@ def process(self): "Control plane matching the required parameters already exists. Reconciliation is not currently supported.", ) self.output = parse_control_plane_result(existing_cp) + + if self.module._diff: + self.before.update(control_plane=parse_control_plane_result(existing_cp)) + self.after.update(control_plane=parse_control_plane_result(existing_cp)) + else: # Install new control plane if not self.module.check_mode: self._install_control_plane(self.cp_api_instance, self.cluster_api_instance) self.changed = True + if self.module._diff: + self.before.update(control_plane={}) + self.after.update(control_plane=self.output) + elif self.state == "absent": if existing_cp: # Uninstall existing control plane @@ -360,7 +372,7 @@ def process(self): f"Control plane does not exist, nothing to uninstall.", ) - def _find_matching_control_plane(self, control_planes: list, experience_cluster: dict) -> bool | None: + def _find_matching_control_plane(self, control_planes: list, experience_cluster: dict) -> dict | None: """Find a control plane that matches the target parameters.""" if not control_planes: return None @@ -553,6 +565,9 @@ def main(): control_plane=result.output, ) + if module._diff: + output.update(diff=result.diff) + if result.debug: log = result.log_capture.getvalue() output.update(debug=log, debug_lines=log.split("\n")) From fdea3a59ac20fead960533d9186ec0e1b0fde5b1 Mon Sep 17 00:00:00 2001 From: Jim Enright Date: Fri, 11 Jul 2025 21:08:44 +0100 Subject: [PATCH 16/16] Fix lint issues Signed-off-by: Jim Enright --- plugins/modules/control_plane.py | 51 +++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/plugins/modules/control_plane.py b/plugins/modules/control_plane.py index 70d4e35e..e1352c29 100755 --- a/plugins/modules/control_plane.py +++ b/plugins/modules/control_plane.py @@ -17,6 +17,7 @@ import yaml +from typing import Optional from cm_client import ( ClustersResourceApi, ControlPlanesResourceApi, @@ -55,7 +56,7 @@ check_mode: support: full diff_mode: - support: full + support: full requirements: - cm-client options: @@ -286,23 +287,23 @@ def process(self): if self.type == "embedded" and self.state == "present": # Define the required parameters for embedded control plane creation embedded_cp_required_params = { - 'name': {'required': True}, - 'remote_repo_url': {'required': True}, - 'datalake_cluster_name': {'required': True}, + "name": {"required": True}, + "remote_repo_url": {"required": True}, + "datalake_cluster_name": {"required": True}, } # Get current parameter values params = { - 'name': self.name, - 'remote_repo_url': self.remote_repo_url, - 'datalake_cluster_name': self.datalake_cluster_name, - } - + "name": self.name, + "remote_repo_url": self.remote_repo_url, + "datalake_cluster_name": self.datalake_cluster_name, + } + # Check for missing parameters try: - check_missing_parameters(params, embedded_cp_required_params) + check_missing_parameters(params, embedded_cp_required_params) except TypeError as e: - self.module.fail_json(msg=to_native(e)) + self.module.fail_json(msg=to_native(e)) try: self.cp_api_instance = ControlPlanesResourceApi(self.api_client) @@ -346,13 +347,20 @@ def process(self): self.output = parse_control_plane_result(existing_cp) if self.module._diff: - self.before.update(control_plane=parse_control_plane_result(existing_cp)) - self.after.update(control_plane=parse_control_plane_result(existing_cp)) + self.before.update( + control_plane=parse_control_plane_result(existing_cp), + ) + self.after.update( + control_plane=parse_control_plane_result(existing_cp), + ) else: # Install new control plane if not self.module.check_mode: - self._install_control_plane(self.cp_api_instance, self.cluster_api_instance) + self._install_control_plane( + self.cp_api_instance, + self.cluster_api_instance, + ) self.changed = True if self.module._diff: @@ -372,8 +380,13 @@ def process(self): f"Control plane does not exist, nothing to uninstall.", ) - def _find_matching_control_plane(self, control_planes: list, experience_cluster: dict) -> dict | None: + def _find_matching_control_plane( + self, + control_planes: list, + experience_cluster: dict, + ) -> Optional[dict]: """Find a control plane that matches the target parameters.""" + if not control_planes: return None @@ -415,7 +428,11 @@ def _find_matching_control_plane(self, control_planes: list, experience_cluster: return None - def _install_control_plane(self, cp_api_instance: ControlPlanesResourceApi, cluster_api_instance: ClustersResourceApi) -> None: + def _install_control_plane( + self, + cp_api_instance: ControlPlanesResourceApi, + cluster_api_instance: ClustersResourceApi, + ) -> None: """Install a control plane based on the type.""" try: @@ -509,7 +526,7 @@ def _install_control_plane(self, cp_api_instance: ControlPlanesResourceApi, clus details=to_native(e), ) - def _uninstall_control_plane(self, experience_cluster: dict ) -> None: + def _uninstall_control_plane(self, experience_cluster: dict) -> None: """Uninstall a control plane. For embedded control planes, this will delete the associated experience cluster. """