From ec312358e27478c9a1869635025f68f6ff57c169 Mon Sep 17 00:00:00 2001 From: sean-m-ssullivan Date: Thu, 22 Apr 2021 13:46:59 -0500 Subject: [PATCH] fix completeness --- .../plugins/module_utils/tower_api.py | 34 ++ .../modules/tower_workflow_approval.py | 125 +++++++ .../modules/tower_workflow_node_wait.py | 115 ++++++ awx_collection/test/awx/test_completeness.py | 8 +- .../tower_workflow_launch/tasks/main.yml | 339 +++++++++++------- 5 files changed, 494 insertions(+), 127 deletions(-) create mode 100644 awx_collection/plugins/modules/tower_workflow_approval.py create mode 100644 awx_collection/plugins/modules/tower_workflow_node_wait.py diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index c637f0f7b7..24977b3119 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -740,3 +740,37 @@ class TowerAPIModule(TowerModule): def wait_output(self, response): for k in ('id', 'status', 'elapsed', 'started', 'finished'): self.json_output[k] = response['json'].get(k) + + def wait_on_workflow_node_url(self, url, object_name, object_type, timeout=30, interval=10, **kwargs): + # Grab our start time to compare against for the timeout + start = time.time() + result = self.get_endpoint(url, **kwargs) + + while result["json"]["count"] == 0: + # If we are past our time out fail with a message + if timeout and timeout < time.time() - start: + # Account for Legacy messages + self.json_output["msg"] = "Monitoring of {0} - {1} aborted due to timeout, {2}".format(object_type, object_name, url) + self.wait_output(result) + self.fail_json(**self.json_output) + + # Put the process to sleep for our interval + time.sleep(interval) + result = self.get_endpoint(url, **kwargs) + + if object_type == "Workflow Approval": + # Approval jobs have no elapsed time so return + return result["json"]["results"][0] + else: + # Removed time so far from timeout. + revised_timeout = timeout - (time.time() - start) + # Now that Job has been found, wait for it to finish + result = self.wait_on_url( + url=result["json"]["results"][0]["related"]["job"], + object_name=object_name, + object_type=object_type, + timeout=revised_timeout, + interval=interval, + ) + self.json_output["job_data"] = result["json"] + return result diff --git a/awx_collection/plugins/modules/tower_workflow_approval.py b/awx_collection/plugins/modules/tower_workflow_approval.py new file mode 100644 index 0000000000..3bbd318db6 --- /dev/null +++ b/awx_collection/plugins/modules/tower_workflow_approval.py @@ -0,0 +1,125 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# (c) 2021, Sean Sullivan +# 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: tower_workflow_approval +author: "Sean Sullivan (@sean-m-sullivan)" +short_description: Approve an approval node in a workflow job. +description: + - Approve an approval node in a workflow job. See + U(https://www.ansible.com/tower) for an overview. +options: + workflow_job_id: + description: + - ID of the workflow job to monitor for approval. + required: True + type: int + name: + description: + - Name of the Approval node to approve or deny. + required: True + type: str + action: + description: + - Type of action to take. + choices: ["approve", "deny"] + default: "approve" + type: str + interval: + description: + - The interval in sections, to request an update from Tower. + required: False + default: 1 + type: float + timeout: + description: + - Maximum time in seconds to wait for a workflow job to to reach approval node. + default: 10 + type: int +extends_documentation_fragment: awx.awx.auth +""" + + +EXAMPLES = """ +- name: Launch a workflow with a timeout of 10 seconds + tower_workflow_launch: + workflow_template: "Test Workflow" + wait: False + register: workflow + +- name: Wait for approval node to activate and approve + tower_workflow_approval: + workflow_job_id: "{{ workflow.id }}" + name: Approve Me + interval: 10 + timeout: 20 + action: deny +""" + +RETURN = """ + +""" + + +from ..module_utils.tower_api import TowerAPIModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + workflow_job_id=dict(type="int", required=True), + name=dict(required=True), + action=dict(choices=["approve", "deny"], default="approve"), + timeout=dict(type="int", default=10), + interval=dict(type="float", default=1), + ) + + # Create a module for ourselves + module = TowerAPIModule(argument_spec=argument_spec) + + # Extract our parameters + workflow_job_id = module.params.get("workflow_job_id") + name = module.params.get("name") + action = module.params.get("action") + timeout = module.params.get("timeout") + interval = module.params.get("interval") + + # Attempt to look up workflow job based on the provided id + approval_job = module.wait_on_workflow_node_url( + url="workflow_jobs/{0}/workflow_nodes/".format(workflow_job_id), + object_name=name, + object_type="Workflow Approval", + timeout=timeout, + interval=interval, + **{ + "data": { + "job__name": name, + } + } + ) + response = module.post_endpoint("{0}{1}".format(approval_job["related"]["job"], action)) + if response["status_code"] == 204: + module.json_output["changed"] = True + + # Attempt to look up jobs based on the status + module.exit_json(**module.json_output) + + +if __name__ == "__main__": + main() diff --git a/awx_collection/plugins/modules/tower_workflow_node_wait.py b/awx_collection/plugins/modules/tower_workflow_node_wait.py new file mode 100644 index 0000000000..1510b4f43f --- /dev/null +++ b/awx_collection/plugins/modules/tower_workflow_node_wait.py @@ -0,0 +1,115 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# (c) 2021, Sean Sullivan +# 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: tower_workflow_node_wait +author: "Sean Sullivan (@sean-m-sullivan)" +short_description: Approve an approval node in a workflow job. +description: + - Approve an approval node in a workflow job. See + U(https://www.ansible.com/tower) for an overview. +options: + workflow_job_id: + description: + - ID of the workflow job to monitor for node. + required: True + type: int + name: + description: + - Name of the workflow node to wait on. + required: True + type: str + interval: + description: + - The interval in sections, to request an update from Tower. + required: False + default: 1 + type: float + timeout: + description: + - Maximum time in seconds to wait for a workflow job to to reach approval node. + default: 10 + type: int +extends_documentation_fragment: awx.awx.auth +""" + + +EXAMPLES = """ +- name: Launch a workflow with a timeout of 10 seconds + tower_workflow_launch: + workflow_template: "Test Workflow" + wait: False + register: workflow + +- name: Wait for a workflow node to finish + tower_workflow_node_wait: + workflow_job_id: "{{ workflow.id }}" + name: Approval Data Step + timeout: 120 +""" + +RETURN = """ + +""" + + +from ..module_utils.tower_api import TowerAPIModule +import time + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + workflow_job_id=dict(type="int", required=True), + name=dict(required=True), + timeout=dict(type="int", default=10), + interval=dict(type="float", default=1), + ) + + # Create a module for ourselves + module = TowerAPIModule(argument_spec=argument_spec) + + # Extract our parameters + workflow_job_id = module.params.get("workflow_job_id") + name = module.params.get("name") + timeout = module.params.get("timeout") + interval = module.params.get("interval") + + node_url = "workflow_jobs/{0}/workflow_nodes/?job__name={1}".format(workflow_job_id, name) + # Attempt to look up workflow job node based on the provided id + + result = module.wait_on_workflow_node_url( + url="workflow_jobs/{0}/workflow_nodes/".format(workflow_job_id), + object_name=name, + object_type="Workflow Node", + timeout=timeout, + interval=interval, + **{ + "data": { + "job__name": name, + } + } + ) + + # Attempt to look up jobs based on the status + 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 66c5ee8604..353dcaea2d 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -36,6 +36,7 @@ no_endpoint_for_module = [ 'tower_receive', 'tower_send', 'tower_workflow_launch', + 'tower_workflow_node_wait', 'tower_job_cancel', 'tower_workflow_template', 'tower_ad_hoc_command_wait', @@ -64,16 +65,17 @@ no_api_parameter_ok = { 'tower_ad_hoc_command': ['interval', 'timeout', 'wait'], # tower_group parameters to perserve hosts and children. 'tower_group': ['preserve_existing_children', 'preserve_existing_hosts'], + # tower_workflow_approval parameters that do not apply when approving an approval node. + 'tower_workflow_approval': ['action', 'interval', 'timeout', 'workflow_job_id'], } # When this tool was created we were not feature complete. Adding something in here indicates a module # that needs to be developed. If the module is found on the file system it will auto-detect that the # work is being done and will bypass this check. At some point this module should be removed from this list. -needs_development = [ - 'tower_workflow_approval', -] +needs_development = ['tower_inventory_script'] needs_param_development = { 'tower_host': ['instance_id'], + 'tower_workflow_approval': ['description', 'execution_environment'], } # ----------------------------------------------------------------------------------------------------------- diff --git a/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml index 5cd4e06d1e..ba75c7ccf0 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_launch/tasks/main.yml @@ -7,146 +7,237 @@ - name: Generate names set_fact: wfjt_name1: "AWX-Collection-tests-tower_workflow_launch--wfjt1-{{ test_id }}" + wfjt_name2: "AWX-Collection-tests-tower_workflow_launch--wfjt1-{{ test_id }}-2" + approval_node_name: "AWX-Collection-tests-tower_workflow_launch_approval_node-{{ test_id }}" -- name: Create our workflow - tower_workflow_job_template: - name: "{{ wfjt_name1 }}" - state: present +- block: -- name: Add a node - tower_workflow_job_template_node: - workflow_job_template: "{{ wfjt_name1 }}" - unified_job_template: "Demo Job Template" - identifier: leaf - register: new_node + - name: Create our workflow + tower_workflow_job_template: + name: "{{ wfjt_name1 }}" + state: present -- name: Connect to Tower server but request an invalid workflow - tower_workflow_launch: - workflow_template: "Does Not Exist" - ignore_errors: true - register: result + - name: Add a node + tower_workflow_job_template_node: + workflow_job_template: "{{ wfjt_name1 }}" + unified_job_template: "Demo Job Template" + identifier: leaf + register: new_node -- assert: - that: - - result is failed - - "'Unable to find workflow job template' in result.msg" + - name: Connect to Tower server but request an invalid workflow + tower_workflow_launch: + workflow_template: "Does Not Exist" + ignore_errors: true + register: result -- name: Run the workflow without waiting (this should just give us back a job ID) - tower_workflow_launch: - workflow_template: "{{ wfjt_name1 }}" - wait: false - ignore_errors: true - register: result + - assert: + that: + - result is failed + - "'Unable to find workflow job template' in result.msg" -- assert: - that: - - result is not failed - - "'id' in result['job_info']" + - name: Run the workflow without waiting (this should just give us back a job ID) + tower_workflow_launch: + workflow_template: "{{ wfjt_name1 }}" + wait: false + ignore_errors: true + register: result -- name: Kick off a workflow and wait for it, but only for a second - tower_workflow_launch: - workflow_template: "{{ wfjt_name1 }}" - timeout: 1 - ignore_errors: true - register: result + - assert: + that: + - result is not failed + - "'id' in result['job_info']" -- assert: - that: - - result is failed - - "'Monitoring of Workflow Job - {{ wfjt_name1 }} aborted due to timeout' in result.msg" + - name: Kick off a workflow and wait for it, but only for a second + tower_workflow_launch: + workflow_template: "{{ wfjt_name1 }}" + timeout: 1 + ignore_errors: true + register: result -- name: Kick off a workflow and wait for it - tower_workflow_launch: - workflow_template: "{{ wfjt_name1 }}" - ignore_errors: true - register: result + - assert: + that: + - result is failed + - "'Monitoring of Workflow Job - {{ wfjt_name1 }} aborted due to timeout' in result.msg" -- assert: - that: - - result is not failed - - "'id' in result['job_info']" + - name: Kick off a workflow and wait for it + tower_workflow_launch: + workflow_template: "{{ wfjt_name1 }}" + ignore_errors: true + register: result -- name: Kick off a workflow with extra_vars but not enabled - tower_workflow_launch: - workflow_template: "{{ wfjt_name1 }}" - extra_vars: - var1: My First Variable - var2: My Second Variable - ignore_errors: true - register: result + - assert: + that: + - result is not failed + - "'id' in result['job_info']" -- assert: - that: - - result is failed - - "'The field extra_vars was specified but the workflow job template does not allow for it to be overridden' in result.errors" + - name: Kick off a workflow with extra_vars but not enabled + tower_workflow_launch: + workflow_template: "{{ wfjt_name1 }}" + extra_vars: + var1: My First Variable + var2: My Second Variable + ignore_errors: true + register: result -- name: Prompt the workflow's with survey - tower_workflow_job_template: - name: "{{ wfjt_name1 }}" - state: present - survey_enabled: true - ask_variables_on_launch: false - survey: - name: '' - description: '' - spec: - - question_name: Basic Name - question_description: Name - required: true - type: text - variable: basic_name - min: 0 - max: 1024 - default: '' - choices: '' - new_question: true - - question_name: Choose yes or no? - question_description: Choosing yes or no. - required: false - type: multiplechoice - variable: option_true_false - min: - max: - default: 'yes' - choices: |- - yes - no - new_question: true + - assert: + that: + - result is failed + - "'The field extra_vars was specified but the workflow job template does not allow for it to be overridden' in result.errors" -- name: Kick off a workflow with survey - tower_workflow_launch: - workflow_template: "{{ wfjt_name1 }}" - extra_vars: - basic_name: My First Variable - option_true_false: 'no' - ignore_errors: true - register: result + - name: Prompt the workflow's with survey + tower_workflow_job_template: + name: "{{ wfjt_name1 }}" + state: present + survey_enabled: true + ask_variables_on_launch: false + survey: + name: '' + description: '' + spec: + - question_name: Basic Name + question_description: Name + required: true + type: text + variable: basic_name + min: 0 + max: 1024 + default: '' + choices: '' + new_question: true + - question_name: Choose yes or no? + question_description: Choosing yes or no. + required: false + type: multiplechoice + variable: option_true_false + min: + max: + default: 'yes' + choices: |- + yes + no + new_question: true -- assert: - that: - - result is not failed + - name: Kick off a workflow with survey + tower_workflow_launch: + workflow_template: "{{ wfjt_name1 }}" + extra_vars: + basic_name: My First Variable + option_true_false: 'no' + ignore_errors: true + register: result -- name: Prompt the workflow's extra_vars on launch - tower_workflow_job_template: - name: "{{ wfjt_name1 }}" - state: present - ask_variables_on_launch: true + - assert: + that: + - result is not failed -- name: Kick off a workflow with extra_vars - tower_workflow_launch: - workflow_template: "{{ wfjt_name1 }}" - extra_vars: - basic_name: My First Variable - var1: My First Variable - var2: My Second Variable - ignore_errors: true - register: result + - name: Prompt the workflow's extra_vars on launch + tower_workflow_job_template: + name: "{{ wfjt_name1 }}" + state: present + ask_variables_on_launch: true -- assert: - that: - - result is not failed + - name: Kick off a workflow with extra_vars + tower_workflow_launch: + workflow_template: "{{ wfjt_name1 }}" + extra_vars: + basic_name: My First Variable + var1: My First Variable + var2: My Second Variable + ignore_errors: true + register: result -- name: Clean up test workflow - tower_workflow_job_template: - name: "{{ wfjt_name1 }}" - state: absent + - assert: + that: + - result is not failed + + - name: Test waiting for an approval node that doesn't exit on the last workflow for failure. + tower_workflow_approval: + workflow_job_id: "{{ result.id }}" + name: Test workflow approval + interval: 1 + timeout: 2 + action: deny + register: result + ignore_errors: true + + - assert: + that: + - result is failed + - "'Monitoring of Workflow Approval - Test workflow approval aborted due to timeout' in result.msg" + + - name: Create new Workflow + tower_workflow_job_template: + name: "{{ wfjt_name2 }}" + state: present + + - name: Add a job node + tower_workflow_job_template_node: + workflow_job_template: "{{ wfjt_name2 }}" + unified_job_template: "Demo Job Template" + identifier: leaf + + # Test tower_workflow_approval and tower_workflow_node_wait + - name: Create approval node + tower_workflow_job_template_node: + identifier: approval_test + approval_node: + name: "{{ approval_node_name }}" + timeout: 900 + workflow: "{{ wfjt_name2 }}" + + - name: Create link for approval node + tower_workflow_job_template_node: + identifier: approval_test + workflow: "{{ wfjt_name2 }}" + always_nodes: + - leaf + + - name: Run the workflow without waiting This should pause waiting for approval + tower_workflow_launch: + workflow_template: "{{ wfjt_name2 }}" + wait: false + ignore_errors: true + register: wfjt_info + + - name: Wait for Job node wait to fail as it is waiting on approval + awx.awx.tower_workflow_node_wait: + workflow_job_id: "{{ wfjt_info.id }}" + name: Demo Job Template + interval: 1 + timeout: 5 + register: result + ignore_errors: true + + - assert: + that: + - result is failed + - "'Monitoring of Workflow Node - Demo Job Template aborted due to timeout' in result.msg" + + - name: Wait for approval node to activate and approve + awx.awx.tower_workflow_approval: + workflow_job_id: "{{ wfjt_info.id }}" + name: "{{ approval_node_name }}" + interval: 1 + timeout: 10 + action: deny + register: result + + - assert: + that: + - result is not failed + - result is changed + + - name: Wait for workflow job to finish max 120s + tower_job_wait: + job_id: "{{ wfjt_info.id }}" + timeout: 120 + job_type: "workflow_jobs" + + always: + - name: Clean up test workflow + tower_workflow_job_template: + name: "{{ item }}" + state: absent + with_items: + - "{{ wfjt_name1 }}" + - "{{ wfjt_name2 }}"