diff --git a/awx_collection/plugins/modules/tower_job_wait.py b/awx_collection/plugins/modules/tower_job_wait.py index 00011468e0..404db043b2 100644 --- a/awx_collection/plugins/modules/tower_job_wait.py +++ b/awx_collection/plugins/modules/tower_job_wait.py @@ -28,24 +28,33 @@ options: - ID of the job to monitor. required: True type: int + interval: + description: + - The interval in sections, to request an update from Tower. + - For backwards compatability if unset this will be set to the average of min and max intervals + required: False + default: 1 + type: float min_interval: description: - Minimum interval in seconds, to request an update from Tower. - default: 1 + - deprecated, use interval instead type: float max_interval: description: - Maximum interval in seconds, to request an update from Tower. - default: 30 + - deprecated, use interval instead type: float timeout: description: - Maximum time in seconds to wait for a job to finish. type: int - -requirements: -- ansible-tower-cli >= 3.0.2 - + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str + version_added: "3.7" extends_documentation_fragment: awx.awx.auth ''' @@ -90,78 +99,94 @@ status: ''' -from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode -from ansible.module_utils.six import PY2 -from ansible.module_utils.six.moves import cStringIO as StringIO -from codecs import getwriter +from ..module_utils.tower_api import TowerModule +import time -try: - import tower_cli - import tower_cli.exceptions as exc +def check_job(module, job_url): + response = module.get_endpoint(job_url) + if response['status_code'] != 200: + module.fail_json(msg="Unable to read job from Tower {0}: {1}".format(response['status_code'], module.extract_errors_from_response(response))) - from tower_cli.conf import settings -except ImportError: - pass + # Since we were successful, extract the fields we want to return + for k in ('id', 'status', 'elapsed', 'started', 'finished'): + module.json_output[k] = response['json'].get(k) + + # And finally return the payload + return response['json'] 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), timeout=dict(type='int'), - min_interval=dict(type='float', default=1), - max_interval=dict(type='float', default=30), + min_interval=dict(type='float'), + max_interval=dict(type='float'), + interval=dict(type='float', default=1), ) - module = TowerModule( - argument_spec, - supports_check_mode=True - ) + # Create a module for ourselves + module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) - json_output = {} - fail_json = None + # Extract our parameters + job_id = module.params.get('job_id') + timeout = module.params.get('timeout') + min_interval = module.params.get('min_interval') + max_interval = module.params.get('max_interval') + interval = module.params.get('interval') - 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() + if min_interval is not None or max_interval is not None: + # We can't tell if we got the default or if someone actually set this to 1. + # For now if we find 1 and had a min or max then we will do the average logic. + if interval == 1: + if not min_interval: + min_interval = 1 + if not max_interval: + max_interval = 30 + interval = abs((min_interval + max_interval) / 2) + module.deprecate( + msg="Min and max interval have been deprecated, please use interval instead; interval will be set to {0}".format(interval), + version="3.7" + ) - # tower-cli gets very noisy when monitoring. - # We pass in our our outfile to suppress the out during our monitor call. - if PY2: - outfile = getwriter('utf-8')(StringIO()) - else: - outfile = StringIO() - params['outfile'] = outfile + # Attempt to look up job based on the provided id + job = module.get_one('jobs', **{ + 'data': { + 'id': job_id, + } + }) - job_id = params.get('job_id') - try: - result = job.monitor(job_id, **params) - except exc.Timeout: - result = job.status(job_id) - result['id'] = job_id - json_output['msg'] = 'Timeout waiting for job to finish.' - json_output['timeout'] = True - except exc.NotFound as excinfo: - fail_json = dict(msg='Unable to wait, no job_id {0} found: {1}'.format(job_id, excinfo), changed=False) - except exc.JobFailure as excinfo: - fail_json = dict(msg='Job with id={0} failed, error: {1}'.format(job_id, excinfo)) - fail_json['success'] = False - result = job.get(job_id) - for k in ('id', 'status', 'elapsed', 'started', 'finished'): - fail_json[k] = result.get(k) - except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: - fail_json = dict(msg='Unable to wait for job: {0}'.format(excinfo), changed=False) + if job is None: + module.fail_json(msg='Unable to wait on job {0}; that ID does not exist in Tower.'.format(job_id)) - if fail_json is not None: - module.fail_json(**fail_json) + job_url = job['url'] - json_output['success'] = True - for k in ('id', 'status', 'elapsed', 'started', 'finished'): - json_output[k] = result.get(k) + # Grab our start time to compare against for the timeout + start = time.time() - module.exit_json(**json_output) + # Get the initial job status from Tower, this will exit if there are any issues with the HTTP call + result = check_job(module, job_url) + + # Loop while the job is not yet completed + while not result['finished']: + # If we are past our time out fail with a message + if timeout and timeout < time.time() - start: + module.json_output['msg'] = "Monitoring aborted due to timeout" + module.fail_json(**module.json_output) + + # Put the process to sleep for our interval + time.sleep(interval) + + # Check the job again + result = check_job(module, job_url) + + # If the job has failed, we want to raise an Exception for that so we get a non-zero response. + if result['failed']: + module.json_output['msg'] = 'Job with id {0} failed'.format(job_id) + module.fail_json(**module.json_output) + + module.exit_json(**module.json_output) if __name__ == '__main__': diff --git a/awx_collection/test/awx/test_job.py b/awx_collection/test/awx/test_job.py index 2fb616c2ac..5e478d9685 100644 --- a/awx_collection/test/awx/test_job.py +++ b/awx_collection/test/awx/test_job.py @@ -18,7 +18,7 @@ def test_job_wait_successful(run_module, admin_user): assert result.pop('started', '')[:10] == str(job.started)[:10] assert result == { "status": "successful", - "success": True, + "changed": False, "elapsed": str(job.elapsed), "id": job.id } @@ -36,10 +36,10 @@ def test_job_wait_failed(run_module, admin_user): assert result == { "status": "failed", "failed": True, - "success": False, + "changed": False, "elapsed": str(job.elapsed), "id": job.id, - "msg": "Job with id=1 failed, error: Job failed." + "msg": "Job with id 1 failed" } @@ -50,7 +50,6 @@ def test_job_wait_not_found(run_module, admin_user): ), admin_user) result.pop('invocation', None) assert result == { - "changed": False, "failed": True, - "msg": "Unable to wait, no job_id 42 found: The requested object could not be found." + "msg": "Unable to wait on job 42; that ID does not exist in Tower." } diff --git a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml index 589ea8629f..a3e1338e02 100644 --- a/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_job_wait/tasks/main.yml @@ -1,18 +1,50 @@ --- -- name: Launch a Job Template - tower_job_launch: - job_template: "Demo Job Template" - register: job +- name: Generate random string for template and project + set_fact: + jt_name: "AWX-Collection-tests-tower_job_wait-long_running-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + proj_name: "AWX-Collection-tests-tower_job_wait-long_running-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + +- name: Assure that the demo project exists + tower_project: + name: "{{ proj_name }}" + scm_type: 'git' + scm_url: 'https://github.com/ansible/test-playbooks.git' + scm_update_on_launch: true + organization: Default + +- name: Create a job template + tower_job_template: + name: "{{ jt_name }}" + playbook: "sleep.yml" + job_type: run + project: "{{ proj_name }}" + inventory: "Demo Inventory" + +- name: Check deprecation warnings + tower_job_wait: + min_interval: 10 + max_interval: 20 + job_id: "99999999" + register: result + ignore_errors: true - assert: that: - - "job is changed" - - "job.status == 'pending'" + - "'Min and max interval have been deprecated, please use interval instead; interval will be set to 15'" -- name: Wait for the Job to finish +- name: Validate that interval superceeds min/max tower_job_wait: - job_id: "{{ job.id }}" - timeout: 60 + min_interval: 10 + max_interval: 20 + interval: 12 + job_id: "99999999" + register: result + ignore_errors: true + +- assert: + that: + - "result.msg =='Unable to wait on job 99999999; that ID does not exist in Tower.' or + 'min and max interval have been depricated, please use interval instead, interval will be set to 12'" - name: Check module fails with correct msg tower_job_wait: @@ -22,4 +54,82 @@ - assert: that: - - "result.msg =='Unable to wait, no job_id 99999999 found: The requested object could not be found.'" + - result is failed + - "result.msg =='Unable to wait, no job_id 99999999 found: The requested object could not be found.' or + 'Unable to wait on job 99999999; that ID does not exist in Tower.'" + +- name: Launch Demo Job Template (take happy path) + tower_job_launch: + job_template: "Demo Job Template" + register: job + +- assert: + that: + - job is changed + +- name: Wait for the Job to finish + tower_job_wait: + job_id: "{{ job.id }}" + register: wait_results + +# Make sure it worked and that we have some data in our results +- assert: + that: + - wait_results is successful + - "'elapsed' in wait_results" + - "'id' in wait_results" + +- name: Launch a long running job + tower_job_launch: + job_template: "{{ jt_name }}" + register: job + +- assert: + that: + - job is changed + +- name: Timeout waiting for the job to complete + tower_job_wait: + job_id: "{{ job.id }}" + timeout: 5 + ignore_errors: true + register: wait_results + +# Make sure that we failed and that we have some data in our results +- assert: + that: + - "wait_results.msg == 'Monitoring aborted due to timeout' or 'Timeout waiting for job to finish.'" + - "'id' in wait_results" + +- name: Async cancel the long running job + tower_job_cancel: + job_id: "{{ job.id }}" + async: 3600 + poll: 0 + +- name: Wait for the job to exit on cancel + tower_job_wait: + job_id: "{{ job.id }}" + register: wait_results + ignore_errors: true + +- assert: + that: + - wait_results is failed + - 'wait_results.status == "canceled"' + - "wait_results.msg == 'Job with id {{ job.id }} failed' or 'Job with id={{ job.id }} failed, error: Job failed.'" + +- name: Delete the job template + tower_job_template: + name: "{{ jt_name }}" + playbook: "sleep.yml" + job_type: run + project: "{{ proj_name }}" + inventory: "Demo Inventory" + state: absent + +- name: Delete the project + tower_project: + name: "{{ proj_name }}" + organization: Default + state: absent