diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index e395f53fe9..0725dc6fe8 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -222,6 +222,8 @@ class TowerModule(AnsibleModule): def get_all_endpoint(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) + if 'next' not in response['json']: + raise RuntimeError('Expected list from API at {0}, got: {1}'.format(endpoint, response)) next_page = response['json']['next'] if response['json']['count'] > 10000: @@ -414,14 +416,14 @@ class TowerModule(AnsibleModule): except(Exception) as excinfo: self.fail_json(changed=False, msg='Failed check mode: {0}'.format(excinfo)) - 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, + def delete_if_needed(self, existing_item, on_delete=None): + # This will exit from the module on its own. + # If 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: + # This 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 + # Note: common error codes from the Tower API can cause the module to fail if existing_item: # If we have an item, we can try to delete it try: @@ -440,9 +442,7 @@ class TowerModule(AnsibleModule): response = self.delete_endpoint(item_url) - if not handle_response: - return response - elif response['status_code'] in [202, 204]: + if response['status_code'] in [202, 204]: if on_delete: on_delete(self, response['json']) self.json_output['changed'] = True @@ -460,21 +460,40 @@ class TowerModule(AnsibleModule): 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) + 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, + def modify_associations(self, association_endpoint, new_association_list): + # First get the existing associations + response = self.get_all_endpoint(association_endpoint) + existing_associated_ids = [association['id'] for association in response['json']['results']] + + # Disassociate anything that is in existing_associated_ids but not in new_association_list + ids_to_remove = list(set(existing_associated_ids) - set(new_association_list)) + for an_id in ids_to_remove: + response = self.post_endpoint(association_endpoint, **{'data': {'id': int(an_id), 'disassociate': True}}) + if response['status_code'] == 204: + self.json_output['changed'] = True + else: + self.fail_json(msg="Failed to disassociate item {0}".format(response['json']['detail'])) + + # Associate anything that is in new_association_list but not in `association` + for an_id in list(set(new_association_list) - set(existing_associated_ids)): + response = self.post_endpoint(association_endpoint, **{'data': {'id': int(an_id)}}) + if response['status_code'] == 204: + self.json_output['changed'] = True + else: + self.fail_json(msg="Failed to associate item {0}".format(response['json']['detail'])) + + def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, item_type='unknown', associations=None): + + # This will exit from the module on its own + # If 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: + # This will return one of two things: # 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 - # + # Note: common error codes from the Tower API can cause the module to fail + if not endpoint: self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type)) @@ -482,11 +501,7 @@ class TowerModule(AnsibleModule): try: 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) + self.fail_json(msg="Unable to process create of item due to missing data {0}".format(ke)) else: # If we don't have an exisitng_item, we can try to create it @@ -495,9 +510,7 @@ class TowerModule(AnsibleModule): 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: + if response['status_code'] == 201: self.json_output['name'] = 'unknown' if 'name' in response['json']: self.json_output['name'] = response['json']['name'] @@ -506,10 +519,6 @@ class TowerModule(AnsibleModule): self.json_output['name'] = response['json']['username'] 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])) @@ -518,16 +527,28 @@ class TowerModule(AnsibleModule): else: 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, + # Process any associations with this item + if associations is not None: + for association_type in associations: + self.modify_associations(response, associations[association_type]) + + # If we have an on_create method and we actually changed something we can call on_create + if on_create is not None and self.json_output['changed']: + on_create(self, response['json']) + else: + self.exit_json(**self.json_output) + + def update_if_needed(self, existing_item, new_item, on_update=None, associations=None): + # This will exit from the module on its own + # If 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: + # This will return one of three things: # 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 + # Note: common error codes from the Tower API can cause the module to fail if existing_item: + # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] @@ -540,6 +561,7 @@ class TowerModule(AnsibleModule): except KeyError as ke: self.fail_json(msg="Unable to process update of item due to missing data {0}".format(ke)) + # Check to see if anything within the item requires the item to be updated needs_update = False for field in new_item: existing_field = existing_item.get(field, None) @@ -550,40 +572,37 @@ class TowerModule(AnsibleModule): needs_update = True break + # If we decided the item needs to be updated, update it + self.json_output['id'] = item_id if needs_update: response = self.patch_endpoint(item_url, **{'data': new_item}) - if not handle_response: - return response - elif response['status_code'] == 200: + if response['status_code'] == 200: self.json_output['changed'] = True - self.json_output['id'] = item_id - if on_update is None: - self.exit_json(**self.json_output) - else: - 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 {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 - 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") + raise RuntimeError('update_if_needed called incorrectly without existing_item') - 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): + # Process any associations with this item + if associations is not None: + for association_type, id_list in associations.items(): + endpoint = '{0}{1}/'.format(item_url, association_type) + self.modify_associations(endpoint, id_list) + + # If we change something and have an on_change call it + if on_update is not None and self.json_output['changed']: + on_update(self, response['json']) + else: + self.exit_json(**self.json_output) + + def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, associations=None): if existing_item: - return self.update_if_needed(existing_item, new_item, handle_response=handle_response, on_update=on_update) + return self.update_if_needed(existing_item, new_item, on_update=on_update, associations=associations) else: - return self.create_if_needed(existing_item, new_item, endpoint, handle_response=handle_response, on_create=on_create, item_type=item_type) + return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations) def logout(self): if self.oauth_token_id is not None and self.username and self.password: diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index 4b68a50bb0..c6a1492c99 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -41,6 +41,18 @@ options: description: - Variables to use for the group. type: dict + hosts: + description: + - List of hosts that should be put in this group. + required: False + type: list + elements: str + groups: + description: + - List of groups that should be nested inside in this group. + required: False + type: list + elements: str state: description: - Desired state of the resource. @@ -85,6 +97,8 @@ def main(): description=dict(required=False), inventory=dict(required=True), variables=dict(type='dict', required=False), + hosts=dict(type='list', elements='str'), + groups=dict(type='list', elements='str'), state=dict(choices=['present', 'absent'], default='present'), ) @@ -120,12 +134,31 @@ def main(): if variables is not None: group_fields['variables'] = json.dumps(variables) + association_fields = {} + for resource, relationship in (('hosts', 'hosts'), ('groups', 'children')): + name_list = module.params.get(resource) + if name_list is None: + continue + id_list = [] + for sub_name in name_list: + sub_obj = module.get_one(resource, **{ + 'data': {'inventory': inventory_id, 'name': sub_name} + }) + if sub_obj is None: + module.fail_json(msg='Could not find {0} with name {1}'.format(resource, sub_name)) + id_list.append(sub_obj['id']) + if id_list: + association_fields[relationship] = id_list + 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') + module.create_or_update_if_needed( + group, group_fields, endpoint='groups', item_type='group', + associations=association_fields + ) if __name__ == '__main__': diff --git a/awx_collection/test/awx/test_group.py b/awx_collection/test/awx/test_group.py index 6d49ca2e49..a366a310ba 100644 --- a/awx_collection/test/awx/test_group.py +++ b/awx_collection/test/awx/test_group.py @@ -3,7 +3,7 @@ __metaclass__ = type import pytest -from awx.main.models import Organization, Inventory, Group +from awx.main.models import Organization, Inventory, Group, Host @pytest.mark.django_db @@ -32,6 +32,30 @@ def test_create_group(run_module, admin_user): } +@pytest.mark.django_db +def test_associate_hosts_and_groups(run_module, admin_user, organization): + inv = Inventory.objects.create(name='test-inv', organization=organization) + group = Group.objects.create(name='Test Group', inventory=inv) + + inv_hosts = [Host.objects.create(inventory=inv, name='foo{0}'.format(i)) for i in range(3)] + group.hosts.add(inv_hosts[0], inv_hosts[1]) + + child = Group.objects.create(inventory=inv, name='child_group') + + result = run_module('tower_group', dict( + name='Test Group', + inventory='test-inv', + hosts=[inv_hosts[1].name, inv_hosts[2].name], + groups=[child.name], + state='present' + ), admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result['changed'] is True + + assert set(group.hosts.all()) == set([inv_hosts[1], inv_hosts[2]]) + assert set(group.children.all()) == set([child]) + + @pytest.mark.django_db def test_tower_group_idempotent(run_module, admin_user): # https://github.com/ansible/ansible/issues/46803