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 + }