From 0d5a9e9c8ccda17e47234d8494ba77ed70d6460e Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 17 Jan 2020 10:21:42 -0500 Subject: [PATCH] Initial implementation of Pull #5337 --- .../plugins/module_utils/tower_api.py | 334 ++++++++++++++++++ .../plugins/modules/tower_license.py | 74 ++++ awx_collection/plugins/modules/tower_team.py | 71 ++-- 3 files changed, 449 insertions(+), 30 deletions(-) create mode 100644 awx_collection/plugins/module_utils/tower_api.py create mode 100644 awx_collection/plugins/modules/tower_license.py diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py new file mode 100644 index 0000000000..17bb953296 --- /dev/null +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -0,0 +1,334 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError +from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode + +from socket import gethostbyname +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.six.moves.http_cookiejar import CookieJar +from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError, NoSectionError +import re +from json import loads, dumps +from os.path import isfile +from os import access, R_OK + + +class TowerModule(AnsibleModule): + url = None + honorred_settings = ['host', 'username', 'password', 'verify_ssl', 'oauth_token'] + host = '127.0.0.1' + username = None + password = None + verify_ssl = True + oauth_token = None + oauth_token_id = None + session = None + cookie_jar = CookieJar() + authenticated = False + json_output = {'changed': False} + + def __init__(self, argument_spec, **kwargs): + args = dict( + tower_host=dict(required=False, fallback=(env_fallback, ['TOWER_HOST'])), + tower_username=dict(required=False, fallback=(env_fallback, ['TOWER_USERNAME'])), + tower_password=dict(no_log=True, required=False, fallback=(env_fallback, ['TOWER_PASSWORD'])), + validate_certs=dict(type='bool', aliases=['tower_verify_ssl'], required=False, fallback=(env_fallback, ['TOWER_VERIFY_SSL'])), + tower_oauthtoken=dict(no_log=True, required=False, fallback=(env_fallback, ['TOWER_OAUTH_TOKEN'])), + tower_config_file=dict(type='path', required=False, default=None), + ) + args.update(argument_spec) + kwargs['supports_check_mode'] = True + + super(TowerModule, self).__init__(argument_spec=args, **kwargs) + + # If we have a tower config, load it + if self.params.get('tower_config_file'): + self.load_config(self.params.get('tower_config_file')) + + # Parameters specified on command line will override settings in config + if self.params.get('tower_host'): + self.host = self.params.get('tower_host') + if self.params.get('tower_username'): + self.username = self.params.get('tower_username') + if self.params.get('tower_password'): + self.password = self.params.get('tower_password') + if self.params.get('validate_certs') is not None: + self.verify_ssl = self.params.get('validate_certs') + if self.params.get('tower_oauthtoken'): + self.oauth_token = self.params.get('tower_oauthtoken') + + # Perform some basic validation + if not re.match('^https{0,1}://', self.host): + self.host = "https://{0}".format(self.host) + + # Try to parse the hostname as a url + try: + self.url = urlparse(self.host) + except Exception as e: + self.fail_json(msg="Unable to parse tower_host as a URL ({1}): {0}".format(self.host, e)) + + # Try to resolve the hostname + hostname = self.url.netloc.split(':')[0] + try: + gethostbyname(hostname) + except Exception as e: + self.fail_json(msg="Unable to resolve tower_host ({1}): {0}".format(hostname, e)) + + self.session = Request(cookies=self.cookie_jar) + + def load_config(self, config_path): + config = ConfigParser() + # Validate the config file is an actual file + if not isfile(config_path): + self.fail_json(msg='The specified config file does not exist') + + if not access(config_path, R_OK): + self.fail_json(msg="The specified config file can not be read") + + config.read(config_path) + + for honorred_setting in self.honorred_settings: + try: + setattr(self, honorred_setting, config.get('general', honorred_setting)) + except (NoSectionError) as nse: + self.fail_json(msg="The specified config file does not contain a general section ({0})".format(nse)) + except (NoOptionError): + pass + + def get_endpoint(self, endpoint, *args, **kwargs): + return self.make_request('GET', endpoint, **kwargs) + + def patch_endpoint(self, endpoint, *args, **kwargs): + return self.make_request('PATCH', endpoint, **kwargs) + + def post_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): + response = self.make_request('POST', endpoint, **kwargs) + if response['status_code'] == 201: + self.json_output['changed'] = True + self.json_output['id'] = response['json']['id'] + self.exit_json(**self.json_output) + else: + if 'json' in response and '__all__' in response['json']: + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) + else: + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code'])) + + def delete_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): + response = self.make_request('DELETE', endpoint, **kwargs) + if not handle_return: + return response + elif response['status_code'] == 204: + self.json_output['changed'] = True + self.exit_json(**self.json_output) + else: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) + + def get_all_endpoint(self, endpoint, *args, **kwargs): + raise Exception("This is not implemented") + + def get_one(self, endpoint, *args, **kwargs): + response = self.get_endpoint(endpoint, *args, **kwargs) + if response['status_code'] != 200: + self.fail_json(msg="Got a {0} response when trying to get one from {1}".format(response['status_code'], endpoint)) + + if 'count' not in response['json'] or 'results' not in response['json']: + self.fail_json(msg="The endpoint did not provide count and results") + + if response['json']['count'] == 0: + return None + elif response['json']['count'] > 1: + self.fail_json(msg="An unexpected number of items was returned from the API ({0})".format(response['json']['count'])) + + return response['json']['results'][0] + + def resolve_name_to_id(self, endpoint, name_or_id): + # Try to resolve the object by name + response = self.get_endpoint(endpoint, **{'data': {'name': name_or_id}}) + if response['json']['count'] == 1: + return response['json']['results'][0]['id'] + elif response['json']['count'] == 0: + self.fail_json(msg="The {} {} was not found on the Tower server".format(endpoint, name_or_id)) + else: + self.fail_json(msg="Found too many names {} at endpoint {}".format(name_or_id, endpoint)) + + def make_request(self, method, endpoint, *args, **kwargs): + # Incase someone is calling us directly; make sure we were given a method, lets not just assume a GET + if not method: + raise Exception("The HTTP method must be defined") + + # Make sure we start with /api/vX + if not endpoint.startswith("/"): + endpoint = "/{0}".format(endpoint) + if not endpoint.startswith("/api/"): + endpoint = "/api/v2{0}".format(endpoint) + if not endpoint.endswith('/'): + endpoint = "{}/".format(endpoint) + + # Extract the headers, this will be used in a couple of places + headers = kwargs.get('headers', {}) + + # Authenticate to Tower (if we've not already done so) + if not self.authenticated: + # This method will set a cookie in the cookie jar for us + self.authenticate(**kwargs) + if self.oauth_token: + # If we have a oauth toekn we just use a bearer header + headers['Authorization'] = 'Bearer {}'.format(self.oauth_token) + + # Update the URL path with the endpoint + self.url = self.url._replace(path=endpoint) + + if method in ['POST', 'PUT', 'PATCH']: + headers.setdefault('Content-Type', 'application/json') + kwargs['headers'] = headers + elif kwargs.get('data'): + self.url = self.url._replace(query=urlencode(kwargs.get('data'))) + + data = {} + if headers.get('Content-Type', '') == 'application/json': + data = dumps(kwargs.get('data', {})) + + try: + response = self.session.open(method, self.url.geturl(), headers=headers, validate_certs=self.verify_ssl, follow_redirects=True, data=data) + self.url = self.url._replace(query=None) + except(SSLValidationError) as ssl_err: + self.fail_json(msg="Could not establish a secure connection to your host ({1}): {0}.".format(self.url.netloc, ssl_err)) + except(ConnectionError) as con_err: + self.fail_json(msg="There was a network error of some kind trying to connect to your host ({1}): {0}.".format(self.url.netloc, con_err)) + except(HTTPError) as he: + # Sanity check: Did the server send back some kind of internal error? + if he.code >= 500: + self.fail_json(msg='The host sent back a server error ({1}): {0}. Please check the logs and try again later'.format(self.url.path, he)) + # Sanity check: Did we fail to authenticate properly? If so, fail out now; this is always a failure. + elif he.code == 401: + self.fail_json(msg='Invalid Tower authentication credentials for {0} (HTTP 401).'.format(self.url.path)) + # Sanity check: Did we get a forbidden response, which means that the user isn't allowed to do this? Report that. + elif he.code == 403: + self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).".format(self.url.path, method)) + # Sanity check: Did we get a 404 response? + # Requests with primary keys will return a 404 if there is no response, and we want to consistently trap these. + elif he.code == 404: + self.fail_json(msg='The requested object could not be found at {0}.'.format(self.url.path)) + # Sanity check: Did we get a 405 response? + # A 405 means we used a method that isn't allowed. Usually this is a bad request, but it requires special treatment because the + # API sends it as a logic error in a few situations (e.g. trying to cancel a job that isn't running). + elif he.code == 405: + self.fail_json(msg="The Tower server says you can't make a request with the {0} method to this endpoing {1}".format(method, self.url.path)) + # Sanity check: Did we get some other kind of error? If so, write an appropriate error message. + elif he.code >= 400: + # We are going to return a 400 so the module can decide what to do with it + page_data = he.read() + try: + return {'status_code': he.code, 'json': loads(page_data)} + # JSONDecodeError only available on Python 3.5+ + except ValueError: + return {'status_code': he.code, 'text': page_data} + # self.fail_json(msg='The Tower server claims it was sent a bad request.\n{0} {1}\nstatus code: {2}\n\nResponse: {3}'.format( + # method, self.url.path, he.code, he.read())) + elif he.code == 204 and method == 'DELETE': + # a 204 is a normal response for a delete function + pass + else: + self.fail_json(msg="Unexpected return code when calling {0}: {1}".format(self.url.geturl(), he)) + except(Exception) as e: + self.fail_json(msg="There was an unknown error when trying to connect to {2}: {0} {1}".format(type(e).__name__, e, self.url.geturl())) + + response_body = '' + try: + response_body = response.read() + except(Exception) as e: + self.fail_json(msg="Failed to read response body: {0}".format(e)) + + response_json = {} + if response_body and response_body != '': + try: + response_json = loads(response_body) + except(Exception) as e: + self.fail_json(msg="Failed to parse the response json: {0}".format(e)) + + return {'status_code': response.status, 'json': response_json} + + def authenticate(self, **kwargs): + if self.username and self.password: + # Attempt to get a token from /api/v2/tokens/ by giving it our username/password combo + # If we have a username and password we need to get a session cookie + login_data = { + "description": "Ansible Tower Module Token", + "application": None, + "scope": "write", + } + # Post to the tokens endpoint with baisc auth to try and get a token + api_token_url = (self.url._replace(path='/api/v2/tokens/')).geturl() + + try: + response = self.session.open( + 'POST', api_token_url, + validate_certs=self.verify_ssl, follow_redirects=True, + force_basic_auth=True, url_username=self.username, url_password=self.password, + data=dumps(login_data), headers={'Content-Type': 'application/json'} + ) + except(Exception) as e: + # Sanity check: Did the server send back some kind of internal error? + self.fail_json(msg='Failed to get token: {0}'.format(e)) + + try: + response_json = loads(response.read()) + self.oauth_token_id = response_json['id'] + self.oauth_token = response_json['token'] + except(Exception) as e: + self.fail_json(msg="Failed to extract token information from response: {0}".format(e)) + + # If we have neiter of these then we can try un-authenticated access + self.authenticated = True + + def default_check_mode(self): + '''Execute check mode logic for Ansible Tower modules''' + if self.check_mode: + try: + result = self.get_endpoint('ping') + self.exit_json(**{'changed': True, 'tower_version': '{0}'.format(result['json']['version'])}) + except(Exception) as excinfo: + self.fail_json(changed=False, msg='Failed check mode: {0}'.format(excinfo)) + + def update_if_needed(self, existing_item, new_item, handle_response=True, **existing_return): + for field in new_item: + # If the two items don't match and we are not comparing '' to None + if existing_item.get(field, None) != new_item.get(field, None) and not (existing_item.get(field, None) is None and new_item.get(field, None) == ''): + # something dosent match so lets do it + response = self.patch_endpoint(existing_item['url'], **{'data': new_item}) + if not handle_response: + return response + elif response['status_code'] == 200: + existing_return['changed'] = True + existing_return['id'] = response['json'].get('id') + self.exit_json(**existing_return) + elif 'json' in response and '__all__' in response['json']: + self.fail_json(msg=response['json']['__all__']) + else: + self.fail_json({'msg': "Unable to update object, see response", 'response': response}) + + # Since we made it here, we don't need to update, status ok + existing_return['changed'] = False + existing_return['id'] = existing_item.get('id') + self.exit_json(**existing_return) + + def logout(self): + if self.oauth_token_id: + try: + self.delete_endpoint('tokens/{0}/'.format(self.oauth_token_id), handle_return=False) + self.authenticated = False + except Exception as e: + self.fail_json(msg="Failed to logut: {0}".format(e)) + + def fail_json(self, **kwargs): + # Try to logout if we are authenticated + self.logout() + super().fail_json(**kwargs) + + def exit_json(self, **kwargs): + # Try to logout if we are authenticated + self.logout() + super().exit_json(**kwargs) diff --git a/awx_collection/plugins/modules/tower_license.py b/awx_collection/plugins/modules/tower_license.py new file mode 100644 index 0000000000..b1419e7375 --- /dev/null +++ b/awx_collection/plugins/modules/tower_license.py @@ -0,0 +1,74 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# (c) 20189, John Westcott IV +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: license +author: "John Westcott IV (@john-westcott-iv)" +version_added: "2.9" +short_description: Set the license for Ansible Tower +description: + - Get or Set Ansible Tower license. See + U(https://www.ansible.com/tower) for an overview. +options: + data: + description: + - The contents of the license file + required: True +extends_documentation_fragment: awx.awx.auth +''' + +RETURN = ''' # ''' + +EXAMPLES = ''' +- name: Set the license using a file + license: + data: "{{ lookup('file', '/tmp/my_tower.license') }}" +''' + +from ..module_utils.tower_api import TowerModule + + +def main(): + + module = TowerModule( + argument_spec=dict( + data=dict(type='dict', required=True), + eula_accepted=dict(type='bool', required=True), + ), + supports_check_mode=True + ) + + json_output = {'changed': False} + + if not module.params.get('eula_accepted'): + module.fail_json(msg='You must accept the EULA by passing in the param eula_acepte as True') + + json_output['old_license'] = module.get_endpoint('settings/system/')['json']['LICENSE'] + new_license = module.params.get('data') + + if json_output['old_license'] != new_license: + json_output['changed'] = True + if module.check_mode: + module.logout() + module.exit_json(**json_output) + # We need to add in the EULA + new_license['eula_accepted'] = True + module.post_endpoint('config', data=new_license) + + module.exit_json(**json_output) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index dc34f4d706..f7fd01d156 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -57,57 +57,68 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" ''' -from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode - -try: - import tower_cli - import tower_cli.exceptions as exc - - from tower_cli.conf import settings -except ImportError: - pass +from ..module_utils.tower_api import TowerModule def main(): - + # Any additional arguments that are not fields of the item can be added here argument_spec = dict( name=dict(required=True), + new_name=dict(required=False), description=dict(), organization=dict(required=True), state=dict(choices=['present', 'absent'], default='present'), ) + # Create a module for ourselves module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + # Extract our parameters name = module.params.get('name') + new_name = module.params.get('new_name') description = module.params.get('description') organization = module.params.get('organization') state = module.params.get('state') - json_output = {'team': name, 'state': state} + # We can either use the default check mode option or we can customize our own + module.default_check_mode() - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - team = tower_cli.get_resource('team') + # Attempt to lookup the org the user specified + org_id = module.resolve_name_to_id('organizations', organization) - try: - org_res = tower_cli.get_resource('organization') - org = org_res.get(name=organization) + # Attempt to lookup team based on the provided name and org ID + team = module.get_one('teams', **{ + 'data': { + 'name': name, + 'organization': org_id + } + }) - if state == 'present': - result = team.modify(name=name, organization=org['id'], - description=description, create_on_missing=True) - json_output['id'] = result['id'] - elif state == 'absent': - result = team.delete(name=name, organization=org['id']) - except (exc.NotFound) as excinfo: - module.fail_json(msg='Failed to update team, organization not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update team: {0}'.format(excinfo), changed=False) + if state == 'absent' and not team: + # If the state was absent and we had no team, we can just return + module.exit_json(**module.json_output) + elif state == 'absent' and team: + # If the state was absent and we had a team, we can try to delete it, the module will handle exiting from this + module.delete_endpoint('teams/{0}'.format(team['id']), item_type='team', item_name=name, **{}) + elif state == 'present' and not team: + # if the state was present and we couldn't find a team we can build one, the module wikl handle exiting from this + module.post_endpoint('teams', item_type='team', item_name=name, **{ + 'data': { + 'name': name, + 'description': description, + 'organization': org_id + } + }) + else: + # If the state was present and we had a team we can see if we need to update it + # This will return on its own + team_fields = { + 'name': new_name if new_name else name, + 'description': description, + 'organization': org_id, + } - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + module.update_if_needed(team, team_fields) if __name__ == '__main__':