1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-27 00:55:06 +03:00

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
This commit is contained in:
AlanCoding 2020-03-13 23:05:01 -04:00
parent d941f11ccd
commit 5e595caf5e
No known key found for this signature in database
GPG Key ID: FD2C3C012A72926B
16 changed files with 879 additions and 40 deletions

View File

@ -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)

View File

@ -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'),
),
]

View File

@ -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)

View File

@ -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: [])

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,239 @@
#!/usr/bin/python
# coding: utf-8 -*-
# (c) 2020, John Westcott IV <john.westcott.iv@redhat.com>
# 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()

View File

@ -0,0 +1,281 @@
#!/usr/bin/python
# coding: utf-8 -*-
# (c) 2020, John Westcott IV <john.westcott.iv@redhat.com>
# 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()

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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]]

View File

@ -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

View File

@ -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,

View File

@ -9,24 +9,25 @@ There are two named-URL-related Tower configuration settings available under `/a
```
"NAMED_URL_FORMATS": {
"job_templates": "<name>++<organization.name>",
"workflow_job_templates": "<name>",
"workflow_job_templates": "<name>++<organization.name>",
"workflow_job_template_nodes": "<identifier>++<workflow_job_template.name>++<organization.name>",
"inventories": "<name>++<organization.name>",
"users": "<username>",
"custom_inventory_scripts": "<name>++<organization.name>",
"applications": "<name>++<organization.name>",
"inventory_scripts": "<name>++<organization.name>",
"labels": "<name>++<organization.name>",
"credential_types": "<name>+<kind>",
"notification_templates": "<name>++<organization.name>",
"instances": "<hostname>",
"instance_groups": "<name>",
"hosts": "<name>++<inventory.name>++<organization.name>",
"system_job_templates": "<name>",
"groups": "<name>++<inventory.name>++<organization.name>",
"organizations": "<name>",
"credentials": "<name>++<credential_type.name>+<credential_type.kind>++<organization.name>",
"teams": "<name>++<organization.name>",
"inventory_sources": "<name>",
"projects": "<name>"
}
"inventory_sources": "<name>++<inventory.name>++<organization.name>",
"projects": "<name>++<organization.name>"
},
```
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:

View File

@ -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