From 5e595caf5eeb393e5e887ac4b42133b9b761d170 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 13 Mar 2020 23:05:01 -0400 Subject: [PATCH 1/6] Add workflow node identifier Generate new modules WFJT and WFJT node Touch up generated syntax, test new modules Add utility method in awxkit Fix some issues with non-name identifier in AWX collection module_utils Update workflow docs for workflow node identifier Test and fix WFJT modules survey_spec Plug in survey spec for the new module Handle survey spec idempotency and test add associations for node connections Handle node credential prompts as well Add indexes for new identifier field Test with unicode dragon in name --- awx/api/serializers.py | 5 +- .../0112_v370_workflow_node_identifier.py | 65 ++++ awx/main/models/workflow.py | 32 +- .../tests/unit/models/test_workflow_unit.py | 6 +- awx/main/utils/named_url_graph.py | 12 +- .../plugins/module_utils/tower_api.py | 29 +- .../modules/tower_workflow_job_template.py | 239 +++++++++++++++ .../tower_workflow_job_template_node.py | 281 ++++++++++++++++++ .../modules/tower_workflow_template.py | 17 +- awx_collection/test/awx/conftest.py | 18 ++ .../test/awx/test_workflow_job_template.py | 62 ++++ .../awx/test_workflow_job_template_node.py | 109 +++++++ .../test/awx/test_workflow_template.py | 19 +- .../api/pages/workflow_job_template_nodes.py | 4 + docs/named_url.md | 13 +- docs/workflow.md | 8 +- 16 files changed, 879 insertions(+), 40 deletions(-) create mode 100644 awx/main/migrations/0112_v370_workflow_node_identifier.py create mode 100644 awx_collection/plugins/modules/tower_workflow_job_template.py create mode 100644 awx_collection/plugins/modules/tower_workflow_job_template_node.py create mode 100644 awx_collection/test/awx/test_workflow_job_template.py create mode 100644 awx_collection/test/awx/test_workflow_job_template_node.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e66233cd52..89bee4049b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3683,7 +3683,8 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): class Meta: model = WorkflowJobTemplateNode fields = ('*', 'workflow_job_template', '-name', '-description', 'id', 'url', 'related', - 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', 'all_parents_must_converge',) + 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', 'all_parents_must_converge', + 'identifier',) def get_related(self, obj): res = super(WorkflowJobTemplateNodeSerializer, self).get_related(obj) @@ -3723,7 +3724,7 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): model = WorkflowJobNode fields = ('*', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', - 'all_parents_must_converge', 'do_not_run',) + 'all_parents_must_converge', 'do_not_run', 'identifier') def get_related(self, obj): res = super(WorkflowJobNodeSerializer, self).get_related(obj) diff --git a/awx/main/migrations/0112_v370_workflow_node_identifier.py b/awx/main/migrations/0112_v370_workflow_node_identifier.py new file mode 100644 index 0000000000..3594cc2b57 --- /dev/null +++ b/awx/main/migrations/0112_v370_workflow_node_identifier.py @@ -0,0 +1,65 @@ +# Generated by Django 2.2.8 on 2020-03-14 02:29 + +from django.db import migrations, models +import uuid +import logging + + +logger = logging.getLogger('awx.main.migrations') + + +def create_uuid(apps, schema_editor): + WorkflowJobTemplateNode = apps.get_model('main', 'WorkflowJobTemplateNode') + ct = 0 + for node in WorkflowJobTemplateNode.objects.iterator(): + node.identifier = uuid.uuid4() + node.save(update_fields=['identifier']) + ct += 1 + if ct: + logger.info(f'Automatically created uuid4 identifier for {ct} workflow nodes') + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0111_v370_delete_channelgroup'), + ] + + operations = [ + migrations.AddField( + model_name='workflowjobnode', + name='identifier', + field=models.CharField(blank=True, help_text='An identifier coresponding to the workflow job template node that this node was created from.', max_length=512), + ), + migrations.AddField( + model_name='workflowjobtemplatenode', + name='identifier', + field=models.CharField(blank=True, null=True, help_text='An identifier for this node that is unique within its workflow. It is copied to workflow job nodes corresponding to this node.', max_length=512), + ), + migrations.RunPython(create_uuid, migrations.RunPython.noop), # this fixes the uuid4 issue + migrations.AlterField( + model_name='workflowjobtemplatenode', + name='identifier', + field=models.CharField(default=uuid.uuid4, help_text='An identifier for this node that is unique within its workflow. It is copied to workflow job nodes corresponding to this node.', max_length=512), + ), + migrations.AlterUniqueTogether( + name='workflowjobtemplatenode', + unique_together={('identifier', 'workflow_job_template')}, + ), + migrations.AddIndex( + model_name='workflowjobnode', + index=models.Index(fields=['identifier', 'workflow_job'], name='main_workfl_identif_87b752_idx'), + ), + migrations.AddIndex( + model_name='workflowjobnode', + index=models.Index(fields=['identifier'], name='main_workfl_identif_efdfe8_idx'), + ), + migrations.AddIndex( + model_name='workflowjobtemplatenode', + index=models.Index(fields=['identifier', 'workflow_job_template'], name='main_workfl_identif_6fda75_idx'), + ), + migrations.AddIndex( + model_name='workflowjobtemplatenode', + index=models.Index(fields=['identifier'], name='main_workfl_identif_0cc025_idx'), + ), + ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 87888b9f92..2ef4123b6d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -4,6 +4,7 @@ # Python import json import logging +from uuid import uuid4 from copy import copy from urllib.parse import urljoin @@ -121,6 +122,7 @@ class WorkflowNodeBase(CreatedModifiedModel, LaunchTimeConfig): create_kwargs[field_name] = kwargs[field_name] elif hasattr(self, field_name): create_kwargs[field_name] = getattr(self, field_name) + create_kwargs['identifier'] = self.identifier new_node = WorkflowJobNode.objects.create(**create_kwargs) if self.pk: allowed_creds = self.credentials.all() @@ -135,7 +137,7 @@ class WorkflowJobTemplateNode(WorkflowNodeBase): FIELDS_TO_PRESERVE_AT_COPY = [ 'unified_job_template', 'workflow_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', 'credentials', 'inventory', 'extra_data', 'survey_passwords', - 'char_prompts', 'all_parents_must_converge' + 'char_prompts', 'all_parents_must_converge', 'identifier' ] REENCRYPTION_BLACKLIST_AT_COPY = ['extra_data', 'survey_passwords'] @@ -144,6 +146,22 @@ class WorkflowJobTemplateNode(WorkflowNodeBase): related_name='workflow_job_template_nodes', on_delete=models.CASCADE, ) + identifier = models.CharField( + max_length=512, + default=uuid4, + blank=False, + help_text=_( + 'An identifier for this node that is unique within its workflow. ' + 'It is copied to workflow job nodes corresponding to this node.'), + ) + + class Meta: + app_label = 'main' + unique_together = (("identifier", "workflow_job_template"),) + indexes = [ + models.Index(fields=["identifier", "workflow_job_template"]), + models.Index(fields=['identifier']), + ] def get_absolute_url(self, request=None): return reverse('api:workflow_job_template_node_detail', kwargs={'pk': self.pk}, request=request) @@ -213,6 +231,18 @@ class WorkflowJobNode(WorkflowNodeBase): "semantics will mark this True if the node is in a path that will " "decidedly not be ran. A value of False means the node may not run."), ) + identifier = models.CharField( + max_length=512, + blank=True, # blank denotes pre-migration job nodes + help_text=_('An identifier coresponding to the workflow job template node that this node was created from.'), + ) + + class Meta: + app_label = 'main' + indexes = [ + models.Index(fields=["identifier", "workflow_job"]), + models.Index(fields=['identifier']), + ] def get_absolute_url(self, request=None): return reverse('api:workflow_job_node_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index 1dc4003ef0..83ce1a58b1 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -177,7 +177,8 @@ class TestWorkflowJobCreate: char_prompts=wfjt_node_no_prompts.char_prompts, inventory=None, unified_job_template=wfjt_node_no_prompts.unified_job_template, - workflow_job=workflow_job_unit) + workflow_job=workflow_job_unit, + identifier=mocker.ANY) def test_create_with_prompts(self, wfjt_node_with_prompts, workflow_job_unit, credential, mocker): mock_create = mocker.MagicMock() @@ -192,7 +193,8 @@ class TestWorkflowJobCreate: char_prompts=wfjt_node_with_prompts.char_prompts, inventory=wfjt_node_with_prompts.inventory, unified_job_template=wfjt_node_with_prompts.unified_job_template, - workflow_job=workflow_job_unit) + workflow_job=workflow_job_unit, + identifier=mocker.ANY) @mock.patch('awx.main.models.workflow.WorkflowNodeBase.get_parent_nodes', lambda self: []) diff --git a/awx/main/utils/named_url_graph.py b/awx/main/utils/named_url_graph.py index 6a9aeb2b85..f1b72e0aef 100644 --- a/awx/main/utils/named_url_graph.py +++ b/awx/main/utils/named_url_graph.py @@ -77,6 +77,8 @@ class GraphNode(object): Performance assured: http://stackoverflow.com/a/27086669 ''' for c in URL_PATH_RESERVED_CHARSET: + if not isinstance(text, str): + text = str(text) # needed for WFJT node creation, identifier temporarily UUID4 type if c in text: text = text.replace(c, URL_PATH_RESERVED_CHARSET[c]) text = text.replace(NAMED_URL_RES_INNER_DILIMITER, @@ -200,14 +202,14 @@ def _get_all_unique_togethers(model): def _check_unique_together_fields(model, ut): - has_name = False + name_field = None fk_names = [] fields = [] is_valid = True for field_name in ut: field = model._meta.get_field(field_name) - if field_name == 'name': - has_name = True + if field_name in ('name', 'identifier'): + name_field = field_name elif type(field) == models.ForeignKey and field.related_model != model: fk_names.append(field_name) elif issubclass(type(field), models.CharField) and field.choices: @@ -219,8 +221,8 @@ def _check_unique_together_fields(model, ut): return (), (), is_valid fk_names.sort() fields.sort(reverse=True) - if has_name: - fields.append('name') + if name_field: + fields.append(name_field) fields.reverse() return tuple(fk_names), tuple(fields), is_valid diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 555564b1ae..2ab6c1c1ab 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -248,7 +248,10 @@ class TowerModule(AnsibleModule): def get_one(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) if response['status_code'] != 200: - self.fail_json(msg="Got a {0} response when trying to get one from {1}".format(response['status_code'], endpoint)) + fail_msg = "Got a {0} response when trying to get one from {1}".format(response['status_code'], endpoint) + if 'detail' in response.get('json', {}): + fail_msg += ', detail: {0}'.format(response['json']['detail']) + self.fail_json(msg=fail_msg) if 'count' not in response['json'] or 'results' not in response['json']: self.fail_json(msg="The endpoint did not provide count and results") @@ -516,16 +519,19 @@ class TowerModule(AnsibleModule): # We have to rely on item_type being passed in since we don't have an existing item that declares its type # We will pull the item_name out from the new_item, if it exists - item_name = new_item.get('name', 'unknown') + for key in ('name', 'username', 'identifier', 'hostname'): + if key in new_item: + item_name = new_item[key] + break + else: + item_name = 'unknown' response = self.post_endpoint(endpoint, **{'data': new_item}) if response['status_code'] == 201: self.json_output['name'] = 'unknown' - if 'name' in response['json']: - self.json_output['name'] = response['json']['name'] - elif 'username' in response['json']: - # User objects return username instead of name - self.json_output['name'] = response['json']['username'] + for key in ('name', 'username', 'identifier', 'hostname'): + if key in response['json']: + self.json_output['name'] = response['json'][key] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True else: @@ -556,6 +562,7 @@ class TowerModule(AnsibleModule): # 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 + response = None if existing_item: # If we have an item, we can see if it needs an update @@ -564,6 +571,8 @@ class TowerModule(AnsibleModule): item_type = existing_item['type'] if item_type == 'user': item_name = existing_item['username'] + elif item_type == 'workflow_job_template_node': + item_name = existing_item['identifier'] else: item_name = existing_item['name'] item_id = existing_item['id'] @@ -603,7 +612,11 @@ class TowerModule(AnsibleModule): # 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']) + if response is None: + last_data = existing_item + else: + last_data = response['json'] + on_update(self, last_data) else: self.exit_json(**self.json_output) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py new file mode 100644 index 0000000000..c1279f8adb --- /dev/null +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -0,0 +1,239 @@ +#!/usr/bin/python +# coding: utf-8 -*- + + +# (c) 2020, John Westcott IV +# 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_job_template +author: "John Westcott IV (@john-westcott-iv)" +version_added: "2.3" +short_description: create, update, or destroy Ansible Tower workflow job templates. +description: + - Create, update, or destroy Ansible Tower workflow job templates. See + U(https://www.ansible.com/tower) for an overview. +options: + name: + description: + - Name of this workflow job template. + required: True + type: str + new_name: + description: + - Setting this option will change the existing name (looked up via the name field. + required: False + type: str + description: + description: + - Optional description of this workflow job template. + required: False + type: str + extra_vars: + description: + - NO DESCRIPTION GIVEN IN THE TOWER API + required: False + type: dict + organization: + description: + - Organization the workflow job template exists in. + - Used to help lookup the object, cannot be modified using this module. + - If not provided, will lookup by name only, which does not work with duplicates. + required: False + type: str + allow_simultaneous: + description: + - Allow simultaneous runs of the workflow job template. + required: False + type: bool + ask_variables_on_launch: + description: + - Prompt user for (extra_vars) on launch. + required: False + type: bool + inventory: + description: + - Inventory applied as a prompt, assuming job template prompts for inventory + required: False + type: str + limit: + description: + - Limit applied as a prompt, assuming job template prompts for limit + required: False + type: str + scm_branch: + description: + - SCM branch applied as a prompt, assuming job template prompts for SCM branch + required: False + type: str + ask_inventory_on_launch: + description: + - Prompt user for inventory on launch of this workflow job template + required: False + type: bool + ask_scm_branch_on_launch: + description: + - Prompt user for SCM branch on launch of this workflow job template + required: False + type: bool + ask_limit_on_launch: + description: + - Prompt user for limit on launch of this workflow job template + required: False + type: bool + webhook_service: + description: + - Service that webhook requests will be accepted from + required: False + type: str + choices: + - github + - gitlab + webhook_credential: + description: + - Personal Access Token for posting back the status to the service API + required: False + type: str + survey_enabled: + description: + - Setting that variable will prompt the user for job type on the + workflow launch. + type: bool + survey: + description: + - The definition of the survey associated to the workflow. + type: dict + required: false + state: + description: + - Desired state of the resource. + choices: + - present + - absent + default: "present" + type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str + version_added: "3.7" +extends_documentation_fragment: awx.awx.auth +''' + +EXAMPLES = ''' +- name: Create a workflow job template + tower_workflow_job_template: + name: example-workflow + description: created by Ansible Playbook + organization: Default +''' + +from ..module_utils.tower_api import TowerModule + +import json + + +def update_survey(module, last_request): + spec_endpoint = last_request.get('related', {}).get('survey_spec') + module.post_endpoint(spec_endpoint, **{'data': module.params.get('survey')}) + module.exit_json(**module.json_output) + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + name=dict(required=True, type='str'), + new_name=dict(type='str'), + description=dict(type='str'), + extra_vars=dict(type='dict'), + organization=dict(type='str'), + survey=dict(type='dict'), # special handling + survey_enabled=dict(type='bool'), + allow_simultaneous=dict(type='bool'), + ask_variables_on_launch=dict(type='bool'), + inventory=dict(type='str'), + limit=dict(type='str'), + scm_branch=dict(type='str'), + ask_inventory_on_launch=dict(type='bool'), + ask_scm_branch_on_launch=dict(type='bool'), + ask_limit_on_launch=dict(type='bool'), + webhook_service=dict(type='str', choices=['github', 'gitlab']), + webhook_credential=dict(type='str'), + state=dict(choices=['present', 'absent'], default='present'), + ) + + # Create a module for ourselves + module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + + # Extract our parameters + name = module.params.get('name') + new_name = module.params.get("new_name") + state = module.params.get('state') + + new_fields = {} + search_fields = {'name': name} + + # Attempt to look up the related items the user specified (these will fail the module if not found) + organization = module.params.get('organization') + if organization: + organization_id = module.resolve_name_to_id('organizations', organization) + search_fields['organization'] = new_fields['organization'] = organization_id + + inventory = module.params.get('inventory') + if inventory: + new_fields['inventory'] = module.resolve_name_to_id('inventory', inventory) + + webhook_credential = module.params.get('webhook_credential') + if webhook_credential: + new_fields['webhook_credential'] = module.resolve_name_to_id('webhook_credential', webhook_credential) + + # Attempt to look up an existing item based on the provided data + existing_item = module.get_one('workflow_job_templates', **{'data': search_fields}) + + # Create the data that gets sent for create and update + new_fields['name'] = new_name if new_name else name + for field_name in ( + 'description', 'survey_enabled', 'allow_simultaneous', + 'limit', 'scm_branch', 'extra_vars', + 'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch', 'ask_variables_on_launch', + 'webhook_service',): + field_val = module.params.get(field_name) + if field_val: + new_fields[field_name] = field_val + + if 'extra_vars' in new_fields: + new_fields['extra_vars'] = json.dumps(new_fields['extra_vars']) + + on_change = None + existing_spec = None + if existing_item: + existing_spec = module.get_endpoint('spec_endpoint') + new_spec = module.params.get('survey') + if new_spec and (new_spec != existing_spec): + module.json_output['changed'] = True + on_change = update_survey + + 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(existing_item) + elif state == 'present': + # 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_templates', item_type='workflow_job_template', + on_create=on_change, on_update=on_change + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/plugins/modules/tower_workflow_job_template_node.py b/awx_collection/plugins/modules/tower_workflow_job_template_node.py new file mode 100644 index 0000000000..e675190970 --- /dev/null +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -0,0 +1,281 @@ +#!/usr/bin/python +# coding: utf-8 -*- + + +# (c) 2020, John Westcott IV +# 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_job_template_node +author: "John Westcott IV (@john-westcott-iv)" +version_added: "2.3" +short_description: create, update, or destroy Ansible Tower workflow job template nodes. +description: + - Create, update, or destroy Ansible Tower workflow job template nodes. See + U(https://www.ansible.com/tower) for an overview. +options: + 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. + required: False + type: dict + default: {} + inventory: + description: + - Inventory applied as a prompt, if job template prompts for inventory + required: False + type: str + scm_branch: + description: + - SCM branch applied as a prompt, if job template prompts for SCM branch + required: False + type: str + job_type: + description: + - Job type applied as a prompt, if job template prompts for job type + required: False + type: str + choices: + - 'run' + - 'check' + job_tags: + description: + - Job tags applied as a prompt, if job template prompts for job tags + required: False + type: str + skip_tags: + description: + - Tags to skip, applied as a prompt, if job tempalte prompts for job tags + required: False + type: str + limit: + description: + - Limit to act on, applied as a prompt, if job template prompts for limit + required: False + type: str + diff_mode: + description: + - Run diff mode, applied as a prompt, if job template prompts for diff mode + required: False + type: bool + verbosity: + description: + - Verbosity applied as a prompt, if job template prompts for verbosity + required: False + type: str + choices: + - '0' + - '1' + - '2' + - '3' + - '4' + - '5' + workflow_job_template: + description: + - The workflow job template the node exists in. + - Used for looking up the node, cannot be modified after creation. + required: True + type: str + aliases: + - workflow + organization: + description: + - The organization of the workflow job template the node exists in. + - Used for looking up the workflow, not a direct model field. + required: False + type: str + unified_job_template: + 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). + required: False + type: str + 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 + required: False + 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 + always_nodes: + description: + - Nodes that will run after this node completes. + - List of node identifiers. + required: False + type: list + elements: str + success_nodes: + description: + - Nodes that will run after this node on success. + - List of node identifiers. + required: False + type: list + elements: str + failure_nodes: + description: + - Nodes that will run after this node on failure. + - List of node identifiers. + required: False + type: list + elements: str + credentials: + description: + - Credentials to be applied to job as launch-time prompts. + - List of credential names. + - Uniqueness is not handled rigorously. + required: False + type: list + elements: str + state: + description: + - Desired state of the resource. + choices: ["present", "absent"] + default: "present" + type: str + tower_oauthtoken: + description: + - The Tower OAuth token to use. + required: False + type: str + version_added: "3.7" +extends_documentation_fragment: awx.awx.auth +''' + +EXAMPLES = ''' +- name: Create a node, follows tower_workflow_job_template example + tower_workflow_job_template_node: + identifier: my-first-node + workflow: example-workflow + unified_job_template: jt-for-node-use + organization: Default # organization of workflow job template + extra_data: + foo_key: bar_value +''' + +from ..module_utils.tower_api import TowerModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + extra_data=dict(required=False, type='dict'), + inventory=dict(required=False, type='str'), + scm_branch=dict(required=False, type='str'), + job_type=dict(required=False, type='str', choices=['run', 'check']), + job_tags=dict(required=False, type='str'), + skip_tags=dict(required=False, type='str'), + limit=dict(required=False, type='str'), + diff_mode=dict(required=False, type='bool'), + verbosity=dict(required=False, type='str', choices=['0', '1', '2', '3', '4', '5']), + workflow_job_template=dict(required=True, type='str', aliases=['workflow']), + organization=dict(required=False, type='str'), + unified_job_template=dict(required=False, type='str'), + all_parents_must_converge=dict(required=False, type='bool'), + identifier=dict(required=True, type='str'), + success_nodes=dict(type='list', elements='str'), + always_nodes=dict(type='list', elements='str'), + failure_nodes=dict(type='list', elements='str'), + credentials=dict(type='list', elements='str'), + state=dict(choices=['present', 'absent'], default='present'), + ) + + # Create a module for ourselves + module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + + # Extract our parameters + identifier = module.params.get('identifier') + state = module.params.get('state') + + new_fields = {} + search_fields = {'identifier': identifier} + + # Attempt to look up the related items the user specified (these will fail the module if not found) + workflow_job_template = module.params.get('workflow_job_template') + workflow_job_template_id = None + if workflow_job_template: + wfjt_search_fields = {'name': workflow_job_template} + organization = module.params.get('organization') + if organization: + organization_id = module.resolve_name_to_id('organizations', organization) + wfjt_search_fields['organization'] = organization_id + wfjt_data = module.get_one('workflow_job_templates', **{'data': wfjt_search_fields}) + if wfjt_data is None: + module.fail_json(msg="The workflow {0} in organization {1} was not found on the Tower server".format( + workflow_job_template, organization + )) + workflow_job_template_id = wfjt_data['id'] + search_fields['workflow_job_template'] = new_fields['workflow_job_template'] = workflow_job_template_id + + unified_job_template = module.params.get('unified_job_template') + if unified_job_template: + new_fields['unified_job_template'] = module.resolve_name_to_id('unified_job_templates', unified_job_template) + + inventory = module.params.get('inventory') + if inventory: + new_fields['inventory'] = module.resolve_name_to_id('inventory', inventory) + + # Attempt to look up an existing item based on the provided data + existing_item = module.get_one('workflow_job_template_nodes', **{'data': search_fields}) + + # Create the data that gets sent for create and update + for field_name in ( + 'identifier', 'extra_data', 'scm_branch', 'job_type', 'job_tags', 'skip_tags', + 'limit', 'diff_mode', 'verbosity', 'all_parents_must_converge',): + field_val = module.params.get(field_name) + if field_val: + new_fields[field_name] = field_val + + association_fields = {} + for association in ('always_nodes', 'success_nodes', 'failure_nodes', 'credentials'): + name_list = module.params.get(association) + if name_list is None: + continue + id_list = [] + for sub_name in name_list: + if association == 'credentials': + endpoint = 'credentials' + lookup_data = {'name': sub_name} + else: + endpoint = 'workflow_job_template_nodes' + lookup_data = {'identifier': sub_name} + if workflow_job_template_id: + lookup_data['workflow_job_template'] = workflow_job_template_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']) + if id_list: + association_fields[association] = id_list + + # In the case of a new object, the utils need to know it is a node + new_fields['type'] = 'workflow_job_template_node' + + 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(existing_item) + elif state == 'present': + # 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', + associations=association_fields + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/plugins/modules/tower_workflow_template.py b/awx_collection/plugins/modules/tower_workflow_template.py index 7a7d84052c..1dfae3f802 100644 --- a/awx_collection/plugins/modules/tower_workflow_template.py +++ b/awx_collection/plugins/modules/tower_workflow_template.py @@ -20,8 +20,10 @@ author: "Adrien Fleury (@fleu42)" version_added: "2.7" short_description: create, update, or destroy Ansible Tower workflow template. description: - - Create, update, or destroy Ansible Tower workflows. See - U(https://www.ansible.com/tower) for an overview. + - A tower-cli based module for CRUD actions on workflow job templates. + - Enables use of the old schema functionality. + - Not updated for new features, convert to the modules for + workflow_job_template and workflow_job_template node instead. options: allow_simultaneous: description: @@ -75,7 +77,8 @@ options: survey: description: - The definition of the survey associated to the workflow. - type: str + type: dict + required: false state: description: - Desired state of the resource. @@ -130,7 +133,7 @@ def main(): organization=dict(required=False), allow_simultaneous=dict(type='bool', required=False), schema=dict(type='list', elements='dict', required=False), - survey=dict(required=False), + survey=dict(type='dict'), survey_enabled=dict(type='bool', required=False), inventory=dict(required=False), ask_inventory=dict(type='bool', required=False), @@ -143,6 +146,12 @@ def main(): supports_check_mode=False ) + module.deprecate(msg=( + "This module is replaced by the combination of tower_workflow_job_template and " + "tower_workflow_job_template_node. This uses the old tower-cli and wll be " + "removed in 2022." + ), version='4.2.0') + name = module.params.get('name') state = module.params.get('state') diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index c8856aa82b..a5a97d1e56 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -164,6 +164,24 @@ def run_module(request, collection_import): return rf +@pytest.fixture +def survey_spec(): + return { + "spec": [ + { + "index": 0, + "question_name": "my question?", + "default": "mydef", + "variable": "myvar", + "type": "text", + "required": False + } + ], + "description": "test", + "name": "test" + } + + @pytest.fixture def organization(): return Organization.objects.create(name='Default') diff --git a/awx_collection/test/awx/test_workflow_job_template.py b/awx_collection/test/awx/test_workflow_job_template.py new file mode 100644 index 0000000000..5ddf6ed5a8 --- /dev/null +++ b/awx_collection/test/awx/test_workflow_job_template.py @@ -0,0 +1,62 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from awx.main.models import WorkflowJobTemplate + + +@pytest.mark.django_db +def test_create_workflow_job_template(run_module, admin_user, organization, survey_spec): + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name, + 'extra_vars': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}, + 'survey': survey_spec, + 'survey_enabled': True, + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + + wfjt = WorkflowJobTemplate.objects.get(name='foo-workflow') + assert wfjt.extra_vars == '{"foo": "bar", "another-foo": {"barz": "bar2"}}' + + result.pop('invocation', None) + assert result == { + "name": "foo-workflow", + "id": wfjt.id, + "changed": True + } + + assert wfjt.organization_id == organization.id + assert wfjt.survey_spec == survey_spec + + +@pytest.mark.django_db +def test_survey_spec_only_changed(run_module, admin_user, organization, survey_spec): + wfjt = WorkflowJobTemplate.objects.create( + organization=organization, name='foo-workflow', + survey_enabled=True, survey_spec=survey_spec + ) + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name, + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert not result.get('changed', True), result + wfjt.refresh_from_db() + assert wfjt.survey_spec == survey_spec + + survey_spec['description'] = 'changed description' + + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name, + 'survey': survey_spec, + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', True), result + wfjt.refresh_from_db() + assert wfjt.survey_spec == survey_spec diff --git a/awx_collection/test/awx/test_workflow_job_template_node.py b/awx_collection/test/awx/test_workflow_job_template_node.py new file mode 100644 index 0000000000..6a911b26b6 --- /dev/null +++ b/awx_collection/test/awx/test_workflow_job_template_node.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from awx.main.models import WorkflowJobTemplateNode, WorkflowJobTemplate, JobTemplate + + +@pytest.fixture +def job_template(project, inventory): + return JobTemplate.objects.create( + project=project, + inventory=inventory, + playbook='helloworld.yml', + name='foo-jt', + ask_variables_on_launch=True, + ask_credential_on_launch=True, + ask_limit_on_launch=True + ) + + +@pytest.fixture +def wfjt(organization): + WorkflowJobTemplate.objects.create(organization=None, name='foo-workflow') # to test org scoping + return WorkflowJobTemplate.objects.create(organization=organization, name='foo-workflow') + + +@pytest.mark.django_db +def test_create_workflow_job_template_node(run_module, admin_user, wfjt, job_template): + this_identifier = '42🐉' + result = run_module('tower_workflow_job_template_node', { + 'identifier': this_identifier, + 'workflow_job_template': 'foo-workflow', + 'organization': wfjt.organization.name, + 'unified_job_template': 'foo-jt', + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + + node = WorkflowJobTemplateNode.objects.get(identifier=this_identifier) + + result.pop('invocation', None) + assert result == { + "name": this_identifier, # FIXME: should this be identifier instead + "id": node.id, + "changed": True + } + + assert node.workflow_job_template_id == wfjt.id + assert node.unified_job_template_id == job_template.id + + +@pytest.mark.django_db +def test_make_use_of_prompts(run_module, admin_user, wfjt, job_template, machine_credential, vault_credential): + # Create to temporarily woraround other issue https://github.com/ansible/awx/issues/5177 + WorkflowJobTemplateNode.objects.create( + identifier='42', workflow_job_template=wfjt, unified_job_template=job_template) + result = run_module('tower_workflow_job_template_node', { + 'identifier': '42', + 'workflow_job_template': 'foo-workflow', + 'organization': wfjt.organization.name, + 'unified_job_template': 'foo-jt', + 'extra_data': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}, + 'limit': 'foo_hosts', + 'credentials': [machine_credential.name, vault_credential.name], + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', False) + + node = WorkflowJobTemplateNode.objects.get(identifier='42') + + assert node.limit == 'foo_hosts' + assert node.extra_data == {'foo': 'bar', 'another-foo': {'barz': 'bar2'}} + assert set(node.credentials.all()) == set([machine_credential, vault_credential]) + + +@pytest.mark.django_db +def test_create_with_edges(run_module, admin_user, wfjt, job_template): + next_nodes = [ + WorkflowJobTemplateNode.objects.create( + identifier='foo{0}'.format(i), + workflow_job_template=wfjt, + unified_job_template=job_template + ) for i in range(3) + ] + # Create to temporarily woraround other issue https://github.com/ansible/awx/issues/5177 + WorkflowJobTemplateNode.objects.create( + identifier='42', workflow_job_template=wfjt, unified_job_template=job_template) + + result = run_module('tower_workflow_job_template_node', { + 'identifier': '42', + 'workflow_job_template': 'foo-workflow', + 'organization': wfjt.organization.name, + 'unified_job_template': 'foo-jt', + 'success_nodes': ['foo0'], + 'always_nodes': ['foo1'], + 'failure_nodes': ['foo2'], + 'state': 'present' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', False) + + node = WorkflowJobTemplateNode.objects.get(identifier='42') + + assert list(node.success_nodes.all()) == [next_nodes[0]] + assert list(node.always_nodes.all()) == [next_nodes[1]] + assert list(node.failure_nodes.all()) == [next_nodes[2]] diff --git a/awx_collection/test/awx/test_workflow_template.py b/awx_collection/test/awx/test_workflow_template.py index 9665d315b7..18fad89f01 100644 --- a/awx_collection/test/awx/test_workflow_template.py +++ b/awx_collection/test/awx/test_workflow_template.py @@ -10,32 +10,29 @@ from awx.main.models import ( @pytest.mark.django_db -def test_create_workflow_job_template(run_module, admin_user, organization, silence_deprecation): - - module_args = { +def test_create_workflow_job_template(run_module, admin_user, organization, survey_spec, silence_deprecation): + result = run_module('tower_workflow_template', { 'name': 'foo-workflow', 'organization': organization.name, 'extra_vars': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}}, + 'survey': survey_spec, + 'survey_enabled': True, 'state': 'present' - } - - result = run_module('tower_workflow_template', module_args, admin_user) + }, admin_user) wfjt = WorkflowJobTemplate.objects.get(name='foo-workflow') assert wfjt.extra_vars == '{"foo": "bar", "another-foo": {"barz": "bar2"}}' - result.pop('module_args', None) + result.pop('invocation', None) assert result == { "workflow_template": "foo-workflow", # TODO: remove after refactor "state": "present", "id": wfjt.id, - "changed": True, - "invocation": { - "module_args": module_args - } + "changed": True } assert wfjt.organization_id == organization.id + assert wfjt.survey_spec == survey_spec @pytest.mark.django_db diff --git a/awxkit/awxkit/api/pages/workflow_job_template_nodes.py b/awxkit/awxkit/api/pages/workflow_job_template_nodes.py index ec56f54380..dbf6d53505 100644 --- a/awxkit/awxkit/api/pages/workflow_job_template_nodes.py +++ b/awxkit/awxkit/api/pages/workflow_job_template_nodes.py @@ -115,6 +115,10 @@ class WorkflowJobTemplateNode(HasCreate, base.Base): self.related.create_approval_template.post(kwargs) return self.get() + def get_job_node(self, workflow_job): + candidates = workflow_job.get_related('workflow_nodes', identifier=self.identifier) + return candidates.results.pop() + page.register_page([resources.workflow_job_template_node, (resources.workflow_job_template_nodes, diff --git a/docs/named_url.md b/docs/named_url.md index fc2fe29711..acd188133f 100644 --- a/docs/named_url.md +++ b/docs/named_url.md @@ -9,24 +9,25 @@ There are two named-URL-related Tower configuration settings available under `/a ``` "NAMED_URL_FORMATS": { "job_templates": "++", - "workflow_job_templates": "", + "workflow_job_templates": "++", + "workflow_job_template_nodes": "++++", "inventories": "++", "users": "", - "custom_inventory_scripts": "++", + "applications": "++", + "inventory_scripts": "++", "labels": "++", "credential_types": "+", "notification_templates": "++", "instances": "", "instance_groups": "", "hosts": "++++", - "system_job_templates": "", "groups": "++++", "organizations": "", "credentials": "+++++", "teams": "++", - "inventory_sources": "", - "projects": "" -} + "inventory_sources": "++++", + "projects": "++" +}, ``` For each item in `NAMED_URL_FORMATS`, the key is the API name of the resource to have named URL, the value is a string indicating how to form a human-readable unique identifiers for that resource. A typical procedure of composing named URL for a specific resource object using `NAMED_URL_FORMATS` is given below: diff --git a/docs/workflow.md b/docs/workflow.md index daa93c0a59..38993917f5 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -2,7 +2,7 @@ Workflows are structured compositions of Tower job resources. The only job of a workflow is to trigger other jobs in specific orders to achieve certain goals, such as tracking the full set of jobs that were part of a release process as a single unit. -A workflow has an associated tree-graph that is composed of multiple nodes. Each node in the tree has one associated job template (job template, inventory update, project update, or workflow job template) along with related resources that, if defined, will override the associated job template resources (*i.e.*, credential, inventory, etc.) if the job template associated with the node is selected to run. +A workflow has an associated tree-graph that is composed of multiple nodes. Each node in the tree has one associated template (job template, inventory update, project update, approval template, or workflow job template) along with related resources that, if defined, will override the associated job template resources (*i.e.*, credential, inventory, etc.) if the job template associated with the node is selected to run. ## Usage Manual @@ -22,6 +22,12 @@ Workflow Nodes are containers of workflow-spawned job resources and function as Workflow job template nodes are listed and created under the `/workflow_job_templates/\d+/workflow_nodes/` endpoint to be associated with the underlying workflow job template, or directly under endpoint `/workflow_job_template_nodes/`. The most important fields of a workflow job template node are `success_nodes`, `failure_nodes`, `always_nodes`, `unified_job_template` and `workflow_job_template`. The first three are lists of workflow job template nodes that, in union, forms the set of all of its child nodes; specifically, `success_nodes` are triggered when the parent node job succeeds, `failure_nodes` are triggered the when parent node job fails, and `always_nodes` are triggered regardless of whether the parent job succeeds or fails. The latter two fields reference the job template resource it contains and workflow job template it belongs to. +Workflow nodes also have an `identifier` field, which enables clients to do idempotent CRUD actions. +This can function the same as the `name` field for other resources, in that the client can set its value as needed. +Unlike `name`, if the client does not provide the `identifier` field, the server will assign a random UUID4 value. +Any workflow job nodes spawned from that node will share the `identifier` value, so that clients +can track which job nodes correspond to which template nodes. + #### Workflow Launch Configuration From 795c989a498c5e3ae403588154d85acf6e9b0851 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 23 Mar 2020 16:52:33 -0400 Subject: [PATCH 2/6] fix bug processing survey spec --- .../modules/tower_workflow_job_template.py | 14 +++--- .../tower_workflow_job_template_node.py | 9 ++++ .../test/awx/test_workflow_job_template.py | 47 +++++++++++++++++-- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index c1279f8adb..9e6c78db65 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -215,13 +215,15 @@ def main(): new_fields['extra_vars'] = json.dumps(new_fields['extra_vars']) on_change = None - existing_spec = None - if existing_item: - existing_spec = module.get_endpoint('spec_endpoint') new_spec = module.params.get('survey') - if new_spec and (new_spec != existing_spec): - module.json_output['changed'] = True - on_change = update_survey + if new_spec: + existing_spec = None + if existing_item: + spec_endpoint = existing_item.get('related', {}).get('survey_spec') + existing_spec = module.get_endpoint(spec_endpoint) + if new_spec != existing_spec: + module.json_output['changed'] = True + on_change = update_survey if state == 'absent': # If the state was absent we can let the module delete it if needed, the module will handle exiting from this 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 e675190970..e62dd15bcc 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -165,6 +165,15 @@ EXAMPLES = ''' organization: Default # organization of workflow job template extra_data: foo_key: bar_value + +- name: Create parent node for prior node + tower_workflow_job_template_node: + identifier: my-root-node + workflow: example-workflow + unified_job_template: jt-for-node-use + organization: Default + success_nodes: + - my-first-node ''' from ..module_utils.tower_api import TowerModule diff --git a/awx_collection/test/awx/test_workflow_job_template.py b/awx_collection/test/awx/test_workflow_job_template.py index 5ddf6ed5a8..903b399dbd 100644 --- a/awx_collection/test/awx/test_workflow_job_template.py +++ b/awx_collection/test/awx/test_workflow_job_template.py @@ -22,16 +22,35 @@ def test_create_workflow_job_template(run_module, admin_user, organization, surv assert wfjt.extra_vars == '{"foo": "bar", "another-foo": {"barz": "bar2"}}' result.pop('invocation', None) - assert result == { - "name": "foo-workflow", - "id": wfjt.id, - "changed": True - } + assert result == {"name": "foo-workflow", "id": wfjt.id, "changed": True} assert wfjt.organization_id == organization.id assert wfjt.survey_spec == survey_spec +@pytest.mark.django_db +def test_create_modify_no_survey(run_module, admin_user, organization, survey_spec): + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', False), result + + wfjt = WorkflowJobTemplate.objects.get(name='foo-workflow') + assert wfjt.organization_id == organization.id + assert wfjt.survey_spec == {} + result.pop('invocation', None) + assert result == {"name": "foo-workflow", "id": wfjt.id, "changed": True} + + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert not result.get('changed', True), result + + @pytest.mark.django_db def test_survey_spec_only_changed(run_module, admin_user, organization, survey_spec): wfjt = WorkflowJobTemplate.objects.create( @@ -60,3 +79,21 @@ def test_survey_spec_only_changed(run_module, admin_user, organization, survey_s assert result.get('changed', True), result wfjt.refresh_from_db() assert wfjt.survey_spec == survey_spec + + +@pytest.mark.django_db +def test_delete_with_spec(run_module, admin_user, organization, survey_spec): + WorkflowJobTemplate.objects.create( + organization=organization, name='foo-workflow', + survey_enabled=True, survey_spec=survey_spec + ) + result = run_module('tower_workflow_job_template', { + 'name': 'foo-workflow', + 'organization': organization.name, + 'state': 'absent' + }, admin_user) + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed', True), result + + assert WorkflowJobTemplate.objects.filter( + name='foo-workflow', organization=organization).count() == 0 From 077461a3ef99390dbfcec2d1f2e5490caf35e232 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 23 Mar 2020 20:33:20 -0400 Subject: [PATCH 3/6] Docs touchups --- .../modules/tower_workflow_job_template.py | 11 +++--- .../tower_workflow_job_template_node.py | 34 +++++++++++-------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/awx_collection/plugins/modules/tower_workflow_job_template.py b/awx_collection/plugins/modules/tower_workflow_job_template.py index 9e6c78db65..e1c1d56c78 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template.py @@ -20,8 +20,9 @@ author: "John Westcott IV (@john-westcott-iv)" version_added: "2.3" short_description: create, update, or destroy Ansible Tower workflow job templates. description: - - Create, update, or destroy Ansible Tower workflow job templates. See - U(https://www.ansible.com/tower) for an overview. + - 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. options: name: description: @@ -30,7 +31,7 @@ options: type: str new_name: description: - - Setting this option will change the existing name (looked up via the name field. + - Setting this option will change the existing name. required: False type: str description: @@ -40,7 +41,7 @@ options: type: str extra_vars: description: - - NO DESCRIPTION GIVEN IN THE TOWER API + - Variables which will be made available to jobs ran inside the workflow. required: False type: dict organization: @@ -57,7 +58,7 @@ options: type: bool ask_variables_on_launch: description: - - Prompt user for (extra_vars) on launch. + - Prompt user for C(extra_vars) on launch. required: False type: bool inventory: 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 e62dd15bcc..d78feb3505 100644 --- a/awx_collection/plugins/modules/tower_workflow_job_template_node.py +++ b/awx_collection/plugins/modules/tower_workflow_job_template_node.py @@ -20,8 +20,12 @@ author: "John Westcott IV (@john-westcott-iv)" version_added: "2.3" short_description: create, update, or destroy Ansible Tower workflow job template nodes. description: - - Create, update, or destroy Ansible Tower workflow job template nodes. See - U(https://www.ansible.com/tower) for an overview. + - Create, update, or destroy Ansible Tower workflow job template nodes. + - Use this to build a graph for a workflow, which dictates what the workflow runs. + - Replaces the deprecated tower_workflow_template module schema command. + - You can create nodes first, and link them afterwards, and not worry about ordering. + For failsafe referencing of a node, specify identifier, WFJT, and organization. + With those specified, you can choose to modify or not modify any other parameter. options: extra_data: description: @@ -182,20 +186,20 @@ from ..module_utils.tower_api import TowerModule def main(): # Any additional arguments that are not fields of the item can be added here argument_spec = dict( - extra_data=dict(required=False, type='dict'), - inventory=dict(required=False, type='str'), - scm_branch=dict(required=False, type='str'), - job_type=dict(required=False, type='str', choices=['run', 'check']), - job_tags=dict(required=False, type='str'), - skip_tags=dict(required=False, type='str'), - limit=dict(required=False, type='str'), - diff_mode=dict(required=False, type='bool'), - verbosity=dict(required=False, type='str', choices=['0', '1', '2', '3', '4', '5']), - workflow_job_template=dict(required=True, type='str', aliases=['workflow']), - organization=dict(required=False, type='str'), - unified_job_template=dict(required=False, type='str'), - all_parents_must_converge=dict(required=False, type='bool'), identifier=dict(required=True, type='str'), + workflow_job_template=dict(required=True, type='str', aliases=['workflow']), + organization=dict(type='str'), + extra_data=dict(type='dict'), + inventory=dict(type='str'), + scm_branch=dict(type='str'), + job_type=dict(type='str', choices=['run', 'check']), + job_tags=dict(type='str'), + skip_tags=dict(type='str'), + limit=dict(type='str'), + diff_mode=dict(type='bool'), + verbosity=dict(type='str', choices=['0', '1', '2', '3', '4', '5']), + unified_job_template=dict(type='str'), + all_parents_must_converge=dict(type='bool'), success_nodes=dict(type='list', elements='str'), always_nodes=dict(type='list', elements='str'), failure_nodes=dict(type='list', elements='str'), From f3e8623a217643ab2d9d0570ccf2cc2a149098be Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 23 Mar 2020 22:34:11 -0400 Subject: [PATCH 4/6] Move workflow test target --- .../tasks/main.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename awx_collection/tests/integration/targets/{tower_workflow_template => tower_workflow_job_template}/tasks/main.yml (100%) diff --git a/awx_collection/tests/integration/targets/tower_workflow_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml similarity index 100% rename from awx_collection/tests/integration/targets/tower_workflow_template/tasks/main.yml rename to awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml From 8ba43880149c30d55414aa026b08cc2ff06000d7 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 23 Mar 2020 22:47:30 -0400 Subject: [PATCH 5/6] Rewrite tests to use the new modules --- Makefile | 3 +- .../tasks/main.yml | 34 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index dbf278f30d..c804ff43bf 100644 --- a/Makefile +++ b/Makefile @@ -371,6 +371,7 @@ prepare_collection_venv: $(VENV_BASE)/awx/bin/pip install --target=$(COLLECTION_VENV) git+https://github.com/ansible/tower-cli.git COLLECTION_TEST_DIRS ?= awx_collection/test/awx +COLLECTION_TEST_TARGET ?= COLLECTION_PACKAGE ?= awx COLLECTION_NAMESPACE ?= awx COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE) @@ -405,7 +406,7 @@ test_collection_sanity: install_collection cd $(COLLECTION_INSTALL) && ansible-test sanity test_collection_integration: install_collection - cd $(COLLECTION_INSTALL) && ansible-test integration + cd $(COLLECTION_INSTALL) && ansible-test integration $(COLLECTION_TEST_TARGET) test_unit: @if [ "$(VENV_BASE)" ]; then \ 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 12b33aefdc..6ac1a84e51 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 @@ -1,11 +1,11 @@ --- - name: Generate names set_fact: - scm_cred_name: "AWX-Collection-tests-tower_workflow_template-scm-cred-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - demo_project_name: "AWX-Collection-tests-tower_workflow_template-proj-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - jt1_name: "AWX-Collection-tests-tower_workflow_template-jt1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - jt2_name: "AWX-Collection-tests-tower_workflow_template-jt2-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - wfjt_name: "AWX-Collection-tests-tower_workflow_template-wfjt-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + scm_cred_name: "AWX-Collection-tests-tower_workflow_job_template-scm-cred-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + demo_project_name: "AWX-Collection-tests-tower_workflow_job_template-proj-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + jt1_name: "AWX-Collection-tests-tower_workflow_job_template-jt1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - name: Create an SCM Credential tower_credential: @@ -80,9 +80,8 @@ - "result is changed" - name: Create a workflow job template - tower_workflow_template: + tower_workflow_job_template: name: "{{ wfjt_name }}" - schema: [{"success": [{"job_template": "{{ jt1_name }}"}], "job_template": "{{ jt2_name }}"}] extra_vars: {'foo': 'bar', 'another-foo': {'barz': 'bar2'}} register: result @@ -90,8 +89,22 @@ that: - "result is changed" +# Node actions do what this schema command used to do +# schema: [{"success": [{"job_template": "{{ jt1_name }}"}], "job_template": "{{ jt2_name }}"}] +- name: Create leaf node + tower_workflow_job_template_node: + identifier: leaf + unified_job_template: "{{ jt2_name }}" + workflow: "{{ wfjt_name }}" + +- name: Create root node + tower_workflow_job_template_node: + identifier: root + unified_job_template: "{{ jt1_name }}" + workflow: "{{ wfjt_name }}" + - name: Delete a workflow job template - tower_workflow_template: + tower_workflow_job_template: name: "{{ wfjt_name }}" state: absent register: result @@ -101,16 +114,15 @@ - "result is changed" - name: Check module fails with correct msg - tower_workflow_template: + tower_workflow_job_template: name: "{{ wfjt_name }}" organization: Non Existing Organization - schema: [{"success": [{"job_template": "{{ jt1_name }}"}], "job_template": "{{ jt2_name }}"}] register: result ignore_errors: true - assert: that: - - "result.msg =='Failed to update organization source,organization not found: The requested object could not be found.'" + - "'The organizations Non Existing Organization was not found' in result.msg" - name: Delete the Job Template tower_job_template: From 653850fa6d9d6dafdb961ec3fbf08ce9aa64c5da Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 23 Mar 2020 22:54:04 -0400 Subject: [PATCH 6/6] Remove duplicated index --- awx/main/migrations/0112_v370_workflow_node_identifier.py | 4 ---- awx/main/models/workflow.py | 1 - 2 files changed, 5 deletions(-) diff --git a/awx/main/migrations/0112_v370_workflow_node_identifier.py b/awx/main/migrations/0112_v370_workflow_node_identifier.py index 3594cc2b57..dff2a348b3 100644 --- a/awx/main/migrations/0112_v370_workflow_node_identifier.py +++ b/awx/main/migrations/0112_v370_workflow_node_identifier.py @@ -54,10 +54,6 @@ class Migration(migrations.Migration): model_name='workflowjobnode', index=models.Index(fields=['identifier'], name='main_workfl_identif_efdfe8_idx'), ), - migrations.AddIndex( - model_name='workflowjobtemplatenode', - index=models.Index(fields=['identifier', 'workflow_job_template'], name='main_workfl_identif_6fda75_idx'), - ), migrations.AddIndex( model_name='workflowjobtemplatenode', index=models.Index(fields=['identifier'], name='main_workfl_identif_0cc025_idx'), diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 2ef4123b6d..3990381623 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -159,7 +159,6 @@ class WorkflowJobTemplateNode(WorkflowNodeBase): app_label = 'main' unique_together = (("identifier", "workflow_job_template"),) indexes = [ - models.Index(fields=["identifier", "workflow_job_template"]), models.Index(fields=['identifier']), ]