diff --git a/.gitignore b/.gitignore index 4124e979..0fd389f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,164 @@ -.idea -.vscode -test* -*.pyc -*.bak -.DS_Store -venv +# Copyright 2022 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. + +# Keep the Galaxy builds +!cloudera-cluster-*.tar.gz + +# Ignore the test output +tests/output + +# Remove any integration configuration +tests/integration/integration_config.yml + +# Remove the Sphinx build directory +site/_build + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + diff --git a/meta/runtime.yml b/meta/runtime.yml new file mode 100644 index 00000000..e6c9457e --- /dev/null +++ b/meta/runtime.yml @@ -0,0 +1,17 @@ +--- + +# Copyright 2022 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. + +requires_ansible: ">=2.10" diff --git a/plugins/doc_fragments/cm_endpoint.py b/plugins/doc_fragments/cm_endpoint.py new file mode 100644 index 00000000..838fad91 --- /dev/null +++ b/plugins/doc_fragments/cm_endpoint.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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. + +class ModuleDocFragment(object): + DOCUMENTATION = r''' + options: + url: + description: + - The CM API endpoint URL and should include scheme, host, port, and API root path. + - Mutually exclusive with I(host). + type: str + required: False + aliases: + - endpoint + - cm_endpoint_url + ''' diff --git a/plugins/doc_fragments/cm_options.py b/plugins/doc_fragments/cm_options.py new file mode 100644 index 00000000..179ba85b --- /dev/null +++ b/plugins/doc_fragments/cm_options.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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. + +class ModuleDocFragment(object): + DOCUMENTATION = r''' + options: + host: + description: + - Hostname of the CM API endpoint. + - If set, the C(host) parameter will trigger CM API endpoint discovery, which will follow redirects. + - Mutually exclusive with I(url). + type: str + required: False + aliases: + - hostname + port: + description: + - Port of the CM API endpoint. + - If set, CM API endpoint discovery will connect to the designated port first and will follow redirects. + type: int + required: False + default: 7180 + version: + description: + - API version of the CM API endpoint. + type: str + required: False + default: True + aliases: + - tls + force_tls: + description: + - Flag to force TLS during CM API endpoint discovery. + - If C(False), discovery will first try HTTP and follow any redirects. + type: bool + required: False + default: False + verify_tls: + description: + - Verify the TLS certificates for the CM API endpoint. + type: bool + required: False + default: True + aliases: + - tls + username: + description: + - Username for access to the CM API endpoint. + type: str + required: True + password: + description: + - Password for access to the CM API endpoint. + - This parameter is set to C(no_log). + type: str + required: True + debug: + description: + - Capture the HTTP interaction logs with the CM API endpoint. + type: bool + required: False + default: False + aliases: + - debug_endpoints + agent_header: + description: + - Set the HTTP user agent header when interacting with the CM API endpoint. + type: str + required: False + default: ClouderaFoundry + ''' diff --git a/plugins/doc_fragments/cm_resource.py b/plugins/doc_fragments/cm_resource.py new file mode 100644 index 00000000..2a95b2e4 --- /dev/null +++ b/plugins/doc_fragments/cm_resource.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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. + +class ModuleDocFragment(object): + DOCUMENTATION = r''' + options: + path: + description: + - Path of the CM API endpoint call. + type: str + required: True + query: + description: + - HTTP query parameters for the CM API endpoint call. + type: dict + aliases: + - query_parameters + - parameters + field: + description: + - Field within the response for result extraction. + - Use I(field) when the returned object has an enclosing field. + type: str + default: 'items' + aliases: + - return_field + ''' diff --git a/plugins/module_utils/cm_utils.py b/plugins/module_utils/cm_utils.py new file mode 100644 index 00000000..4c72e099 --- /dev/null +++ b/plugins/module_utils/cm_utils.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +A common Ansible Module for shared functions for Cloudera Manager +""" + +import io +import json +import logging + +from functools import wraps +from urllib3 import disable_warnings +from urllib3.exceptions import InsecureRequestWarning, MaxRetryError, HTTPError +from urllib3.util import Url +from urllib.parse import urljoin + +from ansible.module_utils.basic import AnsibleModule + +from cm_client import ApiClient, Configuration +from cm_client.rest import ApiException, RESTClientObject +from cm_client.apis.cloudera_manager_resource_api import ClouderaManagerResourceApi + + +__credits__ = ["frisch@cloudera.com"] +__maintainer__ = [ + "wmudge@cloudera.com" +] + + +class ClouderaManagerModule(object): + @classmethod + def handle_process(cls, f): + """Wrapper to handle log capture and common HTTP errors""" + @wraps(f) + def _impl(self, *args, **kwargs): + try: + self._initialize_client() + result = f(self, *args, **kwargs) + if self.debug: + self.log_out = self._get_log() + self.log_lines.append(self.log_out.splitlines()) + return result + except ApiException as ae: + body = ae.body.decode('utf-8') + if body != "": + body = json.loads(body) + self.module.fail_json(msg="API error: " + str(ae.reason), + status_code=ae.status, body=body) + except MaxRetryError as maxe: + self.module.fail_json(msg="Request error: " + str(maxe.reason)) + except HTTPError as he: + self.module.fail_json(msg="HTTP request: " + str(he)) + return _impl + + """A base Cloudera Manager (CM) module class""" + def __init__(self, module): + # Set common parameters + self.module = module + self.url = self._get_param('url', None) + self.force_tls = self._get_param('force_tls') + self.host = self._get_param('host') + self.port = self._get_param('port') + self.version = self._get_param('version') + self.username = self._get_param('username') + self.password = self._get_param('password') + self.verify_tls = self._get_param('verify_tls') + self.debug = self._get_param('debug') + self.agent_header = self._get_param('agent_header') + + # Initialize common return values + self.log_out = None + self.log_lines = [] + self.changed = False + + # Configure the core CM API client parameters + config = Configuration() + config.username = self.username + config.password = self.password + config.verify_ssl = self.verify_tls + config.debug = self.debug + + # Configure logging + _log_format = '%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s' + if self.debug: + self._setup_logger(logging.DEBUG, _log_format) + self.logger.debug("CM API agent: %s", self.agent_header) + else: + self._setup_logger(logging.ERROR, _log_format) + + if self.verify_tls is False: + disable_warnings(InsecureRequestWarning) + + def _get_param(self, param, default=None): + """Fetches an Ansible input parameter if it exists, else returns optional default or None""" + if self.module is not None: + return self.module.params[param] if param in self.module.params else default + return default + + def _setup_logger(self, log_level, log_format): + """Configures the logging of the HTTP activity""" + self.logger = logging.getLogger('urllib3') + self.logger.setLevel(log_level) + + self.__log_capture = io.StringIO() + handler = logging.StreamHandler(self.__log_capture) + handler.setLevel(log_level) + + formatter = logging.Formatter(log_format) + handler.setFormatter(formatter) + + self.logger.addHandler(handler) + + def _get_log(self): + """Retrieves the contents of the captured log""" + contents = self.__log_capture.getvalue() + self.__log_capture.truncate(0) + return contents + + def _initialize_client(self): + """Configures and creates the API client""" + config = Configuration() + + # If provided a CML endpoint URL, use it directly + if self.url: + config.host = self.url + # Otherwise, run discovery on missing parts + else: + config.host = self._discover_endpoint(config) + + # Create and set the API Client + self.api_client = ApiClient() + + def get_auth_headers(self, config): + """Constructs a Basic Auth header dictionary from the Configuration. + This dictionary can be used directly with the API client's REST client.""" + headers = dict() + auth = config.auth_settings().get('basic') + headers[auth['key']] = auth['value'] + return headers + + def _discover_endpoint(self, config): + """Discovers the scheme and version of a potential Cloudara Manager host""" + # Get the authentication headers and REST client + headers = self.get_auth_headers(config) + rest = RESTClientObject() + + # Resolve redirects to establish HTTP scheme and port + pre_rendered = Url(scheme="https" if self.force_tls else "http", host=self.host, port=self.port) + rendered = rest.pool_manager.request('GET', pre_rendered.url, headers=headers.copy()) + rendered_url = rendered.geturl() + + # Discover API version if not set + if not self.version: + pre_versioned = urljoin(rendered_url, "/api/version") + versioned = rest.pool_manager.request('GET', pre_versioned, headers=headers) + self.version = versioned.data.decode('utf-8') + + # Construct the discovered API endpoint + return urljoin(rendered_url, "/api/" + self.version) + + def set_session_cookie(self): + """Utility to cache the session cookie for intra-module operations.""" + if not self.api_client.last_response: + api_instance = ClouderaManagerResourceApi(self.api_client) + api_instance.get_version() + self.api_client.cookie = self.api_client.last_response.getheader('Set-Cookie') + + def call_api(self, path, method, query=None, field='items', body=None): + """Wrapper to call a CM API endpoint path directly.""" + path_params = [] + header_params = {} + header_params['Accept'] = self.api_client.select_header_accept(['application/json']) + header_params['Content-Type'] = self.api_client.select_header_content_type(['application/json']) + + results =self.api_client.call_api(path, method, path_params, query, + header_params, body, auth_settings=['basic'], + _preload_content=False) + + if 200 >= results[1] <= 299: + data = json.loads(results[0].data.decode('utf-8')) + if field in data: + data = data[field] + return data if type(data) is list else [data] + else: + self.module.fail_json(msg="Error interacting with CM resource", status_code=results[1]) + + + @staticmethod + def ansible_module_discovery(argument_spec={}, required_together=[], **kwargs): + """INTERNAL: Creates the Ansible module argument spec and dependencies for CM API endpoint discovery. + Typically, modules will use the ansible_module method to include direct API endpoint URL support.""" + return AnsibleModule( + argument_spec=dict( + **argument_spec, + host=dict(type='str', aliases=['hostname']), + port=dict(type='int', default=7180), + version=dict(type='str'), + force_tls=dict(type='bool', default=False), + verify_tls=dict(required=False, type='bool', default=True, aliases=['tls']), + username=dict(required=True, type='str'), + password=dict(required=True, type='str', no_log=True), + debug=dict(required=False, type='bool', default=False, aliases=['debug_endpoints']), + agent_header=dict(required=False, type='str', default='ClouderaFoundry') + ), + required_together=required_together + [['username', 'password']], + **kwargs + ) + + @staticmethod + def ansible_module(argument_spec={}, mutually_exclusive=[], required_one_of=[], required_together=[], **kwargs): + """Creates the base Ansible module argument spec and dependencies, including discovery and direct endpoint URL support.""" + return ClouderaManagerModule.ansible_module_discovery( + argument_spec=dict( + **argument_spec, + url=dict(type='str', aliases=['endpoint', 'cm_endpoint_url']), + ), + mutually_exclusive=mutually_exclusive + [['url', 'host']], + required_one_of=required_one_of + [['url', 'host']], + required_together=required_together, + **kwargs + ) diff --git a/plugins/modules/cm_endpoint_info.py b/plugins/modules/cm_endpoint_info.py new file mode 100644 index 00000000..96177f48 --- /dev/null +++ b/plugins/modules/cm_endpoint_info.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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_endpoint_info +short_description: Discover the Cloudera Manager API endpoint +description: + - Discover the Cloudera Manager API endpoint. + - The module supports C(check_mode). +author: + - "Webster Mudge (@wmudge)" +requirements: + - cm_client +extends_documentation_fragment: + - cloudera.cluster.cm_options +''' + +EXAMPLES = r''' +--- +# This will first try 'http://example.cloudera.com:7180' and will +# follow any redirects +- name: Gather details using auto-discovery + cloudera.cluster.cm_endpoint_info: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + register: cm_endpoint +''' + +RETURN = r''' +--- +endpoint: + description: The discovered Cloudera Manager API endpoint + type: str + returned: always +''' + +class ClouderaEndpointInfo(ClouderaManagerModule): + def __init__(self, module): + super(ClouderaEndpointInfo, self).__init__(module) + + # Initialize the return values + self.endpoint = "" + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + self.endpoint = self.api_client.host + + +def main(): + module = ClouderaManagerModule.ansible_module_discovery( + supports_check_mode=True + ) + + result = ClouderaEndpointInfo(module) + + output = dict( + changed=False, + endpoint=result.endpoint, + ) + + if result.debug: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines + ) + + module.exit_json(**output) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/cm_resource.py b/plugins/modules/cm_resource.py new file mode 100644 index 00000000..9a9319bc --- /dev/null +++ b/plugins/modules/cm_resource.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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 json + +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_resource +short_description: Create, update, and delete resources from the Cloudera Manager API endpoint +description: + - Create, update, and delete resources from ad-hoc Cloudera Manager API endpoint paths, i.e. unimplemented API calls. + - This module only supports the C(POST), C(PUT), and C(DELETE) HTTP methods. + - To retrieve details, i.e. read-only, from ad-hoc/unimplemented API endpoints, use the M(cloudera.cluster.cm_resource_info) module. + - The module supports C(check_mode). +author: + - "Webster Mudge (@wmudge)" +requirements: + - cm_client +options: + method: + description: + - HTTP method for the CM API endpoint path. + type: str + required: True + choices: + - DELETE + - POST + - PUT + body: + description: + - HTTP body for the CM API endpoint call. + type: dict +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint + - cloudera.cluster.cm_resource +''' + +EXAMPLES = r''' +--- +- name: Create a new local Cloudera Manager user + cloudera.cluster.cm_resource: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + path: "/user" + method: "POST" + body: + items: + - name: new_user + password: "Als*$ecU7e" + +- name: Update a Cloudera Manager user + cloudera.cluster.cm_resource: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + path: "/user/existing_user" + method: "PUT" + body: + authRoles: + - name: "ROLE_LIMITED" + +- name: Delete a Cloudera Manager user + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + path: "/user/existing_user" + method: "DELETE" +''' + +RETURN = r''' +--- +resources: + description: + - The results from the Cloudera Manager API endpoint call. + - If the I(field) is found on the response object, its contents will be returned. + type: list + elements: complex + returned: always +''' + +class ClouderaResource(ClouderaManagerModule): + def __init__(self, module): + super(ClouderaResource, self).__init__(module) + + # Set parameters + self.method = self._get_param('method') + self.path = self._get_param('path') + self.query = self._get_param('query', dict()) + self.field = self._get_param('field') + self.body = self._get_param('body') + + # Initialize the return values + self.resources = [] + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + if not self.module.check_mode: + self.resources = self.call_api(self.path, self.method, self.query, + self.field, self.body) + + +def main(): + module = ClouderaManagerModule.ansible_module( + argument_spec=dict( + method=dict(required=True, type='str', choices=['POST', 'PUT', 'DELETE']), + path=dict(required=True, type='str'), + query=dict(required=False, type='dict', aliases=['query_parameters', 'parameters']), + body=dict(required=False, type='dict'), + field=dict(required=False, type='str', default='items', aliases=['return_field']) + ), + supports_check_mode=True + ) + + result = ClouderaResource(module) + + output = dict( + changed=False, + resources=result.resources, + ) + + if result.debug: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines + ) + + module.exit_json(**output) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/cm_resource_info.py b/plugins/modules/cm_resource_info.py new file mode 100644 index 00000000..0ffe1b45 --- /dev/null +++ b/plugins/modules/cm_resource_info.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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 json + +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_resource_info +short_description: Retrieve resources from the Cloudera Manager API endpoint +description: + - Retrieve resources from ad-hoc Cloudera Manager API endpoint paths, i.e. unimplemented API calls. + - This module only supports the C(GET) HTTP method. + - To interact with ad-hoc/unimplemented API endpoints, use the M(cloudera.cluster.cm_resource) module. + - The module supports C(check_mode). +author: + - "Webster Mudge (@wmudge)" +requirements: + - cm_client +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint + - cloudera.cluster.cm_resource +''' + +EXAMPLES = r''' +--- +- name: Gather details about all Cloudera Manager users + cloudera.cluster.cm_resource_info: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + path: "/users" +''' + +RETURN = r''' +--- +resources: + description: + - The results from the Cloudera Manager API endpoint call. + - If the I(field) is found on the response object, its contents will be returned. + type: list + elements: complex + returned: always +''' + +class ClouderaResourceInfo(ClouderaManagerModule): + def __init__(self, module): + super(ClouderaResourceInfo, self).__init__(module) + + # Set parameters + self.path = self._get_param('path') + self.query = self._get_param('query', dict()) + self.field = self._get_param('field') + + # Initialize the return values + self.resources = [] + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + self.resources = self.call_api(self.path, 'GET', self.query, self.field) + + +def main(): + module = ClouderaManagerModule.ansible_module( + argument_spec=dict( + path=dict(required=True, type='str'), + query=dict(required=False, type='dict', aliases=['query_parameters', 'parameters']), + field=dict(required=False, type='str', default='items', aliases=['return_field']) + ), + supports_check_mode=True + ) + + result = ClouderaResourceInfo(module) + + output = dict( + changed=False, + resources=result.resources, + ) + + if result.debug: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines + ) + + module.exit_json(**output) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/cm_version_info.py b/plugins/modules/cm_version_info.py new file mode 100644 index 00000000..ae354236 --- /dev/null +++ b/plugins/modules/cm_version_info.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2022 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 + +from cm_client import ClouderaManagerResourceApi + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: cm_version_info +short_description: Gather information about Cloudera Manager +description: + - Gather information about the Cloudera Manager instance. + - The module supports C(check_mode). +author: + - "Webster Mudge (@wmudge)" +requirements: + - cm_client +extends_documentation_fragment: + - cloudera.cluster.cm_options + - cloudera.cluster.cm_endpoint +''' + +EXAMPLES = r''' +--- +- name: Gather details using an endpoint URL + cloudera.cluster.cm_version: + url: "https://example.cloudera.com:7183/api/v49" + username: "jane_smith" + password: "S&peR4Ec*re" + register: cm_output + +# This will first try 'http://example.cloudera.com:7180' and will +# follow any redirects +- name: Gather details using auto-discovery + cloudera.cluster.cm_version: + host: example.cloudera.com + username: "jane_smith" + password: "S&peR4Ec*re" + register: cm_discovery +''' + +RETURN = r''' +--- +version: + description: Details for the Cloudera Manager instance + type: dict + contains: + version: + description: The CM version. + type: str + returned: optional + snapshot: + description: Whether this build is a development snapshot. + type: bool + returned: optional + build_user: + description: The user performing the build. + type: str + returned: optional + build_timestamp: + description: Build timestamp. + type: str + returned: optional + git_hash: + description: Source control management hash. + type: str + returned: optional +''' + +class ClouderaManagerVersionInfo(ClouderaManagerModule): + def __init__(self, module): + super(ClouderaManagerVersionInfo, self).__init__(module) + + # Initialize the return values + self.cm = dict() + + # Execute the logic + self.process() + + @ClouderaManagerModule.handle_process + def process(self): + api_instance = ClouderaManagerResourceApi(self.api_client) + self.cm=api_instance.get_version().to_dict() + + +def main(): + module = ClouderaManagerModule.ansible_module( + supports_check_mode=True + ) + + result = ClouderaManagerVersionInfo(module) + + output = dict( + changed=False, + cm=result.cm, + ) + + if result.debug: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines + ) + + module.exit_json(**output) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..f6cbd92d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,17 @@ +# Copyright 2022 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. + +[pytest] +filterwarnings = + ignore::DeprecationWarning \ No newline at end of file diff --git a/tests/config.yml b/tests/config.yml new file mode 100644 index 00000000..db18769d --- /dev/null +++ b/tests/config.yml @@ -0,0 +1,21 @@ +# Copyright 2022 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. + +# Support for this feature was first added in ansible-core 2.12. +# See: +# - https://github.com/ansible-collections/overview/issues/45#issuecomment-827853900 +# - https://github.com/ansible/ansible/blob/devel/test/lib/ansible_test/config/config.yml + +modules: + python_requires: '>=3.6' diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..96e9c67d --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 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 sys +import pytest + +@pytest.fixture(autouse=True) +def skip_python(): + if sys.version_info < (3, 6): + pytest.skip('Skipping on Python %s. cloudera.cloud supports Python 3.6 and higher.' % sys.version) 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 new file mode 100644 index 00000000..5ee00fe6 --- /dev/null +++ b/tests/unit/plugins/modules/cm_endpoint_info/test_cm_endpoint_info_i.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 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 os +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 + + +@unittest.skipUnless(os.getenv('CM_USERNAME'), "Cloudera Manager access parameters not set") +class TestCMVersionIntegration(ModuleTestCase): + + 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" + }) + + 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 diff --git a/tests/unit/plugins/modules/cm_resource/test_cm_resource_i.py b/tests/unit/plugins/modules/cm_resource/test_cm_resource_i.py new file mode 100644 index 00000000..c98a8594 --- /dev/null +++ b/tests/unit/plugins/modules/cm_resource/test_cm_resource_i.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 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 os +import pytest +import unittest + +from ansible_collections.cloudera.cluster.plugins.modules import cm_resource +from ansible_collections.cloudera.cluster.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, setup_module_args + + +@unittest.skipUnless(os.getenv('CM_USERNAME'), "Cloudera Manager access parameters not set") +class TestCMResourceIntegration(ModuleTestCase): + + def test_post(self): + create_module_args = { + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "method": "POST", + "path": "/users", + "body": { + "items": [ + { + "name": "unit_test", + "password": "UnsecurePassword" + } + ] + } + } + + update_module_args = { + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "method": "PUT", + "path": "/users/unit_test", + "body": { + "authRoles": [{ "name": "ROLE_LIMITED" }] + } + } + + delete_module_args = { + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "method": "DELETE", + "path": "/users/unit_test" + } + + # Create + setup_module_args(create_module_args) + with pytest.raises(AnsibleExitJson) as e: + cm_resource.main() + self.assertIsInstance(e.value.args[0]['resources'], list) + + # Create fail on duplicate + setup_module_args(create_module_args) + with pytest.raises(AnsibleFailJson) as e: + cm_resource.main() + self.assertEquals(e.value.args[0]['status_code'], 400) + + # Update + setup_module_args(update_module_args) + with pytest.raises(AnsibleExitJson) as e: + cm_resource.main() + self.assertIsInstance(e.value.args[0]['resources'], list) + + # Delete + setup_module_args(delete_module_args) + with pytest.raises(AnsibleExitJson) as e: + cm_resource.main() + self.assertIsInstance(e.value.args[0]['resources'], list) + + # Delete fail on existence + setup_module_args(delete_module_args) + with pytest.raises(AnsibleFailJson) as e: + cm_resource.main() + self.assertEquals(e.value.args[0]['status_code'], 404) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/unit/plugins/modules/cm_resource_info/test_cm_resource_info_i.py b/tests/unit/plugins/modules/cm_resource_info/test_cm_resource_info_i.py new file mode 100644 index 00000000..2d644e48 --- /dev/null +++ b/tests/unit/plugins/modules/cm_resource_info/test_cm_resource_info_i.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 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 os +import pytest +import re +import unittest + +from ansible_collections.cloudera.cluster.plugins.modules import cm_resource_info +from ansible_collections.cloudera.cluster.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, setup_module_args + + +@unittest.skipUnless(os.getenv('CM_USERNAME'), "Cloudera Manager access parameters not set") +class TestCMResourceInfoIntegration(ModuleTestCase): + + def test_list(self): + setup_module_args({ + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "path": "/clusters" + }) + + with pytest.raises(AnsibleExitJson) as e: + cm_resource_info.main() + + self.assertIsInstance(e.value.args[0]['resources'], list) + + def test_item(self): + setup_module_args({ + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "path": "/cm/license" + }) + + with pytest.raises(AnsibleExitJson) as e: + cm_resource_info.main() + + self.assertIsInstance(e.value.args[0]['resources'], list) + + def test_invalid_host(self): + setup_module_args({ + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": "nope", + "verify_tls": "no", + "debug": "yes", + "path": "/cm/license" + }) + + with pytest.raises(AnsibleFailJson) as e: + cm_resource_info.main() + + self.assertRegexpMatches(e.value.args[0]['msg'], "nodename nor servname provided, or not known") + + def test_invalid_path(self): + setup_module_args({ + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "path": "/cm/licenseZ" + }) + + with pytest.raises(AnsibleFailJson) as e: + cm_resource_info.main() + + self.assertRegexpMatches(e.value.args[0]['msg'], "^API error: Not Found$") + + def test_query_params(self): + setup_module_args({ + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "host": os.getenv('CM_HOST'), + "verify_tls": "no", + "debug": "yes", + "path": "/tools/echo", + "query": { + "message": "foobarbaz" + }, + "field": "message" + }) + + with pytest.raises(AnsibleExitJson) as e: + cm_resource_info.main() + + self.assertRegexpMatches(e.value.args[0]['resources'][0], "^foobarbaz$") + + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/unit/plugins/modules/cm_version_info/test_cm_version_info_i.py b/tests/unit/plugins/modules/cm_version_info/test_cm_version_info_i.py new file mode 100644 index 00000000..8654e000 --- /dev/null +++ b/tests/unit/plugins/modules/cm_version_info/test_cm_version_info_i.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 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 os +import pprint +import pytest +import unittest + +from ansible_collections.cloudera.cluster.plugins.modules import cm_version_info +from ansible_collections.cloudera.cluster.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, setup_module_args + + +@unittest.skipUnless(os.getenv('CM_USERNAME'), "Cloudera Manager access parameters not set") +class TestCMVersionIntegration(ModuleTestCase): + + 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_version_info.main() + + self.assertEquals(e.value.args[0]['cm']['version'], "7.6.5") + + def test_direct_endpoint(self): + setup_module_args({ + "username": os.getenv('CM_USERNAME'), + "password": os.getenv('CM_PASSWORD'), + "url": "https://" + os.getenv('CM_HOST') + ":" + os.getenv('CM_PORT_TLS') + "/api/" + os.getenv('CM_VERSION'), + "verify_tls": "no", + "debug": "yes" + }) + + with pytest.raises(AnsibleExitJson) as e: + cm_version_info.main() + + self.assertEquals(e.value.args[0]['cm']['version'], "7.6.5") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/unit/plugins/modules/utils.py b/tests/unit/plugins/modules/utils.py new file mode 100644 index 00000000..3624d05b --- /dev/null +++ b/tests/unit/plugins/modules/utils.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright 2022 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 json +import unittest + +from unittest.mock import patch +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes + +def setup_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + pass + + +class AnsibleFailJson(Exception): + pass + + +def exit_json(*args, **kwargs): + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + +def fail_json(*args, **kwargs): + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + +class ModuleTestCase(unittest.TestCase): + def setUp(self): + self.mock_module = patch.multiple(basic.AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + self.mock_module.start() + self.mock_sleep = patch('time.sleep') + self.mock_sleep.start() + setup_module_args({}) + self.addCleanup(self.mock_module.stop) + self.addCleanup(self.mock_sleep.stop) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt new file mode 100644 index 00000000..63aa668a --- /dev/null +++ b/tests/unit/requirements.txt @@ -0,0 +1,15 @@ +# Copyright 2022 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. + +cm_client \ No newline at end of file