diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 50ca95cea7..7439cbfb8f 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -337,7 +337,7 @@ class TowerAPIModule(TowerModule): # If we have neither of these, then we can try un-authenticated access self.authenticated = True - def delete_if_needed(self, existing_item, on_delete=None): + def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # 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 @@ -363,6 +363,10 @@ class TowerAPIModule(TowerModule): self.json_output['changed'] = True self.json_output['id'] = item_id self.exit_json(**self.json_output) + if auto_exit: + self.exit_json(**self.json_output) + else: + return 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])) @@ -375,7 +379,10 @@ class TowerAPIModule(TowerModule): else: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) else: - self.exit_json(**self.json_output) + if auto_exit: + self.exit_json(**self.json_output) + else: + return self.json_output def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list @@ -403,7 +410,7 @@ class TowerAPIModule(TowerModule): else: self.fail_json(msg="Failed to associate item {0}".format(response['json'].get('detail', response['json']))) - def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, item_type='unknown', associations=None): + def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, auto_exit=True, 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, @@ -430,7 +437,9 @@ class TowerAPIModule(TowerModule): item_name = self.get_item_name(new_item, allow_unknown=True) response = self.post_endpoint(endpoint, **{'data': new_item}) - if response['status_code'] == 201: + + # 200 is response from approval node creation on tower 3.7.3 or awx 15.0.0 or earlier. + if response['status_code'] in [200, 201]: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: @@ -455,8 +464,11 @@ class TowerAPIModule(TowerModule): # 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: + elif auto_exit: self.exit_json(**self.json_output) + else: + last_data = response['json'] + return last_data def _encrypted_changed_warning(self, field, old, warning=False): if not warning: @@ -518,7 +530,7 @@ class TowerAPIModule(TowerModule): return True return False - def update_if_needed(self, existing_item, new_item, on_update=None, associations=None): + def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, 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 @@ -578,14 +590,23 @@ class TowerAPIModule(TowerModule): else: last_data = response['json'] on_update(self, last_data) - else: + elif auto_exit: 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, on_update=on_update, associations=associations) else: - return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations) + if response is None: + last_data = existing_item + else: + last_data = response['json'] + return last_data + + def create_or_update_if_needed( + self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None + ): + if existing_item: + return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations) + else: + return self.create_if_needed( + existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations) def logout(self): if self.authenticated and self.oauth_token_id: diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py index 0705de6785..14e02d6f70 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -91,8 +91,28 @@ options: description: - Name of unified job template to run in the workflow. - Can be a job template, project, inventory source, etc. - - Omit if creating an approval node (not yet implemented). + - Omit if creating an approval node. + - This parameter is mutually exclusive with C(approval_node). type: str + approval_node: + description: + - A dictionary of Name, description, and timeout values for the approval node. + - This parameter is mutually exclusive with C(unified_job_template). + type: dict + suboptions: + name: + description: + - Name of this workflow approval template. + type: str + required: True + description: + description: + - Optional description of this workflow approval template. + type: str + timeout: + description: + - The amount of time (in seconds) before the approval node expires and fails. + type: int all_parents_must_converge: description: - If enabled then the node will only run if all of the parent nodes have met the criteria to reach this node @@ -176,6 +196,7 @@ def main(): diff_mode=dict(type='bool'), verbosity=dict(choices=['0', '1', '2', '3', '4', '5']), unified_job_template=dict(), + approval_node=dict(type='dict'), all_parents_must_converge=dict(type='bool'), success_nodes=dict(type='list', elements='str'), always_nodes=dict(type='list', elements='str'), @@ -183,14 +204,20 @@ def main(): credentials=dict(type='list', elements='str'), state=dict(choices=['present', 'absent'], default='present'), ) + mutually_exclusive = [("unified_job_template", "approval_node")] + required_one_of = [["unified_job_template", "approval_node", "success_nodes", "always_nodes", "failure_nodes"]] # Create a module for ourselves - module = TowerAPIModule(argument_spec=argument_spec) + module = TowerAPIModule( + argument_spec=argument_spec, + mutually_exclusive=mutually_exclusive, + required_one_of=required_one_of, + ) # Extract our parameters identifier = module.params.get('identifier') state = module.params.get('state') - + approval_node = module.params.get('approval_node') new_fields = {} search_fields = {'identifier': identifier} @@ -264,10 +291,43 @@ def main(): # If the state was present and we can let the module build or update the existing item, this will return on its own module.create_or_update_if_needed( existing_item, new_fields, - endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', + endpoint='workflow_job_template_nodes', item_type='workflow_job_template_node', auto_exit=not approval_node, associations=association_fields ) + # Create approval node unified template or update existing + if approval_node: + # Set Approval Fields + new_fields = {} + + # Extract Parameters + if approval_node.get('name') is None: + module.fail_json(msg="Approval node name is required to create approval node.") + if approval_node.get('name') is not None: + new_fields['name'] = approval_node['name'] + if approval_node.get('description') is not None: + new_fields['description'] = approval_node['description'] + if approval_node.get('timeout') is not None: + new_fields['timeout'] = approval_node['timeout'] + + # Find created workflow node ID + search_fields = {'identifier': identifier} + search_fields['workflow_job_template'] = workflow_job_template_id + workflow_job_template_node = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) + workflow_job_template_node_id = workflow_job_template_node['id'] + module.json_output['workflow_node_id'] = workflow_job_template_node_id + existing_item = None + # Due to not able to lookup workflow_approval_templates, find the existing item in another place + if workflow_job_template_node['related'].get('unified_job_template') is not None: + existing_item = module.get_endpoint(workflow_job_template_node['related']['unified_job_template'])['json'] + approval_endpoint = 'workflow_job_template_nodes/{0}/create_approval_template/'.format(workflow_job_template_node_id) + module.create_or_update_if_needed( + existing_item, new_fields, + endpoint=approval_endpoint, item_type='workflow_job_template_approval_node', + associations=association_fields + ) + module.exit_json(**module.json_output) + if __name__ == '__main__': main() diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py index 3626c42239..08bbd8b0ad 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -44,8 +44,8 @@ no_api_parameter_ok = { # We take an organization here to help with the lookups only 'tower_job_template': ['survey_spec', 'organization'], 'tower_inventory_source': ['organization'], - # Organization is how we are looking up job templates - 'tower_workflow_job_template_node': ['organization'], + # Organization is how we are looking up job templates, Approval node is for workflow_approval_templates + 'tower_workflow_job_template_node': ['organization', 'approval_node'], # Survey is how we handle associations 'tower_workflow_job_template': ['survey'], # ad hoc commands support interval and timeout since its more like tower_job_launc diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py index 935b01541a..6127fde27e 100644 --- a/awx_collection/test/awx/test_workflow_job_template_node.py +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -4,7 +4,7 @@ __metaclass__ = type import pytest -from awx.main.models import WorkflowJobTemplateNode, WorkflowJobTemplate, JobTemplate +from awx.main.models import WorkflowJobTemplateNode, WorkflowJobTemplate, JobTemplate, UnifiedJobTemplate @pytest.fixture @@ -53,27 +53,26 @@ def test_create_workflow_job_template_node(run_module, admin_user, wfjt, job_tem @pytest.mark.django_db -def test_create_workflow_job_template_node_no_template(run_module, admin_user, wfjt, job_template): - """This is a part of the API contract for creating approval nodes - and at some point in the future, tha feature will be supported by the collection - """ +def test_create_workflow_job_template_node_approval_node(run_module, admin_user, wfjt, job_template): + """This is a part of the API contract for creating approval nodes""" this_identifier = '42🐉' result = run_module('tower_workflow_job_template_node', { 'identifier': this_identifier, 'workflow_job_template': wfjt.name, 'organization': wfjt.organization.name, + 'approval_node': {'name': 'foo-jt-approval'} }, admin_user) assert not result.get('failed', False), result.get('msg', result) assert result.get('changed', False), result - node = WorkflowJobTemplateNode.objects.get(pk=result['id']) - # node = WorkflowJobTemplateNode.objects.first() + node = WorkflowJobTemplateNode.objects.get(identifier=this_identifier) + approval_node = UnifiedJobTemplate.objects.get(name='foo-jt-approval') - assert result['id'] == node.id + assert result['id'] == approval_node.id assert node.identifier == this_identifier assert node.workflow_job_template_id == wfjt.id - assert node.unified_job_template_id is None + assert node.unified_job_template_id is approval_node.id @pytest.mark.django_db diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 393fbe33da..1542b42029 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -9,6 +9,7 @@ demo_project_name: "AWX-Collection-tests-tower_workflow_job_template-proj-{{ test_id }}" jt1_name: "AWX-Collection-tests-tower_workflow_job_template-jt1-{{ test_id }}" jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ test_id }}" + approval_node_name: "AWX-Collection-tests-tower_workflow_approval_node-{{ test_id }}" lab1: "AWX-Collection-tests-tower_job_template-lab1-{{ test_id }}" wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}" email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}" @@ -145,6 +146,44 @@ unified_job_template: "{{ jt1_name }}" workflow: "{{ wfjt_name }}" +- name: Fail if no name is set for approval + tower_workflow_job_template_node: + identifier: approval_test + approval_node: + description: "{{ approval_node_name }}" + workflow: "{{ wfjt_name }}" + register: no_name_results + ignore_errors: true + +- assert: + that: + - "no_name_results.msg == 'Approval node name is required to create approval node.'" + +- name: Create approval node + tower_workflow_job_template_node: + identifier: approval_test + approval_node: + name: "{{ approval_node_name }}" + timeout: 900 + workflow: "{{ wfjt_name }}" + +- name: Create link for root node + tower_workflow_job_template_node: + identifier: root + workflow: "{{ wfjt_name }}" + success_nodes: + - approval_test + always_nodes: + - leaf + +- name: Delete approval node + tower_workflow_job_template_node: + identifier: approval_test + approval_node: + name: "{{ approval_node_name }}" + state: absent + workflow: "{{ wfjt_name }}" + - name: Add started notifications to workflow job template tower_workflow_job_template: name: "{{ wfjt_name }}"