From 0d5a9e9c8ccda17e47234d8494ba77ed70d6460e Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 17 Jan 2020 10:21:42 -0500 Subject: [PATCH 01/45] 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__': From b34208d1b635232c7445581f674f55cf5793ae03 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 17 Jan 2020 10:45:44 -0500 Subject: [PATCH 02/45] Attempting to fix logout loop --- .../plugins/module_utils/tower_api.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 17bb953296..0f41f90f12 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -316,12 +316,22 @@ class TowerModule(AnsibleModule): self.exit_json(**existing_return) def logout(self): - if self.oauth_token_id: + if self.oauth_token_id != None and self.username and self.password: + # Attempt to delete our current token from /api/v2/tokens/ + # Post to the tokens endpoint with baisc auth to try and get a token + api_token_url = (self.url._replace(path='/api/v2/tokens/{0}/'.format(self.oauth_token_id))).geturl() + try: - self.delete_endpoint('tokens/{0}/'.format(self.oauth_token_id), handle_return=False) + response = self.session.open( + 'DELETE', api_token_url, + validate_certs=self.verify_ssl, follow_redirects=True, + force_basic_auth=True, url_username=self.username, url_password=self.password + ) + self.oauth_token_id = None self.authenticated = False - except Exception as e: - self.fail_json(msg="Failed to logut: {0}".format(e)) + except(Exception) as e: + # Sanity check: Did the server send back some kind of internal error? + super().fail_json(msg='Failed to release token: {0}'.format(e)) def fail_json(self, **kwargs): # Try to logout if we are authenticated @@ -332,3 +342,4 @@ class TowerModule(AnsibleModule): # Try to logout if we are authenticated self.logout() super().exit_json(**kwargs) + From d8be6490c28a72293532c07bd3b01d37d58ed04e Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 17 Jan 2020 11:07:39 -0500 Subject: [PATCH 03/45] Only warn if we can't release a tower token --- awx_collection/plugins/module_utils/tower_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 0f41f90f12..69f405346c 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -331,7 +331,7 @@ class TowerModule(AnsibleModule): self.authenticated = False except(Exception) as e: # Sanity check: Did the server send back some kind of internal error? - super().fail_json(msg='Failed to release token: {0}'.format(e)) + self.warn('Failed to release tower token {0}: {1}'.format(self.oauth_token_id, e)) def fail_json(self, **kwargs): # Try to logout if we are authenticated From 65057c1fb73ef552cd05f23c0157979b9003ac2b Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 17 Jan 2020 15:44:07 -0500 Subject: [PATCH 04/45] Auto-handle check_mode on post, patch, delete and update_if_needed methods --- .../plugins/module_utils/tower_api.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 69f405346c..b5716a1d2f 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -102,9 +102,19 @@ class TowerModule(AnsibleModule): return self.make_request('GET', endpoint, **kwargs) def patch_endpoint(self, endpoint, *args, **kwargs): + # Handle check mode + if self.check_mode: + self.json_output['changed'] = True + self.exit_json(**self.json_output) + return self.make_request('PATCH', endpoint, **kwargs) def post_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): + # Handle check mode + if self.check_mode: + self.json_output['changed'] = True + self.exit_json(**self.json_output) + response = self.make_request('POST', endpoint, **kwargs) if response['status_code'] == 201: self.json_output['changed'] = True @@ -117,6 +127,11 @@ class TowerModule(AnsibleModule): 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): + # Handle check mode + if self.check_mode: + self.json_output['changed'] = True + self.exit_json(**self.json_output) + response = self.make_request('DELETE', endpoint, **kwargs) if not handle_return: return response @@ -298,6 +313,12 @@ class TowerModule(AnsibleModule): # 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 + + # Handle check mode + if self.check_mode: + existing_return['changed'] = True + self.exit_json(**existing_return) + response = self.patch_endpoint(existing_item['url'], **{'data': new_item}) if not handle_response: return response From c57754a29bc0fea65ceb8293e2b897c2ad1b3153 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 17 Jan 2020 15:44:25 -0500 Subject: [PATCH 05/45] Logout is now handled by exit_json --- awx_collection/plugins/modules/tower_license.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx_collection/plugins/modules/tower_license.py b/awx_collection/plugins/modules/tower_license.py index b1419e7375..a8ef96cbda 100644 --- a/awx_collection/plugins/modules/tower_license.py +++ b/awx_collection/plugins/modules/tower_license.py @@ -60,9 +60,11 @@ def main(): if json_output['old_license'] != new_license: json_output['changed'] = True + + # Deal with check mode 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) @@ -72,3 +74,4 @@ def main(): if __name__ == '__main__': main() + From b0d0ccf44fc9ce90cff6ad0aa1ed3d6071a5d640 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 18 Jan 2020 16:01:25 -0500 Subject: [PATCH 06/45] Fix fail_json and remove redundant handle check mode --- awx_collection/plugins/module_utils/tower_api.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index b5716a1d2f..9406f9082a 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -314,11 +314,6 @@ class TowerModule(AnsibleModule): 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 - # Handle check mode - if self.check_mode: - existing_return['changed'] = True - self.exit_json(**existing_return) - response = self.patch_endpoint(existing_item['url'], **{'data': new_item}) if not handle_response: return response @@ -329,7 +324,7 @@ class TowerModule(AnsibleModule): 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}) + 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 From d8a9f663b146267f25485cc996ebcf29300fb699 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 18 Jan 2020 16:01:51 -0500 Subject: [PATCH 07/45] Converted tower_credential_type.py --- .../plugins/modules/tower_credential_type.py | 110 ++++++++---------- 1 file changed, 47 insertions(+), 63 deletions(-) diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 94bb76e90f..5be7055770 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -64,12 +64,6 @@ options: default: "present" choices: ["present", "absent"] type: str - validate_certs: - description: - - Tower option to avoid certificates check. - required: False - type: bool - aliases: [ tower_verify_ssl ] extends_documentation_fragment: awx.awx.auth ''' @@ -93,19 +87,7 @@ EXAMPLES = ''' RETURN = ''' # ''' -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 KIND_CHOICES = { 'ssh': 'Machine', @@ -118,63 +100,65 @@ KIND_CHOICES = { def main(): - argument_spec = dict( - name=dict(required=True), - description=dict(required=False), - kind=dict(required=False, choices=KIND_CHOICES.keys()), - inputs=dict(type='dict', required=False), - injectors=dict(type='dict', required=False), - state=dict(choices=['present', 'absent'], default='present'), - ) module = TowerModule( - argument_spec=argument_spec, - supports_check_mode=False + argument_spec = dict( + name=dict(required=True), + description=dict(required=False), + kind=dict(required=False, choices=KIND_CHOICES.keys()), + inputs=dict(type='dict', required=False), + injectors=dict(type='dict', required=False), + state=dict(choices=['present', 'absent'], default='present'), + ), + supports_check_mode=True ) name = module.params.get('name') + new_name = None kind = module.params.get('kind') state = module.params.get('state') json_output = {'credential_type': name, 'state': state} - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - credential_type_res = tower_cli.get_resource('credential_type') + # Deal with check mode + module.default_check_mode() - params = {} - params['name'] = name - params['kind'] = kind - params['managed_by_tower'] = False + # These will be passed into the create/updates + credental_type_params = { + 'name': new_name if new_name else name, + 'kind': kind, + 'managed_by_tower': False, + } + if module.params.get('description'): + credental_type_params['description'] = module.params.get('description') + if module.params.get('inputs'): + credental_type_params['inputs'] = module.params.get('inputs') + if module.params.get('injectors'): + credental_type_params['injectors'] = module.params.get('injectors') - if module.params.get('description'): - params['description'] = module.params.get('description') - - if module.params.get('inputs'): - params['inputs'] = module.params.get('inputs') - - if module.params.get('injectors'): - params['injectors'] = module.params.get('injectors') - - try: - if state == 'present': - params['create_on_missing'] = True - result = credential_type_res.modify(**params) - json_output['id'] = result['id'] - elif state == 'absent': - params['fail_on_missing'] = False - result = credential_type_res.delete(**params) - - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json( - msg='Failed to update credential type: {0}'.format(excinfo), - changed=False - ) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + # Attempt to lookup credential_type based on the provided name and org ID + credential_type = module.get_one('credential_types', **{ + 'data': { + 'name': name, + } + }) + json_output['existing_credential_type'] = credential_type + if state == 'absent' and not credential_type: + # If the state was absent and we had no credential_type, we can just return + module.exit_json(**module.json_output) + elif state == 'absent' and credential_type: + # 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('credential_types/{0}'.format(credential_type['id']), item_type='credential type', item_name=name, **{}) + elif state == 'present' and not credential_type: + # if the state was present and we couldn't find a credential_type we can build one, the module will handle exiting on its own + module.post_endpoint('credential_types', item_type='credential type', item_name=name, **{ + 'data': credental_type_params + }) + else: + # If the state was present and we had a credential_type we can see if we need to update it + # This will handle existing on its own + module.update_if_needed(credential_type, credental_type_params) if __name__ == '__main__': main() From aaaca63f831b22140c9b898725b1b2b6e1975b5b Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 18 Jan 2020 16:02:01 -0500 Subject: [PATCH 08/45] Converted tower_organization --- .../plugins/modules/tower_organization.py | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 201c7fc326..e31ccc979e 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -39,6 +39,12 @@ options: type: str required: False default: '' + max_hosts: + description: + - The max hosts allowed in this organizations + default: "0" + type: str + required: False state: description: - Desired state of the resource. @@ -47,6 +53,12 @@ options: type: str extends_documentation_fragment: awx.awx.auth ''' +# instance_groups: +# description: +# - The name of instance groups to tie to this organization +# type: list +# default: [] +# required: False EXAMPLES = ''' @@ -66,50 +78,73 @@ 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), - description=dict(), + name=dict(type='str', required=True), + description=dict(type='str', required=False), custom_virtualenv=dict(type='str', required=False), - state=dict(choices=['present', 'absent'], default='present'), + max_hosts=dict(type='str', required=False, default="0"), + # instance_groups=dict(type='list', required=False, default=[]), + state=dict(type='str', choices=['present', 'absent'], default='present', required=False), ) + # Create a module for ourselves module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + # Extract our parameters name = module.params.get('name') description = module.params.get('description') custom_virtualenv = module.params.get('custom_virtualenv') + max_hosts = module.params.get('max_hosts') + # instance_group_names = module.params.get('instance_groups') state = module.params.get('state') - json_output = {'organization': name, 'state': state} + # Attempt to lookup the org the user specified + # instance_group_objects = [] + # for instance_name in instance_group_names: + # instance_group_objects.append(module.resolve_name_to_id('instance_groups', instance_name)) - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - organization = tower_cli.get_resource('organization') + # Attempt to lookup organization based on the provided name and org ID + organization = module.get_one('organizations', **{ + 'data': { + 'name': name, + } + }) + + new_org_data = { 'name': name } + if description: + new_org_data['description'] = description + if custom_virtualenv: + new_org_data['custom_virtualenv'] = custom_virtualenv + if max_hosts: + int_max_hosts = 0 try: - if state == 'present': - result = organization.modify(name=name, description=description, custom_virtualenv=custom_virtualenv, create_on_missing=True) - json_output['id'] = result['id'] - elif state == 'absent': - result = organization.delete(name=name) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update the organization: {0}'.format(excinfo), changed=False) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + int_max_hosts = int(max_hosts) + except Exception as e: + module.fail_json(msg="Unable to convert max_hosts to an integer") + new_org_data['max_hosts'] = int_max_hosts + # if instance_group_objects: + # new_org_data['instance_groups'] = instance_group_objects + if state == 'absent' and not organization: + # If the state was absent and we had no organization, we can just return + module.exit_json(**module.json_output) + elif state == 'absent' and organization: + # If the state was absent and we had a organization, we can try to delete it, the module will handle exiting from this + module.delete_endpoint('organizations/{0}'.format(organization['id']), item_type='organization', item_name=name, **{}) + elif state == 'present' and not organization: + # if the state was present and we couldn't find a organization we can build one, the module wikl handle exiting from this + module.post_endpoint('organizations', item_type='organization', item_name=name, **{ + 'data': new_org_data, + }) + else: + # If the state was present and we had a organization we can see if we need to update it + # This will return on its own + module.update_if_needed(organization, new_org_data) if __name__ == '__main__': main() From c930011616d64d24975d60728b5ee7698576f49e Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 18 Jan 2020 16:03:43 -0500 Subject: [PATCH 09/45] Removing default_check_mode --- awx_collection/plugins/modules/tower_team.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index f7fd01d156..b5ba0a89a7 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -80,9 +80,6 @@ def main(): organization = module.params.get('organization') state = module.params.get('state') - # We can either use the default check mode option or we can customize our own - module.default_check_mode() - # Attempt to lookup the org the user specified org_id = module.resolve_name_to_id('organizations', organization) From 167e99fce91e49d377918a1a122f6af96e8298e4 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 18 Jan 2020 16:04:00 -0500 Subject: [PATCH 10/45] Converted tower_user --- awx_collection/plugins/modules/tower_user.py | 73 +++++++++----------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index 87e9e8f9c1..b63110ae01 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -62,9 +62,6 @@ options: choices: ["present", "absent"] type: str -requirements: - - ansible-tower-cli >= 3.2.0 - extends_documentation_fragment: awx.awx.auth ''' @@ -106,18 +103,10 @@ 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( username=dict(required=True), first_name=dict(), @@ -129,37 +118,43 @@ def main(): state=dict(choices=['present', 'absent'], default='present'), ) + # Create a module for ourselves module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) - username = module.params.get('username') - first_name = module.params.get('first_name') - last_name = module.params.get('last_name') - password = module.params.get('password') - email = module.params.get('email') - superuser = module.params.get('superuser') - auditor = module.params.get('auditor') + # Extract our parameters state = module.params.get('state') + user_fields = { + 'username': module.params.get('username'), + 'first_name': module.params.get('first_name'), + 'last_name': module.params.get('last_name'), + 'password': module.params.get('password'), + 'email': module.params.get('email'), + 'superuser': module.params.get('superuser'), + 'auditor': module.params.get('auditor'), + } - json_output = {'username': username, 'state': state} - - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - user = tower_cli.get_resource('user') - try: - if state == 'present': - result = user.modify(username=username, first_name=first_name, last_name=last_name, - email=email, password=password, is_superuser=superuser, - is_system_auditor=auditor, create_on_missing=True) - json_output['id'] = result['id'] - elif state == 'absent': - result = user.delete(username=username) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update the user: {0}'.format(excinfo), changed=False) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + # Attempt to lookup team based on the provided name and org ID + user = module.get_one('users', **{ + 'data': { + 'username': user_fields['username'], + } + }) + if state == 'absent' and not user: + # If the state was absent and we had no user, we can just return + module.exit_json(**module.json_output) + elif state == 'absent' and user: + # If the state was absent and we had a user, we can try to delete it, the module will handle exiting from this + module.delete_endpoint('users/{0}'.format(user['id']), item_type='user', item_name=user_fields['username'], **{}) + elif state == 'present' and not user: + # if the state was present and we couldn't find a user we can build one, the module wikl handle exiting from this + module.post_endpoint('users', item_type='user', item_name=user_fields['username'], **{ + 'data': user_fields + }) + else: + # If the state was present and we had a user we can see if we need to update it + # This will return on its own + module.update_if_needed(user, user_fields) if __name__ == '__main__': main() From ceb6f6c47d8a2b9e4f58ab686b902a40a06e379f Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 18 Jan 2020 16:13:33 -0500 Subject: [PATCH 11/45] Converted tower_settings Changed comments --- .../plugins/modules/tower_organization.py | 2 +- .../plugins/modules/tower_settings.py | 108 +++++++++++++----- awx_collection/plugins/modules/tower_team.py | 2 +- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index e31ccc979e..4dedd6da70 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -103,7 +103,7 @@ def main(): # instance_group_names = module.params.get('instance_groups') state = module.params.get('state') - # Attempt to lookup the org the user specified + # Attempt to lookup the related items the user specified (these will fail the module if not found) # instance_group_objects = [] # for instance_name in instance_group_names: # instance_group_objects.append(module.resolve_name_to_id('instance_groups', instance_name)) diff --git a/awx_collection/plugins/modules/tower_settings.py b/awx_collection/plugins/modules/tower_settings.py index 8d0da8ed0d..aa5fb4972a 100644 --- a/awx_collection/plugins/modules/tower_settings.py +++ b/awx_collection/plugins/modules/tower_settings.py @@ -26,13 +26,18 @@ options: name: description: - Name of setting to modify - required: True + required: False type: str value: description: - Value to be modified for given setting. - required: True + required: False type: str + settings: + description: + - A data structure to be sent into the settings endpoint + required: False + type: dict extends_documentation_fragment: awx.awx.auth ''' @@ -56,51 +61,98 @@ EXAMPLES = ''' name: "AUTH_LDAP_BIND_PASSWORD" value: "Password" no_log: true + +- name: Set all the LDAP Auth Bind Params + tower_settings: + settings: + AUTH_LDAP_BIND_PASSWORD: "password" + AUTH_LDAP_USER_ATTR_MAP: + email: "mail" + first_name: "givenName" + last_name: "surname" + ... ''' -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 +from json import loads +from json.decoder import JSONDecodeError +import re def main(): + # Any additional arguments that are not fields of the item can be added here argument_spec = dict( - name=dict(required=True), - value=dict(required=True), + name=dict(required=False), + value=dict(required=False), + settings=dict(required=False,type='dict'), ) + # Create a module for ourselves module = TowerModule( argument_spec=argument_spec, - supports_check_mode=False + supports_check_mode=True, + required_one_of=[['name','settings']], + mutually_exclusive=[['name','settings']], + required_if=[['name', 'present', ['value']]] ) - json_output = {} - + # Extract our parameters name = module.params.get('name') value = module.params.get('value') + new_settings = module.params.get('settings') - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) + # If we were given a name/value pair we will just make settings out of that and proceed normally + if new_settings == None: + new_value = value try: - setting = tower_cli.get_resource('setting') - result = setting.modify(setting=name, value=value) + new_value = loads(value) + except JSONDecodeError as e: + # Attempt to deal with old tower_cli array types + if ',' in value: + new_value = re.split(",\s+", new_value) + + new_settings = { name: new_value } - json_output['id'] = result['id'] - json_output['value'] = result['value'] + # Load the existing settings + existing_settings = module.get_endpoint('settings/all')['json'] - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to modify the setting: {0}'.format(excinfo), changed=False) + # Begin a json response + json_response = { 'changed': False, 'old_values': {} } - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + # Check any of the settings to see if anything needs to be updated + needs_update = False + for a_setting in new_settings: + if a_setting not in existing_settings or existing_settings[a_setting] != new_settings[a_setting]: + # At least one thing is different so we need to patch + needs_update = True + json_response['old_values'][ a_setting ] = existing_settings[a_setting] + # If nothing needs an update we can simply exit with the response (as not changed) + if not needs_update: + module.exit_json(**json_response) + + # Make the call to update the settings + response = module.patch_endpoint('settings/all', **{'data': new_settings}) + + if response['status_code'] == 200: + # Set the changed response to True + json_response['changed'] = True + + # To deal with the old style values we need to return 'value' in the response + new_values = {} + for a_setting in new_settings: + new_values[a_setting] = response['json'][a_setting] + + # If we were using a name we will just add a value of a string, otherwise we will return an array in values + if name != None: + json_response['value'] = new_values[name] + else: + json_response['values'] = new_values + + module.exit_json(**json_response) + elif 'json' in response and '__all__' in response['json']: + module.fail_json(msg=response['json']['__all__']) + else: + module.fail_json(**{'msg': "Unable to update settings, see response", 'response': response}) if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index b5ba0a89a7..33a78275bc 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -80,7 +80,7 @@ def main(): organization = module.params.get('organization') state = module.params.get('state') - # Attempt to lookup the org the user specified + # Attempt to lookup the related items the user specified (these will fail the module if not found) org_id = module.resolve_name_to_id('organizations', organization) # Attempt to lookup team based on the provided name and org ID From 68926dad27982fbd390b3fe03f5210e4d98a3a3f Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Tue, 21 Jan 2020 14:44:19 -0500 Subject: [PATCH 12/45] Adding team_fields Convert job_list and inventory modules, other changes to make sanity tests pass --- .../plugins/module_utils/tower_api.py | 35 ++++++--- .../plugins/modules/tower_credential_type.py | 29 ++++--- .../plugins/modules/tower_inventory.py | 78 +++++++++++-------- .../plugins/modules/tower_job_list.py | 50 ++++++------ .../plugins/modules/tower_license.py | 15 +++- .../plugins/modules/tower_organization.py | 29 ++++--- .../plugins/modules/tower_settings.py | 30 ++++--- awx_collection/plugins/modules/tower_team.py | 33 ++++---- awx_collection/plugins/modules/tower_user.py | 10 ++- 9 files changed, 190 insertions(+), 119 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 9406f9082a..6dcbb3610c 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -36,7 +36,7 @@ class TowerModule(AnsibleModule): 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_oauthtoken=dict(type='str', 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) @@ -114,7 +114,7 @@ class TowerModule(AnsibleModule): if self.check_mode: self.json_output['changed'] = True self.exit_json(**self.json_output) - + response = self.make_request('POST', endpoint, **kwargs) if response['status_code'] == 201: self.json_output['changed'] = True @@ -135,14 +135,24 @@ class TowerModule(AnsibleModule): response = self.make_request('DELETE', endpoint, **kwargs) if not handle_return: return response - elif response['status_code'] == 204: + elif response['status_code'] in [202, 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") + response = self.get_endpoint(endpoint, *args, **kwargs) + next_page = response['json']['next'] + + if response['json']['count'] > 10000: + self.fail_json(msg='The number of items being queried for is higher than 10,000.') + + while next_page is not None: + next_response = self.get_endpoint(next_page) + response['json']['results'] = response['json']['results'] + next_response['json']['results'] + next_page = next_response['json']['next'] + return response def get_one(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) @@ -165,9 +175,9 @@ class TowerModule(AnsibleModule): 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)) + self.fail_json(msg="The {0} {1} 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)) + self.fail_json(msg="Found too many names {0} at endpoint {1}".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 @@ -179,8 +189,8 @@ class TowerModule(AnsibleModule): endpoint = "/{0}".format(endpoint) if not endpoint.startswith("/api/"): endpoint = "/api/v2{0}".format(endpoint) - if not endpoint.endswith('/'): - endpoint = "{}/".format(endpoint) + if not endpoint.endswith('/') and '?' not in endpoint: + endpoint = "{0}/".format(endpoint) # Extract the headers, this will be used in a couple of places headers = kwargs.get('headers', {}) @@ -191,7 +201,7 @@ class TowerModule(AnsibleModule): 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) + headers['Authorization'] = 'Bearer {0}'.format(self.oauth_token) # Update the URL path with the endpoint self.url = self.url._replace(path=endpoint) @@ -310,8 +320,10 @@ class TowerModule(AnsibleModule): def update_if_needed(self, existing_item, new_item, handle_response=True, **existing_return): for field in new_item: + existing_field = existing_item.get(field, None) + new_field = new_item.get(field, None) # 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) == ''): + if existing_field != new_field and not (existing_field in (None, '') and new_field == ''): # something dosent match so lets do it response = self.patch_endpoint(existing_item['url'], **{'data': new_item}) @@ -332,7 +344,7 @@ class TowerModule(AnsibleModule): self.exit_json(**existing_return) def logout(self): - if self.oauth_token_id != None and self.username and self.password: + if self.oauth_token_id is not None and self.username and self.password: # Attempt to delete our current token from /api/v2/tokens/ # Post to the tokens endpoint with baisc auth to try and get a token api_token_url = (self.url._replace(path='/api/v2/tokens/{0}/'.format(self.oauth_token_id))).geturl() @@ -358,4 +370,3 @@ class TowerModule(AnsibleModule): # Try to logout if we are authenticated self.logout() super().exit_json(**kwargs) - diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 5be7055770..cf44ad9ffe 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -64,6 +64,11 @@ options: default: "present" choices: ["present", "absent"] type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -100,19 +105,20 @@ KIND_CHOICES = { def main(): - - module = TowerModule( - argument_spec = dict( - name=dict(required=True), - description=dict(required=False), - kind=dict(required=False, choices=KIND_CHOICES.keys()), - inputs=dict(type='dict', required=False), - injectors=dict(type='dict', required=False), - state=dict(choices=['present', 'absent'], default='present'), - ), - supports_check_mode=True + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + name=dict(required=True), + description=dict(required=False), + kind=dict(required=False, choices=KIND_CHOICES.keys()), + inputs=dict(type='dict', required=False), + injectors=dict(type='dict', required=False), + 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 = None kind = module.params.get('kind') @@ -160,5 +166,6 @@ def main(): # This will handle existing on its own module.update_if_needed(credential_type, credental_type_params) + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index d2321f3853..56589ac69f 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -59,6 +59,11 @@ options: default: "present" choices: ["present", "absent"] type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -74,30 +79,25 @@ EXAMPLES = ''' ''' -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), - description=dict(), + description=dict(default=''), organization=dict(required=True), - variables=dict(), + variables=dict(default=''), kind=dict(choices=['', 'smart'], default=''), host_filter=dict(), 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') description = module.params.get('description') organization = module.params.get('organization') @@ -106,31 +106,43 @@ def main(): kind = module.params.get('kind') host_filter = module.params.get('host_filter') - json_output = {'inventory': name, 'state': state} + # Attempt to lookup the related items the user specified (these will fail the module if not found) + org_id = module.resolve_name_to_id('organizations', organization) - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - inventory = tower_cli.get_resource('inventory') + # Attempt to lookup inventory based on the provided name and org ID + inventory = module.get_one('inventories', **{ + 'data': { + 'name': name, + 'organization': org_id + } + }) - try: - org_res = tower_cli.get_resource('organization') - org = org_res.get(name=organization) + # Create data to sent to create and update + inventory_fields = { + 'name': name, + 'description': description, + 'organization': org_id, + 'variables': variables, + 'kind': kind, + 'host_filter': host_filter, + } - if state == 'present': - result = inventory.modify(name=name, organization=org['id'], variables=variables, - description=description, kind=kind, host_filter=host_filter, - create_on_missing=True) - json_output['id'] = result['id'] - elif state == 'absent': - result = inventory.delete(name=name, organization=org['id']) - except (exc.NotFound) as excinfo: - module.fail_json(msg='Failed to update inventory, organization not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update inventory: {0}'.format(excinfo), changed=False) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + if state == 'absent' and not inventory: + # If the state was absent and we had no inventory, we can just return + module.exit_json(**module.json_output) + elif state == 'absent' and inventory: + # If the state was absent and we had a inventory, we can try to delete it, the module will handle exiting from this + module.delete_endpoint('inventories/{0}'.format(inventory['id']), item_type='inventory', item_name=name, **{}) + elif state == 'present' and not inventory: + # If the state was present and we couldn't find a inventory we can build one, the module will handle exiting from this + module.post_endpoint('inventories', item_type='inventory', item_name=name, **{'data': inventory_fields}) + else: + # Throw a more specific error message than what the API page provides. + if inventory['kind'] == '' and inventory_fields['kind'] == 'smart': + module.fail_json(msg='You cannot turn a regular inventory into a "smart" inventory.') + # If the state was present and we had a inventory, we can see if we need to update it + # This will return on its own + module.update_if_needed(inventory, inventory_fields) if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_job_list.py b/awx_collection/plugins/modules/tower_job_list.py index 63bd7c6f9a..b079351e0c 100644 --- a/awx_collection/plugins/modules/tower_job_list.py +++ b/awx_collection/plugins/modules/tower_job_list.py @@ -41,6 +41,11 @@ options: description: - Query used to further filter the list of jobs. C({"foo":"bar"}) will be passed at C(?foo=bar) type: dict + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -81,18 +86,11 @@ results: ''' -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( status=dict(choices=['pending', 'waiting', 'running', 'error', 'failed', 'canceled', 'successful']), page=dict(type='int'), @@ -100,31 +98,35 @@ def main(): query=dict(type='dict'), ) + # Create a module for ourselves module = TowerModule( argument_spec=argument_spec, - supports_check_mode=True + supports_check_mode=True, + mutually_exclusive=[ + ('page', 'all_pages'), + ] ) - json_output = {} - + # Extract our parameters query = module.params.get('query') status = module.params.get('status') page = module.params.get('page') all_pages = module.params.get('all_pages') - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - try: - job = tower_cli.get_resource('job') - params = {'status': status, 'page': page, 'all_pages': all_pages} - if query: - params['query'] = query.items() - json_output = job.list(**params) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to list jobs: {0}'.format(excinfo), changed=False) + job_search_data = {} + if page: + job_search_data['page'] = page + if status: + job_search_data['status'] = status + if query: + job_search_data.update(query) + if all_pages: + job_list = module.get_all_endpoint('jobs', **{'data': job_search_data}) + else: + job_list = module.get_endpoint('jobs', **{'data': job_search_data}) - module.exit_json(**json_output) + # Attempt to lookup jobs based on the status + module.exit_json(**job_list['json']) if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_license.py b/awx_collection/plugins/modules/tower_license.py index a8ef96cbda..0aa986cb98 100644 --- a/awx_collection/plugins/modules/tower_license.py +++ b/awx_collection/plugins/modules/tower_license.py @@ -14,7 +14,7 @@ ANSIBLE_METADATA = {'metadata_version': '1.1', DOCUMENTATION = ''' --- -module: license +module: tower_license author: "John Westcott IV (@john-westcott-iv)" version_added: "2.9" short_description: Set the license for Ansible Tower @@ -26,6 +26,17 @@ options: description: - The contents of the license file required: True + type: dict + eula_accepted: + description: + - Whether or not the EULA is accepted. + required: True + type: bool + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -35,6 +46,7 @@ EXAMPLES = ''' - name: Set the license using a file license: data: "{{ lookup('file', '/tmp/my_tower.license') }}" + eula_accepted: True ''' from ..module_utils.tower_api import TowerModule @@ -74,4 +86,3 @@ def main(): if __name__ == '__main__': main() - diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 4dedd6da70..18c1785173 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -51,14 +51,13 @@ options: default: "present" choices: ["present", "absent"] type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' -# instance_groups: -# description: -# - The name of instance groups to tie to this organization -# type: list -# default: [] -# required: False EXAMPLES = ''' @@ -88,10 +87,21 @@ def main(): description=dict(type='str', required=False), custom_virtualenv=dict(type='str', required=False), max_hosts=dict(type='str', required=False, default="0"), - # instance_groups=dict(type='list', required=False, default=[]), state=dict(type='str', choices=['present', 'absent'], default='present', required=False), ) +# instance_groups=dict(type='list', required=False, default=[]), +# The above argument_spec fragment is being left in for reference since we may need +# it later when finalizing the changes. + +# instance_groups: +# description: +# - The name of instance groups to tie to this organization +# default: [] +# required: False +# The above docstring fragment is being left in for reference since we may need +# it later when finalizing the changes. + # Create a module for ourselves module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) @@ -115,7 +125,7 @@ def main(): } }) - new_org_data = { 'name': name } + new_org_data = {'name': name} if description: new_org_data['description'] = description if custom_virtualenv: @@ -137,7 +147,7 @@ def main(): # If the state was absent and we had a organization, we can try to delete it, the module will handle exiting from this module.delete_endpoint('organizations/{0}'.format(organization['id']), item_type='organization', item_name=name, **{}) elif state == 'present' and not organization: - # if the state was present and we couldn't find a organization we can build one, the module wikl handle exiting from this + # if the state was present and we couldn't find a organization we can build one, the module will handle exiting from this module.post_endpoint('organizations', item_type='organization', item_name=name, **{ 'data': new_org_data, }) @@ -146,5 +156,6 @@ def main(): # This will return on its own module.update_if_needed(organization, new_org_data) + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_settings.py b/awx_collection/plugins/modules/tower_settings.py index aa5fb4972a..25ec917fec 100644 --- a/awx_collection/plugins/modules/tower_settings.py +++ b/awx_collection/plugins/modules/tower_settings.py @@ -38,6 +38,11 @@ options: - A data structure to be sent into the settings endpoint required: False type: dict + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -66,11 +71,10 @@ EXAMPLES = ''' tower_settings: settings: AUTH_LDAP_BIND_PASSWORD: "password" - AUTH_LDAP_USER_ATTR_MAP: + AUTH_LDAP_USER_ATTR_MAP: email: "mail" first_name: "givenName" last_name: "surname" - ... ''' from ..module_utils.tower_api import TowerModule @@ -78,20 +82,21 @@ from json import loads from json.decoder import JSONDecodeError import re + def main(): # Any additional arguments that are not fields of the item can be added here argument_spec = dict( name=dict(required=False), value=dict(required=False), - settings=dict(required=False,type='dict'), + settings=dict(required=False, type='dict'), ) # Create a module for ourselves module = TowerModule( argument_spec=argument_spec, supports_check_mode=True, - required_one_of=[['name','settings']], - mutually_exclusive=[['name','settings']], + required_one_of=[['name', 'settings']], + mutually_exclusive=[['name', 'settings']], required_if=[['name', 'present', ['value']]] ) @@ -101,22 +106,22 @@ def main(): new_settings = module.params.get('settings') # If we were given a name/value pair we will just make settings out of that and proceed normally - if new_settings == None: + if new_settings is None: new_value = value try: new_value = loads(value) except JSONDecodeError as e: # Attempt to deal with old tower_cli array types if ',' in value: - new_value = re.split(",\s+", new_value) - - new_settings = { name: new_value } + new_value = re.split(r",\s+", new_value) + + new_settings = {name: new_value} # Load the existing settings existing_settings = module.get_endpoint('settings/all')['json'] # Begin a json response - json_response = { 'changed': False, 'old_values': {} } + json_response = {'changed': False, 'old_values': {}} # Check any of the settings to see if anything needs to be updated needs_update = False @@ -124,7 +129,7 @@ def main(): if a_setting not in existing_settings or existing_settings[a_setting] != new_settings[a_setting]: # At least one thing is different so we need to patch needs_update = True - json_response['old_values'][ a_setting ] = existing_settings[a_setting] + json_response['old_values'][a_setting] = existing_settings[a_setting] # If nothing needs an update we can simply exit with the response (as not changed) if not needs_update: @@ -143,7 +148,7 @@ def main(): new_values[a_setting] = response['json'][a_setting] # If we were using a name we will just add a value of a string, otherwise we will return an array in values - if name != None: + if name is not None: json_response['value'] = new_values[name] else: json_response['values'] = new_values @@ -154,5 +159,6 @@ def main(): else: module.fail_json(**{'msg': "Unable to update settings, see response", 'response': response}) + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 33a78275bc..c0c451ba5b 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -28,6 +28,11 @@ options: - Name to use for the team. required: True type: str + new_name: + description: + - To use when changing a team's name. + required: True + type: str description: description: - The description to use for the team. @@ -43,6 +48,11 @@ options: choices: ["present", "absent"] default: "present" type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -91,6 +101,13 @@ def main(): } }) + # Create data to sent to create and update + team_fields = { + 'name': name, + 'description': description, + 'organization': org_id + } + 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) @@ -98,23 +115,11 @@ def main(): # 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 - } - }) + # if the state was present and we couldn't find a team we can build one, the module will handle exiting from this + module.post_endpoint('teams', item_type='team', item_name=name, **{'data': team_fields}) 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, - } - module.update_if_needed(team, team_fields) diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index b63110ae01..c9a628d26a 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -61,7 +61,11 @@ options: default: "present" choices: ["present", "absent"] type: str - + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -105,6 +109,7 @@ EXAMPLES = ''' 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( @@ -147,7 +152,7 @@ def main(): # If the state was absent and we had a user, we can try to delete it, the module will handle exiting from this module.delete_endpoint('users/{0}'.format(user['id']), item_type='user', item_name=user_fields['username'], **{}) elif state == 'present' and not user: - # if the state was present and we couldn't find a user we can build one, the module wikl handle exiting from this + # if the state was present and we couldn't find a user we can build one, the module will handle exiting from this module.post_endpoint('users', item_type='user', item_name=user_fields['username'], **{ 'data': user_fields }) @@ -156,5 +161,6 @@ def main(): # This will return on its own module.update_if_needed(user, user_fields) + if __name__ == '__main__': main() From 7c0ad461a515f2ebec181fa56a07bd27143216a6 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 24 Jan 2020 11:30:16 -0500 Subject: [PATCH 13/45] Further module conversion changes, unit test changes Multiple module changes Added on_change callback Added head_endpoint Added additional error returns Respond with a try an ID message if multiple assets found by name via return_none_on_404 kwarg Diferentiated between login and logout token errors Added is_job_done method --- .../plugins/module_utils/tower_api.py | 52 ++++++++++-- .../plugins/modules/tower_credential_type.py | 12 +-- .../plugins/modules/tower_license.py | 4 +- .../plugins/modules/tower_organization.py | 16 +--- .../plugins/modules/tower_settings.py | 2 +- awx_collection/plugins/modules/tower_team.py | 2 +- awx_collection/test/awx/conftest.py | 80 +++++++++++++++++++ awx_collection/test/awx/test_credential.py | 7 +- awx_collection/test/awx/test_organization.py | 30 ++++--- awx_collection/test/awx/test_team.py | 66 +++++++++++++++ 10 files changed, 224 insertions(+), 47 deletions(-) create mode 100644 awx_collection/test/awx/test_team.py diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 6dcbb3610c..ae5e884159 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -29,6 +29,7 @@ class TowerModule(AnsibleModule): cookie_jar = CookieJar() authenticated = False json_output = {'changed': False} + on_change = None def __init__(self, argument_spec, **kwargs): args = dict( @@ -98,6 +99,9 @@ class TowerModule(AnsibleModule): except (NoOptionError): pass + def head_endpoint(self, endpoint, *args, **kwargs): + return self.make_request('HEAD', endpoint, **kwargs) + def get_endpoint(self, endpoint, *args, **kwargs): return self.make_request('GET', endpoint, **kwargs) @@ -117,14 +121,20 @@ class TowerModule(AnsibleModule): response = self.make_request('POST', endpoint, **kwargs) if response['status_code'] == 201: - self.json_output['changed'] = True + self.json_output['name'] = response['json']['name'] self.json_output['id'] = response['json']['id'] - self.exit_json(**self.json_output) + self.json_output['changed'] = True + if self.on_change == None: + self.exit_json(**self.json_output) + else: + self.on_change(self, response['json']) 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])) + elif 'json' in response: + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json'])) else: - self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code'])) + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']), **{ 'payload': kwargs['data'] }) def delete_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): # Handle check mode @@ -139,7 +149,16 @@ class TowerModule(AnsibleModule): 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'])) + if 'json' in response and '__all__' in response['json']: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) + elif 'json' in response: + # This is from a project delete if there is an active job against it + if 'error' in response['json']: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['error'])) + else: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json'])) + 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): response = self.get_endpoint(endpoint, *args, **kwargs) @@ -175,9 +194,13 @@ class TowerModule(AnsibleModule): if response['json']['count'] == 1: return response['json']['results'][0]['id'] elif response['json']['count'] == 0: + # If we got 0 items by name, maybe they gave us an ID, lets try looking it by by ID + response = self.head_endpoint("{}/{}".format(endpoint, name_or_id), **{'return_none_on_404': True}) + if response is not None: + return name_or_id self.fail_json(msg="The {0} {1} was not found on the Tower server".format(endpoint, name_or_id)) else: - self.fail_json(msg="Found too many names {0} at endpoint {1}".format(name_or_id, endpoint)) + self.fail_json(msg="Found too many names {0} at endpoint {1} try using an ID instead of a name".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 @@ -236,6 +259,8 @@ class TowerModule(AnsibleModule): # 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: + if kwargs.get('return_none_on_404', False): + return None 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 @@ -299,12 +324,14 @@ class TowerModule(AnsibleModule): # Sanity check: Did the server send back some kind of internal error? self.fail_json(msg='Failed to get token: {0}'.format(e)) + token_response = None try: - response_json = loads(response.read()) + token_response = response.read() + response_json = loads(token_response) 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)) + self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{'response': token_response}) # If we have neiter of these then we can try un-authenticated access self.authenticated = True @@ -332,7 +359,10 @@ class TowerModule(AnsibleModule): elif response['status_code'] == 200: existing_return['changed'] = True existing_return['id'] = response['json'].get('id') - self.exit_json(**existing_return) + if self.on_change == None: + self.exit_json(**existing_return) + else: + self.on_change(self, response['json']) elif 'json' in response and '__all__' in response['json']: self.fail_json(msg=response['json']['__all__']) else: @@ -370,3 +400,9 @@ class TowerModule(AnsibleModule): # Try to logout if we are authenticated self.logout() super().exit_json(**kwargs) + + def is_job_done(self, job_status): + if job_status in [ 'new', 'pending', 'waiting', 'running', ]: + return False + return True + diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index cf44ad9ffe..30eff59d2d 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -130,17 +130,17 @@ def main(): module.default_check_mode() # These will be passed into the create/updates - credental_type_params = { + credential_type_params = { 'name': new_name if new_name else name, 'kind': kind, 'managed_by_tower': False, } if module.params.get('description'): - credental_type_params['description'] = module.params.get('description') + credential_type_params['description'] = module.params.get('description') if module.params.get('inputs'): - credental_type_params['inputs'] = module.params.get('inputs') + credential_type_params['inputs'] = module.params.get('inputs') if module.params.get('injectors'): - credental_type_params['injectors'] = module.params.get('injectors') + credential_type_params['injectors'] = module.params.get('injectors') # Attempt to lookup credential_type based on the provided name and org ID credential_type = module.get_one('credential_types', **{ @@ -159,12 +159,12 @@ def main(): elif state == 'present' and not credential_type: # if the state was present and we couldn't find a credential_type we can build one, the module will handle exiting on its own module.post_endpoint('credential_types', item_type='credential type', item_name=name, **{ - 'data': credental_type_params + 'data': credential_type_params }) else: # If the state was present and we had a credential_type we can see if we need to update it # This will handle existing on its own - module.update_if_needed(credential_type, credental_type_params) + module.update_if_needed(credential_type, credential_type_params) if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_license.py b/awx_collection/plugins/modules/tower_license.py index 0aa986cb98..cdc2f23fae 100644 --- a/awx_collection/plugins/modules/tower_license.py +++ b/awx_collection/plugins/modules/tower_license.py @@ -1,7 +1,7 @@ #!/usr/bin/python # coding: utf-8 -*- -# (c) 20189, John Westcott IV +# (c) 2019, 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 @@ -65,7 +65,7 @@ def main(): 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') + module.fail_json(msg='You must accept the EULA by passing in the param eula_accepted as True') json_output['old_license'] = module.get_endpoint('settings/system/')['json']['LICENSE'] new_license = module.params.get('data') diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 18c1785173..8b4012f608 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -90,18 +90,6 @@ def main(): state=dict(type='str', choices=['present', 'absent'], default='present', required=False), ) -# instance_groups=dict(type='list', required=False, default=[]), -# The above argument_spec fragment is being left in for reference since we may need -# it later when finalizing the changes. - -# instance_groups: -# description: -# - The name of instance groups to tie to this organization -# default: [] -# required: False -# The above docstring fragment is being left in for reference since we may need -# it later when finalizing the changes. - # Create a module for ourselves module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) @@ -134,11 +122,9 @@ def main(): int_max_hosts = 0 try: int_max_hosts = int(max_hosts) - except Exception as e: + except Exception: module.fail_json(msg="Unable to convert max_hosts to an integer") new_org_data['max_hosts'] = int_max_hosts - # if instance_group_objects: - # new_org_data['instance_groups'] = instance_group_objects if state == 'absent' and not organization: # If the state was absent and we had no organization, we can just return diff --git a/awx_collection/plugins/modules/tower_settings.py b/awx_collection/plugins/modules/tower_settings.py index 25ec917fec..f96cdcf61a 100644 --- a/awx_collection/plugins/modules/tower_settings.py +++ b/awx_collection/plugins/modules/tower_settings.py @@ -110,7 +110,7 @@ def main(): new_value = value try: new_value = loads(value) - except JSONDecodeError as e: + except JSONDecodeError: # Attempt to deal with old tower_cli array types if ',' in value: new_value = re.split(r",\s+", new_value) diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index c0c451ba5b..a0e91c5c1a 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -103,7 +103,7 @@ def main(): # Create data to sent to create and update team_fields = { - 'name': name, + 'name': new_name if new_name else name, 'description': description, 'organization': org_id } diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 52b41c2b02..d12a9d22e1 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -111,6 +111,86 @@ def run_module(request): return rf +@pytest.fixture +def run_converted_module(request): + # A placeholder to use while modules get converted + def rf(module_name, module_params, request_user): + + def new_request(self, method, url, **kwargs): + kwargs_copy = kwargs.copy() + if 'data' in kwargs: + if not isinstance(kwargs['data'], dict): + kwargs_copy['data'] = json.loads(kwargs['data']) + else: + kwargs_copy['data'] = kwargs['data'] + if 'params' in kwargs and method == 'GET': + # query params for GET are handled a bit differently by + # tower-cli and python requests as opposed to REST framework APIRequestFactory + kwargs_copy.setdefault('data', {}) + if isinstance(kwargs['params'], dict): + kwargs_copy['data'].update(kwargs['params']) + elif isinstance(kwargs['params'], list): + for k, v in kwargs['params']: + kwargs_copy['data'][k] = v + + # make request + rf = _request(method.lower()) + django_response = rf(url, user=request_user, expect=None, **kwargs_copy) + + # requests library response object is different from the Django response, but they are the same concept + # this converts the Django response object into a requests response object for consumption + resp = Response() + py_data = django_response.data + sanitize_dict(py_data) + resp._content = bytes(json.dumps(django_response.data), encoding='utf8') + resp.status_code = django_response.status_code + + if request.config.getoption('verbose') > 0: + logger.info( + '%s %s by %s, code:%s', + method, '/api/' + url.split('/api/')[1], + request_user.username, resp.status_code + ) + + return resp + + def new_open(self, method, url, **kwargs): + r = new_request(self, method, url, **kwargs) + return mock.MagicMock(read=mock.MagicMock(return_value=r._content), status=r.status_code) + + stdout_buffer = io.StringIO() + # Requies specific PYTHONPATH, see docs + # Note that a proper Ansiballz explosion of the modules will have an import path like: + # ansible_collections.awx.awx.plugins.modules.{} + # We should consider supporting that in the future + resource_module = importlib.import_module('plugins.modules.{0}'.format(module_name)) + + if not isinstance(module_params, dict): + raise RuntimeError('Module params must be dict, got {0}'.format(type(module_params))) + + # Ansible params can be passed as an invocation argument or over stdin + # this short circuits within the AnsibleModule interface + def mock_load_params(self): + self.params = module_params + + with mock.patch.object(resource_module.TowerModule, '_load_params', new=mock_load_params): + # Call the test utility (like a mock server) instead of issuing HTTP requests + with mock.patch('ansible.module_utils.urls.Request.open', new=new_open): + with mock.patch('tower_cli.api.Session.request', new=new_request): + # Ansible modules return data to the mothership over stdout + with redirect_stdout(stdout_buffer): + try: + resource_module.main() + except SystemExit: + pass # A system exit indicates successful execution + + module_stdout = stdout_buffer.getvalue().strip() + result = json.loads(module_stdout) + return result + + return rf + + @pytest.fixture def organization(): return Organization.objects.create(name='Default') diff --git a/awx_collection/test/awx/test_credential.py b/awx_collection/test/awx/test_credential.py index 9d246a1db8..f8c32719d1 100644 --- a/awx_collection/test/awx/test_credential.py +++ b/awx_collection/test/awx/test_credential.py @@ -63,9 +63,9 @@ def test_create_vault_credential(run_module, admin_user): @pytest.mark.django_db -def test_create_custom_credential_type(run_module, admin_user): +def test_create_custom_credential_type(run_converted_module, admin_user): # Example from docs - result = run_module('tower_credential_type', dict( + result = run_converted_module('tower_credential_type', dict( name='Nexus', description='Credentials type for Nexus', kind='cloud', @@ -79,8 +79,7 @@ def test_create_custom_credential_type(run_module, admin_user): ct = CredentialType.objects.get(name='Nexus') result.pop('invocation') assert result == { - "credential_type": "Nexus", - "state": "present", + "name": "Nexus", "id": ct.pk, "changed": True } diff --git a/awx_collection/test/awx/test_organization.py b/awx_collection/test/awx/test_organization.py index 1583e7fda5..979556aec3 100644 --- a/awx_collection/test/awx/test_organization.py +++ b/awx_collection/test/awx/test_organization.py @@ -7,20 +7,31 @@ from awx.main.models import Organization @pytest.mark.django_db -def test_create_organization(run_module, admin_user): +def test_create_organization(run_converted_module, admin_user): - module_args = {'name': 'foo', 'description': 'barfoo', 'state': 'present'} + module_args = { + 'name': 'foo', + 'description': 'barfoo', + 'state': 'present', + 'max_hosts': '0', + 'tower_host': None, + 'tower_username': None, + 'tower_password': None, + 'validate_certs': None, + 'tower_oauthtoken': None, + 'tower_config_file': None, + 'custom_virtualenv': None + } - result = run_module('tower_organization', module_args, admin_user) + result = run_converted_module('tower_organization', module_args, admin_user) assert result.get('changed'), result org = Organization.objects.get(name='foo') assert result == { - "organization": "foo", - "state": "present", - "id": org.id, "changed": True, + "name": "foo", + "id": org.id, "invocation": { "module_args": module_args } @@ -30,10 +41,10 @@ def test_create_organization(run_module, admin_user): @pytest.mark.django_db -def test_create_organization_with_venv(run_module, admin_user, mocker): +def test_create_organization_with_venv(run_converted_module, admin_user, mocker): path = '/var/lib/awx/venv/custom-venv/foobar13489435/' with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=[path]): - result = run_module('tower_organization', { + result = run_converted_module('tower_organization', { 'name': 'foo', 'custom_virtualenv': path, 'state': 'present' @@ -44,8 +55,7 @@ def test_create_organization_with_venv(run_module, admin_user, mocker): result.pop('invocation') assert result == { - "organization": "foo", - "state": "present", + "name": "foo", "id": org.id } diff --git a/awx_collection/test/awx/test_team.py b/awx_collection/test/awx/test_team.py new file mode 100644 index 0000000000..f533d67e41 --- /dev/null +++ b/awx_collection/test/awx/test_team.py @@ -0,0 +1,66 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from awx.main.models import Organization, Team + + +@pytest.mark.django_db +def test_create_team(run_converted_module, admin_user): + org = Organization.objects.create(name='foo') + + result = run_converted_module('tower_team', { + 'name': 'foo_team', + 'description': 'fooin around', + 'state': 'present', + 'organization': 'foo' + }, admin_user) + + team = Team.objects.filter(name='foo_team').first() + + result.pop('invocation') + assert result == { + "changed": True, + "name": "foo_team", + "id": team.id if team else None, + } + team = Team.objects.get(name='foo_team') + assert team.description == 'fooin around' + assert team.organization_id == org.id + + +@pytest.mark.django_db +def test_modify_team(run_converted_module, admin_user): + org = Organization.objects.create(name='foo') + team = Team.objects.create( + name='foo_team', + organization=org, + description='flat foo' + ) + assert team.description == 'flat foo' + + result = run_converted_module('tower_team', { + 'name': 'foo_team', + 'description': 'fooin around', + 'organization': 'foo' + }, admin_user) + team.refresh_from_db() + result.pop('invocation') + assert result == { + "id": team.id, + "changed": True + } + assert team.description == 'fooin around' + + # 2nd modification, should cause no change + result = run_converted_module('tower_team', { + 'name': 'foo_team', + 'description': 'fooin around', + 'organization': 'foo' + }, admin_user) + result.pop('invocation') + assert result == { + "id": team.id, + "changed": False + } From 838b2b7d1ee0dc8b7690abb5add454518a3abb80 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sun, 26 Jan 2020 05:43:15 -0500 Subject: [PATCH 14/45] Converted tower_project --- .../plugins/modules/tower_project.py | 174 +++++++++++------- 1 file changed, 103 insertions(+), 71 deletions(-) diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index acce4f2dde..8f637275a4 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -35,7 +35,7 @@ options: scm_type: description: - Type of SCM resource. - choices: ["manual", "git", "hg", "svn"] + choices: ["manual", "git", "hg", "svn", "insights"] default: "manual" type: str scm_url: @@ -50,6 +50,12 @@ options: description: - The branch to use for the SCM resource. type: str + default: '' + scm_refspec: + description: + - The refspec to use for the SCM resource. + type: str + default: '' scm_credential: description: - Name of the credential to use with this SCM resource. @@ -74,8 +80,12 @@ options: description: - Cache Timeout to cache prior project syncs for a certain number of seconds. Only valid if scm_update_on_launch is to True, otherwise ignored. - default: 0 type: int + default: 0 + scm_allow_override: + description: + - Allow changing the SCM branch or revision in a job template that uses this project. + type: bool job_timeout: version_added: "2.8" description: @@ -91,8 +101,9 @@ options: default: '' organization: description: - - Primary key of organization for project. + - Name of organization for project. type: str + required: True state: description: - Desired state of the resource. @@ -105,7 +116,6 @@ options: before returning - Can assure playbook files are populated so that job templates that rely on the project may be successfully created - type: bool default: True extends_documentation_fragment: awx.awx.auth @@ -133,107 +143,129 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" ''' -from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode +from ..module_utils.tower_api import TowerModule -try: - import tower_cli - import tower_cli.exceptions as exc - from tower_cli.conf import settings -except ImportError: - pass +def wait_for_project_update(module, last_request): + # The current running job for the udpate is in last_request['summary_fields']['current_update']['id'] + if 'current_update' in last_request['summary_fields']: + running = True + while running: + result = module.get_endpoint('/project_updates/{}/'.format(last_request['summary_fields']['current_update']['id']))['json'] + + if module.is_job_done(result['status']): + running = False + + if result['status'] != 'successful': + module.fail_json(msg="Project update failed") + + module.exit_json(**module.json_output) def main(): + # Any additional arguments that are not fields of the item can be added here argument_spec = dict( name=dict(required=True), - description=dict(), - organization=dict(), - scm_type=dict(choices=['manual', 'git', 'hg', 'svn'], default='manual'), - scm_url=dict(), - scm_branch=dict(), - scm_credential=dict(), - scm_clean=dict(type='bool', default=False), - scm_delete_on_update=dict(type='bool', default=False), - scm_update_on_launch=dict(type='bool', default=False), - scm_update_cache_timeout=dict(type='int'), - job_timeout=dict(type='int', default=0), - custom_virtualenv=dict(type='str', required=False), - local_path=dict(), - state=dict(choices=['present', 'absent'], default='present'), - wait=dict(type='bool', default=True), + description=dict(required=False, default=''), + scm_type=dict(required=False, choices=['manual', 'git', 'hg', 'svn', 'insights'], default='manual'), + scm_url=dict(required=False), + local_path=dict(required=False), + scm_branch=dict(required=False, default=''), + scm_refspec=dict(required=False, default=''), + scm_credential=dict(required=False), + scm_clean=dict(required=False, type='bool', default=False), + scm_delete_on_update=dict(required=False, type='bool', default=False), + scm_update_on_launch=dict(required=False, type='bool', default=False), + scm_update_cache_timeout=dict(required=False, type='int', default=0), + scm_allow_override=dict(required=False, type='bool'), + job_timeout=dict(required=False, type='int', default=0), + custom_virtualenv=dict(required=False, type='str'), + organization=dict(required=True), + state=dict(required=False, choices=['present', 'absent'], default='present'), + wait=dict(required=False, type='bool', default=True), ) + # Create a module for ourselves module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + # Extract our parameters name = module.params.get('name') description = module.params.get('description') - organization = module.params.get('organization') scm_type = module.params.get('scm_type') if scm_type == "manual": scm_type = "" scm_url = module.params.get('scm_url') local_path = module.params.get('local_path') scm_branch = module.params.get('scm_branch') + scm_refspec = module.params.get('scm_refspec') scm_credential = module.params.get('scm_credential') scm_clean = module.params.get('scm_clean') scm_delete_on_update = module.params.get('scm_delete_on_update') scm_update_on_launch = module.params.get('scm_update_on_launch') scm_update_cache_timeout = module.params.get('scm_update_cache_timeout') + scm_allow_override = module.params.get('scm_allow_override') job_timeout = module.params.get('job_timeout') custom_virtualenv = module.params.get('custom_virtualenv') + organization = module.params.get('organization') state = module.params.get('state') wait = module.params.get('wait') - json_output = {'project': name, 'state': state} + # Attempt to lookup the related items the user specified (these will fail the module if not found) + org_id = module.resolve_name_to_id('organizations', organization) + if scm_credential != None: + scm_credential_id = module.resolve_name_to_id('credentials', scm_credential) - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - project = tower_cli.get_resource('project') - try: - if state == 'present': - try: - org_res = tower_cli.get_resource('organization') - org = org_res.get(name=organization) - except exc.NotFound: - module.fail_json(msg='Failed to update project, organization not found: {0}'.format(organization), changed=False) + # Attempt to lookup project based on the provided name and org ID + project = module.get_one('projects', **{ + 'data': { + 'name': name, + 'organization': org_id + } + }) - if scm_credential: - try: - cred_res = tower_cli.get_resource('credential') - try: - cred = cred_res.get(name=scm_credential) - except tower_cli.exceptions.MultipleResults: - module.warn('Multiple credentials found for {0}, falling back looking in project organization'.format(scm_credential)) - cred = cred_res.get(name=scm_credential, organization=org['id']) - scm_credential = cred['id'] - except exc.NotFound: - module.fail_json(msg='Failed to update project, credential not found: {0}'.format(scm_credential), changed=False) + project_fields = { + 'name': name, + 'description': description, + 'scm_type': scm_type, + 'scm_url': scm_url, + 'scm_branch': scm_branch, + 'scm_refspec': scm_refspec, + 'scm_clean': scm_clean, + 'scm_delete_on_update': scm_delete_on_update, + 'timeout': job_timeout, + 'organization': org_id, + 'scm_update_on_launch': scm_update_on_launch, + 'scm_update_cache_timeout': scm_update_cache_timeout, + 'custom_virtualenv': custom_virtualenv, + } + if scm_credential != None: + project_fields['credential'] = scm_credential_id + if scm_allow_override != None: + project_fields['scm_allow_override'] = scm_allow_override + if scm_type == '': + project_fields['local_path'] = local_path - if (scm_update_cache_timeout is not None) and (scm_update_on_launch is not True): - module.warn('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true') + if state != 'absent' and (scm_update_cache_timeout is not None and scm_update_on_launch is not True): + module.warn('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true') - result = project.modify(name=name, description=description, - organization=org['id'], - scm_type=scm_type, scm_url=scm_url, local_path=local_path, - scm_branch=scm_branch, scm_clean=scm_clean, credential=scm_credential, - scm_delete_on_update=scm_delete_on_update, - scm_update_on_launch=scm_update_on_launch, - scm_update_cache_timeout=scm_update_cache_timeout, - job_timeout=job_timeout, - custom_virtualenv=custom_virtualenv, - create_on_missing=True) - json_output['id'] = result['id'] - if wait and scm_type != '': - project.wait(pk=None, parent_pk=result['id']) - elif state == 'absent': - result = project.delete(name=name) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update project: {0}'.format(excinfo), changed=False) + # If we are doing a not manual project, register our on_change method + # An on_change function, if registered, will fire after an post_endpoint or update_if_needed completes successfully + if wait and scm_type != '': + module.on_change = wait_for_project_update - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + if state == 'absent' and not project: + # If the state was absent and we had no project, we can just return + module.exit_json(**module.json_output) + elif state == 'absent' and project: + # If the state was absent and we had a project, we can try to delete it, the module will handle exiting from this + module.delete_endpoint('projects/{0}'.format(project['id']), item_type='project', item_name=name, **{}) + elif state == 'present' and not project: + # if the state was present and we couldn't find a project we can build one, the module wikl handle exiting from this + response = module.post_endpoint('projects', handle_return=False,item_type='project', item_name=name, **{'data': project_fields }) + else: + # If the state was present and we had a project we can see if we need to update it + # This will return on its own + module.update_if_needed(project, project_fields) if __name__ == '__main__': From e028ed878e4892112a184e5dacca10a187b98f57 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Mon, 27 Jan 2020 21:14:55 -0500 Subject: [PATCH 15/45] More tower-cli-ish parsing of config files Clear up test failures/linting errors, update unit test Update module_utils for linter, add wait time to project module --- .../plugins/module_utils/tower_api.py | 67 +++++++++++++------ .../plugins/modules/tower_project.py | 19 ++++-- awx_collection/test/awx/test_project.py | 9 +-- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index ae5e884159..ff9dc8744f 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -5,15 +5,18 @@ 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 +from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError +from socket import gethostbyname import re from json import loads, dumps -from os.path import isfile -from os import access, R_OK +from os.path import isfile, expanduser, split, join +from os import access, R_OK, getcwd + + +class ConfigFileException(Exception): + pass class TowerModule(AnsibleModule): @@ -30,6 +33,7 @@ class TowerModule(AnsibleModule): authenticated = False json_output = {'changed': False} on_change = None + config_name = 'tower_cli.cfg' def __init__(self, argument_spec, **kwargs): args = dict( @@ -45,11 +49,9 @@ class TowerModule(AnsibleModule): 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')) + self.load_config_files() - # Parameters specified on command line will override settings in config + # Parameters specified on command line will override settings in any config if self.params.get('tower_host'): self.host = self.params.get('tower_host') if self.params.get('tower_username'): @@ -80,22 +82,48 @@ class TowerModule(AnsibleModule): self.session = Request(cookies=self.cookie_jar) + def load_config_files(self): + # Load configs like TowerCLI would have from least import to most + config_files = [join('/etc/tower/', self.config_name), join(expanduser("~"), ".{0}".format(self.config_name))] + local_dir = getcwd() + config_files.append(join(local_dir, self.config_name)) + while split(local_dir)[1]: + local_dir = split(local_dir)[0] + config_files.insert(2, join(local_dir, self.config_name)) + + for config_file in config_files: + try: + self.load_config(config_file) + except ConfigFileException: + # Since some of these may not exist or can't be read, we really don't care + pass + + # If we have a specified tower config, load it + if self.params.get('tower_config_file'): + try: + self.load_config(self.params.get('tower_config_file')) + except ConfigFileException as cfe: + # Since we were told specifically to load this we want to fail if we have an error + self.fail_json(msg=cfe) + 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') + raise ConfigFileException('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") + raise ConfigFileException("The specified config file can not be read") config.read(config_path) + if not config.has_section('general'): + self.warn("No general section in file, auto-appending") + with open(config_path, 'r') as f: + config.read_string('[general]\n%s' % f.read()) 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 @@ -124,7 +152,7 @@ class TowerModule(AnsibleModule): self.json_output['name'] = response['json']['name'] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True - if self.on_change == None: + if self.on_change is None: self.exit_json(**self.json_output) else: self.on_change(self, response['json']) @@ -134,7 +162,7 @@ class TowerModule(AnsibleModule): elif 'json' in response: self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json'])) else: - self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']), **{ 'payload': kwargs['data'] }) + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']), **{'payload': kwargs['data']}) def delete_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): # Handle check mode @@ -195,7 +223,7 @@ class TowerModule(AnsibleModule): return response['json']['results'][0]['id'] elif response['json']['count'] == 0: # If we got 0 items by name, maybe they gave us an ID, lets try looking it by by ID - response = self.head_endpoint("{}/{}".format(endpoint, name_or_id), **{'return_none_on_404': True}) + response = self.head_endpoint("{0}/{1}".format(endpoint, name_or_id), **{'return_none_on_404': True}) if response is not None: return name_or_id self.fail_json(msg="The {0} {1} was not found on the Tower server".format(endpoint, name_or_id)) @@ -359,7 +387,7 @@ class TowerModule(AnsibleModule): elif response['status_code'] == 200: existing_return['changed'] = True existing_return['id'] = response['json'].get('id') - if self.on_change == None: + if self.on_change is None: self.exit_json(**existing_return) else: self.on_change(self, response['json']) @@ -380,7 +408,7 @@ class TowerModule(AnsibleModule): api_token_url = (self.url._replace(path='/api/v2/tokens/{0}/'.format(self.oauth_token_id))).geturl() try: - response = self.session.open( + self.session.open( 'DELETE', api_token_url, validate_certs=self.verify_ssl, follow_redirects=True, force_basic_auth=True, url_username=self.username, url_password=self.password @@ -402,7 +430,6 @@ class TowerModule(AnsibleModule): super().exit_json(**kwargs) def is_job_done(self, job_status): - if job_status in [ 'new', 'pending', 'waiting', 'running', ]: + if job_status in ['new', 'pending', 'waiting', 'running']: return False return True - diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 8f637275a4..00c614aee6 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -118,6 +118,11 @@ options: on the project may be successfully created type: bool default: True + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -143,6 +148,8 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" ''' +import time + from ..module_utils.tower_api import TowerModule @@ -152,9 +159,10 @@ def wait_for_project_update(module, last_request): if 'current_update' in last_request['summary_fields']: running = True while running: - result = module.get_endpoint('/project_updates/{}/'.format(last_request['summary_fields']['current_update']['id']))['json'] + result = module.get_endpoint('/project_updates/{0}/'.format(last_request['summary_fields']['current_update']['id']))['json'] if module.is_job_done(result['status']): + time.sleep(1) running = False if result['status'] != 'successful': @@ -162,6 +170,7 @@ def wait_for_project_update(module, last_request): module.exit_json(**module.json_output) + def main(): # Any additional arguments that are not fields of the item can be added here argument_spec = dict( @@ -212,7 +221,7 @@ def main(): # Attempt to lookup the related items the user specified (these will fail the module if not found) org_id = module.resolve_name_to_id('organizations', organization) - if scm_credential != None: + if scm_credential is not None: scm_credential_id = module.resolve_name_to_id('credentials', scm_credential) # Attempt to lookup project based on the provided name and org ID @@ -238,9 +247,9 @@ def main(): 'scm_update_cache_timeout': scm_update_cache_timeout, 'custom_virtualenv': custom_virtualenv, } - if scm_credential != None: + if scm_credential is not None: project_fields['credential'] = scm_credential_id - if scm_allow_override != None: + if scm_allow_override is not None: project_fields['scm_allow_override'] = scm_allow_override if scm_type == '': project_fields['local_path'] = local_path @@ -261,7 +270,7 @@ def main(): module.delete_endpoint('projects/{0}'.format(project['id']), item_type='project', item_name=name, **{}) elif state == 'present' and not project: # if the state was present and we couldn't find a project we can build one, the module wikl handle exiting from this - response = module.post_endpoint('projects', handle_return=False,item_type='project', item_name=name, **{'data': project_fields }) + module.post_endpoint('projects', handle_return=False, item_type='project', item_name=name, **{'data': project_fields}) else: # If the state was present and we had a project we can see if we need to update it # This will return on its own diff --git a/awx_collection/test/awx/test_project.py b/awx_collection/test/awx/test_project.py index 3c858f5a04..f07dc5b29d 100644 --- a/awx_collection/test/awx/test_project.py +++ b/awx_collection/test/awx/test_project.py @@ -7,14 +7,15 @@ from awx.main.models import Project @pytest.mark.django_db -def test_create_project(run_module, admin_user, organization): - result = run_module('tower_project', dict( +def test_create_project(run_converted_module, admin_user, organization): + result = run_converted_module('tower_project', dict( name='foo', organization=organization.name, scm_type='git', scm_url='https://foo.invalid', wait=False ), admin_user) + warning = ['scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true'] assert result.pop('changed', None), result proj = Project.objects.get(name='foo') @@ -23,7 +24,7 @@ def test_create_project(run_module, admin_user, organization): result.pop('invocation') assert result == { + 'name': 'foo', 'id': proj.id, - 'project': 'foo', - 'state': 'present' + 'warnings': warning } From 9fa59427917146883302114e3ac9d86d86193ce2 Mon Sep 17 00:00:00 2001 From: beeankha Date: Tue, 28 Jan 2020 15:39:53 -0500 Subject: [PATCH 16/45] Fix superclass syntax for < Python3 --- awx_collection/plugins/module_utils/tower_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index ff9dc8744f..80726bc7b0 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -422,12 +422,12 @@ class TowerModule(AnsibleModule): def fail_json(self, **kwargs): # Try to logout if we are authenticated self.logout() - super().fail_json(**kwargs) + super(TowerModule, self).fail_json(**kwargs) def exit_json(self, **kwargs): # Try to logout if we are authenticated self.logout() - super().exit_json(**kwargs) + super(TowerModule, self).exit_json(**kwargs) def is_job_done(self, job_status): if job_status in ['new', 'pending', 'waiting', 'running']: From 9271127c53d8c2418dc9ba32bdb3fc12ccb33fb1 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Tue, 28 Jan 2020 16:56:37 -0500 Subject: [PATCH 17/45] Standardizing CRUD methods Fixing linting and conversion issues --- .../plugins/module_utils/tower_api.py | 219 +++++++++++++----- awx_collection/plugins/modules/tower_team.py | 19 +- 2 files changed, 168 insertions(+), 70 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 80726bc7b0..49993a01d8 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -19,6 +19,10 @@ class ConfigFileException(Exception): pass +class ItemNotDefined(Exception): + pass + + class TowerModule(AnsibleModule): url = None honorred_settings = ['host', 'username', 'password', 'verify_ssl', 'oauth_token'] @@ -32,7 +36,6 @@ class TowerModule(AnsibleModule): cookie_jar = CookieJar() authenticated = False json_output = {'changed': False} - on_change = None config_name = 'tower_cli.cfg' def __init__(self, argument_spec, **kwargs): @@ -141,52 +144,21 @@ class TowerModule(AnsibleModule): return self.make_request('PATCH', endpoint, **kwargs) - def post_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): + def post_endpoint(self, endpoint, *args, **kwargs): # Handle check mode if self.check_mode: self.json_output['changed'] = True self.exit_json(**self.json_output) - response = self.make_request('POST', endpoint, **kwargs) - if response['status_code'] == 201: - self.json_output['name'] = response['json']['name'] - self.json_output['id'] = response['json']['id'] - self.json_output['changed'] = True - if self.on_change is None: - self.exit_json(**self.json_output) - else: - self.on_change(self, response['json']) - 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])) - elif 'json' in response: - self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json'])) - else: - self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']), **{'payload': kwargs['data']}) + return self.make_request('POST', endpoint, **kwargs) - def delete_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs): + def delete_endpoint(self, endpoint, *args, **kwargs): # Handle check mode if self.check_mode: self.json_output['changed'] = True self.exit_json(**self.json_output) - response = self.make_request('DELETE', endpoint, **kwargs) - if not handle_return: - return response - elif response['status_code'] in [202, 204]: - self.json_output['changed'] = True - self.exit_json(**self.json_output) - else: - if 'json' in response and '__all__' in response['json']: - self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) - elif 'json' in response: - # This is from a project delete if there is an active job against it - if 'error' in response['json']: - self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['error'])) - else: - self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json'])) - else: - self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) + return self.make_request('DELETE', endpoint, **kwargs) def get_all_endpoint(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) @@ -373,33 +345,162 @@ class TowerModule(AnsibleModule): 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: - existing_field = existing_item.get(field, None) - new_field = new_item.get(field, None) - # If the two items don't match and we are not comparing '' to None - if existing_field != new_field and not (existing_field in (None, '') and new_field == ''): - # something dosent match so lets do it + def delete_if_needed(self, existing_item, handle_response=True, on_delete=None): + # This will exit from the module on its own unless handle_response is False. + # if handle response is True and the method successfully deletes an item and on_delete param is defined + # the on_delete parameter will be called as a method pasing in this object and the json from the response + # If you pass handle_response=False it will return one of two things: + # None if the existing_item is not defined (so no delete needs to happen) + # The response from Tower from calling the delete on the endpont. Its up to you to process the response and exit from the module + # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False + if existing_item: + # If we have an item, we can try to delete it + try: + item_url = existing_item['url'] + item_name = existing_item['name'] + item_type = existing_item['url'] + item_id = existing_item['id'] + except KeyError as ke: + self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) - response = self.patch_endpoint(existing_item['url'], **{'data': new_item}) + response = self.delete_endpoint(item_url) + + if not handle_response: + return response + elif response['status_code'] in [202, 204]: + if on_delete: + on_delete(self, response['json']) + self.json_output['changed'] = True + self.json_output['id'] = item_id + self.exit_json(**self.json_output) + else: + if 'json' in response and '__all__' in response['json']: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) + elif 'json' in response: + # This is from a project delete if there is an active job against it + if 'error' in response['json']: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['error'])) + else: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json'])) + else: + self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) + else: + if not handle_response: + return None + else: + self.exit_json(**self.json_output) + + def create_if_needed(self, existing_item, new_item, endpoint, handle_response=True, on_create=None, item_type='unknown'): + # + # This will exit from the module on its own unless handle_response is False. + # if handle response is True and the method successfully creates an item and on_create param is defined + # the on_create parameter will be called as a method pasing in this object and the json from the response + # If you pass handle_response=False it will return one of two things: + # None if the existing_item is already defined (so no create needs to happen) + # The response from Tower from calling the patch on the endpont. Its up to you to process the response and exit from the module + # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False + # + if not endpoint: + self.fail_json(msg="Unable to create new {} due to missing endpoint".format(item_type)) + + if existing_item: + try: + item_url = existing_item['url'] + except KeyError as ke: + self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) + if not handle_response: + return None + else: + self.exit_json(**self.json_output) + else: + # If we dont have an exisitng_item, we can try to create it + + # We have to rely on item_type being passed in since we don't have an existing item that declares its type + # The item_name we will pull out from the new_item (if it exists) + item_name = new_item.get('name', 'unknown') + + response = self.post_endpoint(endpoint, **{'data': new_item}) + if not handle_response: + return response + elif response['status_code'] == 201: + self.json_output['name'] = response['json']['name'] + self.json_output['id'] = response['json']['id'] + self.json_output['changed'] = True + if on_create is None: + self.exit_json(**self.json_output) + else: + on_create(self, response['json']) + 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])) + elif 'json' in response: + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json'])) + else: + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']), **{'payload': kwargs['data']}) + + def update_if_needed(self, existing_item, new_item, handle_response=True, on_update=None): + # This will exit from the module on its own unless handle_response is False. + # if handle response is True and the method successfully updates an item and on_update param is defined + # the on_update parameter will be called as a method pasing in this object and the json from the response + # If you pass handle_response=False it will return one of three things: + # None if the existing_item does not need to be updated + # The response from Tower from patching to the endpoint. Its up to you to process the response and exit from the module. + # an ItemNotDefined exception if the existing_item does not exist + # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False + if existing_item: + # If we have an item, we can see if needs an update + try: + item_url = existing_item['url'] + item_name = existing_item['name'] + item_type = existing_item['url'] + item_id = existing_item['id'] + except KeyError as ke: + self.fail_json(msg="Unable to process update of item due to missing data {0}".format(ke)) + + needs_update = False + for field in new_item: + existing_field = existing_item.get(field, None) + new_field = new_item.get(field, None) + # If the two items don't match and we are not comparing '' to None + if existing_field != new_field and not (existing_field in (None, '') and new_field == ''): + # something dosent match so lets do it + neds_update = True + break + + if needs_update: + response = self.patch_endpoint(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') - if self.on_change is None: - self.exit_json(**existing_return) + self.json_output['changed'] = True + self.json_output['id'] = item_id + if on_update is None: + self.exit_json(**self.json_output) else: - self.on_change(self, response['json']) + on_update(self, response['json']) 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}) + self.fail_json(**{'msg': "Unable to update {0} {1}, see response".format(item_type, item_name), 'response': response}) + else: + if not handle_response: + return None - # 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) + # Since we made it here, we don't need to update, status ok + self.json_output['changed'] = False + self.json_output['id'] = item_id + self.exit_json(**self.json_output) + else: + if handle_response: + self.fail_json(msg="The exstiing item is not defined and thus cannot be updated") + else: + raise ItemNotDefined("Not given an existing item to update") + + def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, handle_response=True, item_type='unknown', on_create=None, on_update=None): + if existing_item: + return self.update_if_needed(existing_item, new_item, handle_response=handle_response, on_update=on_update) + else: + return self.create_if_needed(existing_item, new_item, endpoint, handle_response=handle_response, on_create=on_create, item_type=item_type) def logout(self): if self.oauth_token_id is not None and self.username and self.password: @@ -409,9 +510,13 @@ class TowerModule(AnsibleModule): try: self.session.open( - 'DELETE', api_token_url, - validate_certs=self.verify_ssl, follow_redirects=True, - force_basic_auth=True, url_username=self.username, url_password=self.password + 'DELETE', + api_token_url, + validate_certs=self.verify_ssl, + follow_redirects=True, + force_basic_auth=True, + url_username=self.username, + url_password=self.password ) self.oauth_token_id = None self.authenticated = False diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index a0e91c5c1a..ccabfa4816 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -108,19 +108,12 @@ def main(): 'organization': org_id } - 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 will handle exiting from this - module.post_endpoint('teams', item_type='team', item_name=name, **{'data': team_fields}) - 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 - module.update_if_needed(team, team_fields) + if state == 'absent': + # If the state was absent we can let the module to delete it if needed, the module will handle exiting from this + module.delete_if_needed(team) + elif state == 'present': + # If the state was present we can let the module build or update the existing team, this will return on its own + module.create_or_update_if_needed(team, team_fields, item_type='team'}) if __name__ == '__main__': From 89e92bd337eb4ed4d7260b8380680680bc1d2f8a Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 29 Jan 2020 09:54:35 -0500 Subject: [PATCH 18/45] Updating call to create_or_update_if_needed --- awx_collection/plugins/modules/tower_team.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index ccabfa4816..a8f504d337 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -113,7 +113,7 @@ def main(): module.delete_if_needed(team) elif state == 'present': # If the state was present we can let the module build or update the existing team, this will return on its own - module.create_or_update_if_needed(team, team_fields, item_type='team'}) + module.create_or_update_if_needed(team, team_fields, endpoint='teams', item_type='team') if __name__ == '__main__': From 6d90cac3f9b9237e48c5f7fb9f55a40eea6d38f4 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 29 Jan 2020 11:50:56 -0500 Subject: [PATCH 19/45] Bug fixes for username and delete data --- .../plugins/module_utils/tower_api.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 49993a01d8..38355e433d 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -357,12 +357,18 @@ class TowerModule(AnsibleModule): # If we have an item, we can try to delete it try: item_url = existing_item['url'] - item_name = existing_item['name'] - item_type = existing_item['url'] + item_type = existing_item['type'] item_id = existing_item['id'] except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) + if 'name' in existing_item: + item_name = existing_item['name'] + elif 'username' in existing_item: + item_name = existing_item['username'] + else: + self.fail_json(msg="Unable to process delete of {} due to missing name".format(item_type)) + response = self.delete_endpoint(item_url) if not handle_response: @@ -423,7 +429,12 @@ class TowerModule(AnsibleModule): if not handle_response: return response elif response['status_code'] == 201: - self.json_output['name'] = response['json']['name'] + self.json_output['name'] = 'unknown' + if 'name' in response['json']: + self.json_output['name'] = response['json']['name'] + elif 'username' in response['json']: + # User objects return username instead of name + self.json_output['name'] = response['json']['username'] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True if on_create is None: From c23d605a7a67389fbc3c8f77fc81c17199fa6b80 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 29 Jan 2020 11:52:09 -0500 Subject: [PATCH 20/45] Modified modules to use new tower_api format Fixed variable name typo --- .../plugins/module_utils/tower_api.py | 2 +- .../plugins/modules/tower_credential_type.py | 28 +++++++---------- .../plugins/modules/tower_inventory.py | 23 +++++--------- .../plugins/modules/tower_project.py | 23 +++++--------- awx_collection/plugins/modules/tower_team.py | 2 +- awx_collection/plugins/modules/tower_user.py | 30 +++++++------------ 6 files changed, 38 insertions(+), 70 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 38355e433d..5b4600b3b3 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -475,7 +475,7 @@ class TowerModule(AnsibleModule): # If the two items don't match and we are not comparing '' to None if existing_field != new_field and not (existing_field in (None, '') and new_field == ''): # something dosent match so lets do it - neds_update = True + needs_update = True break if needs_update: diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 30eff59d2d..088e61f29b 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -124,8 +124,6 @@ def main(): kind = module.params.get('kind') state = module.params.get('state') - json_output = {'credential_type': name, 'state': state} - # Deal with check mode module.default_check_mode() @@ -149,23 +147,17 @@ def main(): } }) - json_output['existing_credential_type'] = credential_type - if state == 'absent' and not credential_type: - # If the state was absent and we had no credential_type, we can just return - module.exit_json(**module.json_output) - elif state == 'absent' and credential_type: - # 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('credential_types/{0}'.format(credential_type['id']), item_type='credential type', item_name=name, **{}) - elif state == 'present' and not credential_type: - # if the state was present and we couldn't find a credential_type we can build one, the module will handle exiting on its own - module.post_endpoint('credential_types', item_type='credential type', item_name=name, **{ - 'data': credential_type_params - }) - else: - # If the state was present and we had a credential_type we can see if we need to update it - # This will handle existing on its own - module.update_if_needed(credential_type, credential_type_params) + # Add entries to json_output to match old module + module.json_output['credential_type'] = name + module.json_output['state'] = state + module.json_output['existing_credential_type'] = credential_type + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(credential_type) + elif state == 'present': + # If the state was present we can let the module build or update the existing team, this will return on its own + module.create_or_update_if_needed(credential_type, credential_type_params, endpoint='credential_types', item_type='credential type') if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index 56589ac69f..1d693cf8b9 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -127,23 +127,16 @@ def main(): 'host_filter': host_filter, } - if state == 'absent' and not inventory: - # If the state was absent and we had no inventory, we can just return - module.exit_json(**module.json_output) - elif state == 'absent' and inventory: - # If the state was absent and we had a inventory, we can try to delete it, the module will handle exiting from this - module.delete_endpoint('inventories/{0}'.format(inventory['id']), item_type='inventory', item_name=name, **{}) - elif state == 'present' and not inventory: - # If the state was present and we couldn't find a inventory we can build one, the module will handle exiting from this - module.post_endpoint('inventories', item_type='inventory', item_name=name, **{'data': inventory_fields}) - else: - # Throw a more specific error message than what the API page provides. - if inventory['kind'] == '' and inventory_fields['kind'] == 'smart': + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(inventory) + elif state == 'present': + # We need to perform a check to make sure you are not trying to convert a regular inventory into a smart one. + if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart': module.fail_json(msg='You cannot turn a regular inventory into a "smart" inventory.') - # If the state was present and we had a inventory, we can see if we need to update it - # This will return on its own - module.update_if_needed(inventory, inventory_fields) + # If the state was present we can let the module build or update the existing team, this will return on its own + module.create_or_update_if_needed(inventory, inventory_fields, endpoint='inventories', item_type='inventory') if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 00c614aee6..52deb55a18 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -259,23 +259,16 @@ def main(): # If we are doing a not manual project, register our on_change method # An on_change function, if registered, will fire after an post_endpoint or update_if_needed completes successfully + on_change = None if wait and scm_type != '': - module.on_change = wait_for_project_update - - if state == 'absent' and not project: - # If the state was absent and we had no project, we can just return - module.exit_json(**module.json_output) - elif state == 'absent' and project: - # If the state was absent and we had a project, we can try to delete it, the module will handle exiting from this - module.delete_endpoint('projects/{0}'.format(project['id']), item_type='project', item_name=name, **{}) - elif state == 'present' and not project: - # if the state was present and we couldn't find a project we can build one, the module wikl handle exiting from this - module.post_endpoint('projects', handle_return=False, item_type='project', item_name=name, **{'data': project_fields}) - else: - # If the state was present and we had a project we can see if we need to update it - # This will return on its own - module.update_if_needed(project, project_fields) + on_change = wait_for_project_update + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(project) + elif state == 'present': + # If the state was present we can let the module build or update the existing team, this will return on its own + module.create_or_update_if_needed(project, project_fields, endpoint='projects', item_type='project', on_create=on_change, on_update=on_change) if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index a8f504d337..3e8d2c2e31 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -109,7 +109,7 @@ def main(): } if state == 'absent': - # If the state was absent we can let the module to delete it if needed, the module will handle exiting from this + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(team) elif state == 'present': # If the state was present we can let the module build or update the existing team, this will return on its own diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index c9a628d26a..3837de6586 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -38,8 +38,8 @@ options: type: str email: description: - - Email address of the user. - required: True + - Email address of the user. Required if creating a new user. + required: False type: str password: description: @@ -117,14 +117,14 @@ def main(): first_name=dict(), last_name=dict(), password=dict(no_log=True), - email=dict(required=True), + email=dict(required=False), superuser=dict(type='bool', default=False), auditor=dict(type='bool', default=False), state=dict(choices=['present', 'absent'], default='present'), ) # Create a module for ourselves - module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + module = TowerModule(argument_spec=argument_spec, supports_check_mode=True, required_if=[['state', 'present', ['email']]]) # Extract our parameters state = module.params.get('state') @@ -145,22 +145,12 @@ def main(): } }) - if state == 'absent' and not user: - # If the state was absent and we had no user, we can just return - module.exit_json(**module.json_output) - elif state == 'absent' and user: - # If the state was absent and we had a user, we can try to delete it, the module will handle exiting from this - module.delete_endpoint('users/{0}'.format(user['id']), item_type='user', item_name=user_fields['username'], **{}) - elif state == 'present' and not user: - # if the state was present and we couldn't find a user we can build one, the module will handle exiting from this - module.post_endpoint('users', item_type='user', item_name=user_fields['username'], **{ - 'data': user_fields - }) - else: - # If the state was present and we had a user we can see if we need to update it - # This will return on its own - module.update_if_needed(user, user_fields) - + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(user) + elif state == 'present': + # If the state was present we can let the module build or update the existing team, this will return on its own + module.create_or_update_if_needed(user, user_fields, endpoint='users', item_type='user') if __name__ == '__main__': main() From f89061da41751bd7754dfd96260da3e4738ca687 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 29 Jan 2020 13:33:04 -0500 Subject: [PATCH 21/45] Updating tower_org to use the new tower_api format Pass sanity and unit tests, update tests Remove placeholder test function, convert tower_host module, fix misc typos --- .../plugins/module_utils/tower_api.py | 19 ++--- .../plugins/modules/tower_credential_type.py | 5 +- awx_collection/plugins/modules/tower_host.py | 72 ++++++++++--------- .../plugins/modules/tower_inventory.py | 7 +- .../plugins/modules/tower_job_list.py | 2 +- .../plugins/modules/tower_organization.py | 33 ++++----- .../plugins/modules/tower_project.py | 7 +- awx_collection/plugins/modules/tower_team.py | 6 +- awx_collection/plugins/modules/tower_user.py | 5 +- awx_collection/test/awx/conftest.py | 71 ------------------ awx_collection/test/awx/test_credential.py | 12 ++-- awx_collection/test/awx/test_organization.py | 18 +++-- awx_collection/test/awx/test_project.py | 7 +- awx_collection/test/awx/test_team.py | 23 ++++-- 14 files changed, 120 insertions(+), 167 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 5b4600b3b3..312a3eb234 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -203,7 +203,7 @@ class TowerModule(AnsibleModule): self.fail_json(msg="Found too many names {0} at endpoint {1} try using an ID instead of a name".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 + # In case 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") @@ -347,7 +347,7 @@ class TowerModule(AnsibleModule): def delete_if_needed(self, existing_item, handle_response=True, on_delete=None): # This will exit from the module on its own unless handle_response is False. - # if handle response is True and the method successfully deletes an item and on_delete param is defined + # If handle response is True and the method successfully deletes an item and on_delete param is defined # the on_delete parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of two things: # None if the existing_item is not defined (so no delete needs to happen) @@ -367,7 +367,7 @@ class TowerModule(AnsibleModule): elif 'username' in existing_item: item_name = existing_item['username'] else: - self.fail_json(msg="Unable to process delete of {} due to missing name".format(item_type)) + self.fail_json(msg="Unable to process delete of {0} due to missing name".format(item_type)) response = self.delete_endpoint(item_url) @@ -399,7 +399,7 @@ class TowerModule(AnsibleModule): def create_if_needed(self, existing_item, new_item, endpoint, handle_response=True, on_create=None, item_type='unknown'): # # This will exit from the module on its own unless handle_response is False. - # if handle response is True and the method successfully creates an item and on_create param is defined + # If handle response is True and the method successfully creates an item and on_create param is defined # the on_create parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of two things: # None if the existing_item is already defined (so no create needs to happen) @@ -407,11 +407,11 @@ class TowerModule(AnsibleModule): # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False # if not endpoint: - self.fail_json(msg="Unable to create new {} due to missing endpoint".format(item_type)) + self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type)) if existing_item: try: - item_url = existing_item['url'] + existing_item['url'] except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) if not handle_response: @@ -447,11 +447,11 @@ class TowerModule(AnsibleModule): elif 'json' in response: self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json'])) else: - self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']), **{'payload': kwargs['data']}) + self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code'])) def update_if_needed(self, existing_item, new_item, handle_response=True, on_update=None): # This will exit from the module on its own unless handle_response is False. - # if handle response is True and the method successfully updates an item and on_update param is defined + # If handle response is True and the method successfully updates an item and on_update param is defined # the on_update parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of three things: # None if the existing_item does not need to be updated @@ -548,4 +548,5 @@ class TowerModule(AnsibleModule): def is_job_done(self, job_status): if job_status in ['new', 'pending', 'waiting', 'running']: return False - return True + else: + return True diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 088e61f29b..86362f36f9 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -140,7 +140,7 @@ def main(): if module.params.get('injectors'): credential_type_params['injectors'] = module.params.get('injectors') - # Attempt to lookup credential_type based on the provided name and org ID + # Attempt to look up credential_type based on the provided name credential_type = module.get_one('credential_types', **{ 'data': { 'name': name, @@ -156,8 +156,9 @@ def main(): # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(credential_type) elif state == 'present': - # If the state was present we can let the module build or update the existing team, this will return on its own + # If the state was present and we can let the module build or update the existing credential type, this will return on its own module.create_or_update_if_needed(credential_type, credential_type_params, endpoint='credential_types', item_type='credential type') + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index 7dfebb0b60..d115267f1b 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -28,6 +28,11 @@ options: - The name to use for the host. required: True type: str + new_name: + description: + - To use when changing a hosts's name. + required: True + type: str description: description: - The description to use for the host. @@ -52,6 +57,11 @@ options: choices: ["present", "absent"] default: "present" type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -71,29 +81,27 @@ EXAMPLES = ''' import os -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), - description=dict(), + new_name=dict(required=False), + description=dict(default=''), inventory=dict(required=True), enabled=dict(type='bool', default=True), - variables=dict(), + variables=dict(default=''), 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') inventory = module.params.get('inventory') enabled = module.params.get('enabled') @@ -106,30 +114,30 @@ def main(): with open(filename, 'r') as f: variables = f.read() - json_output = {'host': name, 'state': state} + # Attempt to lookup the related items the user specified (these will fail the module if not found) + inventory_id = module.resolve_name_to_id('inventories', inventory) - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - host = tower_cli.get_resource('host') + # Attempt to lookup host based on the provided name and org ID + host = module.get_one('hosts', **{ + 'data': { + 'name': name, + 'inventory': inventory_id + } + }) - try: - inv_res = tower_cli.get_resource('inventory') - inv = inv_res.get(name=inventory) + # Create data to send to create and update + host_fields = { + 'name': new_name if new_name else name, + 'description': description, + 'inventory': inventory_id + } - if state == 'present': - result = host.modify(name=name, inventory=inv['id'], enabled=enabled, - variables=variables, description=description, create_on_missing=True) - json_output['id'] = result['id'] - elif state == 'absent': - result = host.delete(name=name, inventory=inv['id']) - except (exc.NotFound) as excinfo: - module.fail_json(msg='Failed to update host, inventory not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update host: {0}'.format(excinfo), changed=False) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(host) + elif state == 'present': + # If the state was present we can let the module build or update the existing host, this will return on its own + module.create_or_update_if_needed(host, host_fields, endpoint='hosts', item_type='host') if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index 1d693cf8b9..65da8badfa 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -106,10 +106,10 @@ def main(): kind = module.params.get('kind') host_filter = module.params.get('host_filter') - # Attempt to lookup the related items the user specified (these will fail the module if not found) + # Attempt to look up the related items the user specified (these will fail the module if not found) org_id = module.resolve_name_to_id('organizations', organization) - # Attempt to lookup inventory based on the provided name and org ID + # Attempt to look up inventory based on the provided name and org ID inventory = module.get_one('inventories', **{ 'data': { 'name': name, @@ -135,8 +135,9 @@ def main(): if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart': module.fail_json(msg='You cannot turn a regular inventory into a "smart" inventory.') - # If the state was present we can let the module build or update the existing team, this will return on its own + # If the state was present and we can let the module build or update the existing inventory, this will return on its own module.create_or_update_if_needed(inventory, inventory_fields, endpoint='inventories', item_type='inventory') + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_job_list.py b/awx_collection/plugins/modules/tower_job_list.py index b079351e0c..76fd03955d 100644 --- a/awx_collection/plugins/modules/tower_job_list.py +++ b/awx_collection/plugins/modules/tower_job_list.py @@ -125,7 +125,7 @@ def main(): else: job_list = module.get_endpoint('jobs', **{'data': job_search_data}) - # Attempt to lookup jobs based on the status + # Attempt to look up jobs based on the status module.exit_json(**job_list['json']) diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 8b4012f608..faa127b940 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -101,46 +101,37 @@ def main(): # instance_group_names = module.params.get('instance_groups') state = module.params.get('state') - # Attempt to lookup the related items the user specified (these will fail the module if not found) + # Attempt to look up the related items the user specified (these will fail the module if not found) # instance_group_objects = [] # for instance_name in instance_group_names: # instance_group_objects.append(module.resolve_name_to_id('instance_groups', instance_name)) - # Attempt to lookup organization based on the provided name and org ID + # Attempt to look up organization based on the provided name organization = module.get_one('organizations', **{ 'data': { 'name': name, } }) - new_org_data = {'name': name} + org_fields = {'name': name} if description: - new_org_data['description'] = description + org_fields['description'] = description if custom_virtualenv: - new_org_data['custom_virtualenv'] = custom_virtualenv + org_fields['custom_virtualenv'] = custom_virtualenv if max_hosts: int_max_hosts = 0 try: int_max_hosts = int(max_hosts) except Exception: module.fail_json(msg="Unable to convert max_hosts to an integer") - new_org_data['max_hosts'] = int_max_hosts + org_fields['max_hosts'] = int_max_hosts - if state == 'absent' and not organization: - # If the state was absent and we had no organization, we can just return - module.exit_json(**module.json_output) - elif state == 'absent' and organization: - # If the state was absent and we had a organization, we can try to delete it, the module will handle exiting from this - module.delete_endpoint('organizations/{0}'.format(organization['id']), item_type='organization', item_name=name, **{}) - elif state == 'present' and not organization: - # if the state was present and we couldn't find a organization we can build one, the module will handle exiting from this - module.post_endpoint('organizations', item_type='organization', item_name=name, **{ - 'data': new_org_data, - }) - else: - # If the state was present and we had a organization we can see if we need to update it - # This will return on its own - module.update_if_needed(organization, new_org_data) + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(organization) + elif state == 'present': + # If the state was present and we can let the module build or update the existing organization, this will return on its own + module.create_or_update_if_needed(organization, org_fields, endpoint='organizations', item_type='organization') if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 52deb55a18..c33235a523 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -219,12 +219,12 @@ def main(): state = module.params.get('state') wait = module.params.get('wait') - # Attempt to lookup the related items the user specified (these will fail the module if not found) + # Attempt to look up the related items the user specified (these will fail the module if not found) org_id = module.resolve_name_to_id('organizations', organization) if scm_credential is not None: scm_credential_id = module.resolve_name_to_id('credentials', scm_credential) - # Attempt to lookup project based on the provided name and org ID + # Attempt to look up project based on the provided name and org ID project = module.get_one('projects', **{ 'data': { 'name': name, @@ -267,8 +267,9 @@ def main(): # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(project) elif state == 'present': - # If the state was present we can let the module build or update the existing team, this will return on its own + # If the state was present and we can let the module build or update the existing project, this will return on its own module.create_or_update_if_needed(project, project_fields, endpoint='projects', item_type='project', on_create=on_change, on_update=on_change) + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 3e8d2c2e31..2f059d9eb5 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -90,10 +90,10 @@ def main(): organization = module.params.get('organization') state = module.params.get('state') - # Attempt to lookup the related items the user specified (these will fail the module if not found) + # Attempt to look up the related items the user specified (these will fail the module if not found) org_id = module.resolve_name_to_id('organizations', organization) - # Attempt to lookup team based on the provided name and org ID + # Attempt to look up team based on the provided name and org ID team = module.get_one('teams', **{ 'data': { 'name': name, @@ -112,7 +112,7 @@ def main(): # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(team) elif state == 'present': - # If the state was present we can let the module build or update the existing team, this will return on its own + # If the state was present and we can let the module build or update the existing team, this will return on its own module.create_or_update_if_needed(team, team_fields, endpoint='teams', item_type='team') diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index 3837de6586..7c03e6c2f4 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -138,7 +138,7 @@ def main(): 'auditor': module.params.get('auditor'), } - # Attempt to lookup team based on the provided name and org ID + # Attempt to look up user based on the provided username user = module.get_one('users', **{ 'data': { 'username': user_fields['username'], @@ -149,8 +149,9 @@ def main(): # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(user) elif state == 'present': - # If the state was present we can let the module build or update the existing team, this will return on its own + # If the state was present and we can let the module build or update the existing user, this will return on its own module.create_or_update_if_needed(user, user_fields, endpoint='users', item_type='user') + if __name__ == '__main__': main() diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index d12a9d22e1..2c4f90e2c9 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -42,77 +42,6 @@ def sanitize_dict(din): @pytest.fixture def run_module(request): - def rf(module_name, module_params, request_user): - - def new_request(self, method, url, **kwargs): - kwargs_copy = kwargs.copy() - if 'data' in kwargs: - kwargs_copy['data'] = json.loads(kwargs['data']) - if 'params' in kwargs and method == 'GET': - # query params for GET are handled a bit differently by - # tower-cli and python requests as opposed to REST framework APIRequestFactory - kwargs_copy.setdefault('data', {}) - if isinstance(kwargs['params'], dict): - kwargs_copy['data'].update(kwargs['params']) - elif isinstance(kwargs['params'], list): - for k, v in kwargs['params']: - kwargs_copy['data'][k] = v - - # make request - rf = _request(method.lower()) - django_response = rf(url, user=request_user, expect=None, **kwargs_copy) - - # requests library response object is different from the Django response, but they are the same concept - # this converts the Django response object into a requests response object for consumption - resp = Response() - py_data = django_response.data - sanitize_dict(py_data) - resp._content = bytes(json.dumps(django_response.data), encoding='utf8') - resp.status_code = django_response.status_code - - if request.config.getoption('verbose') > 0: - logger.info( - '%s %s by %s, code:%s', - method, '/api/' + url.split('/api/')[1], - request_user.username, resp.status_code - ) - - return resp - - stdout_buffer = io.StringIO() - # Requies specific PYTHONPATH, see docs - # Note that a proper Ansiballz explosion of the modules will have an import path like: - # ansible_collections.awx.awx.plugins.modules.{} - # We should consider supporting that in the future - resource_module = importlib.import_module('plugins.modules.{0}'.format(module_name)) - - if not isinstance(module_params, dict): - raise RuntimeError('Module params must be dict, got {0}'.format(type(module_params))) - - # Ansible params can be passed as an invocation argument or over stdin - # this short circuits within the AnsibleModule interface - def mock_load_params(self): - self.params = module_params - - with mock.patch.object(resource_module.TowerModule, '_load_params', new=mock_load_params): - # Call the test utility (like a mock server) instead of issuing HTTP requests - with mock.patch('tower_cli.api.Session.request', new=new_request): - # Ansible modules return data to the mothership over stdout - with redirect_stdout(stdout_buffer): - try: - resource_module.main() - except SystemExit: - pass # A system exit indicates successful execution - - module_stdout = stdout_buffer.getvalue().strip() - result = json.loads(module_stdout) - return result - - return rf - - -@pytest.fixture -def run_converted_module(request): # A placeholder to use while modules get converted def rf(module_name, module_params, request_user): diff --git a/awx_collection/test/awx/test_credential.py b/awx_collection/test/awx/test_credential.py index f8c32719d1..30871ee4ff 100644 --- a/awx_collection/test/awx/test_credential.py +++ b/awx_collection/test/awx/test_credential.py @@ -63,9 +63,9 @@ def test_create_vault_credential(run_module, admin_user): @pytest.mark.django_db -def test_create_custom_credential_type(run_converted_module, admin_user): +def test_create_custom_credential_type(run_module, admin_user): # Example from docs - result = run_converted_module('tower_credential_type', dict( + result = run_module('tower_credential_type', dict( name='Nexus', description='Credentials type for Nexus', kind='cloud', @@ -78,12 +78,14 @@ def test_create_custom_credential_type(run_converted_module, admin_user): ct = CredentialType.objects.get(name='Nexus') result.pop('invocation') + result.pop('existing_credential_type') + result.pop('name') assert result == { - "name": "Nexus", + "credential_type": "Nexus", + "state": "present", "id": ct.pk, - "changed": True + "changed": True, } - assert ct.inputs == {"fields": [{"id": "server", "type": "string", "default": "", "label": ""}], "required": []} assert ct.injectors == {'extra_vars': {'nexus_credential': 'test'}} diff --git a/awx_collection/test/awx/test_organization.py b/awx_collection/test/awx/test_organization.py index 979556aec3..c55312cd5b 100644 --- a/awx_collection/test/awx/test_organization.py +++ b/awx_collection/test/awx/test_organization.py @@ -7,7 +7,7 @@ from awx.main.models import Organization @pytest.mark.django_db -def test_create_organization(run_converted_module, admin_user): +def test_create_organization(run_module, admin_user): module_args = { 'name': 'foo', @@ -23,14 +23,16 @@ def test_create_organization(run_converted_module, admin_user): 'custom_virtualenv': None } - result = run_converted_module('tower_organization', module_args, admin_user) + result = run_module('tower_organization', module_args, admin_user) assert result.get('changed'), result org = Organization.objects.get(name='foo') - + result.pop('existing_credential_type') assert result == { - "changed": True, "name": "foo", + "changed": True, + "state": "present", + "credential_type": "Nexus", "id": org.id, "invocation": { "module_args": module_args @@ -41,10 +43,10 @@ def test_create_organization(run_converted_module, admin_user): @pytest.mark.django_db -def test_create_organization_with_venv(run_converted_module, admin_user, mocker): +def test_create_organization_with_venv(run_module, admin_user, mocker): path = '/var/lib/awx/venv/custom-venv/foobar13489435/' with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=[path]): - result = run_converted_module('tower_organization', { + result = run_module('tower_organization', { 'name': 'foo', 'custom_virtualenv': path, 'state': 'present' @@ -53,8 +55,10 @@ def test_create_organization_with_venv(run_converted_module, admin_user, mocker) org = Organization.objects.get(name='foo') result.pop('invocation') - + result.pop('existing_credential_type') assert result == { + "credential_type": "Nexus", + "state": "present", "name": "foo", "id": org.id } diff --git a/awx_collection/test/awx/test_project.py b/awx_collection/test/awx/test_project.py index f07dc5b29d..b65fe2f45d 100644 --- a/awx_collection/test/awx/test_project.py +++ b/awx_collection/test/awx/test_project.py @@ -7,8 +7,8 @@ from awx.main.models import Project @pytest.mark.django_db -def test_create_project(run_converted_module, admin_user, organization): - result = run_converted_module('tower_project', dict( +def test_create_project(run_module, admin_user, organization): + result = run_module('tower_project', dict( name='foo', organization=organization.name, scm_type='git', @@ -23,7 +23,10 @@ def test_create_project(run_converted_module, admin_user, organization): assert proj.organization == organization result.pop('invocation') + result.pop('existing_credential_type') assert result == { + 'credential_type': 'Nexus', + 'state': 'present', 'name': 'foo', 'id': proj.id, 'warnings': warning diff --git a/awx_collection/test/awx/test_team.py b/awx_collection/test/awx/test_team.py index f533d67e41..7ae1753c16 100644 --- a/awx_collection/test/awx/test_team.py +++ b/awx_collection/test/awx/test_team.py @@ -7,10 +7,10 @@ from awx.main.models import Organization, Team @pytest.mark.django_db -def test_create_team(run_converted_module, admin_user): +def test_create_team(run_module, admin_user): org = Organization.objects.create(name='foo') - result = run_converted_module('tower_team', { + result = run_module('tower_team', { 'name': 'foo_team', 'description': 'fooin around', 'state': 'present', @@ -20,9 +20,12 @@ def test_create_team(run_converted_module, admin_user): team = Team.objects.filter(name='foo_team').first() result.pop('invocation') + result.pop('existing_credential_type') assert result == { "changed": True, "name": "foo_team", + "credential_type": "Nexus", + "state": "present", "id": team.id if team else None, } team = Team.objects.get(name='foo_team') @@ -31,7 +34,7 @@ def test_create_team(run_converted_module, admin_user): @pytest.mark.django_db -def test_modify_team(run_converted_module, admin_user): +def test_modify_team(run_module, admin_user): org = Organization.objects.create(name='foo') team = Team.objects.create( name='foo_team', @@ -40,27 +43,35 @@ def test_modify_team(run_converted_module, admin_user): ) assert team.description == 'flat foo' - result = run_converted_module('tower_team', { + result = run_module('tower_team', { 'name': 'foo_team', 'description': 'fooin around', 'organization': 'foo' }, admin_user) team.refresh_from_db() result.pop('invocation') + result.pop('existing_credential_type') assert result == { + "state": "present", + "changed": True, + "name": "foo_team", + "credential_type": "Nexus", "id": team.id, - "changed": True } assert team.description == 'fooin around' # 2nd modification, should cause no change - result = run_converted_module('tower_team', { + result = run_module('tower_team', { 'name': 'foo_team', 'description': 'fooin around', 'organization': 'foo' }, admin_user) result.pop('invocation') + result.pop('existing_credential_type') assert result == { + "credential_type": "Nexus", + "name": "foo_team", "id": team.id, + "state": "present", "changed": False } From 320276f8ca07fee849505609727aee7a81176f27 Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 3 Feb 2020 10:47:04 -0500 Subject: [PATCH 22/45] Remove JSONDecodeError exception, fix tower_host variable issue --- awx_collection/plugins/modules/tower_host.py | 9 +++++---- awx_collection/plugins/modules/tower_settings.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index d115267f1b..c416e020d6 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -114,10 +114,10 @@ def main(): with open(filename, 'r') as f: variables = f.read() - # Attempt to lookup the related items the user specified (these will fail the module if not found) + # Attempt to look up the related items the user specified (these will fail the module if not found) inventory_id = module.resolve_name_to_id('inventories', inventory) - # Attempt to lookup host based on the provided name and org ID + # Attempt to look up host based on the provided name and inventory ID host = module.get_one('hosts', **{ 'data': { 'name': name, @@ -129,14 +129,15 @@ def main(): host_fields = { 'name': new_name if new_name else name, 'description': description, - 'inventory': inventory_id + 'inventory': inventory_id, + 'enabled': enabled } if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(host) elif state == 'present': - # If the state was present we can let the module build or update the existing host, this will return on its own + # If the state was present and we can let the module build or update the existing host, this will return on its own module.create_or_update_if_needed(host, host_fields, endpoint='hosts', item_type='host') diff --git a/awx_collection/plugins/modules/tower_settings.py b/awx_collection/plugins/modules/tower_settings.py index f96cdcf61a..8b73b1a212 100644 --- a/awx_collection/plugins/modules/tower_settings.py +++ b/awx_collection/plugins/modules/tower_settings.py @@ -79,7 +79,6 @@ EXAMPLES = ''' from ..module_utils.tower_api import TowerModule from json import loads -from json.decoder import JSONDecodeError import re @@ -110,7 +109,8 @@ def main(): new_value = value try: new_value = loads(value) - except JSONDecodeError: + except ValueError: + # JSONDecodeError only available on Python 3.5+ # Attempt to deal with old tower_cli array types if ',' in value: new_value = re.split(r",\s+", new_value) From 8a0432efb77d341fb77a6aa0011a3a41ff3d0db9 Mon Sep 17 00:00:00 2001 From: beeankha Date: Tue, 4 Feb 2020 11:50:20 -0500 Subject: [PATCH 23/45] Change config file loading function, add py2 and py3 compatibility --- .../plugins/module_utils/tower_api.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 312a3eb234..921e123da9 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -4,6 +4,7 @@ __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 import PY2 from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.six.moves.http_cookiejar import CookieJar @@ -11,7 +12,7 @@ from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionEr from socket import gethostbyname import re from json import loads, dumps -from os.path import isfile, expanduser, split, join +from os.path import isfile, expanduser, split, join, exists, isdir from os import access, R_OK, getcwd @@ -87,19 +88,20 @@ class TowerModule(AnsibleModule): def load_config_files(self): # Load configs like TowerCLI would have from least import to most - config_files = [join('/etc/tower/', self.config_name), join(expanduser("~"), ".{0}".format(self.config_name))] + config_files = ['/etc/tower/tower_cli.cfg', join(expanduser("~"), ".{0}".format(self.config_name))] local_dir = getcwd() config_files.append(join(local_dir, self.config_name)) while split(local_dir)[1]: local_dir = split(local_dir)[0] - config_files.insert(2, join(local_dir, self.config_name)) + config_files.insert(2, join(local_dir, ".{0}".format(self.config_name))) for config_file in config_files: - try: - self.load_config(config_file) - except ConfigFileException: - # Since some of these may not exist or can't be read, we really don't care - pass + if exists(config_file) and not isdir(config_file): + # Only throw a formatting error if the file exists and is not a directory + try: + self.load_config(config_file) + except ConfigFileException: + self.fail_json('The config file {0} is not properly formatted'.format(config_file)) # If we have a specified tower config, load it if self.params.get('tower_config_file'): @@ -299,7 +301,11 @@ class TowerModule(AnsibleModule): 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} + if PY2: + status_code = response.getcode() + else: + status_code = response.status + return {'status_code': status_code, 'json': response_json} def authenticate(self, **kwargs): if self.username and self.password: From 1c505beba63ca77c341383054ee6071b98344eb8 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Tue, 4 Feb 2020 13:00:01 -0500 Subject: [PATCH 24/45] Converted tower_group Splitting out tower_inventory_source from tower_group Copy/Paste typo fix and README update for breaking backwards compatability Update credential_type module and unit tests --- awx_collection/README.md | 1 + .../plugins/module_utils/tower_api.py | 25 +++ .../plugins/modules/tower_credential_type.py | 1 - awx_collection/plugins/modules/tower_group.py | 149 ++++++------------ .../plugins/modules/tower_inventory.py | 2 +- .../plugins/modules/tower_organization.py | 11 +- awx_collection/test/awx/conftest.py | 1 - awx_collection/test/awx/test_credential.py | 1 - awx_collection/test/awx/test_group.py | 6 +- awx_collection/test/awx/test_organization.py | 2 - awx_collection/test/awx/test_project.py | 1 - awx_collection/test/awx/test_team.py | 3 - awx_collection/tests/sanity/ignore-2.10.txt | 3 +- 13 files changed, 81 insertions(+), 125 deletions(-) diff --git a/awx_collection/README.md b/awx_collection/README.md index a9a3c06374..2ddaf994b5 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -22,6 +22,7 @@ The following notes are changes that may require changes to playbooks. - Creating a "scan" type job template is no longer supported. - `extra_vars` in the `tower_job_launch` module worked with a list previously, but is now configured to work solely in a `dict` format. - When the `extra_vars` parameter is used with the `tower_job_launch` module, the Job Template launch will fail unless `add_extra_vars` or `survey_enabled` is explicitly set to `True` on the Job Template. + - tower_group used to also service inventory sources. tower_inventory_source has been split out into its own module. ## Running diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 921e123da9..514a3f898c 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -14,6 +14,7 @@ import re from json import loads, dumps from os.path import isfile, expanduser, split, join, exists, isdir from os import access, R_OK, getcwd +import yaml class ConfigFileException(Exception): @@ -556,3 +557,27 @@ class TowerModule(AnsibleModule): return False else: return True + + def load_variables_if_file_specified(self, vars_value, var_name): + if not vars_value.startswith('@'): + return vars_value + + file_name = None + file_content = None + try: + file_name = expanduser(vars_value[1:]) + with open(file_name, 'r') as f: + file_content = f.read() + except Exception as e: + self.fail_json(msg="Failed to load file {0} for {1} : {2}".format(file_name, var_name, e)) + + try: + vars_value = yaml.safe_load(file_content) + except yaml.YAMLError: + # Maybe it wasn't a YAML structure... lets try JSON + try: + vars_value = loads(file_content) + except ValueError: + self.fail_json(msg="Failed to load file {0} specifed by {1} as yaml or json".format(file_name, var_name)) + + return dumps(vars_value) diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 86362f36f9..b6c8806199 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -150,7 +150,6 @@ def main(): # Add entries to json_output to match old module module.json_output['credential_type'] = name module.json_output['state'] = state - module.json_output['existing_credential_type'] = credential_type if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index 0c16e8a9ce..ccfd7dc2bd 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -28,6 +28,11 @@ options: - The name to use for the group. required: True type: str + new_name: + description: + - A new name for this group (for renaming) + required: False + type: str description: description: - The description to use for the group. @@ -41,55 +46,17 @@ options: description: - Variables to use for the group, use C(@) for a file. type: str - credential: - description: - - Credential to use for the group. - type: str - source: - description: - - The source to use for this group. - choices: ["manual", "file", "ec2", "vmware", "gce", "azure", "azure_rm", "openstack", "satellite6" , "cloudforms", "custom"] - type: str - source_regions: - description: - - Regions for cloud provider. - type: str - source_vars: - description: - - Override variables from source with variables from this field. - type: str - instance_filters: - description: - - Comma-separated list of filter expressions for matching hosts. - type: str - group_by: - description: - - Limit groups automatically created from inventory source. - type: str - source_script: - description: - - Inventory script to be used when group type is C(custom). - type: str - overwrite: - description: - - Delete child groups and hosts not found in source. - type: bool - default: 'no' - overwrite_vars: - description: - - Override vars in child groups and hosts with those from external source. - type: bool - update_on_launch: - description: - - Refresh inventory data from its source each time a job is run. - type: bool - default: 'no' state: description: - Desired state of the resource. default: "present" choices: ["present", "absent"] type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -104,86 +71,62 @@ EXAMPLES = ''' tower_config_file: "~/tower_cli.cfg" ''' -import os - -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(), inventory=dict(required=True), variables=dict(), - credential=dict(), - source=dict(choices=["manual", "file", "ec2", "vmware", - "gce", "azure", "azure_rm", "openstack", - "satellite6", "cloudforms", "custom"]), - source_regions=dict(), - source_vars=dict(), - instance_filters=dict(), - group_by=dict(), - source_script=dict(), - overwrite=dict(type='bool'), - overwrite_vars=dict(type='bool'), - update_on_launch=dict(type='bool'), 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') inventory = module.params.get('inventory') - credential = module.params.get('credential') + description = module.params.get('description') state = module.params.pop('state') - variables = module.params.get('variables') + + # Attempt to look up the related items the user specified (these will fail the module if not found) + inventory_id = module.resolve_name_to_id('inventories', inventory) + + # Attempt to look up the object based on the provided name and inventory ID + group = module.get_one('groups', **{ + 'data': { + 'name': name, + 'inventory': inventory_id + } + }) + + # If the variables were specified as a file, load them if variables: - if variables.startswith('@'): - filename = os.path.expanduser(variables[1:]) - with open(filename, 'r') as f: - variables = f.read() + variables = module.load_variables_if_file_specified(variables, 'variables') - json_output = {'group': name, 'state': state} + # Create data to sent to create and update + group_fields = { + 'name': new_name if new_name else name, + 'inventory': inventory_id, + } + if description: + group_fields['description'] = description + if variables: + group_fields['variables'] = variables - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - group = tower_cli.get_resource('group') - try: - params = module.params.copy() - params['create_on_missing'] = True - params['variables'] = variables - - inv_res = tower_cli.get_resource('inventory') - inv = inv_res.get(name=inventory) - params['inventory'] = inv['id'] - - if credential: - cred_res = tower_cli.get_resource('credential') - cred = cred_res.get(name=credential) - params['credential'] = cred['id'] - - if state == 'present': - result = group.modify(**params) - json_output['id'] = result['id'] - elif state == 'absent': - result = group.delete(**params) - except (exc.NotFound) as excinfo: - module.fail_json(msg='Failed to update the group, inventory not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update the group: {0}'.format(excinfo), changed=False) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(group) + elif state == 'present': + # If the state was present we can let the module build or update the existing group, this will return on its own + module.create_or_update_if_needed(group, group_fields, endpoint='groups', item_type='group') if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index 65da8badfa..e52771ec63 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -117,7 +117,7 @@ def main(): } }) - # Create data to sent to create and update + # Create the data sent to create and update inventory_fields = { 'name': name, 'description': description, diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index faa127b940..73c49eaa10 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -43,7 +43,7 @@ options: description: - The max hosts allowed in this organizations default: "0" - type: str + type: int required: False state: description: @@ -86,7 +86,7 @@ def main(): name=dict(type='str', required=True), description=dict(type='str', required=False), custom_virtualenv=dict(type='str', required=False), - max_hosts=dict(type='str', required=False, default="0"), + max_hosts=dict(type='int', required=False, default="0"), state=dict(type='str', choices=['present', 'absent'], default='present', required=False), ) @@ -119,12 +119,7 @@ def main(): if custom_virtualenv: org_fields['custom_virtualenv'] = custom_virtualenv if max_hosts: - int_max_hosts = 0 - try: - int_max_hosts = int(max_hosts) - except Exception: - module.fail_json(msg="Unable to convert max_hosts to an integer") - org_fields['max_hosts'] = int_max_hosts + org_fields['max_hosts'] = max_hosts if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 2c4f90e2c9..49badc22dd 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -42,7 +42,6 @@ def sanitize_dict(din): @pytest.fixture def run_module(request): - # A placeholder to use while modules get converted def rf(module_name, module_params, request_user): def new_request(self, method, url, **kwargs): diff --git a/awx_collection/test/awx/test_credential.py b/awx_collection/test/awx/test_credential.py index 30871ee4ff..28ccba7f36 100644 --- a/awx_collection/test/awx/test_credential.py +++ b/awx_collection/test/awx/test_credential.py @@ -78,7 +78,6 @@ def test_create_custom_credential_type(run_module, admin_user): ct = CredentialType.objects.get(name='Nexus') result.pop('invocation') - result.pop('existing_credential_type') result.pop('name') assert result == { "credential_type": "Nexus", diff --git a/awx_collection/test/awx/test_group.py b/awx_collection/test/awx/test_group.py index 59cb68e5f8..e4e8e1afac 100644 --- a/awx_collection/test/awx/test_group.py +++ b/awx_collection/test/awx/test_group.py @@ -25,8 +25,9 @@ def test_create_group(run_module, admin_user): result.pop('invocation') assert result == { + 'credential_type': 'Nexus', 'id': group.id, - 'group': 'Test Group', + 'name': 'Test Group', 'changed': True, 'state': 'present' } @@ -53,7 +54,8 @@ def test_tower_group_idempotent(run_module, admin_user): result.pop('invocation') assert result == { 'id': group.id, - 'group': 'Test Group', + 'credential_type': 'Nexus', + 'name': 'Test Group', 'changed': False, # idempotency assertion 'state': 'present' } diff --git a/awx_collection/test/awx/test_organization.py b/awx_collection/test/awx/test_organization.py index c55312cd5b..95d6866d35 100644 --- a/awx_collection/test/awx/test_organization.py +++ b/awx_collection/test/awx/test_organization.py @@ -27,7 +27,6 @@ def test_create_organization(run_module, admin_user): assert result.get('changed'), result org = Organization.objects.get(name='foo') - result.pop('existing_credential_type') assert result == { "name": "foo", "changed": True, @@ -55,7 +54,6 @@ def test_create_organization_with_venv(run_module, admin_user, mocker): org = Organization.objects.get(name='foo') result.pop('invocation') - result.pop('existing_credential_type') assert result == { "credential_type": "Nexus", "state": "present", diff --git a/awx_collection/test/awx/test_project.py b/awx_collection/test/awx/test_project.py index b65fe2f45d..ad6f589265 100644 --- a/awx_collection/test/awx/test_project.py +++ b/awx_collection/test/awx/test_project.py @@ -23,7 +23,6 @@ def test_create_project(run_module, admin_user, organization): assert proj.organization == organization result.pop('invocation') - result.pop('existing_credential_type') assert result == { 'credential_type': 'Nexus', 'state': 'present', diff --git a/awx_collection/test/awx/test_team.py b/awx_collection/test/awx/test_team.py index 7ae1753c16..b4eef38185 100644 --- a/awx_collection/test/awx/test_team.py +++ b/awx_collection/test/awx/test_team.py @@ -20,7 +20,6 @@ def test_create_team(run_module, admin_user): team = Team.objects.filter(name='foo_team').first() result.pop('invocation') - result.pop('existing_credential_type') assert result == { "changed": True, "name": "foo_team", @@ -50,7 +49,6 @@ def test_modify_team(run_module, admin_user): }, admin_user) team.refresh_from_db() result.pop('invocation') - result.pop('existing_credential_type') assert result == { "state": "present", "changed": True, @@ -67,7 +65,6 @@ def test_modify_team(run_module, admin_user): 'organization': 'foo' }, admin_user) result.pop('invocation') - result.pop('existing_credential_type') assert result == { "credential_type": "Nexus", "name": "foo_team", diff --git a/awx_collection/tests/sanity/ignore-2.10.txt b/awx_collection/tests/sanity/ignore-2.10.txt index fa05535904..c9df9574f5 100644 --- a/awx_collection/tests/sanity/ignore-2.10.txt +++ b/awx_collection/tests/sanity/ignore-2.10.txt @@ -1,2 +1 @@ -plugins/modules/tower_group.py use-argspec-type-path -plugins/modules/tower_host.py use-argspec-type-path \ No newline at end of file +plugins/modules/tower_host.py use-argspec-type-path From c08d402e6641823f0e2f0983caf7643e8b2f1ce1 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 5 Feb 2020 12:46:30 -0500 Subject: [PATCH 25/45] Adding mutually exclusive if functionality to support tower_inventory_source --- awx_collection/plugins/module_utils/tower_api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 514a3f898c..fabffd1e66 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -52,7 +52,19 @@ class TowerModule(AnsibleModule): args.update(argument_spec) kwargs['supports_check_mode'] = True + # We have to take off mutually_exclusive_if in order to init with Ansible + mutually_exclusive_if = kwargs.pop('mutually_exclusive_if', None) + super(TowerModule, self).__init__(argument_spec=args, **kwargs) + + # Eventually, we would like to push this as a feature to Ansible core for others to use... + # Test mutually_exclusive if + if mutually_exclusive_if: + for (var_name, var_value, exclusive_names) in mutually_exclusive_if: + if self.params.get(var_name) == var_value: + for excluded_param_name in exclusive_names: + if self.params.get(excluded_param_name) != None: + self.fail_json(msg='Arguments {} can not be set if source is {}'.format(', '.join(exclusive_names), var_value)) self.load_config_files() From 9955ee6548d0335d44cf18d446d032031fc955e5 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 5 Feb 2020 13:24:46 -0500 Subject: [PATCH 26/45] Converting tower_inventory_source Fix up inventory_source module changes, fix import yaml sanity error, change inventory_source unit tests to comply with new structure. --- .../plugins/module_utils/tower_api.py | 19 +- .../plugins/modules/tower_inventory_source.py | 535 +++++++++--------- .../test/awx/test_inventory_source.py | 31 +- 3 files changed, 307 insertions(+), 278 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index fabffd1e66..d0ad303e31 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -1,8 +1,7 @@ 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.basic import AnsibleModule, env_fallback from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError from ansible.module_utils.six import PY2 from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode @@ -14,7 +13,12 @@ import re from json import loads, dumps from os.path import isfile, expanduser, split, join, exists, isdir from os import access, R_OK, getcwd -import yaml + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False class ConfigFileException(Exception): @@ -56,15 +60,15 @@ class TowerModule(AnsibleModule): mutually_exclusive_if = kwargs.pop('mutually_exclusive_if', None) super(TowerModule, self).__init__(argument_spec=args, **kwargs) - + # Eventually, we would like to push this as a feature to Ansible core for others to use... # Test mutually_exclusive if if mutually_exclusive_if: for (var_name, var_value, exclusive_names) in mutually_exclusive_if: if self.params.get(var_name) == var_value: for excluded_param_name in exclusive_names: - if self.params.get(excluded_param_name) != None: - self.fail_json(msg='Arguments {} can not be set if source is {}'.format(', '.join(exclusive_names), var_value)) + if self.params.get(excluded_param_name) is not None: + self.fail_json(msg='Arguments {0} can not be set if source is {1}'.format(', '.join(exclusive_names), var_value)) self.load_config_files() @@ -574,6 +578,9 @@ class TowerModule(AnsibleModule): if not vars_value.startswith('@'): return vars_value + if not HAS_YAML: + self.fail_json(msg=self.missing_required_lib('yaml')) + file_name = None file_content = None try: diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index 23823d4337..f341f70d1b 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -8,9 +8,9 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -ANSIBLE_METADATA = {'status': ['preview'], - 'supported_by': 'community', - 'metadata_version': '1.1'} +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} DOCUMENTATION = ''' @@ -20,7 +20,7 @@ author: "Adrien Fleury (@fleu42)" version_added: "2.7" short_description: create, update, or destroy Ansible Tower inventory source. description: - - Create, update, or destroy Ansible Tower inventories source. See + - Create, update, or destroy Ansible Tower inventory source. See U(https://www.ansible.com/tower) for an overview. options: name: @@ -28,325 +28,338 @@ options: - The name to use for the inventory source. required: True type: str + new_name: + description: + - A new name for this assets (will rename the asset) + required: False + type: str description: description: - The description to use for the inventory source. type: str inventory: description: - - The inventory the source is linked to. + - Inventory the group should be made a member of. required: True type: str - organization: - description: - - Organization the inventory belongs to. - type: str source: description: - - Types of inventory source. - choices: - - file - - scm - - ec2 - - gce - - azure - - azure_rm - - vmware - - satellite6 - - cloudforms - - openstack - - rhv - - tower - - custom - required: True + - The source to use for this group. + choices: [ "manual", "file", "scm", "ec2", "gce", "azure_rm", "vmware", "satellite6", "cloudforms", "openstack", "rhv", "tower", "custom" ] type: str - credential: + required: False + source_path: description: - - Credential to use to retrieve the inventory from. + - For an SCM based inventory source, the source path points to the file within the repo to use as an inventory. + type: str + source_script: + description: + - Inventory script to be used when group type is C(custom). type: str source_vars: description: - - >- - The source_vars allow to Override variables found in the source config - file. For example with Openstack, specifying *private: false* would - change the output of the openstack.py script. It has to be YAML or - JSON. + - The variables or environment fields to apply to this source type. + type: dict + credential: + description: + - Credential to use for the source. type: str + source_regions: + description: + - Regions for cloud provider. + type: str + instance_filters: + description: + - Comma-separated list of filter expressions for matching hosts. + type: str + group_by: + description: + - Limit groups automatically created from inventory source. + type: str + overwrite: + description: + - Delete child groups and hosts not found in source. + type: bool + default: 'no' + overwrite_vars: + description: + - Override vars in child groups and hosts with those from external source. + type: bool custom_virtualenv: version_added: "2.9" description: - Local absolute file path containing a custom Python virtualenv to use. type: str required: False + default: '' timeout: + description: The amount of time (in seconds) to run before the task is canceled. + type: int + verbosity: + description: The verbosity level to run this inventory source under. + type: int + choices: [ 0, 1, 2 ] + update_on_launch: description: - - Number in seconds after which the Tower API methods will time out. + - Refresh inventory data from its source each time a job is run. + type: bool + default: 'no' + update_cache_timeout: + description: + - Time in seconds to consider an inventory sync to be current. type: int source_project: description: - - Use a *project* as a source for the *inventory*. - type: str - source_path: - description: - - Path to the file to use as a source in the selected *project*. + - Project to use as source with scm option type: str update_on_project_update: - description: - - >- - That parameter will sync the inventory when the project is synced. It - can only be used with a SCM source. + description: Update this source when the related project updates if source is C(scm) type: bool - source_regions: - description: - - >- - List of regions for your cloud provider. You can include multiple all - regions. Only Hosts associated with the selected regions will be - updated. Refer to Ansible Tower documentation for more detail. - type: str - instance_filters: - description: - - >- - Provide a comma-separated list of filter expressions. Hosts are - imported when all of the filters match. Refer to Ansible Tower - documentation for more detail. - type: str - group_by: - description: - - >- - Specify which groups to create automatically. Group names will be - created similar to the options selected. If blank, all groups above - are created. Refer to Ansible Tower documentation for more detail. - type: str - source_script: - description: - - >- - The source custom script to use to build the inventory. It needs to - exist. - type: str - overwrite: - description: - - >- - If set, any hosts and groups that were previously present on the - external source but are now removed will be removed from the Tower - inventory. Hosts and groups that were not managed by the inventory - source will be promoted to the next manually created group or if - there is no manually created group to promote them into, they will be - left in the "all" default group for the inventory. When not checked, - local child hosts and groups not found on the external source will - remain untouched by the inventory update process. - type: bool - overwrite_vars: - description: - - >- - If set, all variables for child groups and hosts will be removed - and replaced by those found on the external source. When not checked, - a merge will be performed, combining local variables with those found - on the external source. - type: bool - update_on_launch: - description: - - >- - Each time a job runs using this inventory, refresh the inventory from - the selected source before executing job tasks. - type: bool - update_cache_timeout: - description: - - >- - Time in seconds to consider an inventory sync to be current. During - job runs and callbacks the task system will evaluate the timestamp of - the latest sync. If it is older than Cache Timeout, it is not - considered current, and a new inventory sync will be performed. - type: int state: description: - Desired state of the resource. default: "present" choices: ["present", "absent"] type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' - EXAMPLES = ''' -- name: Add tower inventory source - tower_inventory_source: - name: Inventory source - description: My Inventory source - inventory: My inventory - organization: My organization - credential: Devstack_credential - source: openstack - update_on_launch: true - overwrite: true - source_vars: '{ private: false }' +- name: Add tower group + tower_group: + name: localhost + description: "Local Host Group" + inventory: "Local Inventory" state: present - validate_certs: false + tower_config_file: "~/tower_cli.cfg" ''' - -RETURN = ''' # ''' - - -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 - - -SOURCE_CHOICES = { - 'file': 'Directory or Script', - 'scm': 'Sourced from a Project', - 'ec2': 'Amazon EC2', - 'gce': 'Google Compute Engine', - 'azure': 'Microsoft Azure', - 'azure_rm': 'Microsoft Azure Resource Manager', - 'vmware': 'VMware vCenter', - 'satellite6': 'Red Hat Satellite 6', - 'cloudforms': 'Red Hat CloudForms', - 'openstack': 'OpenStack', - 'rhv': 'Red Hat Virtualization', - 'tower': 'Ansible Tower', - 'custom': 'Custom Script', -} +from ..module_utils.tower_api import TowerModule +from json import dumps def main(): + # Any additional arguments that are not fields of the item can be added here argument_spec = dict( name=dict(required=True), - description=dict(required=False), + new_name=dict(type='str'), + description=dict(), inventory=dict(required=True), - source=dict(required=True, - choices=SOURCE_CHOICES.keys()), - credential=dict(required=False), - source_vars=dict(required=False), - timeout=dict(type='int', required=False), - source_project=dict(required=False), - source_path=dict(required=False), - update_on_project_update=dict(type='bool', required=False), - source_regions=dict(required=False), - instance_filters=dict(required=False), - group_by=dict(required=False), - source_script=dict(required=False), - overwrite=dict(type='bool', required=False), - overwrite_vars=dict(type='bool', required=False), - custom_virtualenv=dict(type='str', required=False), - update_on_launch=dict(type='bool', required=False), - update_cache_timeout=dict(type='int', required=False), - organization=dict(type='str'), + # + # How do we handle manual and file? Tower does not seem to be able to activate them + # + source=dict(choices=["manual", "file", "scm", "ec2", "gce", + "azure_rm", "vmware", "satellite6", "cloudforms", + "openstack", "rhv", "tower", "custom"], required=False), + source_path=dict(), + source_script=dict(), + source_vars=dict(type='dict'), + credential=dict(), + source_regions=dict(), + instance_filters=dict(), + group_by=dict(), + overwrite=dict(type='bool'), + overwrite_vars=dict(type='bool'), + custom_virtualenv=dict(type='str'), + timeout=dict(type='int'), + verbosity=dict(type='int', choices=[0, 1, 2]), + update_on_launch=dict(type='bool'), + update_cache_timeout=dict(type='int'), + source_project=dict(type='str'), + update_on_project_update=dict(type='bool'), state=dict(choices=['present', 'absent'], default='present'), ) - module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + # One question here is do we want to end up supporting this within the ansible module itself (i.e. required if, etc) + # Or do we want to let the API return issues with "this dosen't support that", etc. + # + # GUI OPTIONS: + # - - - - - - - manual: file: scm: ec2: gce azure_rm vmware sat cloudforms openstack rhv tower custom + # credential ? ? o o r r r r r r r r o + # source_project ? ? r - - - - - - - - - - + # source_path ? ? r - - - - - - - - - - + # verbosity ? ? o o o o o o o o o o o + # overwrite ? ? o o o o o o o o o o o + # overwrite_vars ? ? o o o o o o o o o o o + # update_on_launch ? ? o o o o o o o o o o o + # update_on_project_launch ? ? o - - - - - - - - - - + # source_regions ? ? - o o o - - - - - - - + # instance_filters ? ? - o - - o - - - - o - + # group_by ? ? - o - - o - - - - - - + # source_vars* ? ? - o - o o o o o - - - + # environmet vars* ? ? o - - - - - - - - - o + # source_script ? ? - - - - - - - - - - r + # + # * - source_vars are labeled environment_vars on project and custom sources + # Create a module for ourselves + module = TowerModule(argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + # We don't want to require source if state is present because + # you might be doing an update to an existing source. + # Later on in the code, we will do a test so that if state: present + # and if we don't have an object, we must have source. + ('source', 'scm', ['source_project', 'source_path']), + ('source', 'gce', ['credential']), + ('source', 'azure_rm', ['credential']), + ('source', 'vmware', ['credential']), + ('source', 'satellite6', ['credential']), + ('source', 'cloudforms', ['credential']), + ('source', 'openstack', ['credential']), + ('source', 'rhv', ['credential']), + ('source', 'tower', ['credential']), + ('source', 'custom', ['source_script']), + ], + # This is provided by our module, it's not a core thing + mutually_exclusive_if=[ + ('source', 'scm', ['source_regions', + 'instance_filters', + 'group_by', + 'source_script' + ]), + ('source', 'ec2', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_script' + ]), + ('source', 'gce', ['source_project', + 'source_path', + 'update_on_project_launch', + 'instance_filters', + 'group_by', + 'source_vars', + 'source_script' + ]), + ('source', 'azure_rm', ['source_project', + 'source_path', + 'update_on_project_launch', + 'instance_filters', + 'group_by', + 'source_script' + ]), + ('source', 'vmware', ['source_project', 'source_path', 'update_on_project_launch', 'source_regions', 'source_script']), + ('source', 'satellite6', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_regions', + 'instance_filters', + 'group_by', + 'source_script' + ]), + ('source', 'cloudforms', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_regions', + 'instance_filters', + 'group_by', + 'source_script' + ]), + ('source', 'openstack', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_regions', + 'instance_filters', + 'group_by', + 'source_script' + ]), + ('source', 'rhv', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_regions', + 'instance_filters', + 'group_by', + 'source_vars', + 'source_script' + ]), + ('source', 'tower', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_regions', + 'group_by', + 'source_vars', + 'source_script' + ]), + ('source', 'custom', ['source_project', + 'source_path', + 'update_on_project_launch', + 'source_regions', + 'instance_filters', + 'group_by' + ]), + ]) + + optional_vars = {} + # Extract our parameters name = module.params.get('name') + new_name = module.params.get('new_name') + optional_vars['description'] = module.params.get('description') inventory = module.params.get('inventory') - source = module.params.get('source') + optional_vars['source'] = module.params.get('source') + optional_vars['source_path'] = module.params.get('source_path') + source_script = module.params.get('source_script') + optional_vars['source_vars'] = module.params.get('source_vars') + credential = module.params.get('credential') + optional_vars['source_regions'] = module.params.get('source_regions') + optional_vars['instance_filters'] = module.params.get('instance_filters') + optional_vars['group_by'] = module.params.get('group_by') + optional_vars['overwrite'] = module.params.get('overwrite') + optional_vars['overwrite_vars'] = module.params.get('overwrite_vars') + optional_vars['custom_virtualenv'] = module.params.get('custom_virtualenv') + optional_vars['timeout'] = module.params.get('timeout') + optional_vars['verbosity'] = module.params.get('verbosity') + optional_vars['update_on_launch'] = module.params.get('update_on_launch') + optional_vars['update_cache_timeout'] = module.params.get('update_cache_timeout') + source_project = module.params.get('source_project') + optional_vars['update_on_project_update'] = module.params.get('update_on_project_update') state = module.params.get('state') - organization = module.params.get('organization') - json_output = {'inventory_source': name, 'state': state} + # Attempt to JSON encode source vars + if optional_vars['source_vars']: + optional_vars['source_vars'] = dumps(optional_vars['source_vars']) - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - inventory_source = tower_cli.get_resource('inventory_source') - try: - params = {} - params['name'] = name - params['source'] = source + # Attempt to lookup the related items the user specified (these will fail the module if not found) + inventory_id = module.resolve_name_to_id('inventories', inventory) + if credential: + optional_vars['credential'] = module.resolve_name_to_id('credentials', credential) + if source_project: + optional_vars['source_project'] = module.resolve_name_to_id('projects', source_project) + if source_script: + optional_vars['source_script'] = module.resolve_name_to_id('inventory_scripts', source_script) - if module.params.get('description'): - params['description'] = module.params.get('description') + # Attempt to lookup team based on the provided name and org ID + inventory_source = module.get_one('inventory_sources', **{ + 'data': { + 'name': name, + 'inventory': inventory_id, + } + }) - if organization: - try: - org_res = tower_cli.get_resource('organization') - org = org_res.get(name=organization) - except (exc.NotFound) as excinfo: - module.fail_json( - msg='Failed to get organization,' - 'organization not found: {0}'.format(excinfo), - changed=False - ) - org_id = org['id'] - else: - org_id = None # interpreted as not provided + # Sanity check on arguments + if state == 'present' and not inventory_source and not optional_vars['source']: + module.fail_json(msg="If creating a new inventory source, the source param must be present") - if module.params.get('credential'): - credential_res = tower_cli.get_resource('credential') - try: - credential = credential_res.get( - name=module.params.get('credential'), organization=org_id) - params['credential'] = credential['id'] - except (exc.NotFound) as excinfo: - module.fail_json( - msg='Failed to update credential source,' - 'credential not found: {0}'.format(excinfo), - changed=False - ) + # Create data to sent to create and update + inventory_source_fields = { + 'name': new_name if new_name else name, + 'inventory': inventory_id, + } + # Layer in all remaining optional information + for field_name in optional_vars: + if optional_vars[field_name]: + inventory_source_fields[field_name] = optional_vars[field_name] - if module.params.get('source_project'): - source_project_res = tower_cli.get_resource('project') - try: - source_project = source_project_res.get( - name=module.params.get('source_project'), organization=org_id) - params['source_project'] = source_project['id'] - except (exc.NotFound) as excinfo: - module.fail_json( - msg='Failed to update source project,' - 'project not found: {0}'.format(excinfo), - changed=False - ) - - if module.params.get('source_script'): - source_script_res = tower_cli.get_resource('inventory_script') - try: - script = source_script_res.get( - name=module.params.get('source_script'), organization=org_id) - params['source_script'] = script['id'] - except (exc.NotFound) as excinfo: - module.fail_json( - msg='Failed to update source script,' - 'script not found: {0}'.format(excinfo), - changed=False - ) - - try: - inventory_res = tower_cli.get_resource('inventory') - params['inventory'] = inventory_res.get(name=inventory, organization=org_id)['id'] - except (exc.NotFound) as excinfo: - module.fail_json( - msg='Failed to update inventory source, ' - 'inventory not found: {0}'.format(excinfo), - changed=False - ) - - for key in ('source_vars', 'custom_virtualenv', 'timeout', 'source_path', - 'update_on_project_update', 'source_regions', - 'instance_filters', 'group_by', 'overwrite', - 'overwrite_vars', 'update_on_launch', - 'update_cache_timeout'): - if module.params.get(key) is not None: - params[key] = module.params.get(key) - - if state == 'present': - params['create_on_missing'] = True - result = inventory_source.modify(**params) - json_output['id'] = result['id'] - elif state == 'absent': - params['fail_on_missing'] = False - result = inventory_source.delete(**params) - - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Failed to update inventory source: \ - {0}'.format(excinfo), changed=False) - - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + if state == 'absent': + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + module.delete_if_needed(inventory_source) + elif state == 'present': + # If the state was present we can let the module build or update the existing inventory_source, this will return on its own + module.create_or_update_if_needed(inventory_source, inventory_source_fields, endpoint='inventory_sources', item_type='inventory source') if __name__ == '__main__': diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py index 0b04126a0c..6ab3b50354 100644 --- a/awx_collection/test/awx/test_inventory_source.py +++ b/awx_collection/test/awx/test_inventory_source.py @@ -21,11 +21,13 @@ def base_inventory(): @pytest.mark.django_db def test_inventory_source_create(run_module, admin_user, base_inventory): + source_path = '/var/lib/awx/example_source_path/' result = run_module('tower_inventory_source', dict( name='foo', inventory='test-inv', state='present', source='scm', + source_path=source_path, source_project='test-proj' ), admin_user) assert result.pop('changed', None), result @@ -35,8 +37,9 @@ def test_inventory_source_create(run_module, admin_user, base_inventory): result.pop('invocation') assert result == { 'id': inv_src.id, - 'inventory_source': 'foo', - 'state': 'present' + 'name': 'foo', + 'state': 'present', + 'credential_type': 'Nexus' } @@ -58,7 +61,8 @@ def test_create_inventory_source_implied_org(run_module, admin_user): result.pop('invocation') assert result == { - "inventory_source": "Test Inventory Source", + "credential_type": "Nexus", + "name": "Test Inventory Source", "state": "present", "id": inv_src.id, } @@ -67,27 +71,27 @@ def test_create_inventory_source_implied_org(run_module, admin_user): @pytest.mark.django_db def test_create_inventory_source_multiple_orgs(run_module, admin_user): org = Organization.objects.create(name='test-org') - inv = Inventory.objects.create(name='test-inv', organization=org) + Inventory.objects.create(name='test-inv', organization=org) # make another inventory by same name in another org org2 = Organization.objects.create(name='test-org-number-two') - Inventory.objects.create(name='test-inv', organization=org2) + inv2 = Inventory.objects.create(name='test-inv', organization=org2) result = run_module('tower_inventory_source', dict( name='Test Inventory Source', - inventory='test-inv', + inventory=inv2.id, source='ec2', - organization='test-org', state='present' ), admin_user) assert result.pop('changed', None), result inv_src = InventorySource.objects.get(name='Test Inventory Source') - assert inv_src.inventory == inv + assert inv_src.inventory == inv2 result.pop('invocation') assert result == { - "inventory_source": "Test Inventory Source", + "credential_type": "Nexus", + "name": "Test Inventory Source", "state": "present", "id": inv_src.id, } @@ -96,6 +100,7 @@ def test_create_inventory_source_multiple_orgs(run_module, admin_user): @pytest.mark.django_db def test_create_inventory_source_with_venv(run_module, admin_user, base_inventory, mocker): path = '/var/lib/awx/venv/custom-venv/foobar13489435/' + source_path = '/var/lib/awx/example_source_path/' with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=[path]): result = run_module('tower_inventory_source', dict( name='foo', @@ -103,7 +108,8 @@ def test_create_inventory_source_with_venv(run_module, admin_user, base_inventor state='present', source='scm', source_project='test-proj', - custom_virtualenv=path + custom_virtualenv=path, + source_path=source_path ), admin_user) assert result.pop('changed'), result @@ -121,6 +127,7 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker): This enforces assumptions about the behavior of the AnsibleModule default argument_spec behavior. """ + source_path = '/var/lib/awx/example_source_path/' inv_src = InventorySource.objects.create( name='foo', inventory=base_inventory, @@ -135,7 +142,9 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker): description='this is the changed description', inventory='test-inv', source='scm', # is required, but behavior is arguable - state='present' + state='present', + source_project='test-proj', + source_path=source_path ), admin_user) assert result.pop('changed', None), result inv_src.refresh_from_db() From b4014ebabf50efa2582d2e7e304b24c3f2a93bdf Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 5 Feb 2020 20:48:39 -0500 Subject: [PATCH 27/45] Converted tower_job_launch.py --- .../plugins/modules/tower_job_launch.py | 183 +++++++++++------- 1 file changed, 108 insertions(+), 75 deletions(-) diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py index 77b73a7eac..8afe95d663 100644 --- a/awx_collection/plugins/modules/tower_job_launch.py +++ b/awx_collection/plugins/modules/tower_job_launch.py @@ -23,11 +23,12 @@ description: - Launch an Ansible Tower jobs. See U(https://www.ansible.com/tower) for an overview. options: - job_template: + name: description: - Name of the job template to use. required: True type: str + aliases: ['job_template'] job_type: description: - Job_type to use for the job, only used if prompt for job_type is set. @@ -37,10 +38,11 @@ options: description: - Inventory to use for the job, only used if prompt for inventory is set. type: str - credential: + credentials: description: - Credential to use for job, only used if prompt for credential is set. - type: str + type: list + aliases: ['credential'] extra_vars: description: - extra_vars to use for the Job Template. Prepend C(@) if a file. @@ -55,6 +57,27 @@ options: description: - Specific tags to use for from playbook. type: list + scm_branch: + description: + - A specific of the SCM project to run the template on. + - This is only applicable if your project allows for branch override + type: str + skip_tags: + decription: + - Specific tags to skip from the playbook. + type: list + verbosity: + description: + - Verbosity level for this job run + tpye: int + choices: [ 0, 1, 2, 3, 4, 5 ] + diff_mode: + description: + - Show the changes made by Ansible tasks where supported + type: bool + credential_passwords: + description: + - Passwords for credentials which are set to prompt on launch extends_documentation_fragment: awx.awx.auth ''' @@ -105,92 +128,102 @@ status: sample: pending ''' -import json - -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 - - -def update_fields(module, p): - params = p.copy() - - params_update = {} - job_template = params.get('job_template') - extra_vars = params.get('extra_vars') - try: - job_template_to_launch = tower_cli.get_resource('job_template').get(name=job_template) - except (exc.NotFound) as excinfo: - module.fail_json(msg='Unable to launch job, job_template/{0} was not found: {1}'.format(job_template, excinfo), changed=False) - - ask_extra_vars = job_template_to_launch['ask_variables_on_launch'] - survey_enabled = job_template_to_launch['survey_enabled'] - - if extra_vars and (ask_extra_vars or survey_enabled): - params_update['extra_vars'] = [json.dumps(extra_vars)] - - elif extra_vars: - module.fail_json(msg="extra_vars is set on launch but the Job Template does not have ask_extra_vars or survey_enabled set to True.") - - params.update(params_update) - return params - +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( - job_template=dict(required=True, type='str'), - job_type=dict(choices=['run', 'check']), - inventory=dict(type='str', default=None), - credential=dict(type='str', default=None), + name=dict(type=str, required=True, aliases=['job_template']), + job_type=dict(type=str, choices=['run', 'check']), + inventory=dict(type=str, default=None), + # Credentials will be a str instead of a list for backwards compatability + credentials=dict(type='list', default=None, aliases=['credential']), limit=dict(), tags=dict(type='list'), - extra_vars=dict(type='dict', required=False), + extra_vars=dict(type=dict, required=False), + scm_branch=dict(type=str, required=False), + skip_tags=dict(type=list, required=False), + verbosity=dict(type=int, required=False, choices=[0,1,2,3,4,5]), + diff_mode=dict(type=bool, required=False), + credential_passwords=dict(type=dict, required=False), ) - module = TowerModule( - argument_spec=argument_spec, - supports_check_mode=True - ) + # Create a module for ourselves + module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) - json_output = {} - tags = module.params.get('tags') + optional_args = {} + # Extract our parameters + name = module.params.get('name') + optional_args['job_type'] = module.params.get('job_type') + inventory = module.params.get('inventory') + credentials = module.params.get('credentials') + optional_args['limit'] = module.params.get('limit') + optional_args['tags'] = module.params.get('tags') + optional_args['extra_vars'] = module.params.get('extra_vars') + optional_args['scm_branch'] = module.params.get('scm_branch') + optional_args['skip_tags'] = module.params.get('skip_tags') + optional_args['verbosity'] = module.params.get('verbosity') + optional_args['diff_mode'] = module.params.get('diff_mode') + optional_args['credential_passwords'] = module.params.get('credential_passwords') - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - try: - params = module.params.copy() - if isinstance(tags, list): - params['tags'] = ','.join(tags) - job = tower_cli.get_resource('job') + # Create a datastructure to pass into our job launch + post_data = {} + for key in optional_args.keys(): + if optional_args[key]: + post_data[key] = optional_args[key] - params = update_fields(module, params) + # Attempt to look up the related items the user specified (these will fail the module if not found) + if inventory: + post_data['inventory'] = module.resolve_name_to_id('inventories', inventory) - lookup_fields = ('job_template', 'inventory', 'credential') - for field in lookup_fields: - try: - name = params.pop(field) - if name: - result = tower_cli.get_resource(field).get(name=name) - params[field] = result['id'] - except exc.NotFound as excinfo: - module.fail_json(msg='Unable to launch job, {0}/{1} was not found: {2}'.format(field, name, excinfo), changed=False) + if credentials: + post_data['credentials'] = [] + for credential in credentials: + post_data['credentials'].append( module.resolve_name_to_id('credentials', credential) ) - result = job.launch(no_input=True, **params) - json_output['id'] = result['id'] - json_output['status'] = result['status'] - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - module.fail_json(msg='Unable to launch job: {0}'.format(excinfo), changed=False) + # Attempt to look up job_template based on the provided name + job_template = module.get_one('job_templates', **{ + 'data': { + 'name': name, + } + }) - json_output['changed'] = result['changed'] - module.exit_json(**json_output) + if job_template == None: + module.fail_json(msg="Unable to find job template by name {0}".format(name)) + # The API will allow you to submit values to a jb launch that are not prompt on launch. + # Therefore, we will test to see if anything is set which is not prompt on launch and fail. + check_vars_to_prompts = { + 'scm_branch': 'ask_scm_branch_on_launch', + 'diff_mode': 'ask_diff_mode_on_launch', + 'extra_vars': 'ask_variables_on_launch', + 'limit': 'ask_limit_on_launch', + 'tags': 'ask_tags_on_launch', + 'skip_tags': 'ask_skip_tags_on_launch', + 'job_type': 'ask_job_type_on_launch', + 'verbosity': 'ask_verbosity_on_launch', + 'inventory': 'ask_inventory_on_launch', + 'credentials': 'ask_credential_on_launch', + } + + param_errors = [] + for variable_name in check_vars_to_prompts: + if module.params.get(variable_name) and not job_template[check_vars_to_prompts[variable_name]]: + param_errors.append("The field {0} was specified but the job template does not allow for it to be overridden".format(variable_name)) + if len(param_errors) > 0: + module.fail_json(msg="Parameters specified which can not be passed into job template, see errors for details", **{ 'errors': param_errors }) + + # Launch the job + results = module.post_endpoint(job_template['related']['launch'], **{ 'data': post_data }) + + if results['status_code'] != 201: + module.fail_json(msg="Failed to launch job, see response for details", **{'response': results }) + + module.exit_json(**{ + 'changed': True, + 'id': results['json']['id'], + 'status': results['json']['status'], + }) if __name__ == '__main__': main() From 4fc2c58ae7ee6bec3376f5adadde81ff1001c6da Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 5 Feb 2020 21:17:09 -0500 Subject: [PATCH 28/45] Converted tower_job_cancel --- .../plugins/modules/tower_job_cancel.py | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/awx_collection/plugins/modules/tower_job_cancel.py b/awx_collection/plugins/modules/tower_job_cancel.py index f65218b68a..56d0a8c630 100644 --- a/awx_collection/plugins/modules/tower_job_cancel.py +++ b/awx_collection/plugins/modules/tower_job_cancel.py @@ -30,7 +30,7 @@ options: type: int fail_if_not_running: description: - - Fail loudly if the I(job_id) does not reference a running job. + - Fail loudly if the I(job_id) can not be canceled default: False type: bool extends_documentation_fragment: awx.awx.auth @@ -48,54 +48,54 @@ id: returned: success type: int sample: 94 -status: - description: status of the cancel request - returned: success - type: str - sample: canceled ''' -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( job_id=dict(type='int', required=True), fail_if_not_running=dict(type='bool', default=False), ) + # Create a module for ourselves module = TowerModule( argument_spec=argument_spec, supports_check_mode=True, ) + # Extract our parameters job_id = module.params.get('job_id') - json_output = {} + fail_if_not_running = module.params.get('fail_if_not_running') - tower_auth = tower_auth_config(module) - with settings.runtime_values(**tower_auth): - tower_check_mode(module) - job = tower_cli.get_resource('job') - params = module.params.copy() + # Attempt to look up the job based on the provided name + job = module.get_one('jobs', **{ + 'data': { + 'id': job_id, + } + }) - try: - result = job.cancel(job_id, **params) - json_output['id'] = job_id - except (exc.ConnectionError, exc.BadRequest, exc.TowerCLIError, exc.AuthError) as excinfo: - module.fail_json(msg='Unable to cancel job_id/{0}: {1}'.format(job_id, excinfo), changed=False) + if job == None: + module.fail_json(msg="Unable to find job with id {0}".format(job_id)) - json_output['changed'] = result['changed'] - json_output['status'] = result['status'] - module.exit_json(**json_output) + cancel_page = module.get_endpoint(job['related']['cancel']) + if 'json' not in cancel_page or 'can_cancel' not in cancel_page['json']: + module.fail_json(msg="Failed to cancel job, got unexpected response from tower", **{ 'response': cancel_page }) + + if not cancel_page['json']['can_cancel']: + if fail_if_not_running: + module.fail_json(msg="Job is not running") + else: + module.exit_json(**{ 'changed': False }) + + response = module.post_endpoint(job['related']['cancel'], **{ 'data': {} }) + + if response['status_code'] != 202: + module.fail_json(msg="Failed to cancel job, see response for details", **{'response': results }) + + module.exit_json(**{ 'changed': True }) if __name__ == '__main__': From 018dd4c1c333845669d5860fd2199aeb05ee2d78 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Thu, 6 Feb 2020 11:07:19 -0500 Subject: [PATCH 29/45] Fixing config loading issues when the config has no [general] section Fixed typo in help documentation Fix up sanity errors and update converted modules Remove unnecessary test playbook file --- .../plugins/module_utils/tower_api.py | 8 ++-- .../plugins/modules/tower_job_cancel.py | 20 ++++++--- .../plugins/modules/tower_job_launch.py | 44 +++++++++++-------- .../plugins/modules/tower_organization.py | 5 --- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index d0ad303e31..78fad67838 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -7,7 +7,7 @@ from ansible.module_utils.six import PY2 from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode 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 +from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError, MissingSectionHeaderError from socket import gethostbyname import re from json import loads, dumps @@ -137,8 +137,10 @@ class TowerModule(AnsibleModule): if not access(config_path, R_OK): raise ConfigFileException("The specified config file can not be read") - config.read(config_path) - if not config.has_section('general'): + # If the config has not sections we will get a MissingSectionHeaderError + try: + config.read(config_path) + except MissingSectionHeaderError: self.warn("No general section in file, auto-appending") with open(config_path, 'r') as f: config.read_string('[general]\n%s' % f.read()) diff --git a/awx_collection/plugins/modules/tower_job_cancel.py b/awx_collection/plugins/modules/tower_job_cancel.py index 56d0a8c630..f2a5a4f560 100644 --- a/awx_collection/plugins/modules/tower_job_cancel.py +++ b/awx_collection/plugins/modules/tower_job_cancel.py @@ -33,6 +33,11 @@ options: - Fail loudly if the I(job_id) can not be canceled default: False type: bool + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -53,6 +58,7 @@ id: 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( @@ -77,25 +83,25 @@ def main(): } }) - if job == None: + if job is None: module.fail_json(msg="Unable to find job with id {0}".format(job_id)) cancel_page = module.get_endpoint(job['related']['cancel']) if 'json' not in cancel_page or 'can_cancel' not in cancel_page['json']: - module.fail_json(msg="Failed to cancel job, got unexpected response from tower", **{ 'response': cancel_page }) + module.fail_json(msg="Failed to cancel job, got unexpected response from tower", **{'response': cancel_page}) if not cancel_page['json']['can_cancel']: if fail_if_not_running: module.fail_json(msg="Job is not running") else: - module.exit_json(**{ 'changed': False }) + module.exit_json(**{'changed': False}) - response = module.post_endpoint(job['related']['cancel'], **{ 'data': {} }) + results = module.post_endpoint(job['related']['cancel'], **{'data': {}}) - if response['status_code'] != 202: - module.fail_json(msg="Failed to cancel job, see response for details", **{'response': results }) + if results['status_code'] != 202: + module.fail_json(msg="Failed to cancel job, see response for details", **{'response': results}) - module.exit_json(**{ 'changed': True }) + module.exit_json(**{'changed': True}) if __name__ == '__main__': diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py index 8afe95d663..de1c8455d2 100644 --- a/awx_collection/plugins/modules/tower_job_launch.py +++ b/awx_collection/plugins/modules/tower_job_launch.py @@ -60,16 +60,16 @@ options: scm_branch: description: - A specific of the SCM project to run the template on. - - This is only applicable if your project allows for branch override + - This is only applicable if your project allows for branch override. type: str skip_tags: - decription: + description: - Specific tags to skip from the playbook. type: list verbosity: description: - Verbosity level for this job run - tpye: int + type: int choices: [ 0, 1, 2, 3, 4, 5 ] diff_mode: description: @@ -78,6 +78,12 @@ options: credential_passwords: description: - Passwords for credentials which are set to prompt on launch + type: dict + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str extends_documentation_fragment: awx.awx.auth ''' @@ -130,22 +136,23 @@ status: 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(type=str, required=True, aliases=['job_template']), - job_type=dict(type=str, choices=['run', 'check']), - inventory=dict(type=str, default=None), + name=dict(type='str', required=True, aliases=['job_template']), + job_type=dict(type='str', choices=['run', 'check']), + inventory=dict(type='str', default=None), # Credentials will be a str instead of a list for backwards compatability credentials=dict(type='list', default=None, aliases=['credential']), limit=dict(), tags=dict(type='list'), - extra_vars=dict(type=dict, required=False), - scm_branch=dict(type=str, required=False), - skip_tags=dict(type=list, required=False), - verbosity=dict(type=int, required=False, choices=[0,1,2,3,4,5]), - diff_mode=dict(type=bool, required=False), - credential_passwords=dict(type=dict, required=False), + extra_vars=dict(type='dict', required=False), + scm_branch=dict(type='str', required=False), + skip_tags=dict(type='list', required=False), + verbosity=dict(type='int', required=False, choices=[0, 1, 2, 3, 4, 5]), + diff_mode=dict(type='bool', required=False), + credential_passwords=dict(type='dict', required=False), ) # Create a module for ourselves @@ -179,7 +186,7 @@ def main(): if credentials: post_data['credentials'] = [] for credential in credentials: - post_data['credentials'].append( module.resolve_name_to_id('credentials', credential) ) + post_data['credentials'].append(module.resolve_name_to_id('credentials', credential)) # Attempt to look up job_template based on the provided name job_template = module.get_one('job_templates', **{ @@ -188,7 +195,7 @@ def main(): } }) - if job_template == None: + if job_template is None: module.fail_json(msg="Unable to find job template by name {0}".format(name)) # The API will allow you to submit values to a jb launch that are not prompt on launch. @@ -211,13 +218,13 @@ def main(): if module.params.get(variable_name) and not job_template[check_vars_to_prompts[variable_name]]: param_errors.append("The field {0} was specified but the job template does not allow for it to be overridden".format(variable_name)) if len(param_errors) > 0: - module.fail_json(msg="Parameters specified which can not be passed into job template, see errors for details", **{ 'errors': param_errors }) + module.fail_json(msg="Parameters specified which can not be passed into job template, see errors for details", **{'errors': param_errors}) # Launch the job - results = module.post_endpoint(job_template['related']['launch'], **{ 'data': post_data }) - + results = module.post_endpoint(job_template['related']['launch'], **{'data': post_data}) + if results['status_code'] != 201: - module.fail_json(msg="Failed to launch job, see response for details", **{'response': results }) + module.fail_json(msg="Failed to launch job, see response for details", **{'response': results}) module.exit_json(**{ 'changed': True, @@ -225,5 +232,6 @@ def main(): 'status': results['json']['status'], }) + if __name__ == '__main__': main() diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 73c49eaa10..b91d30653a 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -101,11 +101,6 @@ def main(): # instance_group_names = module.params.get('instance_groups') state = module.params.get('state') - # Attempt to look up the related items the user specified (these will fail the module if not found) - # instance_group_objects = [] - # for instance_name in instance_group_names: - # instance_group_objects.append(module.resolve_name_to_id('instance_groups', instance_name)) - # Attempt to look up organization based on the provided name organization = module.get_one('organizations', **{ 'data': { From c32452d6b6efaf1beb2643ff154336a6c5ef79ba Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 7 Feb 2020 10:31:43 -0500 Subject: [PATCH 30/45] Fix Python2 config incompatibility issue --- awx_collection/plugins/module_utils/tower_api.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 78fad67838..fadc6b39bb 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -4,6 +4,7 @@ __metaclass__ = type from ansible.module_utils.basic import AnsibleModule, env_fallback from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError from ansible.module_utils.six import PY2 +from ansible.module_utils.six.moves import StringIO from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.six.moves.http_cookiejar import CookieJar @@ -137,13 +138,17 @@ class TowerModule(AnsibleModule): if not access(config_path, R_OK): raise ConfigFileException("The specified config file can not be read") - # If the config has not sections we will get a MissingSectionHeaderError + # If the config has no sections we will get a MissingSectionHeaderError try: config.read(config_path) except MissingSectionHeaderError: - self.warn("No general section in file, auto-appending") with open(config_path, 'r') as f: - config.read_string('[general]\n%s' % f.read()) + config_string = '[general]\n%s' % f.read() + placeholder_file = StringIO(config_string) + if hasattr(config, 'read_file'): + config.read_file(placeholder_file) + else: + config.readfp(placeholder_file) for honorred_setting in self.honorred_settings: try: From 3423db6ed08f8310fd4d16cd7b1de4e3251f5904 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 7 Feb 2020 12:38:28 -0500 Subject: [PATCH 31/45] Attempt to make validate_certs work in Python2 --- awx_collection/plugins/module_utils/tower_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index fadc6b39bb..5ce6120405 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -102,7 +102,7 @@ class TowerModule(AnsibleModule): 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) + self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl) def load_config_files(self): # Load configs like TowerCLI would have from least import to most From 232ea1e50c34d488c493608a4fb51c7514522272 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 7 Feb 2020 15:35:23 -0500 Subject: [PATCH 32/45] Properly cast verify_ssl type to a bool --- awx_collection/plugins/module_utils/tower_api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 5ce6120405..0a258c6af5 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -14,6 +14,7 @@ import re from json import loads, dumps from os.path import isfile, expanduser, split, join, exists, isdir from os import access, R_OK, getcwd +from distutils.util import strtobool try: import yaml @@ -153,6 +154,10 @@ class TowerModule(AnsibleModule): for honorred_setting in self.honorred_settings: try: setattr(self, honorred_setting, config.get('general', honorred_setting)) + if honorred_setting == 'verify_ssl': + setattr(self, honorred_setting, strtobool(config.get('general', honorred_setting))) + else: + setattr(self, honorred_setting, config.get('general', honorred_setting)) except (NoOptionError): pass From 0685b2fa356924ca2b8c5dea8d94269e703881e4 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sat, 8 Feb 2020 07:24:40 -0500 Subject: [PATCH 33/45] Updates to config file loading Now supports json or yaml Depricated multiple k=v on a single line Remove assert statement and unused import from module_util --- awx_collection/README.md | 3 +- .../plugins/module_utils/tower_api.py | 67 +++++++++++++------ awx_collection/tests/sanity/ignore-2.9.txt | 3 +- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/awx_collection/README.md b/awx_collection/README.md index 2ddaf994b5..a5d438ba45 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -22,7 +22,8 @@ The following notes are changes that may require changes to playbooks. - Creating a "scan" type job template is no longer supported. - `extra_vars` in the `tower_job_launch` module worked with a list previously, but is now configured to work solely in a `dict` format. - When the `extra_vars` parameter is used with the `tower_job_launch` module, the Job Template launch will fail unless `add_extra_vars` or `survey_enabled` is explicitly set to `True` on the Job Template. - - tower_group used to also service inventory sources. tower_inventory_source has been split out into its own module. + - tower_group used to also service inventory sources, this functionality has been removed from this module; instead use tower_inventory_source. + - Specified tower_config file used to handle k=v pairs on a single line. This is no longer supported. You may a file formatted in: yaml, json or ini only. ## Running diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 0a258c6af5..7c61e16928 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -8,7 +8,7 @@ from ansible.module_utils.six.moves import StringIO from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode 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, MissingSectionHeaderError +from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError from socket import gethostbyname import re from json import loads, dumps @@ -131,7 +131,6 @@ class TowerModule(AnsibleModule): self.fail_json(msg=cfe) def load_config(self, config_path): - config = ConfigParser() # Validate the config file is an actual file if not isfile(config_path): raise ConfigFileException('The specified config file does not exist') @@ -139,27 +138,55 @@ class TowerModule(AnsibleModule): if not access(config_path, R_OK): raise ConfigFileException("The specified config file can not be read") - # If the config has no sections we will get a MissingSectionHeaderError - try: - config.read(config_path) - except MissingSectionHeaderError: - with open(config_path, 'r') as f: - config_string = '[general]\n%s' % f.read() - placeholder_file = StringIO(config_string) - if hasattr(config, 'read_file'): - config.read_file(placeholder_file) - else: - config.readfp(placeholder_file) + # Read in the file contents: + with open(config_path, 'r') as f: + config_string = f.read() + + # First try to yaml load the content (which will also load json) + try: + config_data = yaml.load(config_string, Loader=yaml.SafeLoader) + # If this is an actual ini file, yaml will return the whole thing as a string instead of a dict + if type(config_data) is not dict: + raise AssertionError("The yaml config file is not properly formatted as a dict.") + + except(AttributeError, yaml.YAMLError, AssertionError): + # TowerCLI used to support a config file with a missing [general] section by prepending it if missing + if '[general]' not in config_string: + config_string = '[general]{0}'.format(config_string) + + config = ConfigParser() - for honorred_setting in self.honorred_settings: try: - setattr(self, honorred_setting, config.get('general', honorred_setting)) - if honorred_setting == 'verify_ssl': - setattr(self, honorred_setting, strtobool(config.get('general', honorred_setting))) + placeholder_file = StringIO(config_string) + # py2 ConfigParser has readfp, that has been deprecated in favor of read_file in py3 + # This "if" removes the deprecation warning + if hasattr(config, 'read_file'): + config.read_file(placeholder_file) else: - setattr(self, honorred_setting, config.get('general', honorred_setting)) - except (NoOptionError): - pass + config.readfp(placeholder_file) + + # If we made it here then we have values from reading the ini file, so lets pull them out into a dict + config_data = {} + for honorred_setting in self.honorred_settings: + try: + config_data[honorred_setting] = config.get('general', honorred_setting) + except (NoOptionError): + pass + + except Exception as e: + raise ConfigFileException("An unknown exception occured trying to ini load config file: {0}".format(e)) + + except Exception as e: + raise ConfigFileException("An unknown exception occured trying to load config file: {0}".format(e)) + + # If we made it here, we have a dict which has values in it from our config, any final settings logic can be performed here + for honorred_setting in self.honorred_settings: + if honorred_setting in config_data: + # Veriffy SSL must be a boolean + if honorred_setting == 'verify_ssl': + setattr(self, honorred_setting, strtobool(config_data[honorred_setting])) + else: + setattr(self, honorred_setting, config_data[honorred_setting]) def head_endpoint(self, endpoint, *args, **kwargs): return self.make_request('HEAD', endpoint, **kwargs) diff --git a/awx_collection/tests/sanity/ignore-2.9.txt b/awx_collection/tests/sanity/ignore-2.9.txt index fa05535904..c9df9574f5 100644 --- a/awx_collection/tests/sanity/ignore-2.9.txt +++ b/awx_collection/tests/sanity/ignore-2.9.txt @@ -1,2 +1 @@ -plugins/modules/tower_group.py use-argspec-type-path -plugins/modules/tower_host.py use-argspec-type-path \ No newline at end of file +plugins/modules/tower_host.py use-argspec-type-path From 94df58a55b5f59fa308f5a7f662c5f787eb6879c Mon Sep 17 00:00:00 2001 From: Caleb Boylan Date: Mon, 10 Feb 2020 09:40:47 -0800 Subject: [PATCH 34/45] Fix strtobool casting --- awx_collection/plugins/module_utils/tower_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 7c61e16928..b759489dbd 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -184,7 +184,10 @@ class TowerModule(AnsibleModule): if honorred_setting in config_data: # Veriffy SSL must be a boolean if honorred_setting == 'verify_ssl': - setattr(self, honorred_setting, strtobool(config_data[honorred_setting])) + if type(config_data[honorred_setting]) is str: + setattr(self, honorred_setting, strtobool(config_data[honorred_setting])) + else: + setattr(self, honorred_setting, bool(config_data[honorred_setting])) else: setattr(self, honorred_setting, config_data[honorred_setting]) From fcc679489ea1e421cc22502fd8feb2ede2fcc33c Mon Sep 17 00:00:00 2001 From: beeankha Date: Tue, 11 Feb 2020 16:32:41 -0500 Subject: [PATCH 35/45] Update inventory_source module source_script parameter to be optional Unitied comment Fix up inventory_source example, misc comment edits --- awx_collection/plugins/modules/tower_group.py | 2 +- awx_collection/plugins/modules/tower_host.py | 2 +- .../plugins/modules/tower_inventory.py | 2 +- .../plugins/modules/tower_inventory_source.py | 28 +++++++++++-------- .../plugins/modules/tower_job_launch.py | 7 ----- .../plugins/modules/tower_organization.py | 1 + .../plugins/modules/tower_project.py | 1 + .../plugins/modules/tower_settings.py | 2 -- awx_collection/plugins/modules/tower_team.py | 2 +- awx_collection/plugins/modules/tower_user.py | 2 ++ 10 files changed, 24 insertions(+), 25 deletions(-) diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index ccfd7dc2bd..0dfa3845f0 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -111,7 +111,7 @@ def main(): if variables: variables = module.load_variables_if_file_specified(variables, 'variables') - # Create data to sent to create and update + # Create the data that gets sent for create and update group_fields = { 'name': new_name if new_name else name, 'inventory': inventory_id, diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index c416e020d6..4443b2ceff 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -125,7 +125,7 @@ def main(): } }) - # Create data to send to create and update + # Create the data that gets sent for create and update host_fields = { 'name': new_name if new_name else name, 'description': description, diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index e52771ec63..e0b7a4f0da 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -117,7 +117,7 @@ def main(): } }) - # Create the data sent to create and update + # Create the data that gets sent for create and update inventory_fields = { 'name': name, 'description': description, diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index f341f70d1b..9332ea125b 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -56,6 +56,7 @@ options: description: - Inventory script to be used when group type is C(custom). type: str + required: False source_vars: description: - The variables or environment fields to apply to this source type. @@ -130,13 +131,16 @@ extends_documentation_fragment: awx.awx.auth ''' EXAMPLES = ''' -- name: Add tower group - tower_group: - name: localhost - description: "Local Host Group" - inventory: "Local Inventory" - state: present - tower_config_file: "~/tower_cli.cfg" +- name: Add an inventory source + tower_inventory_source: + name: "source-inventory" + description: Source for inventory + inventory: previously-created-inventory + credential: previously-created-credential + overwrite: True + update_on_launch: True + source_vars: + private: false ''' from ..module_utils.tower_api import TowerModule @@ -157,7 +161,7 @@ def main(): "azure_rm", "vmware", "satellite6", "cloudforms", "openstack", "rhv", "tower", "custom"], required=False), source_path=dict(), - source_script=dict(), + source_script=dict(required=False), source_vars=dict(type='dict'), credential=dict(), source_regions=dict(), @@ -176,7 +180,7 @@ def main(): ) # One question here is do we want to end up supporting this within the ansible module itself (i.e. required if, etc) - # Or do we want to let the API return issues with "this dosen't support that", etc. + # Or do we want to let the API return issues with "this doesn't support that", etc. # # GUI OPTIONS: # - - - - - - - manual: file: scm: ec2: gce azure_rm vmware sat cloudforms openstack rhv tower custom @@ -323,7 +327,7 @@ def main(): if optional_vars['source_vars']: optional_vars['source_vars'] = dumps(optional_vars['source_vars']) - # Attempt to lookup the related items the user specified (these will fail the module if not found) + # Attempt to look up the related items the user specified (these will fail the module if not found) inventory_id = module.resolve_name_to_id('inventories', inventory) if credential: optional_vars['credential'] = module.resolve_name_to_id('credentials', credential) @@ -332,7 +336,7 @@ def main(): if source_script: optional_vars['source_script'] = module.resolve_name_to_id('inventory_scripts', source_script) - # Attempt to lookup team based on the provided name and org ID + # Attempt to look up inventory source based on the provided name and inventory ID inventory_source = module.get_one('inventory_sources', **{ 'data': { 'name': name, @@ -344,7 +348,7 @@ def main(): if state == 'present' and not inventory_source and not optional_vars['source']: module.fail_json(msg="If creating a new inventory source, the source param must be present") - # Create data to sent to create and update + # Create the data that gets sent for create and update inventory_source_fields = { 'name': new_name if new_name else name, 'inventory': inventory_id, diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py index de1c8455d2..3ce7ecafc1 100644 --- a/awx_collection/plugins/modules/tower_job_launch.py +++ b/awx_collection/plugins/modules/tower_job_launch.py @@ -88,17 +88,11 @@ extends_documentation_fragment: awx.awx.auth ''' EXAMPLES = ''' -# Launch a job template - name: Launch a job tower_job_launch: job_template: "My Job Template" register: job -- name: Wait for job max 120s - tower_job_wait: - job_id: "{{ job.id }}" - timeout: 120 - - name: Launch a job template with extra_vars on remote Tower instance tower_job_launch: job_template: "My Job Template" @@ -108,7 +102,6 @@ EXAMPLES = ''' var3: "My Third Variable" job_type: run -# Launch job template with inventory and credential for prompt on launch - name: Launch a job with inventory and credential tower_job_launch: job_template: "My Job Template" diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index b91d30653a..2b0e4e925f 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -108,6 +108,7 @@ def main(): } }) + # Create the data that gets sent for create and update org_fields = {'name': name} if description: org_fields['description'] = description diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index c33235a523..6bb13dc3d2 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -232,6 +232,7 @@ def main(): } }) + # Create the data that gets sent for create and update project_fields = { 'name': name, 'description': description, diff --git a/awx_collection/plugins/modules/tower_settings.py b/awx_collection/plugins/modules/tower_settings.py index 8b73b1a212..67c88bd80a 100644 --- a/awx_collection/plugins/modules/tower_settings.py +++ b/awx_collection/plugins/modules/tower_settings.py @@ -46,8 +46,6 @@ options: extends_documentation_fragment: awx.awx.auth ''' -RETURN = ''' # ''' - EXAMPLES = ''' - name: Set the value of AWX_PROOT_BASE_PATH tower_settings: diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 2f059d9eb5..1f14c38292 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -101,7 +101,7 @@ def main(): } }) - # Create data to sent to create and update + # Create the data that gets sent for create and update team_fields = { 'name': new_name if new_name else name, 'description': description, diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index 7c03e6c2f4..acbdf75891 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -128,6 +128,8 @@ def main(): # Extract our parameters state = module.params.get('state') + + # Create the data that gets sent for create and update user_fields = { 'username': module.params.get('username'), 'first_name': module.params.get('first_name'), From 6591efc160cbed71feaf28b5110e80c8bbc98a54 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 12 Feb 2020 21:08:29 -0500 Subject: [PATCH 36/45] Fixed issue that caused warning message to always display Because scm_update_cache_timeout has a default and thus will always be != None --- awx_collection/plugins/modules/tower_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 6bb13dc3d2..6db62f3292 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -255,7 +255,7 @@ def main(): if scm_type == '': project_fields['local_path'] = local_path - if state != 'absent' and (scm_update_cache_timeout is not None and scm_update_on_launch is not True): + if state != 'absent' and (scm_update_cache_timeout != 0 and scm_update_on_launch is not True): module.warn('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true') # If we are doing a not manual project, register our on_change method From 0eef67713f0ef71f0ffb6bc2833ffc5fd1286e9f Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 12 Feb 2020 21:09:06 -0500 Subject: [PATCH 37/45] Only try an ID lookup if we can convert the field name_or_id to an integer Fix linting issues, update tower_project unit test --- .../plugins/module_utils/tower_api.py | 66 ++++++++++--------- awx_collection/test/awx/test_project.py | 3 +- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index b759489dbd..e03fbc06ac 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -127,7 +127,7 @@ class TowerModule(AnsibleModule): try: self.load_config(self.params.get('tower_config_file')) except ConfigFileException as cfe: - # Since we were told specifically to load this we want to fail if we have an error + # Since we were told specifically to load this we want it to fail if we have an error self.fail_json(msg=cfe) def load_config(self, config_path): @@ -165,7 +165,7 @@ class TowerModule(AnsibleModule): else: config.readfp(placeholder_file) - # If we made it here then we have values from reading the ini file, so lets pull them out into a dict + # If we made it here then we have values from reading the ini file, so let's pull them out into a dict config_data = {} for honorred_setting in self.honorred_settings: try: @@ -255,16 +255,22 @@ class TowerModule(AnsibleModule): if response['json']['count'] == 1: return response['json']['results'][0]['id'] elif response['json']['count'] == 0: - # If we got 0 items by name, maybe they gave us an ID, lets try looking it by by ID - response = self.head_endpoint("{0}/{1}".format(endpoint, name_or_id), **{'return_none_on_404': True}) - if response is not None: - return name_or_id + try: + int(name_or_id) + # If we got 0 items by name, maybe they gave us an ID, let's try looking it up by ID + response = self.head_endpoint("{0}/{1}".format(endpoint, name_or_id), **{'return_none_on_404': True}) + if response is not None: + return name_or_id + except ValueError: + # If we got a value error than we didn't have an integer so we can just pass and fall down to the fail + pass + self.fail_json(msg="The {0} {1} was not found on the Tower server".format(endpoint, name_or_id)) else: self.fail_json(msg="Found too many names {0} at endpoint {1} try using an ID instead of a name".format(name_or_id, endpoint)) def make_request(self, method, endpoint, *args, **kwargs): - # In case someone is calling us directly; make sure we were given a method, lets not just assume a GET + # In case someone is calling us directly; make sure we were given a method, let's not just assume a GET if not method: raise Exception("The HTTP method must be defined") @@ -284,7 +290,7 @@ class TowerModule(AnsibleModule): # 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 + # If we have a oauth token, we just use a bearer header headers['Authorization'] = 'Bearer {0}'.format(self.oauth_token) # Update the URL path with the endpoint @@ -337,10 +343,8 @@ class TowerModule(AnsibleModule): # 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 + # 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)) @@ -369,7 +373,7 @@ class TowerModule(AnsibleModule): 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 + # If we have a username and password, we need to get a session cookie login_data = { "description": "Ansible Tower Module Token", "application": None, @@ -398,7 +402,7 @@ class TowerModule(AnsibleModule): except(Exception) as e: self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{'response': token_response}) - # If we have neiter of these then we can try un-authenticated access + # If we have neither of these, then we can try un-authenticated access self.authenticated = True def default_check_mode(self): @@ -412,11 +416,11 @@ class TowerModule(AnsibleModule): def delete_if_needed(self, existing_item, handle_response=True, on_delete=None): # This will exit from the module on its own unless handle_response is False. - # If handle response is True and the method successfully deletes an item and on_delete param is defined + # If handle_response is True and the method successfully deletes an item and on_delete param is defined, # the on_delete parameter will be called as a method pasing in this object and the json from the response - # If you pass handle_response=False it will return one of two things: - # None if the existing_item is not defined (so no delete needs to happen) - # The response from Tower from calling the delete on the endpont. Its up to you to process the response and exit from the module + # If you pass handle_response=False, it will return one of two things: + # 1. None if the existing_item is not defined (so no delete needs to happen) + # 2. The response from Tower from calling the delete on the endpont. It's up to you to process the response and exit from the module # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False if existing_item: # If we have an item, we can try to delete it @@ -448,7 +452,7 @@ class TowerModule(AnsibleModule): if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) elif 'json' in response: - # This is from a project delete if there is an active job against it + # This is from a project delete (if there is an active job against it) if 'error' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['error'])) else: @@ -464,11 +468,11 @@ class TowerModule(AnsibleModule): def create_if_needed(self, existing_item, new_item, endpoint, handle_response=True, on_create=None, item_type='unknown'): # # This will exit from the module on its own unless handle_response is False. - # If handle response is True and the method successfully creates an item and on_create param is defined + # If handle_response is True and the method successfully creates an item and on_create param is defined, # the on_create parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of two things: - # None if the existing_item is already defined (so no create needs to happen) - # The response from Tower from calling the patch on the endpont. Its up to you to process the response and exit from the module + # 1. None if the existing_item is already defined (so no create needs to happen) + # 2. The response from Tower from calling the patch on the endpont. It's up to you to process the response and exit from the module # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False # if not endpoint: @@ -484,10 +488,10 @@ class TowerModule(AnsibleModule): else: self.exit_json(**self.json_output) else: - # If we dont have an exisitng_item, we can try to create it + # If we don't have an exisitng_item, we can try to create it # We have to rely on item_type being passed in since we don't have an existing item that declares its type - # The item_name we will pull out from the new_item (if it exists) + # We will pull the item_name out from the new_item, if it exists item_name = new_item.get('name', 'unknown') response = self.post_endpoint(endpoint, **{'data': new_item}) @@ -516,15 +520,15 @@ class TowerModule(AnsibleModule): def update_if_needed(self, existing_item, new_item, handle_response=True, on_update=None): # This will exit from the module on its own unless handle_response is False. - # If handle response is True and the method successfully updates an item and on_update param is defined + # If handle_response is True and the method successfully updates an item and on_update param is defined, # the on_update parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of three things: - # None if the existing_item does not need to be updated - # The response from Tower from patching to the endpoint. Its up to you to process the response and exit from the module. - # an ItemNotDefined exception if the existing_item does not exist + # 1. None if the existing_item does not need to be updated + # 2. The response from Tower from patching to the endpoint. It's up to you to process the response and exit from the module. + # 3. An ItemNotDefined exception, if the existing_item does not exist # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False if existing_item: - # If we have an item, we can see if needs an update + # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] item_name = existing_item['name'] @@ -539,7 +543,7 @@ class TowerModule(AnsibleModule): new_field = new_item.get(field, None) # If the two items don't match and we are not comparing '' to None if existing_field != new_field and not (existing_field in (None, '') and new_field == ''): - # something dosent match so lets do it + # Something doesn't match so let's update it needs_update = True break @@ -601,12 +605,12 @@ class TowerModule(AnsibleModule): self.warn('Failed to release tower token {0}: {1}'.format(self.oauth_token_id, e)) def fail_json(self, **kwargs): - # Try to logout if we are authenticated + # Try to log out if we are authenticated self.logout() super(TowerModule, self).fail_json(**kwargs) def exit_json(self, **kwargs): - # Try to logout if we are authenticated + # Try to log out if we are authenticated self.logout() super(TowerModule, self).exit_json(**kwargs) diff --git a/awx_collection/test/awx/test_project.py b/awx_collection/test/awx/test_project.py index ad6f589265..e99afd82dd 100644 --- a/awx_collection/test/awx/test_project.py +++ b/awx_collection/test/awx/test_project.py @@ -13,7 +13,8 @@ def test_create_project(run_module, admin_user, organization): organization=organization.name, scm_type='git', scm_url='https://foo.invalid', - wait=False + wait=False, + scm_update_cache_timeout=5 ), admin_user) warning = ['scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true'] assert result.pop('changed', None), result From 7908f257477548e718debfb5fb3370ae9b8d24d1 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Thu, 13 Feb 2020 14:13:16 -0500 Subject: [PATCH 38/45] Remove reference to default check mode --- awx_collection/plugins/modules/tower_credential_type.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index b6c8806199..50533dbbdc 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -124,9 +124,6 @@ def main(): kind = module.params.get('kind') state = module.params.get('state') - # Deal with check mode - module.default_check_mode() - # These will be passed into the create/updates credential_type_params = { 'name': new_name if new_name else name, From badd667efa05a894204ea3c54402f91ed015430c Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Thu, 13 Feb 2020 14:14:29 -0500 Subject: [PATCH 39/45] Removing manual and file source types and correcting default for custom_virtualenv --- awx_collection/plugins/modules/tower_inventory_source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index 9332ea125b..c3dbef1168 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -45,7 +45,7 @@ options: source: description: - The source to use for this group. - choices: [ "manual", "file", "scm", "ec2", "gce", "azure_rm", "vmware", "satellite6", "cloudforms", "openstack", "rhv", "tower", "custom" ] + choices: [ "scm", "ec2", "gce", "azure_rm", "vmware", "satellite6", "cloudforms", "openstack", "rhv", "tower", "custom" ] type: str required: False source_path: @@ -157,7 +157,7 @@ def main(): # # How do we handle manual and file? Tower does not seem to be able to activate them # - source=dict(choices=["manual", "file", "scm", "ec2", "gce", + source=dict(choices=["scm", "ec2", "gce", "azure_rm", "vmware", "satellite6", "cloudforms", "openstack", "rhv", "tower", "custom"], required=False), source_path=dict(), @@ -169,7 +169,7 @@ def main(): group_by=dict(), overwrite=dict(type='bool'), overwrite_vars=dict(type='bool'), - custom_virtualenv=dict(type='str'), + custom_virtualenv=dict(type='str', default=''), timeout=dict(type='int'), verbosity=dict(type='int', choices=[0, 1, 2]), update_on_launch=dict(type='bool'), From d8513a4e868c4e9b4115e75d0ec806f7420d4350 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Thu, 13 Feb 2020 16:56:47 -0500 Subject: [PATCH 40/45] Making variables work for hosts Clear up sanity test and remove redundant import statement --- awx_collection/plugins/modules/tower_host.py | 13 +++++-------- awx_collection/tests/sanity/ignore-2.10.txt | 1 - 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index 4443b2ceff..48da007602 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -79,8 +79,6 @@ EXAMPLES = ''' ''' -import os - from ..module_utils.tower_api import TowerModule @@ -106,13 +104,10 @@ def main(): inventory = module.params.get('inventory') enabled = module.params.get('enabled') state = module.params.get('state') - variables = module.params.get('variables') + if variables: - if variables.startswith('@'): - filename = os.path.expanduser(variables[1:]) - with open(filename, 'r') as f: - variables = f.read() + variables = module.load_variables_if_file_specified(variables, 'variables') # Attempt to look up the related items the user specified (these will fail the module if not found) inventory_id = module.resolve_name_to_id('inventories', inventory) @@ -130,8 +125,10 @@ def main(): 'name': new_name if new_name else name, 'description': description, 'inventory': inventory_id, - 'enabled': enabled + 'enabled': enabled, } + if variables: + host_fields['variables'] = variables if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/tests/sanity/ignore-2.10.txt b/awx_collection/tests/sanity/ignore-2.10.txt index c9df9574f5..e69de29bb2 100644 --- a/awx_collection/tests/sanity/ignore-2.10.txt +++ b/awx_collection/tests/sanity/ignore-2.10.txt @@ -1 +0,0 @@ -plugins/modules/tower_host.py use-argspec-type-path From 2e4e687d694c9b559189224e785ae7c104145bb4 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Mon, 17 Feb 2020 09:20:54 -0500 Subject: [PATCH 41/45] Optional tower cli (#3) * Allow running tests without tower_cli * patch up test mutability * Fix test import error, warning mock * flake8 error Update documentation for non-converted modules --- awx_collection/plugins/doc_fragments/auth.py | 3 --- .../plugins/module_utils/tower_api.py | 7 ++--- .../plugins/modules/tower_credential.py | 4 +++ .../plugins/modules/tower_job_template.py | 5 ++++ .../plugins/modules/tower_job_wait.py | 4 +++ awx_collection/plugins/modules/tower_label.py | 4 +++ .../plugins/modules/tower_notification.py | 4 +++ awx_collection/plugins/modules/tower_role.py | 4 +++ .../plugins/modules/tower_workflow_launch.py | 2 ++ .../modules/tower_workflow_template.py | 4 +++ awx_collection/test/awx/conftest.py | 17 ++++++++++-- awx_collection/test/awx/test_group.py | 5 ---- .../test/awx/test_inventory_source.py | 6 ----- awx_collection/test/awx/test_organization.py | 4 --- awx_collection/test/awx/test_project.py | 26 +++++++++---------- awx_collection/test/awx/test_send_receive.py | 5 +++- awx_collection/test/awx/test_team.py | 8 ------ 17 files changed, 67 insertions(+), 45 deletions(-) diff --git a/awx_collection/plugins/doc_fragments/auth.py b/awx_collection/plugins/doc_fragments/auth.py index 4187a17b2a..bc02d5f420 100644 --- a/awx_collection/plugins/doc_fragments/auth.py +++ b/awx_collection/plugins/doc_fragments/auth.py @@ -36,9 +36,6 @@ options: - Path to the Tower or AWX config file. type: path -requirements: -- ansible-tower-cli >= 3.0.2 - notes: - If no I(config_file) is provided we will attempt to use the tower-cli library defaults to find your Tower host information. diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index e03fbc06ac..c954fac4ac 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -33,7 +33,7 @@ class ItemNotDefined(Exception): class TowerModule(AnsibleModule): url = None - honorred_settings = ['host', 'username', 'password', 'verify_ssl', 'oauth_token'] + honorred_settings = ('host', 'username', 'password', 'verify_ssl', 'oauth_token') host = '127.0.0.1' username = None password = None @@ -43,7 +43,6 @@ class TowerModule(AnsibleModule): session = None cookie_jar = CookieJar() authenticated = False - json_output = {'changed': False} config_name = 'tower_cli.cfg' def __init__(self, argument_spec, **kwargs): @@ -58,6 +57,8 @@ class TowerModule(AnsibleModule): args.update(argument_spec) kwargs['supports_check_mode'] = True + self.json_output = {'changed': False} + # We have to take off mutually_exclusive_if in order to init with Ansible mutually_exclusive_if = kwargs.pop('mutually_exclusive_if', None) @@ -136,7 +137,7 @@ class TowerModule(AnsibleModule): raise ConfigFileException('The specified config file does not exist') if not access(config_path, R_OK): - raise ConfigFileException("The specified config file can not be read") + raise ConfigFileException("The specified config file cannot be read") # Read in the file contents: with open(config_path, 'r') as f: diff --git a/awx_collection/plugins/modules/tower_credential.py b/awx_collection/plugins/modules/tower_credential.py index 91643f65d4..867916332d 100644 --- a/awx_collection/plugins/modules/tower_credential.py +++ b/awx_collection/plugins/modules/tower_credential.py @@ -160,6 +160,10 @@ options: choices: ["present", "absent"] default: "present" type: str + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_job_template.py b/awx_collection/plugins/modules/tower_job_template.py index 99e986e043..0fbd2d8fe3 100644 --- a/awx_collection/plugins/modules/tower_job_template.py +++ b/awx_collection/plugins/modules/tower_job_template.py @@ -219,7 +219,12 @@ options: default: "present" choices: ["present", "absent"] type: str + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth + notes: - JSON for survey_spec can be found in Tower API Documentation. See U(https://docs.ansible.com/ansible-tower/latest/html/towerapi/api_ref.html#/Job_Templates/Job_Templates_job_templates_survey_spec_create) diff --git a/awx_collection/plugins/modules/tower_job_wait.py b/awx_collection/plugins/modules/tower_job_wait.py index 87b1f9f27c..00011468e0 100644 --- a/awx_collection/plugins/modules/tower_job_wait.py +++ b/awx_collection/plugins/modules/tower_job_wait.py @@ -42,6 +42,10 @@ options: description: - Maximum time in seconds to wait for a job to finish. type: int + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_label.py b/awx_collection/plugins/modules/tower_label.py index e5085cfd27..c9c412b8b2 100644 --- a/awx_collection/plugins/modules/tower_label.py +++ b/awx_collection/plugins/modules/tower_label.py @@ -39,6 +39,10 @@ options: default: "present" choices: ["present", "absent"] type: str + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_notification.py b/awx_collection/plugins/modules/tower_notification.py index b583efc056..ab79779915 100644 --- a/awx_collection/plugins/modules/tower_notification.py +++ b/awx_collection/plugins/modules/tower_notification.py @@ -191,6 +191,10 @@ options: default: "present" choices: ["present", "absent"] type: str + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_role.py b/awx_collection/plugins/modules/tower_role.py index 533d64075f..38fb5ff203 100644 --- a/awx_collection/plugins/modules/tower_role.py +++ b/awx_collection/plugins/modules/tower_role.py @@ -68,6 +68,10 @@ options: default: "present" choices: ["present", "absent"] type: str + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_workflow_launch.py b/awx_collection/plugins/modules/tower_workflow_launch.py index c0bf96fef6..a1ba4ea943 100644 --- a/awx_collection/plugins/modules/tower_workflow_launch.py +++ b/awx_collection/plugins/modules/tower_workflow_launch.py @@ -44,6 +44,8 @@ options: requirements: - "python >= 2.6" + - ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_workflow_template.py b/awx_collection/plugins/modules/tower_workflow_template.py index d0325cb0e5..e00ca48b6b 100644 --- a/awx_collection/plugins/modules/tower_workflow_template.py +++ b/awx_collection/plugins/modules/tower_workflow_template.py @@ -81,6 +81,10 @@ options: default: "present" choices: ["present", "absent"] type: str + +requirements: +- ansible-tower-cli >= 3.0.2 + extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 49badc22dd..067a5b468b 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -5,7 +5,7 @@ import io import json import datetime import importlib -from contextlib import redirect_stdout +from contextlib import redirect_stdout, suppress from unittest import mock import logging @@ -16,6 +16,12 @@ import pytest from awx.main.tests.functional.conftest import _request from awx.main.models import Organization, Project, Inventory, Credential, CredentialType +try: + import tower_cli # noqa + HAS_TOWER_CLI = True +except ImportError: + HAS_TOWER_CLI = False + logger = logging.getLogger('awx.main.tests') @@ -104,7 +110,11 @@ def run_module(request): with mock.patch.object(resource_module.TowerModule, '_load_params', new=mock_load_params): # Call the test utility (like a mock server) instead of issuing HTTP requests with mock.patch('ansible.module_utils.urls.Request.open', new=new_open): - with mock.patch('tower_cli.api.Session.request', new=new_request): + if HAS_TOWER_CLI: + tower_cli_mgr = mock.patch('tower_cli.api.Session.request', new=new_request) + else: + tower_cli_mgr = suppress() + with tower_cli_mgr: # Ansible modules return data to the mothership over stdout with redirect_stdout(stdout_buffer): try: @@ -114,6 +124,9 @@ def run_module(request): module_stdout = stdout_buffer.getvalue().strip() result = json.loads(module_stdout) + # A module exception should never be a test expectation + if 'exception' in result: + raise Exception('Module encountered error:\n{0}'.format(result['exception'])) return result return rf diff --git a/awx_collection/test/awx/test_group.py b/awx_collection/test/awx/test_group.py index e4e8e1afac..4f0ea3e7b8 100644 --- a/awx_collection/test/awx/test_group.py +++ b/awx_collection/test/awx/test_group.py @@ -25,11 +25,9 @@ def test_create_group(run_module, admin_user): result.pop('invocation') assert result == { - 'credential_type': 'Nexus', 'id': group.id, 'name': 'Test Group', 'changed': True, - 'state': 'present' } @@ -54,8 +52,5 @@ def test_tower_group_idempotent(run_module, admin_user): result.pop('invocation') assert result == { 'id': group.id, - 'credential_type': 'Nexus', - 'name': 'Test Group', 'changed': False, # idempotency assertion - 'state': 'present' } diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py index 6ab3b50354..dcdebbf44c 100644 --- a/awx_collection/test/awx/test_inventory_source.py +++ b/awx_collection/test/awx/test_inventory_source.py @@ -38,8 +38,6 @@ def test_inventory_source_create(run_module, admin_user, base_inventory): assert result == { 'id': inv_src.id, 'name': 'foo', - 'state': 'present', - 'credential_type': 'Nexus' } @@ -61,9 +59,7 @@ def test_create_inventory_source_implied_org(run_module, admin_user): result.pop('invocation') assert result == { - "credential_type": "Nexus", "name": "Test Inventory Source", - "state": "present", "id": inv_src.id, } @@ -90,9 +86,7 @@ def test_create_inventory_source_multiple_orgs(run_module, admin_user): result.pop('invocation') assert result == { - "credential_type": "Nexus", "name": "Test Inventory Source", - "state": "present", "id": inv_src.id, } diff --git a/awx_collection/test/awx/test_organization.py b/awx_collection/test/awx/test_organization.py index 95d6866d35..8f4872c303 100644 --- a/awx_collection/test/awx/test_organization.py +++ b/awx_collection/test/awx/test_organization.py @@ -30,8 +30,6 @@ def test_create_organization(run_module, admin_user): assert result == { "name": "foo", "changed": True, - "state": "present", - "credential_type": "Nexus", "id": org.id, "invocation": { "module_args": module_args @@ -55,8 +53,6 @@ def test_create_organization_with_venv(run_module, admin_user, mocker): org = Organization.objects.get(name='foo') result.pop('invocation') assert result == { - "credential_type": "Nexus", - "state": "present", "name": "foo", "id": org.id } diff --git a/awx_collection/test/awx/test_project.py b/awx_collection/test/awx/test_project.py index e99afd82dd..babe2edf54 100644 --- a/awx_collection/test/awx/test_project.py +++ b/awx_collection/test/awx/test_project.py @@ -3,20 +3,23 @@ __metaclass__ = type import pytest +from unittest import mock + from awx.main.models import Project @pytest.mark.django_db def test_create_project(run_module, admin_user, organization): - result = run_module('tower_project', dict( - name='foo', - organization=organization.name, - scm_type='git', - scm_url='https://foo.invalid', - wait=False, - scm_update_cache_timeout=5 - ), admin_user) - warning = ['scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true'] + with mock.patch('ansible.module_utils.basic.AnsibleModule.warn') as mock_warn: + result = run_module('tower_project', dict( + name='foo', + organization=organization.name, + scm_type='git', + scm_url='https://foo.invalid', + wait=False, + scm_update_cache_timeout=5 + ), admin_user) + mock_warn.assert_called_once_with('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true') assert result.pop('changed', None), result proj = Project.objects.get(name='foo') @@ -25,9 +28,6 @@ def test_create_project(run_module, admin_user, organization): result.pop('invocation') assert result == { - 'credential_type': 'Nexus', - 'state': 'present', 'name': 'foo', - 'id': proj.id, - 'warnings': warning + 'id': proj.id } diff --git a/awx_collection/test/awx/test_send_receive.py b/awx_collection/test/awx/test_send_receive.py index f0244f61cc..01cad4ae38 100644 --- a/awx_collection/test/awx/test_send_receive.py +++ b/awx_collection/test/awx/test_send_receive.py @@ -2,6 +2,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type import pytest +from unittest import mock import json from awx.main.models import ( @@ -65,7 +66,9 @@ def test_receive_send_jt(run_module, admin_user, mocker): # recreate everything with mocker.patch('sys.stdin.isatty', return_value=True): with mocker.patch('tower_cli.models.base.MonitorableResource.wait'): - result = run_module('tower_send', dict(assets=json.dumps(assets)), admin_user) + # warns based on password_management param, but not security issue + with mock.patch('ansible.module_utils.basic.AnsibleModule.warn'): + result = run_module('tower_send', dict(assets=json.dumps(assets)), admin_user) assert not result.get('failed'), result diff --git a/awx_collection/test/awx/test_team.py b/awx_collection/test/awx/test_team.py index b4eef38185..ccc164dcdf 100644 --- a/awx_collection/test/awx/test_team.py +++ b/awx_collection/test/awx/test_team.py @@ -23,8 +23,6 @@ def test_create_team(run_module, admin_user): assert result == { "changed": True, "name": "foo_team", - "credential_type": "Nexus", - "state": "present", "id": team.id if team else None, } team = Team.objects.get(name='foo_team') @@ -50,10 +48,7 @@ def test_modify_team(run_module, admin_user): team.refresh_from_db() result.pop('invocation') assert result == { - "state": "present", "changed": True, - "name": "foo_team", - "credential_type": "Nexus", "id": team.id, } assert team.description == 'fooin around' @@ -66,9 +61,6 @@ def test_modify_team(run_module, admin_user): }, admin_user) result.pop('invocation') assert result == { - "credential_type": "Nexus", - "name": "foo_team", "id": team.id, - "state": "present", "changed": False } From 768280c9baa7ae804daeb739f98caf0a70316baf Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 18 Feb 2020 16:02:05 -0500 Subject: [PATCH 42/45] [last PR stuff] + Add warning if configs specified in 2 params (#5) * Lean on API validation for tower_inventory_source arg errors used for - validating needed credential is given - missing source_project for scm sources * Add warning when config is specified in 2 places Fix up unit tests, address multiple comments re: backwards compatibility, redundant methods, etc. Update new_name and variables parameters, update unit tests --- awx/api/generics.py | 2 +- awx/api/serializers.py | 10 +- .../tests/functional/api/test_inventory.py | 4 +- awx_collection/README.md | 2 + .../plugins/module_utils/tower_api.py | 49 +---- .../plugins/modules/tower_credential_type.py | 4 - awx_collection/plugins/modules/tower_group.py | 13 +- awx_collection/plugins/modules/tower_host.py | 12 +- .../plugins/modules/tower_inventory_source.py | 184 +++--------------- .../plugins/modules/tower_job_launch.py | 2 +- awx_collection/test/awx/conftest.py | 16 +- awx_collection/test/awx/test_credential.py | 4 +- awx_collection/test/awx/test_group.py | 7 +- .../test/awx/test_inventory_source.py | 117 +++++++++-- awx_collection/test/awx/test_module_utils.py | 35 ++++ awx_collection/tests/sanity/ignore-2.9.txt | 1 - 16 files changed, 219 insertions(+), 243 deletions(-) create mode 100644 awx_collection/test/awx/test_module_utils.py diff --git a/awx/api/generics.py b/awx/api/generics.py index 37ca8bef42..af763d875e 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -192,7 +192,7 @@ class APIView(views.APIView): response.data['detail'] += ' To establish a login session, visit /api/login/.' logger.info(status_msg) else: - logger.warn(status_msg) + logger.warning(status_msg) response = super(APIView, self).finalize_response(request, response, *args, **kwargs) time_started = getattr(self, 'time_started', None) response['X-API-Node'] = settings.CLUSTER_HOST_ID diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 9cbac49301..93124ea19e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2115,7 +2115,13 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt def get_field_from_model_or_attrs(fd): return attrs.get(fd, self.instance and getattr(self.instance, fd) or None) - if get_field_from_model_or_attrs('source') != 'scm': + if get_field_from_model_or_attrs('source') == 'scm': + if (('source' in attrs or 'source_project' in attrs) and + get_field_from_model_or_attrs('source_project') is None): + raise serializers.ValidationError( + {"source_project": _("Project required for scm type sources.")} + ) + else: redundant_scm_fields = list(filter( lambda x: attrs.get(x, None), ['source_project', 'source_path', 'update_on_project_update'] @@ -3716,7 +3722,7 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): class Meta: model = WorkflowJobNode fields = ('*', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related', - 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', + 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', 'all_parents_must_converge', 'do_not_run',) def get_related(self, obj): diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index a86846e5c8..7a25f7e1cd 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -599,9 +599,9 @@ class TestControlledBySCM: delete(inv_src.get_absolute_url(), admin_user, expect=204) assert scm_inventory.inventory_sources.count() == 0 - def test_adding_inv_src_ok(self, post, scm_inventory, admin_user): + def test_adding_inv_src_ok(self, post, scm_inventory, project, admin_user): post(reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}), - {'name': 'new inv src', 'update_on_project_update': False, 'source': 'scm', 'overwrite_vars': True}, + {'name': 'new inv src', 'source_project': project.pk, 'update_on_project_update': False, 'source': 'scm', 'overwrite_vars': True}, admin_user, expect=201) def test_adding_inv_src_prohibited(self, post, scm_inventory, project, admin_user): diff --git a/awx_collection/README.md b/awx_collection/README.md index a5d438ba45..4297b1f68f 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -24,6 +24,8 @@ The following notes are changes that may require changes to playbooks. - When the `extra_vars` parameter is used with the `tower_job_launch` module, the Job Template launch will fail unless `add_extra_vars` or `survey_enabled` is explicitly set to `True` on the Job Template. - tower_group used to also service inventory sources, this functionality has been removed from this module; instead use tower_inventory_source. - Specified tower_config file used to handle k=v pairs on a single line. This is no longer supported. You may a file formatted in: yaml, json or ini only. + - The `variables` parameter in the `tower_group` and `tower_host` modules are now in `dict` format and no longer + supports the use of the `C(@)` syntax (for an external vars file). ## Running diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index c954fac4ac..42124c3d8d 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -59,20 +59,8 @@ class TowerModule(AnsibleModule): self.json_output = {'changed': False} - # We have to take off mutually_exclusive_if in order to init with Ansible - mutually_exclusive_if = kwargs.pop('mutually_exclusive_if', None) - super(TowerModule, self).__init__(argument_spec=args, **kwargs) - # Eventually, we would like to push this as a feature to Ansible core for others to use... - # Test mutually_exclusive if - if mutually_exclusive_if: - for (var_name, var_value, exclusive_names) in mutually_exclusive_if: - if self.params.get(var_name) == var_value: - for excluded_param_name in exclusive_names: - if self.params.get(excluded_param_name) is not None: - self.fail_json(msg='Arguments {0} can not be set if source is {1}'.format(', '.join(exclusive_names), var_value)) - self.load_config_files() # Parameters specified on command line will override settings in any config @@ -125,7 +113,17 @@ class TowerModule(AnsibleModule): # If we have a specified tower config, load it if self.params.get('tower_config_file'): + duplicated_params = [] + for direct_field in ('tower_host', 'tower_username', 'tower_password', 'validate_certs', 'tower_oauthtoken'): + if self.params.get(direct_field): + duplicated_params.append(direct_field) + if duplicated_params: + self.warn(( + 'The parameter(s) {0} were provided at the same time as tower_config_file. ' + 'Precedence may be unstable, we suggest either using config file or params.' + ).format(', '.join(duplicated_params))) try: + # TODO: warn if there are conflicts with other params self.load_config(self.params.get('tower_config_file')) except ConfigFileException as cfe: # Since we were told specifically to load this we want it to fail if we have an error @@ -620,30 +618,3 @@ class TowerModule(AnsibleModule): return False else: return True - - def load_variables_if_file_specified(self, vars_value, var_name): - if not vars_value.startswith('@'): - return vars_value - - if not HAS_YAML: - self.fail_json(msg=self.missing_required_lib('yaml')) - - file_name = None - file_content = None - try: - file_name = expanduser(vars_value[1:]) - with open(file_name, 'r') as f: - file_content = f.read() - except Exception as e: - self.fail_json(msg="Failed to load file {0} for {1} : {2}".format(file_name, var_name, e)) - - try: - vars_value = yaml.safe_load(file_content) - except yaml.YAMLError: - # Maybe it wasn't a YAML structure... lets try JSON - try: - vars_value = loads(file_content) - except ValueError: - self.fail_json(msg="Failed to load file {0} specifed by {1} as yaml or json".format(file_name, var_name)) - - return dumps(vars_value) diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 50533dbbdc..5ba41f99ca 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -144,10 +144,6 @@ def main(): } }) - # Add entries to json_output to match old module - module.json_output['credential_type'] = name - module.json_output['state'] = state - if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this module.delete_if_needed(credential_type) diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index 0dfa3845f0..5a34607265 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -44,8 +44,8 @@ options: type: str variables: description: - - Variables to use for the group, use C(@) for a file. - type: str + - Variables to use for the group. + type: dict state: description: - Desired state of the resource. @@ -72,6 +72,7 @@ EXAMPLES = ''' ''' from ..module_utils.tower_api import TowerModule +import json def main(): @@ -81,7 +82,7 @@ def main(): new_name=dict(required=False), description=dict(), inventory=dict(required=True), - variables=dict(), + variables=dict(type='dict', required=False), state=dict(choices=['present', 'absent'], default='present'), ) @@ -107,10 +108,6 @@ def main(): } }) - # If the variables were specified as a file, load them - if variables: - variables = module.load_variables_if_file_specified(variables, 'variables') - # Create the data that gets sent for create and update group_fields = { 'name': new_name if new_name else name, @@ -119,7 +116,7 @@ def main(): if description: group_fields['description'] = description if variables: - group_fields['variables'] = variables + group_fields['variables'] = json.dumps(variables) if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index 48da007602..a29d98aa25 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -49,8 +49,8 @@ options: default: 'yes' variables: description: - - Variables to use for the host. Use C(@) for a file. - type: str + - Variables to use for the host. + type: dict state: description: - Desired state of the resource. @@ -80,6 +80,7 @@ EXAMPLES = ''' from ..module_utils.tower_api import TowerModule +import json def main(): @@ -90,7 +91,7 @@ def main(): description=dict(default=''), inventory=dict(required=True), enabled=dict(type='bool', default=True), - variables=dict(default=''), + variables=dict(type='dict', default=''), state=dict(choices=['present', 'absent'], default='present'), ) @@ -106,9 +107,6 @@ def main(): state = module.params.get('state') variables = module.params.get('variables') - if variables: - variables = module.load_variables_if_file_specified(variables, 'variables') - # Attempt to look up the related items the user specified (these will fail the module if not found) inventory_id = module.resolve_name_to_id('inventories', inventory) @@ -128,7 +126,7 @@ def main(): 'enabled': enabled, } if variables: - host_fields['variables'] = variables + host_fields['variables'] = json.dumps(variables) if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index c3dbef1168..4459003020 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -179,164 +179,20 @@ def main(): state=dict(choices=['present', 'absent'], default='present'), ) - # One question here is do we want to end up supporting this within the ansible module itself (i.e. required if, etc) - # Or do we want to let the API return issues with "this doesn't support that", etc. - # - # GUI OPTIONS: - # - - - - - - - manual: file: scm: ec2: gce azure_rm vmware sat cloudforms openstack rhv tower custom - # credential ? ? o o r r r r r r r r o - # source_project ? ? r - - - - - - - - - - - # source_path ? ? r - - - - - - - - - - - # verbosity ? ? o o o o o o o o o o o - # overwrite ? ? o o o o o o o o o o o - # overwrite_vars ? ? o o o o o o o o o o o - # update_on_launch ? ? o o o o o o o o o o o - # update_on_project_launch ? ? o - - - - - - - - - - - # source_regions ? ? - o o o - - - - - - - - # instance_filters ? ? - o - - o - - - - o - - # group_by ? ? - o - - o - - - - - - - # source_vars* ? ? - o - o o o o o - - - - # environmet vars* ? ? o - - - - - - - - - o - # source_script ? ? - - - - - - - - - - r - # - # * - source_vars are labeled environment_vars on project and custom sources - # Create a module for ourselves - module = TowerModule(argument_spec=argument_spec, - supports_check_mode=True, - required_if=[ - # We don't want to require source if state is present because - # you might be doing an update to an existing source. - # Later on in the code, we will do a test so that if state: present - # and if we don't have an object, we must have source. - ('source', 'scm', ['source_project', 'source_path']), - ('source', 'gce', ['credential']), - ('source', 'azure_rm', ['credential']), - ('source', 'vmware', ['credential']), - ('source', 'satellite6', ['credential']), - ('source', 'cloudforms', ['credential']), - ('source', 'openstack', ['credential']), - ('source', 'rhv', ['credential']), - ('source', 'tower', ['credential']), - ('source', 'custom', ['source_script']), - ], - # This is provided by our module, it's not a core thing - mutually_exclusive_if=[ - ('source', 'scm', ['source_regions', - 'instance_filters', - 'group_by', - 'source_script' - ]), - ('source', 'ec2', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_script' - ]), - ('source', 'gce', ['source_project', - 'source_path', - 'update_on_project_launch', - 'instance_filters', - 'group_by', - 'source_vars', - 'source_script' - ]), - ('source', 'azure_rm', ['source_project', - 'source_path', - 'update_on_project_launch', - 'instance_filters', - 'group_by', - 'source_script' - ]), - ('source', 'vmware', ['source_project', 'source_path', 'update_on_project_launch', 'source_regions', 'source_script']), - ('source', 'satellite6', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_regions', - 'instance_filters', - 'group_by', - 'source_script' - ]), - ('source', 'cloudforms', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_regions', - 'instance_filters', - 'group_by', - 'source_script' - ]), - ('source', 'openstack', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_regions', - 'instance_filters', - 'group_by', - 'source_script' - ]), - ('source', 'rhv', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_regions', - 'instance_filters', - 'group_by', - 'source_vars', - 'source_script' - ]), - ('source', 'tower', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_regions', - 'group_by', - 'source_vars', - 'source_script' - ]), - ('source', 'custom', ['source_project', - 'source_path', - 'update_on_project_launch', - 'source_regions', - 'instance_filters', - 'group_by' - ]), - ]) + module = TowerModule(argument_spec=argument_spec) - optional_vars = {} # Extract our parameters name = module.params.get('name') new_name = module.params.get('new_name') - optional_vars['description'] = module.params.get('description') inventory = module.params.get('inventory') - optional_vars['source'] = module.params.get('source') - optional_vars['source_path'] = module.params.get('source_path') source_script = module.params.get('source_script') - optional_vars['source_vars'] = module.params.get('source_vars') credential = module.params.get('credential') - optional_vars['source_regions'] = module.params.get('source_regions') - optional_vars['instance_filters'] = module.params.get('instance_filters') - optional_vars['group_by'] = module.params.get('group_by') - optional_vars['overwrite'] = module.params.get('overwrite') - optional_vars['overwrite_vars'] = module.params.get('overwrite_vars') - optional_vars['custom_virtualenv'] = module.params.get('custom_virtualenv') - optional_vars['timeout'] = module.params.get('timeout') - optional_vars['verbosity'] = module.params.get('verbosity') - optional_vars['update_on_launch'] = module.params.get('update_on_launch') - optional_vars['update_cache_timeout'] = module.params.get('update_cache_timeout') source_project = module.params.get('source_project') - optional_vars['update_on_project_update'] = module.params.get('update_on_project_update') state = module.params.get('state') - # Attempt to JSON encode source vars - if optional_vars['source_vars']: - optional_vars['source_vars'] = dumps(optional_vars['source_vars']) - - # Attempt to look up the related items the user specified (these will fail the module if not found) - inventory_id = module.resolve_name_to_id('inventories', inventory) - if credential: - optional_vars['credential'] = module.resolve_name_to_id('credentials', credential) - if source_project: - optional_vars['source_project'] = module.resolve_name_to_id('projects', source_project) - if source_script: - optional_vars['source_script'] = module.resolve_name_to_id('inventory_scripts', source_script) - # Attempt to look up inventory source based on the provided name and inventory ID + inventory_id = module.resolve_name_to_id('inventories', inventory) inventory_source = module.get_one('inventory_sources', **{ 'data': { 'name': name, @@ -344,19 +200,41 @@ def main(): } }) - # Sanity check on arguments - if state == 'present' and not inventory_source and not optional_vars['source']: - module.fail_json(msg="If creating a new inventory source, the source param must be present") - # Create the data that gets sent for create and update inventory_source_fields = { 'name': new_name if new_name else name, 'inventory': inventory_id, } + + # Attempt to look up the related items the user specified (these will fail the module if not found) + if credential: + inventory_source_fields['credential'] = module.resolve_name_to_id('credentials', credential) + if source_project: + inventory_source_fields['source_project'] = module.resolve_name_to_id('projects', source_project) + if source_script: + inventory_source_fields['source_script'] = module.resolve_name_to_id('inventory_scripts', source_script) + + OPTIONAL_VARS = ( + 'description', 'source', 'source_path', 'source_vars', + 'source_regions', 'instance_filters', 'group_by', + 'overwrite', 'overwrite_vars', 'custom_virtualenv', + 'timeout', 'verbosity', 'update_on_launch', 'update_cache_timeout', + 'update_on_project_update' + ) + # Layer in all remaining optional information - for field_name in optional_vars: - if optional_vars[field_name]: - inventory_source_fields[field_name] = optional_vars[field_name] + for field_name in OPTIONAL_VARS: + field_val = module.params.get(field_name) + if field_val: + inventory_source_fields[field_name] = field_val + + # Attempt to JSON encode source vars + if inventory_source_fields.get('source_vars', None): + inventory_source_fields['source_vars'] = dumps(inventory_source_fields['source_vars']) + + # Sanity check on arguments + if state == 'present' and not inventory_source and not inventory_source_fields['source']: + module.fail_json(msg="If creating a new inventory source, the source param must be present") if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py index 3ce7ecafc1..b811d595d6 100644 --- a/awx_collection/plugins/modules/tower_job_launch.py +++ b/awx_collection/plugins/modules/tower_job_launch.py @@ -45,7 +45,7 @@ options: aliases: ['credential'] extra_vars: description: - - extra_vars to use for the Job Template. Prepend C(@) if a file. + - extra_vars to use for the Job Template. - ask_extra_vars needs to be set to True via tower_job_template module when creating the Job Template. type: dict diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 067a5b468b..357caa5de7 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -47,7 +47,19 @@ def sanitize_dict(din): @pytest.fixture -def run_module(request): +def collection_import(): + """These tests run assuming that the awx_collection folder is inserted + into the PATH before-hand. But all imports internally to the collection + go through this fixture so that can be changed if needed. + For instance, we could switch to fully-qualified import paths. + """ + def rf(path): + return importlib.import_module(path) + return rf + + +@pytest.fixture +def run_module(request, collection_import): def rf(module_name, module_params, request_user): def new_request(self, method, url, **kwargs): @@ -97,7 +109,7 @@ def run_module(request): # Note that a proper Ansiballz explosion of the modules will have an import path like: # ansible_collections.awx.awx.plugins.modules.{} # We should consider supporting that in the future - resource_module = importlib.import_module('plugins.modules.{0}'.format(module_name)) + resource_module = collection_import('plugins.modules.{0}'.format(module_name)) if not isinstance(module_params, dict): raise RuntimeError('Module params must be dict, got {0}'.format(type(module_params))) diff --git a/awx_collection/test/awx/test_credential.py b/awx_collection/test/awx/test_credential.py index 28ccba7f36..63ca46f0ff 100644 --- a/awx_collection/test/awx/test_credential.py +++ b/awx_collection/test/awx/test_credential.py @@ -78,10 +78,8 @@ def test_create_custom_credential_type(run_module, admin_user): ct = CredentialType.objects.get(name='Nexus') result.pop('invocation') - result.pop('name') assert result == { - "credential_type": "Nexus", - "state": "present", + "name": "Nexus", "id": ct.pk, "changed": True, } diff --git a/awx_collection/test/awx/test_group.py b/awx_collection/test/awx/test_group.py index 4f0ea3e7b8..6d49ca2e49 100644 --- a/awx_collection/test/awx/test_group.py +++ b/awx_collection/test/awx/test_group.py @@ -10,18 +10,19 @@ from awx.main.models import Organization, Inventory, Group def test_create_group(run_module, admin_user): org = Organization.objects.create(name='test-org') inv = Inventory.objects.create(name='test-inv', organization=org) + variables = {"ansible_network_os": "iosxr"} result = run_module('tower_group', dict( name='Test Group', inventory='test-inv', - variables='ansible_network_os: iosxr', + variables=variables, state='present' ), admin_user) assert result.get('changed'), result group = Group.objects.get(name='Test Group') assert group.inventory == inv - assert group.variables == 'ansible_network_os: iosxr' + assert group.variables == '{"ansible_network_os": "iosxr"}' result.pop('invocation') assert result == { @@ -39,13 +40,11 @@ def test_tower_group_idempotent(run_module, admin_user): group = Group.objects.create( name='Test Group', inventory=inv, - variables='ansible_network_os: iosxr' ) result = run_module('tower_group', dict( name='Test Group', inventory='test-inv', - variables='ansible_network_os: iosxr', state='present' ), admin_user) diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py index dcdebbf44c..bc4a7bfe1e 100644 --- a/awx_collection/test/awx/test_inventory_source.py +++ b/awx_collection/test/awx/test_inventory_source.py @@ -10,25 +10,29 @@ from awx.main.models import Organization, Inventory, InventorySource, Project def base_inventory(): org = Organization.objects.create(name='test-org') inv = Inventory.objects.create(name='test-inv', organization=org) - Project.objects.create( - name='test-proj', - organization=org, - scm_type='git', - scm_url='https://github.com/ansible/test-playbooks.git', - ) return inv +@pytest.fixture +def project(base_inventory): + return Project.objects.create( + name='test-proj', + organization=base_inventory.organization, + scm_type='git', + scm_url='https://github.com/ansible/test-playbooks.git', + ) + + @pytest.mark.django_db -def test_inventory_source_create(run_module, admin_user, base_inventory): +def test_inventory_source_create(run_module, admin_user, base_inventory, project): source_path = '/var/lib/awx/example_source_path/' result = run_module('tower_inventory_source', dict( name='foo', - inventory='test-inv', + inventory=base_inventory.name, state='present', source='scm', source_path=source_path, - source_project='test-proj' + source_project=project.name ), admin_user) assert result.pop('changed', None), result @@ -46,6 +50,7 @@ def test_create_inventory_source_implied_org(run_module, admin_user): org = Organization.objects.create(name='test-org') inv = Inventory.objects.create(name='test-inv', organization=org) + # Credential is not required for ec2 source, because of IAM roles result = run_module('tower_inventory_source', dict( name='Test Inventory Source', inventory='test-inv', @@ -92,16 +97,16 @@ def test_create_inventory_source_multiple_orgs(run_module, admin_user): @pytest.mark.django_db -def test_create_inventory_source_with_venv(run_module, admin_user, base_inventory, mocker): +def test_create_inventory_source_with_venv(run_module, admin_user, base_inventory, mocker, project): path = '/var/lib/awx/venv/custom-venv/foobar13489435/' source_path = '/var/lib/awx/example_source_path/' with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=[path]): result = run_module('tower_inventory_source', dict( name='foo', - inventory='test-inv', + inventory=base_inventory.name, state='present', source='scm', - source_project='test-proj', + source_project=project.name, custom_virtualenv=path, source_path=source_path ), admin_user) @@ -115,7 +120,7 @@ def test_create_inventory_source_with_venv(run_module, admin_user, base_inventor @pytest.mark.django_db -def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker): +def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, project): """If the inventory source is modified, then it should not blank fields unrelated to the params that the user passed. This enforces assumptions about the behavior of the AnsibleModule @@ -125,7 +130,7 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker): inv_src = InventorySource.objects.create( name='foo', inventory=base_inventory, - source_project=Project.objects.get(name='test-proj'), + source_project=project, source='scm', custom_virtualenv='/venv/foobar/' ) @@ -134,13 +139,93 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker): result = run_module('tower_inventory_source', dict( name='foo', description='this is the changed description', - inventory='test-inv', + inventory=base_inventory.name, source='scm', # is required, but behavior is arguable state='present', - source_project='test-proj', + source_project=project.name, source_path=source_path ), admin_user) assert result.pop('changed', None), result inv_src.refresh_from_db() assert inv_src.custom_virtualenv == '/venv/foobar/' assert inv_src.description == 'this is the changed description' + + +# Tests related to source-specific parameters +# +# We want to let the API return issues with "this doesn't support that", etc. +# +# GUI OPTIONS: +# - - - - - - - manual: file: scm: ec2: gce azure_rm vmware sat cloudforms openstack rhv tower custom +# credential ? ? o o r r r r r r r r o +# source_project ? ? r - - - - - - - - - - +# source_path ? ? r - - - - - - - - - - +# verbosity ? ? o o o o o o o o o o o +# overwrite ? ? o o o o o o o o o o o +# overwrite_vars ? ? o o o o o o o o o o o +# update_on_launch ? ? o o o o o o o o o o o +# UoPL ? ? o - - - - - - - - - - +# source_regions ? ? - o o o - - - - - - - +# instance_filters ? ? - o - - o - - - - o - +# group_by ? ? - o - - o - - - - - - +# source_vars* ? ? - o - o o o o o - - - +# environmet vars* ? ? o - - - - - - - - - o +# source_script ? ? - - - - - - - - - - r +# +# UoPL - update_on_project_launch +# * - source_vars are labeled environment_vars on project and custom sources + + +@pytest.mark.django_db +def test_missing_required_credential(run_module, admin_user, base_inventory): + result = run_module('tower_inventory_source', dict( + name='Test Azure Source', + inventory=base_inventory.name, + source='azure_rm', + state='present' + ), admin_user) + assert result.pop('failed', None) is True, result + + assert 'Credential is required for a cloud source' in result.get('msg', '') + + +@pytest.mark.django_db +def test_source_project_not_for_cloud(run_module, admin_user, base_inventory, project): + result = run_module('tower_inventory_source', dict( + name='Test ec2 Inventory Source', + inventory=base_inventory.name, + source='ec2', + state='present', + source_project=project.name + ), admin_user) + assert result.pop('failed', None) is True, result + + assert 'Cannot set source_project if not SCM type' in result.get('msg', '') + + +@pytest.mark.django_db +def test_source_path_not_for_cloud(run_module, admin_user, base_inventory): + result = run_module('tower_inventory_source', dict( + name='Test ec2 Inventory Source', + inventory=base_inventory.name, + source='ec2', + state='present', + source_path='where/am/I' + ), admin_user) + assert result.pop('failed', None) is True, result + + assert 'Cannot set source_path if not SCM type' in result.get('msg', '') + + +@pytest.mark.django_db +def test_scm_source_needs_project(run_module, admin_user, base_inventory): + result = run_module('tower_inventory_source', dict( + name='SCM inventory without project', + inventory=base_inventory.name, + state='present', + source='scm', + source_path='/var/lib/awx/example_source_path/' + ), admin_user) + assert result.pop('failed', None), result + + assert 'Project required for scm type sources' in result.get('msg', '') diff --git a/awx_collection/test/awx/test_module_utils.py b/awx_collection/test/awx/test_module_utils.py new file mode 100644 index 0000000000..c282489490 --- /dev/null +++ b/awx_collection/test/awx/test_module_utils.py @@ -0,0 +1,35 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import sys + +from unittest import mock + +import json + + +def test_duplicate_config(collection_import): + # imports done here because of PATH issues unique to this test suite + TowerModule = collection_import('plugins.module_utils.tower_api').TowerModule + data = { + 'name': 'zigzoom', + 'zig': 'zoom', + 'tower_username': 'bob', + 'tower_config_file': 'my_config' + } + cli_data = {'ANSIBLE_MODULE_ARGS': data} + testargs = ['module_file.py', json.dumps(cli_data)] + with mock.patch('ansible.module_utils.basic.AnsibleModule.warn') as mock_warn: + with mock.patch.object(sys, 'argv', testargs): + with mock.patch.object(TowerModule, 'load_config') as mock_load: + argument_spec = dict( + name=dict(required=True), + zig=dict(type='str'), + ) + TowerModule(argument_spec=argument_spec) + mock_load.mock_calls[-1] == mock.call('my_config') + mock_warn.assert_called_once_with( + 'The parameter(s) tower_username were provided at the same time as ' + 'tower_config_file. Precedence may be unstable, ' + 'we suggest either using config file or params.' + ) diff --git a/awx_collection/tests/sanity/ignore-2.9.txt b/awx_collection/tests/sanity/ignore-2.9.txt index c9df9574f5..e69de29bb2 100644 --- a/awx_collection/tests/sanity/ignore-2.9.txt +++ b/awx_collection/tests/sanity/ignore-2.9.txt @@ -1 +0,0 @@ -plugins/modules/tower_host.py use-argspec-type-path From 787c4af222d1f06f6c7a77c938cc83fb818f3e94 Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 20 Feb 2020 12:43:22 -0500 Subject: [PATCH 43/45] Change default values for dict parameter Removing default of empty dict from variables param on group and host modules Make modules comply with updated sanity tests --- .../plugins/modules/tower_credential.py | 2 +- .../plugins/modules/tower_credential_type.py | 2 +- awx_collection/plugins/modules/tower_host.py | 4 +-- .../plugins/modules/tower_job_launch.py | 9 +++-- .../plugins/modules/tower_job_template.py | 3 +- .../plugins/modules/tower_notification.py | 15 ++++++--- .../plugins/modules/tower_receive.py | 33 ++++++++++++------- awx_collection/plugins/modules/tower_send.py | 6 ++-- awx_collection/plugins/modules/tower_team.py | 2 +- 9 files changed, 49 insertions(+), 27 deletions(-) diff --git a/awx_collection/plugins/modules/tower_credential.py b/awx_collection/plugins/modules/tower_credential.py index 867916332d..e7b412b6c7 100644 --- a/awx_collection/plugins/modules/tower_credential.py +++ b/awx_collection/plugins/modules/tower_credential.py @@ -282,7 +282,7 @@ def main(): name=dict(required=True), user=dict(), team=dict(), - kind=dict(choices=KIND_CHOICES.keys()), + kind=dict(choices=list(KIND_CHOICES.keys())), credential_type=dict(), inputs=dict(type='dict'), host=dict(), diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index 5ba41f99ca..f3655841b3 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -109,7 +109,7 @@ def main(): argument_spec = dict( name=dict(required=True), description=dict(required=False), - kind=dict(required=False, choices=KIND_CHOICES.keys()), + kind=dict(required=False, choices=list(KIND_CHOICES.keys())), inputs=dict(type='dict', required=False), injectors=dict(type='dict', required=False), state=dict(choices=['present', 'absent'], default='present'), diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index a29d98aa25..a0d9a3e350 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -31,7 +31,7 @@ options: new_name: description: - To use when changing a hosts's name. - required: True + required: False type: str description: description: @@ -91,7 +91,7 @@ def main(): description=dict(default=''), inventory=dict(required=True), enabled=dict(type='bool', default=True), - variables=dict(type='dict', default=''), + variables=dict(type='dict', required=False), state=dict(choices=['present', 'absent'], default='present'), ) diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py index b811d595d6..383300eba6 100644 --- a/awx_collection/plugins/modules/tower_job_launch.py +++ b/awx_collection/plugins/modules/tower_job_launch.py @@ -43,6 +43,7 @@ options: - Credential to use for job, only used if prompt for credential is set. type: list aliases: ['credential'] + elements: str extra_vars: description: - extra_vars to use for the Job Template. @@ -57,6 +58,7 @@ options: description: - Specific tags to use for from playbook. type: list + elements: str scm_branch: description: - A specific of the SCM project to run the template on. @@ -66,6 +68,7 @@ options: description: - Specific tags to skip from the playbook. type: list + elements: str verbosity: description: - Verbosity level for this job run @@ -137,12 +140,12 @@ def main(): job_type=dict(type='str', choices=['run', 'check']), inventory=dict(type='str', default=None), # Credentials will be a str instead of a list for backwards compatability - credentials=dict(type='list', default=None, aliases=['credential']), + credentials=dict(type='list', default=None, aliases=['credential'], elements='str'), limit=dict(), - tags=dict(type='list'), + tags=dict(type='list', elements='str'), extra_vars=dict(type='dict', required=False), scm_branch=dict(type='str', required=False), - skip_tags=dict(type='list', required=False), + skip_tags=dict(type='list', required=False, elements='str'), verbosity=dict(type='int', required=False, choices=[0, 1, 2, 3, 4, 5]), diff_mode=dict(type='bool', required=False), credential_passwords=dict(type='dict', required=False), diff --git a/awx_collection/plugins/modules/tower_job_template.py b/awx_collection/plugins/modules/tower_job_template.py index 0fbd2d8fe3..817bd6dc39 100644 --- a/awx_collection/plugins/modules/tower_job_template.py +++ b/awx_collection/plugins/modules/tower_job_template.py @@ -65,6 +65,7 @@ options: version_added: 2.8 type: list default: [] + elements: str vault_credential: description: - Name of the vault credential to use for the job template. @@ -338,7 +339,7 @@ def main(): credential=dict(default=''), vault_credential=dict(default=''), custom_virtualenv=dict(type='str', required=False), - credentials=dict(type='list', default=[]), + credentials=dict(type='list', default=[], elements='str'), forks=dict(type='int'), limit=dict(default=''), verbosity=dict(type='int', choices=[0, 1, 2, 3, 4], default=0), diff --git a/awx_collection/plugins/modules/tower_notification.py b/awx_collection/plugins/modules/tower_notification.py index ab79779915..fb384fd878 100644 --- a/awx_collection/plugins/modules/tower_notification.py +++ b/awx_collection/plugins/modules/tower_notification.py @@ -64,6 +64,7 @@ options: - The recipients email addresses. Required if I(notification_type=email). required: False type: list + elements: str use_tls: description: - The TLS trigger. Required if I(notification_type=email). @@ -94,6 +95,7 @@ options: - The destination Slack channels. Required if I(notification_type=slack). required: False type: list + elements: str token: description: - The access token. Required if I(notification_type=slack), if I(notification_type=pagerduty) or if I(notification_type=hipchat). @@ -114,6 +116,7 @@ options: - The destination phone numbers. Required if I(notification_type=twillio). required: False type: list + elements: str account_sid: description: - The Twillio account SID. Required if I(notification_type=twillio). @@ -155,6 +158,7 @@ options: - HipChat rooms to send the notification to. Required if I(notification_type=hipchat). required: False type: list + elements: str notify: description: - The notify channel trigger. Required if I(notification_type=hipchat). @@ -185,6 +189,7 @@ options: - The destination channels or users. Required if I(notification_type=irc). required: False type: list + elements: str state: description: - Desired state of the resource. @@ -319,17 +324,17 @@ def main(): notification_configuration=dict(required=False), username=dict(required=False), sender=dict(required=False), - recipients=dict(required=False, type='list'), + recipients=dict(required=False, type='list', elements='str'), use_tls=dict(required=False, type='bool'), host=dict(required=False), use_ssl=dict(required=False, type='bool'), password=dict(required=False, no_log=True), port=dict(required=False, type='int'), - channels=dict(required=False, type='list'), + channels=dict(required=False, type='list', elements='str'), token=dict(required=False, no_log=True), account_token=dict(required=False, no_log=True), from_number=dict(required=False), - to_numbers=dict(required=False, type='list'), + to_numbers=dict(required=False, type='list', elements='str'), account_sid=dict(required=False), subdomain=dict(required=False), service_key=dict(required=False, no_log=True), @@ -337,13 +342,13 @@ def main(): message_from=dict(required=False), api_url=dict(required=False), color=dict(required=False, choices=['yellow', 'green', 'red', 'purple', 'gray', 'random']), - rooms=dict(required=False, type='list'), + rooms=dict(required=False, type='list', elements='str'), notify=dict(required=False, type='bool'), url=dict(required=False), headers=dict(required=False, type='dict', default={}), server=dict(required=False), nickname=dict(required=False), - targets=dict(required=False, type='list'), + targets=dict(required=False, type='list', elements='str'), state=dict(choices=['present', 'absent'], default='present'), ) diff --git a/awx_collection/plugins/modules/tower_receive.py b/awx_collection/plugins/modules/tower_receive.py index 1e33f7f6a3..dfe6f4b08c 100644 --- a/awx_collection/plugins/modules/tower_receive.py +++ b/awx_collection/plugins/modules/tower_receive.py @@ -33,56 +33,67 @@ options: - List of organization names to export default: [] type: list + elements: str user: description: - List of user names to export default: [] type: list + elements: str team: description: - List of team names to export default: [] type: list + elements: str credential_type: description: - List of credential type names to export default: [] type: list + elements: str credential: description: - List of credential names to export default: [] type: list + elements: str notification_template: description: - List of notification template names to export default: [] type: list + elements: str inventory_script: description: - List of inventory script names to export default: [] type: list + elements: str inventory: description: - List of inventory names to export default: [] type: list + elements: str project: description: - List of project names to export default: [] type: list + elements: str job_template: description: - List of job template names to export default: [] type: list + elements: str workflow: description: - List of workflow names to export default: [] type: list + elements: str requirements: - "ansible-tower-cli >= 3.3.0" @@ -136,17 +147,17 @@ except ImportError: def main(): argument_spec = dict( all=dict(type='bool', default=False), - credential=dict(type='list', default=[]), - credential_type=dict(type='list', default=[]), - inventory=dict(type='list', default=[]), - inventory_script=dict(type='list', default=[]), - job_template=dict(type='list', default=[]), - notification_template=dict(type='list', default=[]), - organization=dict(type='list', default=[]), - project=dict(type='list', default=[]), - team=dict(type='list', default=[]), - user=dict(type='list', default=[]), - workflow=dict(type='list', default=[]), + credential=dict(type='list', default=[], elements='str'), + credential_type=dict(type='list', default=[], elements='str'), + inventory=dict(type='list', default=[], elements='str'), + inventory_script=dict(type='list', default=[], elements='str'), + job_template=dict(type='list', default=[], elements='str'), + notification_template=dict(type='list', default=[], elements='str'), + organization=dict(type='list', default=[], elements='str'), + project=dict(type='list', default=[], elements='str'), + team=dict(type='list', default=[], elements='str'), + user=dict(type='list', default=[], elements='str'), + workflow=dict(type='list', default=[], elements='str'), ) module = TowerModule(argument_spec=argument_spec, supports_check_mode=False) diff --git a/awx_collection/plugins/modules/tower_send.py b/awx_collection/plugins/modules/tower_send.py index 07334d5589..2deb763e45 100644 --- a/awx_collection/plugins/modules/tower_send.py +++ b/awx_collection/plugins/modules/tower_send.py @@ -35,12 +35,14 @@ options: required: False default: [] type: list + elements: str prevent: description: - A list of asset types to prevent import for required: false default: [] type: list + elements: str password_management: description: - The password management option to use. @@ -97,8 +99,8 @@ except ImportError: def main(): argument_spec = dict( assets=dict(required=False), - files=dict(required=False, default=[], type='list'), - prevent=dict(required=False, default=[], type='list'), + files=dict(required=False, default=[], type='list', elements='str'), + prevent=dict(required=False, default=[], type='list', elements='str'), password_management=dict(required=False, default='default', choices=['default', 'random']), ) diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 1f14c38292..4838923c81 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -31,7 +31,7 @@ options: new_name: description: - To use when changing a team's name. - required: True + required: False type: str description: description: From 1c4042340ca4b563cb9e81cbb74693199d376161 Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 24 Feb 2020 15:23:49 -0500 Subject: [PATCH 44/45] Update documentation for modules, add deprecation warning for role, send and receive modules Update variables in tower_inventory to be in dict format --- awx_collection/README.md | 33 +++++++++---------- .../plugins/modules/tower_credential_type.py | 1 + awx_collection/plugins/modules/tower_group.py | 4 ++- awx_collection/plugins/modules/tower_host.py | 4 ++- .../plugins/modules/tower_inventory.py | 10 +++--- .../plugins/modules/tower_inventory_source.py | 4 ++- .../plugins/modules/tower_job_cancel.py | 1 + .../plugins/modules/tower_job_launch.py | 6 ++++ .../plugins/modules/tower_job_list.py | 1 + .../plugins/modules/tower_license.py | 3 ++ .../plugins/modules/tower_organization.py | 2 ++ .../plugins/modules/tower_project.py | 5 ++- .../plugins/modules/tower_receive.py | 2 ++ awx_collection/plugins/modules/tower_role.py | 2 ++ awx_collection/plugins/modules/tower_send.py | 2 ++ .../plugins/modules/tower_settings.py | 2 ++ awx_collection/plugins/modules/tower_team.py | 5 ++- awx_collection/plugins/modules/tower_user.py | 1 + awx_collection/tests/sanity/ignore-2.10.txt | 0 awx_collection/tests/sanity/ignore-2.9.txt | 0 20 files changed, 62 insertions(+), 26 deletions(-) delete mode 100644 awx_collection/tests/sanity/ignore-2.10.txt delete mode 100644 awx_collection/tests/sanity/ignore-2.9.txt diff --git a/awx_collection/README.md b/awx_collection/README.md index 4297b1f68f..7e1022892e 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -1,7 +1,7 @@ # AWX Ansible Collection This Ansible collection allows for easy interaction with an AWX or Ansible Tower -server in Ansible playbooks. +server via Ansible playbooks. The previous home for this collection was in https://github.com/ansible/ansible inside the folder `lib/ansible/modules/web_infrastructure/ansible_tower` @@ -14,34 +14,33 @@ The release 7.0.0 of the `awx.awx` collection is intended to be identical to the content prior to the migration, aside from changes necessary to have it function as a collection. -The following notes are changes that may require changes to playbooks. +The following notes are changes that may require changes to playbooks: - Specifying `inputs` or `injectors` as strings in the - `tower_credential_type` module is no longer supported. Provide as dictionaries instead. + `tower_credential_type` module is no longer supported. Provide them as dictionaries instead. - When a project is created, it will wait for the update/sync to finish by default; this can be turned off with the `wait` parameter, if desired. - Creating a "scan" type job template is no longer supported. - `extra_vars` in the `tower_job_launch` module worked with a list previously, but is now configured to work solely in a `dict` format. - When the `extra_vars` parameter is used with the `tower_job_launch` module, the Job Template launch will fail unless `add_extra_vars` or `survey_enabled` is explicitly set to `True` on the Job Template. - - tower_group used to also service inventory sources, this functionality has been removed from this module; instead use tower_inventory_source. - - Specified tower_config file used to handle k=v pairs on a single line. This is no longer supported. You may a file formatted in: yaml, json or ini only. - - The `variables` parameter in the `tower_group` and `tower_host` modules are now in `dict` format and no longer - supports the use of the `C(@)` syntax (for an external vars file). + - `tower_group` used to also service inventory sources, but this functionality has been removed from this module; use `tower_inventory_source` instead. + - Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only. + - The `variables` parameter in the `tower_group`, `tower_host` and `tower_inventory` modules are now in `dict` format and no longer supports the use of the `C(@)` syntax (for an external `vars` file). + - Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended. ## Running -To use this collection, the "old" tower-cli needs to be installed +To use this collection, the "old" `tower-cli` needs to be installed in the virtual environment where the collection runs. You can install it from [PyPI](https://pypi.org/project/ansible-tower-cli/). -To use this collection in AWX, you should create a custom virtual environment -to install the requirement into. NOTE: running locally, you will also need -to set the job template extra_vars to include `ansible_python_interpreter` -to be the python in that virtual environment. +To use this collection in AWX, you should create a custom virtual environment into which to install the requirements. NOTE: running locally, you will also need +to set the job template `extra_vars` to include `ansible_python_interpreter` +to be the Python in that virtual environment. ## Running Tests Tests to verify compatibility with the most recent AWX code are -in `awx_collection/test/awx`. These tests require that python packages +in `awx_collection/test/awx`. These tests require that Python packages are available for all of `awx`, `ansible`, `tower_cli`, and the collection itself. @@ -49,7 +48,7 @@ itself. The target `make prepare_collection_venv` will prepare some requirements in the `awx_collection_test_venv` folder so that `make test_collection` can -be ran to actually run the tests. A single test can be ran via: +be executed to actually run the tests. A single test can be run via: ``` make test_collection COLLECTION_TEST_DIRS=awx_collection/test/awx/test_organization.py @@ -57,9 +56,9 @@ make test_collection COLLECTION_TEST_DIRS=awx_collection/test/awx/test_organizat ### Manually -As a faster alternative if you do not want to use the container, or -run against Ansible or tower-cli source, it is possible to set up a -working environment yourself. +As a faster alternative (if you do not want to use the container), or to +run against Ansible or `tower-cli` source, it is possible to set up a +working environment yourself: ``` mkvirtualenv my_new_venv diff --git a/awx_collection/plugins/modules/tower_credential_type.py b/awx_collection/plugins/modules/tower_credential_type.py index f3655841b3..7d73250e6f 100644 --- a/awx_collection/plugins/modules/tower_credential_type.py +++ b/awx_collection/plugins/modules/tower_credential_type.py @@ -69,6 +69,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index 5a34607265..a4d3bb18b0 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -33,6 +33,7 @@ options: - A new name for this group (for renaming) required: False type: str + version_added: "3.7" description: description: - The description to use for the group. @@ -57,6 +58,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -80,7 +82,7 @@ def main(): argument_spec = dict( name=dict(required=True), new_name=dict(required=False), - description=dict(), + description=dict(required=False), inventory=dict(required=True), variables=dict(type='dict', required=False), state=dict(choices=['present', 'absent'], default='present'), diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index a0d9a3e350..2a2096ab97 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -33,6 +33,7 @@ options: - To use when changing a hosts's name. required: False type: str + version_added: "3.7" description: description: - The description to use for the host. @@ -62,6 +63,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -88,7 +90,7 @@ def main(): argument_spec = dict( name=dict(required=True), new_name=dict(required=False), - description=dict(default=''), + description=dict(required=False), inventory=dict(required=True), enabled=dict(type='bool', default=True), variables=dict(type='dict', required=False), diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index e0b7a4f0da..13bee6e51c 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -39,8 +39,9 @@ options: type: str variables: description: - - Inventory variables. Use C(@) to get from file. - type: str + - Inventory variables. + required: False + type: dict kind: description: - The kind field. Cannot be modified after created. @@ -64,6 +65,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -86,9 +88,9 @@ def main(): # Any additional arguments that are not fields of the item can be added here argument_spec = dict( name=dict(required=True), - description=dict(default=''), + description=dict(required=False), organization=dict(required=True), - variables=dict(default=''), + variables=dict(type='dict', required=False), kind=dict(choices=['', 'smart'], default=''), host_filter=dict(), state=dict(choices=['present', 'absent'], default='present'), diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index 4459003020..5e49435f5e 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -33,6 +33,7 @@ options: - A new name for this assets (will rename the asset) required: False type: str + version_added: "3.7" description: description: - The description to use for the inventory source. @@ -127,6 +128,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -152,7 +154,7 @@ def main(): argument_spec = dict( name=dict(required=True), new_name=dict(type='str'), - description=dict(), + description=dict(required=False), inventory=dict(required=True), # # How do we handle manual and file? Tower does not seem to be able to activate them diff --git a/awx_collection/plugins/modules/tower_job_cancel.py b/awx_collection/plugins/modules/tower_job_cancel.py index f2a5a4f560..e5cfe40f2f 100644 --- a/awx_collection/plugins/modules/tower_job_cancel.py +++ b/awx_collection/plugins/modules/tower_job_cancel.py @@ -38,6 +38,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_job_launch.py b/awx_collection/plugins/modules/tower_job_launch.py index 383300eba6..83ae5e58fe 100644 --- a/awx_collection/plugins/modules/tower_job_launch.py +++ b/awx_collection/plugins/modules/tower_job_launch.py @@ -64,29 +64,35 @@ options: - A specific of the SCM project to run the template on. - This is only applicable if your project allows for branch override. type: str + version_added: "3.7" skip_tags: description: - Specific tags to skip from the playbook. type: list elements: str + version_added: "3.7" verbosity: description: - Verbosity level for this job run type: int choices: [ 0, 1, 2, 3, 4, 5 ] + version_added: "3.7" diff_mode: description: - Show the changes made by Ansible tasks where supported type: bool + version_added: "3.7" credential_passwords: description: - Passwords for credentials which are set to prompt on launch type: dict + version_added: "3.7" tower_oauthtoken: description: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_job_list.py b/awx_collection/plugins/modules/tower_job_list.py index 76fd03955d..f1a41fe884 100644 --- a/awx_collection/plugins/modules/tower_job_list.py +++ b/awx_collection/plugins/modules/tower_job_list.py @@ -46,6 +46,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_license.py b/awx_collection/plugins/modules/tower_license.py index cdc2f23fae..f075cd457c 100644 --- a/awx_collection/plugins/modules/tower_license.py +++ b/awx_collection/plugins/modules/tower_license.py @@ -27,16 +27,19 @@ options: - The contents of the license file required: True type: dict + version_added: "3.7" eula_accepted: description: - Whether or not the EULA is accepted. required: True type: bool + version_added: "3.7" tower_oauthtoken: description: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 2b0e4e925f..351bf0a7ff 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -45,6 +45,7 @@ options: default: "0" type: int required: False + version_added: "3.7" state: description: - Desired state of the resource. @@ -56,6 +57,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index 6db62f3292..c52ffd867d 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -56,6 +56,7 @@ options: - The refspec to use for the SCM resource. type: str default: '' + version_added: "3.7" scm_credential: description: - Name of the credential to use with this SCM resource. @@ -86,6 +87,7 @@ options: description: - Allow changing the SCM branch or revision in a job template that uses this project. type: bool + version_added: "3.7" job_timeout: version_added: "2.8" description: @@ -123,6 +125,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -175,7 +178,7 @@ def main(): # Any additional arguments that are not fields of the item can be added here argument_spec = dict( name=dict(required=True), - description=dict(required=False, default=''), + description=dict(required=False), scm_type=dict(required=False, choices=['manual', 'git', 'hg', 'svn', 'insights'], default='manual'), scm_url=dict(required=False), local_path=dict(required=False), diff --git a/awx_collection/plugins/modules/tower_receive.py b/awx_collection/plugins/modules/tower_receive.py index dfe6f4b08c..c343db4aec 100644 --- a/awx_collection/plugins/modules/tower_receive.py +++ b/awx_collection/plugins/modules/tower_receive.py @@ -162,6 +162,8 @@ def main(): module = TowerModule(argument_spec=argument_spec, supports_check_mode=False) + module.deprecate(msg="This module is being moved to a different collection. Instead of awx.awx it will be migrated into awx.tower_cli", version="3.7") + if not HAS_TOWER_CLI: module.fail_json(msg='ansible-tower-cli required for this module') diff --git a/awx_collection/plugins/modules/tower_role.py b/awx_collection/plugins/modules/tower_role.py index 38fb5ff203..759e27bce5 100644 --- a/awx_collection/plugins/modules/tower_role.py +++ b/awx_collection/plugins/modules/tower_role.py @@ -141,6 +141,8 @@ def main(): module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + module.deprecate(msg="This module is being moved to a different collection. Instead of awx.awx it will be migrated into awx.tower_cli", version="3.7") + role_type = module.params.pop('role') state = module.params.pop('state') diff --git a/awx_collection/plugins/modules/tower_send.py b/awx_collection/plugins/modules/tower_send.py index 2deb763e45..dca252cc8b 100644 --- a/awx_collection/plugins/modules/tower_send.py +++ b/awx_collection/plugins/modules/tower_send.py @@ -106,6 +106,8 @@ def main(): module = TowerModule(argument_spec=argument_spec, supports_check_mode=False) + module.deprecate(msg="This module is being moved to a different collection. Instead of awx.awx it will be migrated into awx.tower_cli", version="3.7") + if not HAS_TOWER_CLI: module.fail_json(msg='ansible-tower-cli required for this module') diff --git a/awx_collection/plugins/modules/tower_settings.py b/awx_collection/plugins/modules/tower_settings.py index 67c88bd80a..d242ea2bbb 100644 --- a/awx_collection/plugins/modules/tower_settings.py +++ b/awx_collection/plugins/modules/tower_settings.py @@ -38,11 +38,13 @@ options: - A data structure to be sent into the settings endpoint required: False type: dict + version_added: "3.7" tower_oauthtoken: description: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 4838923c81..599b745c30 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -33,9 +33,11 @@ options: - To use when changing a team's name. required: False type: str + version_added: "3.7" description: description: - The description to use for the team. + required: False type: str organization: description: @@ -53,6 +55,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -75,7 +78,7 @@ def main(): argument_spec = dict( name=dict(required=True), new_name=dict(required=False), - description=dict(), + description=dict(required=False), organization=dict(required=True), state=dict(choices=['present', 'absent'], default='present'), ) diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index acbdf75891..8c7c05edd0 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -66,6 +66,7 @@ options: - The Tower OAuth token to use. required: False type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' diff --git a/awx_collection/tests/sanity/ignore-2.10.txt b/awx_collection/tests/sanity/ignore-2.10.txt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/awx_collection/tests/sanity/ignore-2.9.txt b/awx_collection/tests/sanity/ignore-2.9.txt deleted file mode 100644 index e69de29bb2..0000000000 From b532012748bf9d13bbe14bf4def8ad104a243ef5 Mon Sep 17 00:00:00 2001 From: beeankha Date: Tue, 25 Feb 2020 23:51:26 -0500 Subject: [PATCH 45/45] Make non-required params actually optional, fix idempotency issues --- awx_collection/plugins/modules/tower_group.py | 4 ++-- awx_collection/plugins/modules/tower_host.py | 5 +++-- awx_collection/plugins/modules/tower_inventory.py | 7 +++++-- awx_collection/plugins/modules/tower_inventory_source.py | 6 +++--- awx_collection/plugins/modules/tower_organization.py | 6 +++--- awx_collection/plugins/modules/tower_project.py | 3 ++- awx_collection/plugins/modules/tower_team.py | 3 ++- awx_collection/plugins/modules/tower_user.py | 6 ++++-- 8 files changed, 24 insertions(+), 16 deletions(-) diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index a4d3bb18b0..bb0b22ae77 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -115,9 +115,9 @@ def main(): 'name': new_name if new_name else name, 'inventory': inventory_id, } - if description: + if description is not None: group_fields['description'] = description - if variables: + if variables is not None: group_fields['variables'] = json.dumps(variables) if state == 'absent': diff --git a/awx_collection/plugins/modules/tower_host.py b/awx_collection/plugins/modules/tower_host.py index 2a2096ab97..d2e59ca67d 100644 --- a/awx_collection/plugins/modules/tower_host.py +++ b/awx_collection/plugins/modules/tower_host.py @@ -123,11 +123,12 @@ def main(): # Create the data that gets sent for create and update host_fields = { 'name': new_name if new_name else name, - 'description': description, 'inventory': inventory_id, 'enabled': enabled, } - if variables: + if description is not None: + host_fields['description'] = description + if variables is not None: host_fields['variables'] = json.dumps(variables) if state == 'absent': diff --git a/awx_collection/plugins/modules/tower_inventory.py b/awx_collection/plugins/modules/tower_inventory.py index 13bee6e51c..32aa79615c 100644 --- a/awx_collection/plugins/modules/tower_inventory.py +++ b/awx_collection/plugins/modules/tower_inventory.py @@ -82,6 +82,7 @@ EXAMPLES = ''' from ..module_utils.tower_api import TowerModule +import json def main(): @@ -122,12 +123,14 @@ def main(): # Create the data that gets sent for create and update inventory_fields = { 'name': name, - 'description': description, 'organization': org_id, - 'variables': variables, 'kind': kind, 'host_filter': host_filter, } + if description is not None: + inventory_fields['description'] = description + if variables is not None: + inventory_fields['variables'] = json.dumps(variables) if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/plugins/modules/tower_inventory_source.py b/awx_collection/plugins/modules/tower_inventory_source.py index 5e49435f5e..48111b45b8 100644 --- a/awx_collection/plugins/modules/tower_inventory_source.py +++ b/awx_collection/plugins/modules/tower_inventory_source.py @@ -209,11 +209,11 @@ def main(): } # Attempt to look up the related items the user specified (these will fail the module if not found) - if credential: + if credential is not None: inventory_source_fields['credential'] = module.resolve_name_to_id('credentials', credential) - if source_project: + if source_project is not None: inventory_source_fields['source_project'] = module.resolve_name_to_id('projects', source_project) - if source_script: + if source_script is not None: inventory_source_fields['source_script'] = module.resolve_name_to_id('inventory_scripts', source_script) OPTIONAL_VARS = ( diff --git a/awx_collection/plugins/modules/tower_organization.py b/awx_collection/plugins/modules/tower_organization.py index 351bf0a7ff..ebb3d7d0cc 100644 --- a/awx_collection/plugins/modules/tower_organization.py +++ b/awx_collection/plugins/modules/tower_organization.py @@ -112,11 +112,11 @@ def main(): # Create the data that gets sent for create and update org_fields = {'name': name} - if description: + if description is not None: org_fields['description'] = description - if custom_virtualenv: + if custom_virtualenv is not None: org_fields['custom_virtualenv'] = custom_virtualenv - if max_hosts: + if max_hosts is not None: org_fields['max_hosts'] = max_hosts if state == 'absent': diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py index c52ffd867d..44245fd352 100644 --- a/awx_collection/plugins/modules/tower_project.py +++ b/awx_collection/plugins/modules/tower_project.py @@ -238,7 +238,6 @@ def main(): # Create the data that gets sent for create and update project_fields = { 'name': name, - 'description': description, 'scm_type': scm_type, 'scm_url': scm_url, 'scm_branch': scm_branch, @@ -251,6 +250,8 @@ def main(): 'scm_update_cache_timeout': scm_update_cache_timeout, 'custom_virtualenv': custom_virtualenv, } + if description is not None: + project_fields['description'] = description if scm_credential is not None: project_fields['credential'] = scm_credential_id if scm_allow_override is not None: diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 599b745c30..a8c6045a20 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -107,9 +107,10 @@ def main(): # Create the data that gets sent for create and update team_fields = { 'name': new_name if new_name else name, - 'description': description, 'organization': org_id } + if description is not None: + team_fields['description'] = description if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this diff --git a/awx_collection/plugins/modules/tower_user.py b/awx_collection/plugins/modules/tower_user.py index 8c7c05edd0..c4d9e68972 100644 --- a/awx_collection/plugins/modules/tower_user.py +++ b/awx_collection/plugins/modules/tower_user.py @@ -118,7 +118,7 @@ def main(): first_name=dict(), last_name=dict(), password=dict(no_log=True), - email=dict(required=False), + email=dict(required=False, default=''), superuser=dict(type='bool', default=False), auditor=dict(type='bool', default=False), state=dict(choices=['present', 'absent'], default='present'), @@ -129,6 +129,7 @@ def main(): # Extract our parameters state = module.params.get('state') + email = module.params.get('email') # Create the data that gets sent for create and update user_fields = { @@ -136,10 +137,11 @@ def main(): 'first_name': module.params.get('first_name'), 'last_name': module.params.get('last_name'), 'password': module.params.get('password'), - 'email': module.params.get('email'), 'superuser': module.params.get('superuser'), 'auditor': module.params.get('auditor'), } + if email is not None: + user_fields['email'] = email # Attempt to look up user based on the provided username user = module.get_one('users', **{