diff --git a/plugins/doc_fragments/cm_options.py b/plugins/doc_fragments/cm_options.py index 2a531e0d..1e51fc55 100644 --- a/plugins/doc_fragments/cm_options.py +++ b/plugins/doc_fragments/cm_options.py @@ -42,7 +42,7 @@ class ModuleDocFragment(object): required: False default: True aliases: - - tls + - api_version force_tls: description: - Flag to force TLS during CM API endpoint discovery. @@ -69,6 +69,8 @@ class ModuleDocFragment(object): - Username for access to the CM API endpoint. type: str required: True + aliases: + - user password: description: - Password for access to the CM API endpoint. diff --git a/plugins/module_utils/cm_utils.py b/plugins/module_utils/cm_utils.py index 4de562d5..ca047b07 100644 --- a/plugins/module_utils/cm_utils.py +++ b/plugins/module_utils/cm_utils.py @@ -30,7 +30,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_text from time import sleep -from cm_client import ApiClient, Configuration +from cm_client import ApiClient, ApiConfigList, Configuration from cm_client.rest import ApiException, RESTClientObject from cm_client.apis.cloudera_manager_resource_api import ClouderaManagerResourceApi from cm_client.apis.commands_resource_api import CommandsResourceApi @@ -246,7 +246,7 @@ def initialize_client(self): # If provided a CML endpoint URL, use it directly if self.url: - config.host = self.url + config.host = str(self.url).rstrip(" /") # Otherwise, run discovery on missing parts else: config.host = self.discover_endpoint(config) @@ -340,6 +340,9 @@ def call_api(self, path, method, query=None, field="items", body=None): data = data[field] return data if type(data) is list else [data] + def get_cm_config(self, scope: str = "summary") -> ApiConfigList: + return ClouderaManagerResourceApi(self.api_client).get_config(view=scope).items + @staticmethod def ansible_module_internal(argument_spec={}, required_together=[], **kwargs): """ @@ -358,7 +361,7 @@ def ansible_module_internal(argument_spec={}, required_together=[], **kwargs): required=False, type="bool", default=True, aliases=["tls"] ), ssl_ca_cert=dict(type="path", aliases=["tls_cert", "ssl_cert"]), - username=dict(required=True, type="str"), + username=dict(required=True, type="str", aliases=["user"]), password=dict(required=True, type="str", no_log=True), debug=dict( required=False, @@ -396,3 +399,28 @@ def ansible_module( required_together=required_together, **kwargs, ) + + +class ClouderaManagerMutableModule(ClouderaManagerModule): + def __init__(self, module): + super(ClouderaManagerMutableModule, self).__init__(module) + self.message = self.get_param("message") + + @staticmethod + def ansible_module( + argument_spec={}, + mutually_exclusive=[], + required_one_of=[], + required_together=[], + **kwargs, + ): + return ClouderaManagerModule.ansible_module( + dict( + **argument_spec, + message=dict(default="Managed by Ansible", aliases=["msg"]) + ), + mutually_exclusive, + required_one_of, + required_together, + **kwargs + ) diff --git a/plugins/modules/cm_config.py b/plugins/modules/cm_config.py new file mode 100644 index 00000000..01e9564e --- /dev/null +++ b/plugins/modules/cm_config.py @@ -0,0 +1,231 @@ +# Copyright 2023 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 cm_client + +from ansible.module_utils.common.dict_transformations import recursive_diff + +from ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( + ClouderaManagerMutableModule, +) + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = r""" +--- +module: cm_config +short_description: Manage the configuration of Cloudera Manager +description: + - Manage Cloudera Manager configuration settings. +author: + - "Webster Mudge (@wmudge)" +requirements: + - cm_client +options: + parameters: + description: + - The Cloudera Manager configuration to set. + - To unset a parameter, use C(None) as the value. + type: dict + required: yes + aliases: + - params +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint +attributes: + check_mode: + support: full + diff_mode: + support: full +""" + +EXAMPLES = r""" +--- +- name: Update several Cloudera Manager parameters + cloudera.cluster.cm_config: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + parameters: + frontend_url: "schema://host:port" + custom_header_color: "PURPLE" + +- name: Reset or remove a Cloudera Manager parameter + cloudera.cluster.cm_config: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + parameters: + custom_header_color: None +""" + +RETURN = r""" +--- +config: + description: + - List of Cloudera Manager configurations. + - Returns the C(summary) view of the resulting configuration. + type: list + elements: dict + returned: always + contains: + name: + description: + - The canonical name that identifies this configuration parameter. + type: str + returned: when supported + value: + description: + - The user-defined value. + - When absent, the default value (if any) will be used. + - Can also be absent, when enumerating allowed configs. + type: str + returned: when supported + required: + description: + - Whether this configuration is required for the object. + - If any required configuration is not set, operations on the object may not work. + - Requires I(full) view. + type: bool + returned: when supported + default: + description: + - The default value. + - Requires I(full) view. + type: str + returned: when supported + display_name: + description: + - A user-friendly name of the parameters, as would have been shown in the web UI. + - Requires I(full) view. + type: str + returned: when supported + description: + description: + - A textual description of the parameter. + - Requires I(full) view. + type: str + returned: when supported + related_name: + description: + - If applicable, contains the related configuration variable used by the source project. + - Requires I(full) view. + type: str + returned: when supported + sensitive: + description: + - Whether this configuration is sensitive, i.e. contains information such as passwords, which might affect how the value of this configuration might be shared by the caller. + type: bool + returned: when supported + validate_state: + description: + - State of the configuration parameter after validation. + - Requires I(full) view. + type: str + returned: when supported + validation_message: + description: + - A message explaining the parameter's validation state. + - Requires I(full) view. + type: str + returned: when supported + validation_warnings_suppressed: + description: + - Whether validation warnings associated with this parameter are suppressed. + - In general, suppressed validation warnings are hidden in the Cloudera Manager UI. + - Configurations that do not produce warnings will not contain this field. + - Requires I(full) view. + type: bool + returned: when supported +""" + + +class ClouderaManagerConfig(ClouderaManagerMutableModule): + def __init__(self, module): + super(ClouderaManagerConfig, self).__init__(module) + + # Set the parameters + self.params = self.get_param("parameters") + + # Initialize the return value + self.changed = False + self.diff = {} + self.config = [] + + # Execute the logic + self.process() + + @ClouderaManagerMutableModule.handle_process + def process(self): + existing = self.get_cm_config("full") + + current = {r.name: r.value for r in existing} + incoming = {k.upper(): v for k, v in self.params.items()} + + (_, add) = recursive_diff(current, incoming) + + if add: + self.changed = True + + if self.module._diff: + self.diff = dict(before={k: current[k] for k in add.keys()}, after=add) + + if not self.module.check_mode: + body = cm_client.ApiConfigList( + items=[cm_client.ApiConfig(name=k, value=v) for k, v in add.items()] + ) + # Return 'summary' + self.config = [ + p.to_dict() + for p in cm_client.ClouderaManagerResourceApi(self.api_client) + .update_config(message=self.message, body=body) + .items + ] + else: + # Return 'summary' + self.config = [p.to_dict() for p in self.get_cm_config()] + + +def main(): + module = ClouderaManagerMutableModule.ansible_module( + argument_spec=dict( + parameters=dict(type=dict, required=True, aliases=["params"]), + ), + supports_check_mode=True, + ) + + result = ClouderaManagerConfig(module) + + output = dict( + changed=result.changed, + config=result.config, + ) + + 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")) + + module.exit_json(**output) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/cm_config_info.py b/plugins/modules/cm_config_info.py new file mode 100644 index 00000000..83017120 --- /dev/null +++ b/plugins/modules/cm_config_info.py @@ -0,0 +1,187 @@ +# Copyright 2023 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 ansible_collections.cloudera.cluster.plugins.module_utils.cm_utils import ( + ClouderaManagerModule, +) + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = r""" +--- +module: cm_config_info +short_description: Retrieve the Cloudera Manager configuration +description: + - Retrieve the Cloudera Manager configuration settings. + - The module supports C(check_mode). +author: + - "Webster Mudge (@wmudge)" +requirements: + - cm_client +options: + view: + description: + - The view to materialize, either C(summary) or C(full). + type: str + default: summary + choices: + - summary + - full +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint +""" + +EXAMPLES = r""" +--- +- name: Retrieve the summary (default) settings + cloudera.cluster.cm_config_info + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + register: summary + +- name: Retrieve the full settings + cloudera.cluster.cm_config_info + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + view: full + register: full +""" + +RETURN = r""" +--- +config: + description: + - List of Cloudera Manager Server configurations. + type: list + elements: dict + returned: always + contains: + name: + description: + - The canonical name that identifies this configuration parameter. + type: str + returned: when supported + value: + description: + - The user-defined value. + - When absent, the default value (if any) will be used. + - Can also be absent, when enumerating allowed configs. + type: str + returned: when supported + required: + description: + - Whether this configuration is required for the object. + - If any required configuration is not set, operations on the object may not work. + - Requires I(full) view. + type: bool + returned: when supported + default: + description: + - The default value. + - Requires I(full) view. + type: str + returned: when supported + display_name: + description: + - A user-friendly name of the parameters, as would have been shown in the web UI. + - Requires I(full) view. + type: str + returned: when supported + description: + description: + - A textual description of the parameter. + - Requires I(full) view. + type: str + returned: when supported + related_name: + description: + - If applicable, contains the related configuration variable used by the source project. + - Requires I(full) view. + type: str + returned: when supported + sensitive: + description: + - Whether this configuration is sensitive, i.e. contains information such as passwords, which might affect how the value of this configuration might be shared by the caller. + type: bool + returned: when supported + validate_state: + description: + - State of the configuration parameter after validation. + - Requires I(full) view. + type: str + returned: when supported + validation_message: + description: + - A message explaining the parameter's validation state. + - Requires I(full) view. + type: str + returned: when supported + validation_warnings_suppressed: + description: + - Whether validation warnings associated with this parameter are suppressed. + - In general, suppressed validation warnings are hidden in the Cloudera Manager UI. + - Configurations that do not produce warnings will not contain this field. + - Requires I(full) view. + type: bool + returned: when supported +""" + + +class ClouderaManagerConfigInfo(ClouderaManagerModule): + def __init__(self, module): + super(ClouderaManagerConfigInfo, self).__init__(module) + + # Set the parameters + self.view = self.get_param("view") + + # Initialize the return value + self.config = [] + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + self.config = [r.to_dict() for r in self.get_cm_config(self.view)] + + +def main(): + module = ClouderaManagerModule.ansible_module( + argument_spec=dict(view=dict(default="summary", choices=["summary", "full"])), + supports_check_mode=True, + ) + + result = ClouderaManagerConfigInfo(module) + + output = dict( + changed=False, + config=result.config, + ) + + 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/pytest.ini b/pytest.ini index f71a11df..024207ab 100644 --- a/pytest.ini +++ b/pytest.ini @@ -15,5 +15,10 @@ [pytest] filterwarnings = ignore::DeprecationWarning + ignore:AnsibleCollectionFinder has already been configured:UserWarning + +; log_cli = 1 +; log_cli_level = INFO + pythonpath = "../../../" \ No newline at end of file diff --git a/tests/unit/plugins/modules/cm_config/test_cm_config.py b/tests/unit/plugins/modules/cm_config/test_cm_config.py new file mode 100644 index 00000000..93749cf1 --- /dev/null +++ b/tests/unit/plugins/modules/cm_config/test_cm_config.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +# Copyright 2023 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 + +from ansible_collections.cloudera.cluster.plugins.modules import cm_config +from ansible_collections.cloudera.cluster.tests.unit import AnsibleExitJson, AnsibleFailJson + +LOG = logging.getLogger(__name__) + + +@pytest.fixture() +def conn(): + return { + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes" + } + +def test_missing_parameters(conn, module_args): + module_args(conn) + + with pytest.raises(AnsibleFailJson, match="params"): + cm_config.main() + +def test_set_config(conn, module_args): + module_args({ + **conn, + "params": dict(custom_header_color="PURPLE") + }) + + with pytest.raises(AnsibleExitJson) as e: + cm_config.main() + + assert len(e.value.config) > 0 diff --git a/tests/unit/plugins/modules/cm_config_info/test_cm_config_info.py b/tests/unit/plugins/modules/cm_config_info/test_cm_config_info.py new file mode 100644 index 00000000..5f4a6161 --- /dev/null +++ b/tests/unit/plugins/modules/cm_config_info/test_cm_config_info.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +# Copyright 2023 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 + +from ansible_collections.cloudera.cluster.plugins.modules import cm_config_info +from ansible_collections.cloudera.cluster.tests.unit import AnsibleExitJson, AnsibleFailJson + +LOG = logging.getLogger(__name__) + + +@pytest.fixture() +def conn(): + return { + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "port": "7180", + "verify_tls": "no", + "debug": "yes", + } + +def test_get_cluster_config(conn, module_args): + module_args(conn) + + with pytest.raises(AnsibleExitJson) as e: + cm_config_info.main() + + assert len(e.value.config) > 0 + +def test_get_cluster_config_full(conn, module_args): + module_args({ + **conn, + "view": "full" + }) + + with pytest.raises(AnsibleExitJson) as e: + cm_config_info.main() + + assert len(e.value.config) > 0 diff --git a/tests/unit/plugins/modules/cm_endpoint_info/test_cm_endpoint_info_i.py b/tests/unit/plugins/modules/cm_endpoint_info/test_cm_endpoint_info_i.py index 97255bb7..cd2c0350 100644 --- a/tests/unit/plugins/modules/cm_endpoint_info/test_cm_endpoint_info_i.py +++ b/tests/unit/plugins/modules/cm_endpoint_info/test_cm_endpoint_info_i.py @@ -17,47 +17,37 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -import os +import logging import pytest -import re -import unittest from ansible_collections.cloudera.cluster.plugins.modules import cm_endpoint_info -from ansible_collections.cloudera.cluster.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, setup_module_args +from ansible_collections.cloudera.cluster.tests.unit import AnsibleExitJson, AnsibleFailJson +from cm_client.rest import RESTClientObject +from urllib3.response import HTTPResponse -@unittest.skipUnless(os.getenv('CM_USERNAME'), "Cloudera Manager access parameters not set") -class TestCMVersionIntegration(ModuleTestCase): +LOG = logging.getLogger(__name__) - def test_host_discovery(self): - setup_module_args({ - "username": os.getenv('CM_USERNAME'), - "password": os.getenv('CM_PASSWORD'), - "host": os.getenv('CM_HOST'), - "port": "7180", - "verify_tls": "no", - "debug": "yes" - }) - - with pytest.raises(AnsibleExitJson) as e: - cm_endpoint_info.main() - - self.assertEquals(e.value.args[0]['endpoint'], "https://" + os.getenv('CM_HOST') + ":" + os.getenv('CM_PORT_TLS') + "/api/" + os.getenv('CM_VERSION')) - - def test_direct_endpoint(self): - setup_module_args({ - "username": os.getenv('CM_USERNAME'), - "password": os.getenv('CM_PASSWORD'), - "url": "http://not.supported", - "verify_tls": "no", - "debug": "yes" - }) +def test_host_discovery(module_args, monkeypatch): + spec = { + "username": "testuser", + "password": "testpassword", + "host": "test.cldr.info", + "port": "7180", + "verify_tls": "no", + "debug": "yes" + } + + def response(): + return HTTPResponse() + + monkeypatch.setattr("urllib3.HTTPConnectionPool.urlopen", response) + + + module_args(spec) + + with pytest.raises(AnsibleExitJson) as e: + cm_endpoint_info.main() - with pytest.raises(AnsibleFailJson) as e: - cm_endpoint_info.main() - - self.assertRegexpMatches(e.value.args[0]['msg'], "^Unsupported parameters") - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file + assert e.value.endpoint == f"https://{spec['host']}:7183/api/v01" +