From ce1f3009f973a20168cd3da7b31517493c643d1f Mon Sep 17 00:00:00 2001 From: sean-m-ssullivan Date: Tue, 13 Apr 2021 10:09:24 -0500 Subject: [PATCH 1/2] add tower workflow schema update --- .../plugins/module_utils/tower_api.py | 9 +- .../modules/tower_workflow_job_template.py | 505 +++++++++++++++++- awx_collection/test/awx/test_completeness.py | 2 +- .../tasks/main.yml | 145 +++++ 4 files changed, 655 insertions(+), 6 deletions(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index c637f0f7b7..a615fd6908 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -474,7 +474,7 @@ class TowerAPIModule(TowerModule): # 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 - + response = None if not endpoint: self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type)) @@ -522,8 +522,11 @@ class TowerAPIModule(TowerModule): elif auto_exit: self.exit_json(**self.json_output) else: - last_data = response['json'] - return last_data + if response is not None: + last_data = response['json'] + return last_data + else: + return def _encrypted_changed_warning(self, field, old, warning=False): if not warning: diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index ec0eecd41a..9ea7ec92bf 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -20,7 +20,7 @@ short_description: create, update, or destroy Ansible Tower workflow job templat description: - Create, update, or destroy Ansible Tower workflow job templates. - Replaces the deprecated tower_workflow_template module. - - Use the tower_workflow_job_template_node after this to build the workflow's graph. + - Use the tower_workflow_job_template_node after this, or use the schema paramater to build the workflow's graph options: name: description: @@ -144,6 +144,185 @@ options: - list of notifications to send on start type: list elements: str + schema: + description: + - A json list of nodes and their coresponding options. The following suboptions describe a single node. + type: list + suboptions: + extra_data: + description: + - Variables to apply at launch time. + - Will only be accepted if job template prompts for vars or has a survey asking for those vars. + type: dict + default: {} + inventory: + description: + - Inventory applied as a prompt, if job template prompts for inventory + type: str + scm_branch: + description: + - SCM branch applied as a prompt, if job template prompts for SCM branch + type: str + job_type: + description: + - Job type applied as a prompt, if job template prompts for job type + type: str + choices: + - 'run' + - 'check' + job_tags: + description: + - Job tags applied as a prompt, if job template prompts for job tags + type: str + skip_tags: + description: + - Tags to skip, applied as a prompt, if job tempalte prompts for job tags + type: str + limit: + description: + - Limit to act on, applied as a prompt, if job template prompts for limit + type: str + diff_mode: + description: + - Run diff mode, applied as a prompt, if job template prompts for diff mode + type: bool + verbosity: + description: + - Verbosity applied as a prompt, if job template prompts for verbosity + type: str + choices: + - '0' + - '1' + - '2' + - '3' + - '4' + - '5' + 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 + type: bool + identifier: + description: + - An identifier for this node that is unique within its workflow. + - It is copied to workflow job nodes corresponding to this node. + required: True + type: str + state: + description: + - Desired state of the resource. + choices: ["present", "absent"] + default: "present" + type: str + unified_job_template: + description: + - Name of unified job template to run in the workflow. + - Can be a job template, project sync, inventory source sync, etc. + - Omit if creating an approval node (not yet implemented). + type: dict + suboptions: + organization: + description: + - Name of key for use in model for organizational reference + - Only Valid and used if referencing a job template or project sync + - This parameter is mutually exclusive with suboption C(inventory). + type: dict + suboptions: + name: + description: + - The organization of the job template or project sync the node exists in. + - Used for looking up the job template or project sync, not a direct model field. + type: str + inventory: + description: + - Name of key for use in model for organizational reference + - Only Valid and used if referencing an inventory sync + - This parameter is mutually exclusive with suboption C(organization). + type: dict + suboptions: + organization: + description: + - Name of key for use in model for organizational reference + type: dict + suboptions: + name: + description: + - The organization of the inventory the node exists in. + - Used for looking up the job template or project, not a direct model field. + type: str + name: + description: + - Name of unified job template to run in the workflow. + - Can be a job template, project, inventory source, etc. + type: str + description: + description: + - Optional description of this workflow approval template. + type: str + type: + description: + - Name of unified job template type to run in the workflow. + - Can be a job_template, project, inventory_source, workflow_approval. + type: str + timeout: + description: + - The amount of time (in seconds) to wait before Approval is canceled. A value of 0 means no timeout. + - Only Valid and used if referencing an Approval Node + default: 0 + type: int + related: + description: + - Related items to this workflow node. + - Must include credentials, failure_nodes, always_nodes, success_nodes, even if empty. + type: dict + suboptions: + always_nodes: + description: + - Nodes that will run after this node completes. + - List of node identifiers. + type: list + suboptions: + identifier: + description: + - Identifier of Node that will run after this node completes given this option. + elements: str + success_nodes: + description: + - Nodes that will run after this node on success. + - List of node identifiers. + type: list + suboptions: + identifier: + description: + - Identifier of Node that will run after this node completes given this option. + elements: str + failure_nodes: + description: + - Nodes that will run after this node on failure. + - List of node identifiers. + type: list + suboptions: + identifier: + description: + - Identifier of Node that will run after this node completes given this option. + elements: str + credentials: + description: + - Credentials to be applied to job as launch-time prompts. + - List of credential names. + - Uniqueness is not handled rigorously. + type: list + suboptions: + name: + description: + - Name Credentials to be applied to job as launch-time prompts. + elements: str + destroy_current_schema: + description: + - Set in order to destroy current schema on the workflow. + - This option is used for full schema update, if not used, nodes not described in schema will persist and keep current associations and links. + type: bool + default: False + extends_documentation_fragment: awx.awx.auth ''' @@ -154,16 +333,134 @@ EXAMPLES = ''' description: created by Ansible Playbook organization: Default +- name: Create a workflow job template with schema in template + awx.awx.tower_workflow_job_template: + name: example-workflow + inventory: Demo Inventory + extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}} + schema: + - identifier: node101 + unified_job_template: + name: example-project + inventory: + organization: + name: Default + type: inventory_source + related: + success_nodes: [] + failure_nodes: + - identifier: node201 + always_nodes: [] + credentials: [] + - identifier: node201 + unified_job_template: + organization: + name: Default + name: job template 1 + type: job_template + credentials: [] + related: + success_nodes: + - identifier: node301 + failure_nodes: [] + always_nodes: [] + credentials: [] + - identifier: node202 + unified_job_template: + organization: + name: Default + name: example-project + type: project + related: + success_nodes: [] + failure_nodes: [] + always_nodes: [] + credentials: [] + - identifier: node301 + all_parents_must_converge: false + unified_job_template: + organization: + name: Default + name: job template 2 + type: job_template + related: + success_nodes: [] + failure_nodes: [] + always_nodes: [] + credentials: [] + register: result + - name: Copy a workflow job template tower_workflow_job_template: name: copy-workflow copy_from: example-workflow organization: Foo + +- name: Create a workflow job template with schema in template + awx.awx.tower_workflow_job_template: + name: example-workflow + inventory: Demo Inventory + extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}} + schema: + - identifier: node101 + unified_job_template: + name: example-project + inventory: + organization: + name: Default + type: inventory_source + related: + success_nodes: [] + failure_nodes: + - identifier: node201 + always_nodes: [] + credentials: [] + - identifier: node201 + unified_job_template: + organization: + name: Default + name: job template 1 + type: job_template + credentials: [] + related: + success_nodes: + - identifier: node301 + failure_nodes: [] + always_nodes: [] + credentials: [] + - identifier: node202 + unified_job_template: + organization: + name: Default + name: example-project + type: project + related: + success_nodes: [] + failure_nodes: [] + always_nodes: [] + credentials: [] + - identifier: node301 + all_parents_must_converge: false + unified_job_template: + organization: + name: Default + name: job template 2 + type: job_template + related: + success_nodes: [] + failure_nodes: [] + always_nodes: [] + credentials: [] + register: result + ''' from ..module_utils.tower_api import TowerAPIModule import json +response = [] + +response = [] def update_survey(module, last_request): @@ -177,7 +474,185 @@ def update_survey(module, last_request): response = module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey_spec')}) if response['status_code'] != 200: module.fail_json(msg="Failed to update survey: {0}".format(response['json']['error'])) - module.exit_json(**module.json_output) + + +def create_schema_nodes(module, response, schema, workflow_id): + for workflow_node in schema: + workflow_node_fields = {} + search_fields = {} + association_fields = {} + + # Lookup Job Template ID + if workflow_node['unified_job_template']['name']: + search_fields = {'name': workflow_node['unified_job_template']['name']} + if workflow_node['unified_job_template']['type'] is None: + module.fail_json(msg='Could not find unified job template type in schema {1}'.format(workflow_node)) + if workflow_node['unified_job_template']['type'] == 'inventory_source': + # workflow_node['unified_job_template']['inventory']: + organization_id = module.resolve_name_to_id('organizations', workflow_node['unified_job_template']['inventory']['organization']['name']) + search_fields['organization'] = organization_id + elif workflow_node['unified_job_template']['type'] == 'workflow_approval': + pass + else: + # workflow_node['unified_job_template']['organization']: + organization_id = module.resolve_name_to_id('organizations', workflow_node['unified_job_template']['organization']['name']) + search_fields['organization'] = organization_id + unified_job_template = module.get_one('unified_job_templates', **{'data': search_fields}) + if unified_job_template: + workflow_node_fields['unified_job_template'] = unified_job_template['id'] + else: + if workflow_node['unified_job_template']['type'] != 'workflow_approval': + module.fail_json(msg="Unable to Find unified_job_template: {0}".format(search_fields)) + + # Lookup Values for other fields + + for field_name in ( + 'identifier', + 'extra_data', + 'scm_branch', + 'job_type', + 'job_tags', + 'skip_tags', + 'limit', + 'diff_mode', + 'verbosity', + 'all_parents_must_converge', + 'state', + ): + field_val = workflow_node.get(field_name) + if field_val: + workflow_node_fields[field_name] = field_val + if workflow_node['identifier']: + search_fields = {'identifier': workflow_node['identifier']} + + # Set Search fields + search_fields['workflow_job_template'] = workflow_node_fields['workflow_job_template'] = workflow_id + + # Attempt to look up an existing item based on the provided data + existing_item = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) + + # Determine if state is present or absent. + state = True + if 'state' in workflow_node: + if workflow_node['state'] == 'absent': + state = False + if state: + response.append( + module.create_or_update_if_needed( + existing_item, + workflow_node_fields, + endpoint='workflow_job_template_nodes', + item_type='workflow_job_template_node', + auto_exit=False, + ) + ) + else: + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + response.append( + module.delete_if_needed( + existing_item, + auto_exit=False, + ) + ) + + # Start Approval Node creation process + if workflow_node['unified_job_template']['type'] == 'workflow_approval': + new_fields = {} + + for field_name in ( + 'name', + 'description', + 'timeout', + ): + field_val = workflow_node['unified_job_template'].get(field_name) + if field_val: + workflow_node_fields[field_name] = field_val + + # Attempt to look up an existing item just created + workflow_job_template_node = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) + workflow_job_template_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, + workflow_node_fields, + endpoint=approval_endpoint, + item_type='workflow_job_template_approval_node', + associations=association_fields, + auto_exit=False, + ) + + +def create_schema_nodes_association(module, response, schema, workflow_id): + for workflow_node in schema: + workflow_node_fields = {} + search_fields = {} + association_fields = {} + + # Set Search fields + search_fields['workflow_job_template'] = workflow_node_fields['workflow_job_template'] = workflow_id + + # Lookup Values for other fields + if workflow_node['identifier']: + workflow_node_fields['identifier'] = workflow_node['identifier'] + search_fields['identifier'] = workflow_node['identifier'] + + # Attempt to look up an existing item based on the provided data + existing_item = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) + + if 'state' in workflow_node: + if workflow_node['state'] == 'absent': + continue + + if 'related' in workflow_node: + # Get id's for association fields + association_fields = {} + + for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): + # Extract out information if it exists + # Test if it is defined, else move to next association. + if association in workflow_node['related']: + id_list = [] + for sub_name in workflow_node['related'][association]: + if association == 'credentials': + endpoint = 'credentials' + lookup_data = {'name': sub_name['name']} + else: + endpoint = 'workflow_job_template_nodes' + lookup_data = {'identifier': sub_name['identifier']} + lookup_data['workflow_job_template'] = workflow_id + sub_obj = module.get_one(endpoint, **{'data': lookup_data}) + if sub_obj is None: + module.fail_json(msg='Could not find {0} entry with name {1}'.format(association, sub_name)) + id_list.append(sub_obj['id']) + temp = sub_obj['id'] + if id_list: + association_fields[association] = id_list + + module.create_or_update_if_needed( + existing_item, + workflow_node_fields, + endpoint='workflow_job_template_nodes', + item_type='workflow_job_template_node', + auto_exit=False, + associations=association_fields, + ) + + +def destroy_schema_nodes(module, response, workflow_id): + search_fields = {} + + # Search for existing nodes. + search_fields['workflow_job_template'] = workflow_id + existing_items = module.get_all_endpoint('workflow_job_template_nodes', **{'data': search_fields}) + + # Loop through found fields + for workflow_node in existing_items['json']['results']: + response.append(module.delete_endpoint(workflow_node['url'])) def main(): @@ -207,6 +682,8 @@ def main(): notification_templates_success=dict(type="list", elements='str'), notification_templates_error=dict(type="list", elements='str'), notification_templates_approvals=dict(type="list", elements='str'), + schema=dict(type='list', elements='dict'), + destroy_current_schema=dict(type='bool', default=False), state=dict(choices=['present', 'absent'], default='present'), ) @@ -219,6 +696,12 @@ def main(): copy_from = module.params.get('copy_from') state = module.params.get('state') + # Extract schema parameters + schema = None + if module.params.get('schema'): + schema = module.params.get('schema') + destroy_current_schema = module.params.get('destroy_current_schema') + new_fields = {} search_fields = {} @@ -341,8 +824,26 @@ def main(): associations=association_fields, on_create=on_change, on_update=on_change, + auto_exit=False, ) + # Get Workflow information in case one was just created. + existing_item = module.get_one('workflow_job_templates', name_or_id=name, **{'data': search_fields}) + workflow_job_template_id = existing_item['id'] + # Destroy current nodes if selected. + if destroy_current_schema: + destroy_schema_nodes(module, response, workflow_job_template_id) + + # Work thorugh and lookup value for schema fields + if schema: + # Create Schema Nodes + create_schema_nodes(module, response, schema, workflow_job_template_id) + # Create Schema Associations + create_schema_nodes_association(module, response, schema, workflow_job_template_id) + module.json_output['schema_creation_data'] = response + + 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..16cb92c1fd 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -59,7 +59,7 @@ no_api_parameter_ok = { # 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_spec'], + 'tower_workflow_job_template': ['survey_spec', 'destroy_current_schema'], # ad hoc commands support interval and timeout since its more like tower_job_launch 'tower_ad_hoc_command': ['interval', 'timeout', 'wait'], # tower_group parameters to perserve hosts and children. 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 0b2374831c..5e9e9159f0 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 @@ -14,6 +14,8 @@ wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}" email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}" webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}" + project_inv: "AWX-Collection-tests-tower_inventory_source-inv-project-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + project_inv_source: "AWX-Collection-tests-tower_inventory_source-inv-source-project-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - name: Create an SCM Credential tower_credential: @@ -72,6 +74,27 @@ that: - "result is changed" +- name: Add a Tower inventory + tower_inventory: + description: Test inventory + organization: Default + name: "{{ project_inv }}" + +- name: Create a source inventory + tower_inventory_source: + name: "{{ project_inv_source }}" + description: Source for Test inventory + inventory: "{{ project_inv }}" + source_project: "{{ demo_project_name }}" + source_path: "/inventories/inventory.ini" + overwrite: true + source: scm + register: result + +- assert: + that: + - "result is changed" + - name: Create a Job Template tower_job_template: name: "{{ jt1_name }}" @@ -324,6 +347,106 @@ - "'Non_Existing_Organization' in result.msg" - "result.total_results == 0" +- name: Create a workflow job template with schema in template + awx.awx.tower_workflow_job_template: + name: "{{ wfjt_name }}" + inventory: Demo Inventory + extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}} + schema: + - identifier: node101 + unified_job_template: + name: "{{ project_inv_source }}" + inventory: + organization: + name: Default + type: inventory_source + related: + failure_nodes: + - identifier: node201 + - identifier: node201 + unified_job_template: + organization: + name: Default + name: "{{ jt1_name }}" + type: job_template + credentials: [] + related: + success_nodes: + - identifier: node301 + - identifier: node202 + unified_job_template: + organization: + name: Default + name: "{{ demo_project_name }}" + type: project + - all_parents_must_converge: false + identifier: node301 + unified_job_template: + organization: + name: Default + name: "{{ jt2_name }}" + type: job_template + register: result + +- assert: + that: + - "result is changed" + +- name: Kick off a workflow and wait for it + tower_workflow_launch: + workflow_template: "{{ wfjt_name }}" + ignore_errors: true + register: result + +- assert: + that: + - result is not failed + - "'id' in result['job_info']" + +- name: Destroy previous schema for one that fails + awx.awx.tower_workflow_job_template: + name: "{{ wfjt_name }}" + destroy_current_schema: true + schema: + - identifier: node101 + unified_job_template: + organization: + name: Default + name: "{{ jt1_name }}" + type: job_template + credentials: [] + related: + success_nodes: + - identifier: node201 + - identifier: node201 + unified_job_template: + name: "{{ project_inv_source }}" + inventory: + organization: + name: Default + type: inventory_source + register: result + +- name: Kick off a workflow and wait for it + tower_workflow_launch: + workflow_template: "{{ wfjt_name }}" + ignore_errors: true + register: result + +- assert: + that: + - result is failed + +- name: Delete a workflow job template + awx.awx.tower_workflow_job_template: + name: "{{ wfjt_name }}" + state: absent + register: result + +- assert: + that: + - "result is changed" + - name: Delete the Job Template tower_job_template: name: "{{ jt1_name }}" @@ -352,6 +475,28 @@ that: - "result is changed" +- name: Delete the inventory source + tower_inventory_source: + name: "{{ project_inv_source }}" + inventory: "{{ project_inv }}" + source: scm + state: absent + +- assert: + that: + - "result is changed" + +- name: Delete the inventory + tower_inventory: + description: Test inventory + organization: Default + name: "{{ project_inv }}" + state: absent + +- assert: + that: + - "result is changed" + - name: Delete the Demo Project tower_project: name: "{{ demo_project_name }}" From 224c3de2c9af978d582daffc44a9c2d42dbccde2 Mon Sep 17 00:00:00 2001 From: sean-m-ssullivan Date: Tue, 13 Apr 2021 10:43:37 -0500 Subject: [PATCH 2/2] linting --- awx_collection/plugins/modules/tower_workflow_job_template.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index 9ea7ec92bf..4e38d4976d 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -458,6 +458,7 @@ EXAMPLES = ''' from ..module_utils.tower_api import TowerAPIModule import json + response = [] response = []