Merge pull request #2352 from ansible/workflows_squared

[Feature] Allow use of workflows inside of workflows

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2018-11-08 16:28:37 +00:00 committed by GitHub
commit 6a8454f748
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1525 additions and 1111 deletions

View File

@ -25,7 +25,6 @@ from rest_framework.filters import BaseFilterBackend
from awx.main.utils import get_type_for_model, to_python_boolean
from awx.main.utils.db import get_all_field_names
from awx.main.models.credential import CredentialType
from awx.main.models.rbac import RoleAncestorEntry
class V1CredentialFilterBackend(BaseFilterBackend):
@ -347,12 +346,12 @@ class FieldLookupBackend(BaseFilterBackend):
else:
args.append(Q(**{k:v}))
for role_name in role_filters:
if not hasattr(queryset.model, 'accessible_pk_qs'):
raise ParseError(_(
'Cannot apply role_level filter to this list because its model '
'does not use roles for access control.'))
args.append(
Q(pk__in=RoleAncestorEntry.objects.filter(
ancestor__in=request.user.roles.all(),
content_type_id=ContentType.objects.get_for_model(queryset.model).id,
role_field=role_name
).values_list('object_id').distinct())
Q(pk__in=queryset.model.accessible_pk_qs(request.user, role_name))
)
if or_filters:
q = Q()

View File

@ -104,7 +104,7 @@ SUMMARIZABLE_FK_FIELDS = {
'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',),
'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'vault_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed'),
'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed', 'type'),
'job_template': DEFAULT_SUMMARY_FIELDS,
'workflow_job_template': DEFAULT_SUMMARY_FIELDS,
'workflow_job': DEFAULT_SUMMARY_FIELDS,
@ -3830,9 +3830,6 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer):
ujt_obj = attrs['unified_job_template']
elif self.instance:
ujt_obj = self.instance.unified_job_template
if isinstance(ujt_obj, (WorkflowJobTemplate)):
raise serializers.ValidationError({
"unified_job_template": _("Cannot nest a %s inside a WorkflowJobTemplate") % ujt_obj.__class__.__name__})
if 'credential' in deprecated_fields: # TODO: remove when v2 API is deprecated
cred = deprecated_fields['credential']
attrs['credential'] = cred

View File

@ -31,7 +31,7 @@ from awx.main.models.mixins import (
SurveyJobMixin,
RelatedJobsMixin,
)
from awx.main.models.jobs import LaunchTimeConfig
from awx.main.models.jobs import LaunchTimeConfig, JobTemplate
from awx.main.models.credential import Credential
from awx.main.redact import REPLACE_STR
from awx.main.fields import JSONField
@ -199,7 +199,14 @@ class WorkflowJobNode(WorkflowNodeBase):
data = {}
ujt_obj = self.unified_job_template
if ujt_obj is not None:
accepted_fields, ignored_fields, errors = ujt_obj._accept_or_ignore_job_kwargs(**self.prompts_dict())
# MERGE note: move this to prompts_dict method on node when merging
# with the workflow inventory branch
prompts_data = self.prompts_dict()
if isinstance(ujt_obj, WorkflowJobTemplate):
if self.workflow_job.extra_vars:
prompts_data.setdefault('extra_vars', {})
prompts_data['extra_vars'].update(self.workflow_job.extra_vars_dict)
accepted_fields, ignored_fields, errors = ujt_obj._accept_or_ignore_job_kwargs(**prompts_data)
if errors:
logger.info(_('Bad launch configuration starting template {template_pk} as part of '
'workflow {workflow_pk}. Errors:\n{error_text}').format(
@ -246,8 +253,9 @@ class WorkflowJobNode(WorkflowNodeBase):
functional_aa_dict.pop('_ansible_no_log', None)
extra_vars.update(functional_aa_dict)
# Workflow Job extra_vars higher precedence than ancestor artifacts
if self.workflow_job and self.workflow_job.extra_vars:
extra_vars.update(self.workflow_job.extra_vars_dict)
if ujt_obj and isinstance(ujt_obj, JobTemplate):
if self.workflow_job and self.workflow_job.extra_vars:
extra_vars.update(self.workflow_job.extra_vars_dict)
if extra_vars:
data['extra_vars'] = extra_vars
# ensure that unified jobs created by WorkflowJobs are marked
@ -505,6 +513,24 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
def task_impact(self):
return 0
def get_ancestor_workflows(self):
"""Returns a list of WFJTs that are indirect parents of this workflow job
say WFJTs are set up to spawn in order of A->B->C, and this workflow job
came from C, then C is the parent and [B, A] will be returned from this.
"""
ancestors = []
wj_ids = set([self.pk])
wj = self.get_workflow_job()
while wj and wj.workflow_job_template_id:
if wj.pk in wj_ids:
logger.critical('Cycles detected in the workflow jobs graph, '
'this is not normal and suggests task manager degeneracy.')
break
wj_ids.add(wj.pk)
ancestors.append(wj.workflow_job_template)
wj = wj.get_workflow_job()
return ancestors
def get_notification_templates(self):
return self.workflow_job_template.notification_templates

View File

@ -26,6 +26,7 @@ from awx.main.models import (
ProjectUpdate,
SystemJob,
WorkflowJob,
WorkflowJobTemplate
)
from awx.main.scheduler.dag_workflow import WorkflowDAG
from awx.main.utils.pglock import advisory_lock
@ -120,7 +121,26 @@ class TaskManager():
spawn_node.job = job
spawn_node.save()
logger.info('Spawned %s in %s for node %s', job.log_format, workflow_job.log_format, spawn_node.pk)
if job._resources_sufficient_for_launch():
can_start = True
if isinstance(spawn_node.unified_job_template, WorkflowJobTemplate):
workflow_ancestors = job.get_ancestor_workflows()
if spawn_node.unified_job_template in set(workflow_ancestors):
can_start = False
logger.info('Refusing to start recursive workflow-in-workflow id={}, wfjt={}, ancestors={}'.format(
job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors]))
display_list = [spawn_node.unified_job_template] + workflow_ancestors
job.job_explanation = _(
"Workflow Job spawned from workflow could not start because it "
"would result in recursion (spawn order, most recent first: {})"
).format(six.text_type(', ').join([six.text_type('<{}>').format(tmp) for tmp in display_list]))
else:
logger.debug('Starting workflow-in-workflow id={}, wfjt={}, ancestors={}'.format(
job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors]))
if not job._resources_sufficient_for_launch():
can_start = False
job.job_explanation = _("Job spawned from workflow could not start because it "
"was missing a related resource such as project or inventory")
if can_start:
if workflow_job.start_args:
start_args = json.loads(decrypt_field(workflow_job, 'start_args'))
else:
@ -129,14 +149,10 @@ class TaskManager():
if not can_start:
job.job_explanation = _("Job spawned from workflow could not start because it "
"was not in the right state or required manual credentials")
else:
can_start = False
job.job_explanation = _("Job spawned from workflow could not start because it "
"was missing a related resource such as project or inventory")
if not can_start:
job.status = 'failed'
job.save(update_fields=['status', 'job_explanation'])
connection.on_commit(lambda: job.websocket_emit_status('failed'))
job.websocket_emit_status('failed')
# TODO: should we emit a status on the socket here similar to tasks.py awx_periodic_scheduler() ?
#emit_websocket_notification('/socket.io/jobs', '', dict(id=))
@ -153,7 +169,7 @@ class TaskManager():
workflow_job.status = 'canceled'
workflow_job.start_args = '' # blank field to remove encrypted passwords
workflow_job.save(update_fields=['status', 'start_args'])
connection.on_commit(lambda: workflow_job.websocket_emit_status(workflow_job.status))
workflow_job.websocket_emit_status(workflow_job.status)
else:
is_done, has_failed = dag.is_workflow_done()
if not is_done:
@ -165,7 +181,7 @@ class TaskManager():
workflow_job.status = new_status
workflow_job.start_args = '' # blank field to remove encrypted passwords
workflow_job.save(update_fields=['status', 'start_args'])
connection.on_commit(lambda: workflow_job.websocket_emit_status(workflow_job.status))
workflow_job.websocket_emit_status(workflow_job.status)
return result
def get_dependent_jobs_for_inv_and_proj_update(self, job_obj):
@ -231,7 +247,6 @@ class TaskManager():
self.consume_capacity(task, rampart_group.name)
def post_commit():
task.websocket_emit_status(task.status)
if task.status != 'failed' and type(task) is not WorkflowJob:
task_cls = task._get_task_class()
task_cls.apply_async(
@ -250,6 +265,7 @@ class TaskManager():
}],
)
task.websocket_emit_status(task.status) # adds to on_commit
connection.on_commit(post_commit)
def process_running_tasks(self, running_tasks):

View File

@ -215,3 +215,55 @@ class TestWorkflowJobTemplate:
wfjt2.validate_unique()
wfjt2 = WorkflowJobTemplate(name='foo', organization=None)
wfjt2.validate_unique()
@pytest.mark.django_db
def test_workflow_ancestors(organization):
# Spawn order of templates grandparent -> parent -> child
# create child WFJT and workflow job
child = WorkflowJobTemplate.objects.create(organization=organization, name='child')
child_job = WorkflowJob.objects.create(
workflow_job_template=child,
launch_type='workflow'
)
# create parent WFJT and workflow job, and link it up
parent = WorkflowJobTemplate.objects.create(organization=organization, name='parent')
parent_job = WorkflowJob.objects.create(
workflow_job_template=parent,
launch_type='workflow'
)
WorkflowJobNode.objects.create(
workflow_job=parent_job,
unified_job_template=child,
job=child_job
)
# create grandparent WFJT and workflow job and link it up
grandparent = WorkflowJobTemplate.objects.create(organization=organization, name='grandparent')
grandparent_job = WorkflowJob.objects.create(
workflow_job_template=grandparent,
launch_type='schedule'
)
WorkflowJobNode.objects.create(
workflow_job=grandparent_job,
unified_job_template=parent,
job=parent_job
)
# ancestors method gives a list of WFJT ids
assert child_job.get_ancestor_workflows() == [parent, grandparent]
@pytest.mark.django_db
def test_workflow_ancestors_recursion_prevention(organization):
# This is toxic database data, this tests that it doesn't create an infinite loop
wfjt = WorkflowJobTemplate.objects.create(organization=organization, name='child')
wfj = WorkflowJob.objects.create(
workflow_job_template=wfjt,
launch_type='workflow'
)
WorkflowJobNode.objects.create(
workflow_job=wfj,
unified_job_template=wfjt,
job=wfj # well, this is a problem
)
# mostly, we just care that this assertion finishes in finite time
assert wfj.get_ancestor_workflows() == []

View File

@ -334,7 +334,7 @@
.JobResults-container {
display: grid;
grid-gap: 20px;
grid-template-columns: minmax(300px, 1fr) minmax(500px, 2fr);
grid-template-columns: minmax(400px, 1fr) minmax(500px, 2fr);
grid-template-rows: minmax(500px, ~"calc(100vh - 130px)");
.at-Panel {
@ -457,5 +457,6 @@
.JobResults-container {
display: flex;
flex-direction: column;
min-width: 400px;
}
}

View File

@ -120,10 +120,12 @@ function getSourceWorkflowJobDetails () {
return null;
}
const label = strings.get('labels.SOURCE_WORKFLOW_JOB');
const value = sourceWorkflowJob.name;
const link = `/#/workflows/${sourceWorkflowJob.id}`;
const tooltip = strings.get('tooltips.SOURCE_WORKFLOW_JOB');
return { link, tooltip };
return { label, value, link, tooltip };
}
function getSliceJobDetails () {

View File

@ -281,6 +281,19 @@
</div>
</div>
<!-- SOURCE WORKFLOW JOB DETAIL -->
<div class="JobResults-resultRow" ng-if="vm.sourceWorkflowJob">
<label class="JobResults-resultRowLabel">{{ vm.sourceWorkflowJob.label }}</label>
<div class="JobResults-resultRowText">
<a href="{{ vm.sourceWorkflowJob.link }}"
aw-tool-tip="{{ vm.sourceWorkflowJob.tooltip }}"
data-placement="top">
{{ vm.sourceWorkflowJob.value }}
</a>
</div>
</div>
<!-- EXTRA VARIABLES DETAIL -->
<at-code-mirror
class="JobResults-resultRow"

View File

@ -74,6 +74,7 @@ function OutputStrings (BaseString) {
SKIP_TAGS: t.s('Skip Tags'),
SOURCE: t.s('Source'),
SOURCE_CREDENTIAL: t.s('Source Credential'),
SOURCE_WORKFLOW_JOB: t.s('Source Workflow'),
STARTED: t.s('Started'),
STATUS: t.s('Status'),
VERBOSITY: t.s('Verbosity'),

View File

@ -102,6 +102,7 @@ function TemplatesStrings (BaseString) {
ALWAYS: t.s('Always'),
PROJECT_SYNC: t.s('Project Sync'),
INVENTORY_SYNC: t.s('Inventory Sync'),
WORKFLOW: t.s('Workflow'),
WARNING: t.s('Warning'),
TOTAL_TEMPLATES: t.s('TOTAL TEMPLATES'),
ADD_A_TEMPLATE: t.s('ADD A TEMPLATE'),

View File

@ -493,21 +493,6 @@ table, tbody {
}
}
.List-infoCell--badge {
height: 15px;
color: @default-interface-txt;
background-color: @default-list-header-bg;
border-radius: 5px;
font-size: 10px;
padding-left: 10px;
padding-right: 10px;
margin-left: 10px;
text-transform: uppercase;
font-weight: 100;
margin-top: 2.25px;
outline: none;
}
.List-actionsInner {
display: flex;
}

View File

@ -190,6 +190,7 @@
padding: @at-padding-list-row-item-tag;
line-height: @at-line-height-list-row-item-tag;
word-break: keep-all;
display: inline-flex;
}
.at-RowItem-tag--primary {

View File

@ -323,7 +323,7 @@
@at-highlight-left-border-margin-makeup: -5px;
@at-z-index-nav: 1040;
@at-z-index-side-nav: 1030;
@at-line-height-list-row-item-tag: 22px;
@at-line-height-list-row-item-tag: 14px;
// 6. Breakpoints ---------------------------------------------------------------------------------

View File

@ -99,7 +99,6 @@
@media screen and (max-width: @breakpoint-sm) {
.BreadCrumb {
padding-left: 50px;
position: fixed;
z-index: 2;
}

View File

@ -1,3 +1,4 @@
<div class="List-infoCell">
<span class="List-infoCell--badge" aw-pop-over="<dl><dt>{{ 'INVENTORY' | translate }}</dt><dd>{{(job_template.summary_fields.inventory.name | sanitize) || ('NONE SELECTED' | translate)}}</dd></dl><dl><dt>{{ 'PROJECT' | translate }}</dt><dd>{{job_template.summary_fields.project.name | sanitize}}</dd></dl><dl><dt>{{ 'PLAYBOOK' | translate }}</dt><dd>{{job_template.playbook| sanitize}}</dd></dl><dl><dt>{{ 'CREDENTIAL' | translate }}</dt> <dd>{{(job_template.summary_fields.credential.name | sanitize) || ('NONE SELECTED' | translate)}}</dd></dl>" data-popover-title="{{job_template.name| sanitize}}" translate>INFO</span>
<div class="List-infoCell" ng-if="wf_maker_template.type === 'job_template'">
<span class="Key-icon Key-icon--circle Key-icon--default" aw-pop-over="<dl><dt>{{ 'INVENTORY' | translate }}</dt><dd>{{(wf_maker_template.summary_fields.inventory.name | sanitize) || ('NONE SELECTED' | translate)}}</dd></dl><dl><dt>{{ 'PROJECT' | translate }}</dt><dd>{{wf_maker_template.summary_fields.project.name | sanitize}}</dd></dl><dl><dt>{{ 'PLAYBOOK' | translate }}</dt><dd>{{wf_maker_template.playbook| sanitize}}</dd></dl><dl><dt>{{ 'CREDENTIAL' | translate }}</dt> <dd>{{(wf_maker_template.summary_fields.credential.name | sanitize) || ('NONE SELECTED' | translate)}}</dd></dl>"
data-popover-title="{{wf_maker_template.name| sanitize}}">?</span>
</div>

View File

@ -696,6 +696,13 @@ angular.module('GeneratorHelpers', [systemStatus.name])
if (options.mode !== 'lookup' && field.badgeIcon && field.badgePlacement && field.badgePlacement !== 'left') {
html += Badge(field);
}
// Field Tag
if (field.tag) {
html += `<span class="at-RowItem-tag" ng-show="${field.showTag}">
${field.tag}
</span>`;
}
}
}

View File

@ -409,34 +409,33 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
workflowMaker = {
name: 'templates.editWorkflowJobTemplate.workflowMaker',
url: '/workflow-maker',
// ncyBreadcrumb: {
// label: 'WORKFLOW MAKER'
// },
data: {
formChildState: true
},
params: {
job_template_search: {
wf_maker_template_search: {
value: {
page_size: '5',
order_by: 'name'
order_by: 'name',
page_size: '10',
role_level: 'execute_role',
type: 'workflow_job_template,job_template'
},
squash: false,
dynamic: true
},
project_search: {
wf_maker_project_search: {
value: {
page_size: '5',
order_by: 'name'
order_by: 'name',
page_size: '10'
},
squash: true,
dynamic: true
},
inventory_source_search: {
wf_maker_inventory_source_search: {
value: {
page_size: '5',
not__source: '',
order_by: 'name'
order_by: 'name',
page_size: '10'
},
squash: true,
dynamic: true
@ -467,14 +466,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
$scope[`${list.iterator}_dataset`] = Dataset.data;
$scope[list.name] = $scope[`${list.iterator}_dataset`].results;
$scope.$watch('job_templates', function(){
$scope.$watch('wf_maker_templates', function(){
if($scope.selectedTemplate){
$scope.job_templates.forEach(function(row, i) {
$scope.wf_maker_templates.forEach(function(row, i) {
if(row.id === $scope.selectedTemplate.id) {
$scope.job_templates[i].checked = 1;
$scope.wf_maker_templates[i].checked = 1;
}
else {
$scope.job_templates[i].checked = 0;
$scope.wf_maker_templates[i].checked = 0;
}
});
}
@ -483,9 +482,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
$scope.toggle_row = function(selectedRow) {
if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) {
$scope.job_templates.forEach(function(row, i) {
$scope.wf_maker_templates.forEach(function(row, i) {
if (row.id === selectedRow.id) {
$scope.job_templates[i].checked = 1;
$scope.wf_maker_templates[i].checked = 1;
$scope.selection[list.iterator] = {
id: row.id,
name: row.name
@ -498,27 +497,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
};
$scope.$watch('selectedTemplate', () => {
$scope.job_templates.forEach(function(row, i) {
if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) {
$scope.job_templates[i].checked = 1;
$scope.wf_maker_templates.forEach(function(row, i) {
if(_.has($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) {
$scope.wf_maker_templates[i].checked = 1;
}
else {
$scope.job_templates[i].checked = 0;
$scope.wf_maker_templates[i].checked = 0;
}
});
});
$scope.$watch('activeTab', () => {
if(!$scope.activeTab || $scope.activeTab !== "jobs") {
$scope.job_templates.forEach(function(row, i) {
$scope.job_templates[i].checked = 0;
$scope.wf_maker_templates.forEach(function(row, i) {
$scope.wf_maker_templates[i].checked = 0;
});
}
});
$scope.$on('clearWorkflowLists', function() {
$scope.job_templates.forEach(function(row, i) {
$scope.job_templates[i].checked = 0;
$scope.wf_maker_templates.forEach(function(row, i) {
$scope.wf_maker_templates[i].checked = 0;
});
});
}
@ -544,14 +543,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
$scope[`${list.iterator}_dataset`] = Dataset.data;
$scope[list.name] = $scope[`${list.iterator}_dataset`].results;
$scope.$watch('workflow_inventory_sources', function(){
$scope.$watch('wf_maker_inventory_sources', function(){
if($scope.selectedTemplate){
$scope.workflow_inventory_sources.forEach(function(row, i) {
$scope.wf_maker_inventory_sources.forEach(function(row, i) {
if(row.id === $scope.selectedTemplate.id) {
$scope.workflow_inventory_sources[i].checked = 1;
$scope.wf_maker_inventory_sources[i].checked = 1;
}
else {
$scope.workflow_inventory_sources[i].checked = 0;
$scope.wf_maker_inventory_sources[i].checked = 0;
}
});
}
@ -560,9 +559,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
$scope.toggle_row = function(selectedRow) {
if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) {
$scope.workflow_inventory_sources.forEach(function(row, i) {
$scope.wf_maker_inventory_sources.forEach(function(row, i) {
if (row.id === selectedRow.id) {
$scope.workflow_inventory_sources[i].checked = 1;
$scope.wf_maker_inventory_sources[i].checked = 1;
$scope.selection[list.iterator] = {
id: row.id,
name: row.name
@ -575,27 +574,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
};
$scope.$watch('selectedTemplate', () => {
$scope.workflow_inventory_sources.forEach(function(row, i) {
$scope.wf_maker_inventory_sources.forEach(function(row, i) {
if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) {
$scope.workflow_inventory_sources[i].checked = 1;
$scope.wf_maker_inventory_sources[i].checked = 1;
}
else {
$scope.workflow_inventory_sources[i].checked = 0;
$scope.wf_maker_inventory_sources[i].checked = 0;
}
});
});
$scope.$watch('activeTab', () => {
if(!$scope.activeTab || $scope.activeTab !== "inventory_sync") {
$scope.workflow_inventory_sources.forEach(function(row, i) {
$scope.workflow_inventory_sources[i].checked = 0;
$scope.wf_maker_inventory_sources.forEach(function(row, i) {
$scope.wf_maker_inventory_sources[i].checked = 0;
});
}
});
$scope.$on('clearWorkflowLists', function() {
$scope.workflow_inventory_sources.forEach(function(row, i) {
$scope.workflow_inventory_sources[i].checked = 0;
$scope.wf_maker_inventory_sources.forEach(function(row, i) {
$scope.wf_maker_inventory_sources[i].checked = 0;
});
});
}
@ -621,14 +620,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
$scope[`${list.iterator}_dataset`] = Dataset.data;
$scope[list.name] = $scope[`${list.iterator}_dataset`].results;
$scope.$watch('projects', function(){
$scope.$watch('wf_maker_projects', function(){
if($scope.selectedTemplate){
$scope.projects.forEach(function(row, i) {
$scope.wf_maker_projects.forEach(function(row, i) {
if(row.id === $scope.selectedTemplate.id) {
$scope.projects[i].checked = 1;
$scope.wf_maker_projects[i].checked = 1;
}
else {
$scope.projects[i].checked = 0;
$scope.wf_maker_projects[i].checked = 0;
}
});
}
@ -637,9 +636,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
$scope.toggle_row = function(selectedRow) {
if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) {
$scope.projects.forEach(function(row, i) {
$scope.wf_maker_projects.forEach(function(row, i) {
if (row.id === selectedRow.id) {
$scope.projects[i].checked = 1;
$scope.wf_maker_projects[i].checked = 1;
$scope.selection[list.iterator] = {
id: row.id,
name: row.name
@ -652,27 +651,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
};
$scope.$watch('selectedTemplate', () => {
$scope.projects.forEach(function(row, i) {
$scope.wf_maker_projects.forEach(function(row, i) {
if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) {
$scope.projects[i].checked = 1;
$scope.wf_maker_projects[i].checked = 1;
}
else {
$scope.projects[i].checked = 0;
$scope.wf_maker_projects[i].checked = 0;
}
});
});
$scope.$watch('activeTab', () => {
if(!$scope.activeTab || $scope.activeTab !== "project_sync") {
$scope.projects.forEach(function(row, i) {
$scope.projects[i].checked = 0;
$scope.wf_maker_projects.forEach(function(row, i) {
$scope.wf_maker_projects[i].checked = 0;
});
}
});
$scope.$on('clearWorkflowLists', function() {
$scope.projects.forEach(function(row, i) {
$scope.projects[i].checked = 0;
$scope.wf_maker_projects.forEach(function(row, i) {
$scope.wf_maker_projects[i].checked = 0;
});
});
}
@ -698,8 +697,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
return qs.search(path, $stateParams[`${list.iterator}_search`]);
}
],
WorkflowMakerJobTemplateList: ['TemplateList',
(TemplateList) => {
WorkflowMakerJobTemplateList: ['TemplateList', 'i18n',
(TemplateList, i18n) => {
let list = _.cloneDeep(TemplateList);
delete list.actions;
delete list.fields.type;
@ -707,16 +706,19 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
delete list.fields.smart_status;
delete list.fields.labels;
delete list.fieldActions;
list.name = 'wf_maker_templates';
list.iterator = 'wf_maker_template';
list.fields.name.columnClass = "col-md-8";
list.fields.name.tag = i18n._('WORKFLOW');
list.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}";
list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}";
list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit';
list.iterator = 'job_template';
list.name = 'job_templates';
list.basePath = "job_templates";
list.basePath = 'unified_job_templates';
list.fields.info = {
ngInclude: "'/static/partials/job-template-details.html'",
type: 'template',
columnClass: 'col-md-3',
infoHeaderClass: 'col-md-3',
label: '',
nosort: true
};
@ -732,6 +734,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
delete list.fields.status;
delete list.fields.scm_type;
delete list.fields.last_updated;
list.name = 'wf_maker_projects';
list.iterator = 'wf_maker_project';
list.fields.name.columnClass = "col-md-11";
list.maxVisiblePages = 5;
list.searchBarFullWidth = true;
@ -744,6 +748,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
WorkflowInventorySourcesList: ['InventorySourcesList',
(InventorySourcesList) => {
let list = _.cloneDeep(InventorySourcesList);
list.name = 'wf_maker_inventory_sources';
list.iterator = 'wf_maker_inventory_source';
list.maxVisiblePages = 5;
list.searchBarFullWidth = true;
list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}";

View File

@ -51,7 +51,7 @@
overflow: hidden;
}
.WorkflowMaker-contentLeft {
flex: 1 0 auto;
flex: 1;
flex-direction: column;
height: 100%;
}
@ -152,6 +152,7 @@
}
.WorkflowMaker-chart {
display: flex;
width: 100%;
}
.WorkflowMaker-totalJobs {
margin-right: 5px;
@ -202,23 +203,17 @@
line-height: 20px;
}
.WorkflowLegend-details {
align-items: center;
display: flex;
height: 40px;
line-height: 40px;
padding-left: 20px;
margin-top:10px;
border: 1px solid @d7grey;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.WorkflowLegend-legendItem {
display: flex;
}
.WorkflowLegend-legendItem:not(:last-child) {
padding-right: 20px;
}
.WorkflowLegend-details--left {
display: flex;
display: block;
flex: 1 0 auto;
}
.WorkflowLegend-details--right {
@ -304,6 +299,7 @@
height: 3px;
width: 20px;
margin: 9px 5px 9px 0px;
outline: none;
}
.Key-heading {
font-weight: 700;

View File

@ -5,10 +5,10 @@
*************************************************/
export default ['$scope', 'WorkflowService', 'TemplatesService',
'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel',
'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'WorkflowJobTemplateModel',
'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout',
function ($scope, WorkflowService, TemplatesService,
ProcessErrors, CreateSelect2, $q, JobTemplate,
ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate,
Empty, PromptService, Rest, TemplatesStrings, $timeout) {
let promptWatcher, surveyQuestionWatcher, credentialsWatcher;
@ -110,7 +110,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
// Check to see if the user has provided any prompt values that are different
// from the defaults in the job template
if (params.node.unifiedJobTemplate.type === "job_template" && params.node.promptData) {
if ((params.node.unifiedJobTemplate.type === "job_template" || params.node.unifiedJobTemplate.type === "workflow_job_template") && params.node.promptData) {
sendableNodeData = PromptService.bundlePromptDataForSaving({
promptData: params.node.promptData,
dataToSave: sendableNodeData
@ -188,7 +188,11 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
params.node.isNew = false;
continueRecursing(data.data.id);
}, function ({ data, config, status }) {
}, function ({
data,
config,
status
}) {
ProcessErrors($scope, data, status, null, {
hdr: $scope.strings.get('error.HEADER'),
msg: $scope.strings.get('error.CALL', {
@ -339,7 +343,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
let missingPromptValue = false;
if ($scope.missingSurveyValue) {
missingPromptValue = true;
} else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) {
} else if ($scope.selectedTemplate.type === 'job_template' && (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id)) {
missingPromptValue = true;
}
$scope.promptModalMissingReqFields = missingPromptValue;
@ -481,7 +485,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
$scope.placeholderNode.unifiedJobTemplate = $scope.selectedTemplate;
$scope.placeholderNode.edgeType = $scope.edgeType.value;
if ($scope.placeholderNode.unifiedJobTemplate.type === 'job_template') {
if ($scope.placeholderNode.unifiedJobTemplate.type === 'job_template' ||
$scope.placeholderNode.unifiedJobTemplate.type === 'workflow_job_template') {
$scope.placeholderNode.promptData = _.cloneDeep($scope.promptData);
}
$scope.placeholderNode.canEdit = true;
@ -498,8 +503,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
if ($scope.selectedTemplate && $scope.edgeType && $scope.edgeType.value) {
$scope.nodeBeingEdited.unifiedJobTemplate = $scope.selectedTemplate;
$scope.nodeBeingEdited.edgeType = $scope.edgeType.value;
if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template') {
if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template' || $scope.nodeBeingEdited.unifiedJobTemplate.type === 'workflow_job_template') {
$scope.nodeBeingEdited.promptData = _.cloneDeep($scope.promptData);
}
@ -591,9 +595,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
$scope.nodeBeingEdited.isActiveEdit = true;
let finishConfiguringEdit = function () {
let jobTemplate = new JobTemplate();
let templateType = $scope.nodeBeingEdited.unifiedJobTemplate.type;
let jobTemplate = templateType === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate();
if (!_.isEmpty($scope.nodeBeingEdited.promptData)) {
$scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData);
const launchConf = $scope.promptData.launchConf;
@ -615,15 +618,17 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
} else {
$scope.showPromptButton = true;
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) {
if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template' && launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) {
$scope.promptModalMissingReqFields = true;
} else {
$scope.promptModalMissingReqFields = false;
}
}
} else if (
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job_template' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template'
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'workflow_job' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'workflow_job_template'
) {
let promises = [jobTemplate.optionsLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id), jobTemplate.getLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id)];
@ -674,8 +679,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides);
if ((!$scope.nodeBeingEdited.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeBeingEdited.unifiedJobTemplate.project) {
$scope.selectedTemplateInvalid = true;
if ($scope.nodeBeingEdited.unifiedJobTemplate.unified_job_template === 'job') {
if ((!$scope.nodeBeingEdited.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeBeingEdited.unifiedJobTemplate.project) {
$scope.selectedTemplateInvalid = true;
} else {
$scope.selectedTemplateInvalid = false;
}
} else {
$scope.selectedTemplateInvalid = false;
}
@ -774,7 +783,9 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
}
if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate')) {
if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "job_template") {
if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "job_template" ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "workflow_job_template") {
$scope.workflowMakerFormConfig.activeTab = "jobs";
}
@ -783,6 +794,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
if ($scope.selectedTemplate.unified_job_type) {
switch ($scope.selectedTemplate.unified_job_type) {
case "job":
case "workflow_job":
$scope.workflowMakerFormConfig.activeTab = "jobs";
break;
case "project_update":
@ -795,6 +807,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
} else if ($scope.selectedTemplate.type) {
switch ($scope.selectedTemplate.type) {
case "job_template":
case "workflow_job_template":
$scope.workflowMakerFormConfig.activeTab = "jobs";
break;
case "project":
@ -843,8 +856,9 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
// Determine whether or not we need to go out and GET this nodes unified job template
// in order to determine whether or not prompt fields are needed
if (!$scope.nodeBeingEdited.isNew && !$scope.nodeBeingEdited.edited && $scope.nodeBeingEdited.unifiedJobTemplate && $scope.nodeBeingEdited.unifiedJobTemplate.unified_job_type && $scope.nodeBeingEdited.unifiedJobTemplate.unified_job_type === 'job') {
if (!$scope.nodeBeingEdited.isNew && !$scope.nodeBeingEdited.edited &&
(_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'workflow_job')) {
// This is a node that we got back from the api with an incomplete
// unified job template so we're going to pull down the whole object
@ -852,15 +866,19 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
.then(function (data) {
$scope.nodeBeingEdited.unifiedJobTemplate = _.clone(data.data.results[0]);
finishConfiguringEdit();
}, function ({ data, status, config }) {
ProcessErrors($scope, data, status, null, {
hdr: $scope.strings.get('error.HEADER'),
msg: $scope.strings.get('error.CALL', {
path: `${config.url}`,
action: `${config.method}`,
status
})
});
}, function ({
data,
status,
config
}) {
ProcessErrors($scope, data, status, null, {
hdr: $scope.strings.get('error.HEADER'),
msg: $scope.strings.get('error.CALL', {
path: `${config.url}`,
action: `${config.method}`,
status
})
});
});
} else {
finishConfiguringEdit();
@ -982,24 +1000,24 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
}
$scope.promptData = null;
if (selectedTemplate.type === "job_template") {
let jobTemplate = new JobTemplate();
if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") {
let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate();
$q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)])
.then((responses) => {
let launchConf = responses[1].data;
if (selectedTemplate.type === 'job_template') {
if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) {
$scope.selectedTemplateInvalid = true;
} else {
$scope.selectedTemplateInvalid = false;
}
if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) {
$scope.selectedTemplateInvalid = true;
} else {
$scope.selectedTemplateInvalid = false;
}
if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) {
$scope.credentialRequiresPassword = true;
} else {
$scope.credentialRequiresPassword = false;
if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) {
$scope.credentialRequiresPassword = true;
} else {
$scope.credentialRequiresPassword = false;
}
}
$scope.selectedTemplate = angular.copy(selectedTemplate);
@ -1021,8 +1039,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
} else {
$scope.showPromptButton = true;
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) {
$scope.promptModalMissingReqFields = true;
if (selectedTemplate.type === 'job_template') {
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) {
$scope.promptModalMissingReqFields = true;
} else {
$scope.promptModalMissingReqFields = false;
}
} else {
$scope.promptModalMissingReqFields = false;
}
@ -1156,7 +1178,11 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
// This is the last page
buildTreeFromNodes();
}
}, function ({ data, status, config }) {
}, function ({
data,
status,
config
}) {
ProcessErrors($scope, data, status, null, {
hdr: $scope.strings.get('error.HEADER'),
msg: $scope.strings.get('error.CALL', {

View File

@ -62,6 +62,10 @@
<div class="Key-icon Key-icon--circle Key-icon--default">I</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.INVENTORY_SYNC')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">W</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.WORKFLOW')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--warning">!</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.WARNING')}}</p>
@ -94,8 +98,10 @@
<div id="workflow-inventory-sync-list" ui-view="inventorySyncList" ng-show="workflowMakerFormConfig.activeTab === 'inventory_sync'"></div>
</div>
<span ng-show="selectedTemplate &&
((selectedTemplate.type === 'job_template' && workflowMakerFormConfig.activeTab === 'jobs') ||
((selectedTemplate.type === 'job_template' || selectedTemplate.type === 'workflow_job_template' && workflowMakerFormConfig.activeTab === 'jobs') ||
(selectedTemplate.unified_job_type === 'job' || selectedTemplate.unified_job_type === 'workflow_job' && workflowMakerFormConfig.activeTab === 'jobs') ||
(selectedTemplate.type === 'project' && workflowMakerFormConfig.activeTab === 'project_sync') ||
(selectedTemplate.unified_job_type === 'inventory_update' && workflowMakerFormConfig.activeTab === 'inventory_sync') ||
(selectedTemplate.type === 'inventory_source' && workflowMakerFormConfig.activeTab === 'inventory_sync'))">
<div ng-if="selectedTemplate && selectedTemplateInvalid">
<div class="WorkflowMaker-invalidJobTemplateWarning">

View File

@ -9,6 +9,11 @@
max-width: 100%;
}
}
@media screen and (max-width: @breakpoint-md) {
display: block;
min-width: 400px;
}
}
.WorkflowResults-leftSide {
@ -26,6 +31,7 @@
.OnePlusTwo-right--panel(100%, @breakpoint-md);
height: ~"calc(100vh - 177px)";
min-height: 350px;
min-width: 0;
@media (max-width: @breakpoint-md - 1px) {
padding-right: 15px;
@ -74,7 +80,7 @@
.WorkflowResults-resultRowLabel {
text-transform: uppercase;
color: @default-interface-txt;
font-size: 14px;
font-size: 12px;
font-weight: normal!important;
width: 30%;
margin-right: 20px;
@ -141,3 +147,13 @@
.WorkflowResults-extraVarsLabel {
font-size:14px!important;
}
.WorkflowResults-seeMoreLess {
color: #337AB7;
margin: 4px 0px;
text-transform: uppercase;
padding: 2px 0px;
cursor: pointer;
border-radius: 5px;
font-size: 11px;
}

View File

@ -1,9 +1,9 @@
export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange',
'ParseVariableString', 'WorkflowService', 'count', '$state', 'i18n',
'moment', function(workflowData, workflowResultsService,
'moment', '$filter', function(workflowData, workflowResultsService,
workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange,
ParseVariableString, WorkflowService, count, $state, i18n, moment) {
ParseVariableString, WorkflowService, count, $state, i18n, moment, $filter) {
var runTimeElapsedTimer = null;
var getLinks = function() {
@ -41,6 +41,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
EDIT_WORKFLOW: i18n._('Edit the workflow job template'),
EDIT_SLICE_TEMPLATE: i18n._('Edit the slice job template'),
EDIT_SCHEDULE: i18n._('Edit the schedule'),
SOURCE_WORKFLOW_JOB: i18n._('View the source Workflow Job'),
TOGGLE_STDOUT_FULLSCREEN: i18n._('Expand Output'),
STATUS: '' // re-assigned elsewhere
},
@ -50,13 +51,17 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
STARTED: i18n._('Started'),
FINISHED: i18n._('Finished'),
LABELS: i18n._('Labels'),
STATUS: i18n._('Status'),
SLICE_TEMPLATE: i18n._('Slice Job Template'),
STATUS: i18n._('Status')
JOB_EXPLANATION: i18n._('Explanation'),
SOURCE_WORKFLOW_JOB: i18n._('Source Workflow')
},
details: {
HEADER: i18n._('DETAILS'),
NOT_FINISHED: i18n._('Not Finished'),
NOT_STARTED: i18n._('Not Started'),
SHOW_LESS: i18n._('Show Less'),
SHOW_MORE: i18n._('Show More'),
},
results: {
TOTAL_JOBS: i18n._('Total Jobs'),
@ -64,10 +69,11 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
},
legend: {
ON_SUCCESS: i18n._('On Success'),
ON_FAIL: i18n._('On Fail'),
ON_FAILURE: i18n._('On Failure'),
ALWAYS: i18n._('Always'),
PROJECT_SYNC: i18n._('Project Sync'),
INVENTORY_SYNC: i18n._('Inventory Sync'),
WORKFLOW: i18n._('Workflow'),
KEY: i18n._('KEY'),
}
};
@ -100,6 +106,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
$scope.labels = jobLabels;
$scope.count = count.val;
$scope.showManualControls = false;
$scope.showKey = false;
$scope.toggleKey = () => $scope.showKey = !$scope.showKey;
$scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`;
// Start elapsed time updater for job known to be running
if ($scope.workflow.started !== null && $scope.workflow.status === 'running') {
@ -116,6 +125,27 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
$scope.slice_job_template_link = `/#/templates/job_template/${$scope.workflow.summary_fields.job_template.id}`;
}
if (_.get(workflowData, 'summary_fields.source_workflow_job.id')) {
$scope.source_workflow_job_link = `/#/workflows/${workflowData.summary_fields.source_workflow_job.id}`;
}
if (workflowData.job_explanation) {
const limit = 150;
const more = workflowData.job_explanation;
const less = $filter('limitTo')(more, limit);
const showMore = false;
const hasMoreToShow = more.length > limit;
const job_explanation = {
more: more,
less: less,
showMore: showMore,
hasMoreToShow: hasMoreToShow
};
$scope.job_explanation = job_explanation;
}
// turn related api browser routes into front end routes
getLinks();

View File

@ -75,6 +75,33 @@
</div>
</div>
<!-- EXPLANATION DETAIL -->
<div class="WorkflowResults-resultRow" ng-show="workflow.job_explanation">
<label class="WorkflowResults-resultRowLabel">
{{ strings.labels.JOB_EXPLANATION }}
</label>
<div class="WorkflowResults-resultRowText"
ng-show="!job_explanation.showMore">
{{ job_explanation.less }}
<span ng-show="job_explanation.hasMoreToShow">...</span>
<span ng-show="job_explanation.hasMoreToShow"
class="WorkflowResults-seeMoreLess"
ng-click="job_explanation.showMore = true">
{{ strings.details.SHOW_MORE }}
</span>
</div>
<div class="WorkflowResults-resultRowText"
ng-show="job_explanation.showMore">
{{ job_explanation.more }}
<span class="WorkflowResults-seeMoreLess"
ng-click="job_explanation.showMore = false">
{{ strings.details.SHOW_LESS }}
</span>
</div>
</div>
<!-- START TIME DETAIL -->
<div class="WorkflowResults-resultRow"
ng-show="workflow.started">
@ -144,18 +171,33 @@
</div>
</div>
<!-- SLIIIIIICE -->
<!-- SLIIIIIICE -->
<div class="WorkflowResults-resultRow"
ng-show="workflow.summary_fields.job_template.name">
<label
class="WorkflowResults-resultRowLabel">
{{ strings.labels.SLICE_TEMPLATE }}
</label>
<div class="WorkflowResults-resultRowText">
<a href="{{ slice_job_template_link }}"
aw-tool-tip="{{ strings.tooltips.EDIT_SLICE_TEMPLATE }}"
data-placement="top">
{{ workflow.summary_fields.job_template.name }}
</a>
</div>
</div>
<!-- SOURCE WORKFLOW JOB DETAIL -->
<div class="WorkflowResults-resultRow"
ng-show="workflow.summary_fields.job_template.name">
<label
class="WorkflowResults-resultRowLabel">
{{ strings.labels.SLICE_TEMPLATE }}
ng-if="workflow.summary_fields.source_workflow_job">
<label class="WorkflowResults-resultRowLabel">
{{ strings.labels.SOURCE_WORKFLOW_JOB }}
</label>
<div class="WorkflowResults-resultRowText">
<a href="{{ slice_job_template_link }}"
aw-tool-tip="{{ strings.tooltips.EDIT_SLICE_TEMPLATE }}"
data-placement="top">
{{ workflow.summary_fields.job_template.name }}
<a href="{{ source_workflow_job_link }}"
aw-tool-tip="{{ strings.tooltips.SOURCE_WORKFLOW_JOB }}"
data-placement="top">
{{ workflow.summary_fields.source_workflow_job.name }}
</a>
</div>
</div>
@ -268,27 +310,36 @@
<workflow-status-bar></workflow-status-bar>
<div class="WorkflowLegend-details">
<div class="WorkflowLegend-details--left">
<div class="WorkflowLegend-legendItem">{{ strings.legend.KEY }}:</div>
<div class="WorkflowLegend-legendItem">
<div class="WorkflowLegend-onSuccessLegend"></div>
<div>{{ strings.legend.ON_SUCCESS }}</div>
</div>
<div class="WorkflowLegend-legendItem">
<div class="WorkflowLegend-onFailLegend"></div>
<div>{{ strings.legend.ON_FAIL }}</div>
</div>
<div class="WorkflowLegend-legendItem">
<div class="WorkflowLegend-alwaysLegend"></div>
<div>{{ strings.legend.ALWAYS }}</div>
</div>
<div class="WorkflowLegend-legendItem">
<div class="WorkflowLegend-letterCircle">P</div>
<div>{{ strings.legend.PROJECT_SYNC }}</div>
</div>
<div class="WorkflowLegend-legendItem">
<div class="WorkflowLegend-letterCircle">I</div>
<div>{{ strings.legend.INVENTORY_SYNC }}</div>
</div>
<i ng-class="{{ keyClassList }}" class="fa fa-key Key-menuIcon" ng-click="toggleKey()"></i>
<ul ng-show="showKey" class="Key-list noselect">
<li class="Key-listItem">
<p class="Key-heading">{{strings.legend.KEY}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--success"></div>
<p class="Key-listItemContent">{{strings.legend.ON_SUCCESS}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--fail"></div>
<p class="Key-listItemContent">{{strings.legend.ON_FAILURE}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--always"></div>
<p class="Key-listItemContent">{{strings.legend.ALWAYS}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">P</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.legend.PROJECT_SYNC}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">I</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.legend.INVENTORY_SYNC}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">W</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.legend.WORKFLOW}}</p>
</li>
</ul>
</div>
<div class="WorkflowLegend-details--right">
<i ng-class="{'WorkflowMaker-manualControlsIcon--active': showManualControls}" class="fa fa-cog WorkflowMaker-manualControlsIcon" aria-hidden="true" alt="Controls" ng-click="toggleManualControls()"></i>

View File

@ -1,7 +1,7 @@
## Tower Workflow Overview
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, or project update) 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 chosen to run.
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 chosen to run.
## Usage Manual
@ -26,6 +26,18 @@ See the document on saved launch configurations for how these are processed
when the job is launched, and the API validation involved in building
the launch configurations on workflow nodes.
#### Workflows as Workflow Nodes
A workflow can be added as a node in another workflow. The child workflow is the associated
`unified_job_template` that the node references, when that node is added to the parent workflow.
When the parent workflow dispatches that node, then the child workflow will begin running, and
the parent will resume execution of that branch when the child workflow finishes.
Branching into success / failed pathways is decided based on the status of the child workflow.
In the event that spawning the workflow would result in recursion, the child workflow
will be marked as failed with a message explaining that recursion was detected.
This is to prevent saturation of the task system with an infinite chain of workflows.
### Tree-Graph Formation and Restrictions
The tree-graph structure of a workflow is enforced by associating workflow job template nodes via endpoints `/workflow_job_template_nodes/\d+/*_nodes/`, where `*` has options `success`, `failure` and `always`. However there are restrictions that must be enforced when setting up new connections. Here are the three restrictions that will raise validation error when break:
* Cycle restriction: According to tree definition, no cycle is allowed.
@ -83,7 +95,7 @@ Artifact support starts in Ansible and is carried through in Tower. The `set_sta
* No CRUD actions are possible on workflow job nodes by any user, and they may only be deleted by deleting their workflow job.
* Workflow jobs can be deleted by superusers and org admins of the organization of its associated workflow job template, and no one else.
* Verify that workflow job template nodes can be created under, or (dis)associated with workflow job templates.
* Verify that only the permitted types of job template types can be associated with a workflow job template node. Currently the permitted types are *job templates, inventory sources and projects*.
* Verify that the permitted types of job template types can be associated with a workflow job template node. Currently the permitted types are *job templates, inventory sources, projects, and workflow job templates*.
* Verify that workflow job template nodes under the same workflow job template can be associated to form parent-child relationship of decision trees. In specific, one node takes another as its child node by POSTing another node's id to one of the three endpoints: `/success_nodes/`, `/failure_nodes/` and `/always_nodes/`.
* Verify that workflow job template nodes are not allowed to have invalid association. Any attempt that causes invalidity will trigger 400-level response. The three types of invalid associations are cycle, convergence(multiple parent).
* Verify that a workflow job template can be successfully copied and the created workflow job template does not miss any field that should be copied or intentionally modified.