mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Merge pull request #4369 from AlanCoding/workflow_limit
Implementation of WFJT limit & SCM_branch prompting Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
b5fa1606bd
@ -3314,11 +3314,14 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
|
||||
'admin', 'execute',
|
||||
{'copy': 'organization.workflow_admin'}
|
||||
]
|
||||
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
|
||||
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
|
||||
|
||||
class Meta:
|
||||
model = WorkflowJobTemplate
|
||||
fields = ('*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous',
|
||||
'ask_variables_on_launch', 'inventory', 'ask_inventory_on_launch',)
|
||||
'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch',
|
||||
'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch',)
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(WorkflowJobTemplateSerializer, self).get_related(obj)
|
||||
@ -3344,6 +3347,22 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
|
||||
def validate_extra_vars(self, value):
|
||||
return vars_validate_or_raise(value)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super(WorkflowJobTemplateSerializer, self).validate(attrs)
|
||||
|
||||
# process char_prompts, these are not direct fields on the model
|
||||
mock_obj = self.Meta.model()
|
||||
for field_name in ('scm_branch', 'limit'):
|
||||
if field_name in attrs:
|
||||
setattr(mock_obj, field_name, attrs[field_name])
|
||||
attrs.pop(field_name)
|
||||
|
||||
# Model `.save` needs the container dict, not the pseudo fields
|
||||
if mock_obj.char_prompts:
|
||||
attrs['char_prompts'] = mock_obj.char_prompts
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer):
|
||||
'''
|
||||
@ -3356,13 +3375,15 @@ class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer):
|
||||
|
||||
|
||||
class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
|
||||
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
|
||||
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
|
||||
|
||||
class Meta:
|
||||
model = WorkflowJob
|
||||
fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous',
|
||||
'job_template', 'is_sliced_job',
|
||||
'-execution_node', '-event_processing_finished', '-controller_node',
|
||||
'inventory',)
|
||||
'inventory', 'limit', 'scm_branch',)
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(WorkflowJobSerializer, self).get_related(obj)
|
||||
@ -3596,7 +3617,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
# Model `.save` needs the container dict, not the psuedo fields
|
||||
# Model `.save` needs the container dict, not the pseudo fields
|
||||
if mock_obj.char_prompts:
|
||||
attrs['char_prompts'] = mock_obj.char_prompts
|
||||
|
||||
@ -4180,12 +4201,16 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
|
||||
queryset=Inventory.objects.all(),
|
||||
required=False, write_only=True
|
||||
)
|
||||
limit = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
||||
scm_branch = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
||||
workflow_job_template_data = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WorkflowJobTemplate
|
||||
fields = ('ask_inventory_on_launch', 'can_start_without_user_input', 'defaults', 'extra_vars',
|
||||
'inventory', 'survey_enabled', 'variables_needed_to_start',
|
||||
fields = ('ask_inventory_on_launch', 'ask_limit_on_launch', 'ask_scm_branch_on_launch',
|
||||
'can_start_without_user_input', 'defaults', 'extra_vars',
|
||||
'inventory', 'limit', 'scm_branch',
|
||||
'survey_enabled', 'variables_needed_to_start',
|
||||
'node_templates_missing', 'node_prompts_rejected',
|
||||
'workflow_job_template_data', 'survey_enabled', 'ask_variables_on_launch')
|
||||
read_only_fields = ('ask_inventory_on_launch', 'ask_variables_on_launch')
|
||||
@ -4225,9 +4250,14 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
|
||||
|
||||
WFJT_extra_vars = template.extra_vars
|
||||
WFJT_inventory = template.inventory
|
||||
WFJT_limit = template.limit
|
||||
WFJT_scm_branch = template.scm_branch
|
||||
super(WorkflowJobLaunchSerializer, self).validate(attrs)
|
||||
template.extra_vars = WFJT_extra_vars
|
||||
template.inventory = WFJT_inventory
|
||||
template.limit = WFJT_limit
|
||||
template.scm_branch = WFJT_scm_branch
|
||||
|
||||
return accepted
|
||||
|
||||
|
||||
|
||||
@ -3111,6 +3111,17 @@ class WorkflowJobTemplateCopy(CopyAPIView):
|
||||
data.update(messages)
|
||||
return Response(data)
|
||||
|
||||
def _build_create_dict(self, obj):
|
||||
"""Special processing of fields managed by char_prompts
|
||||
"""
|
||||
r = super(WorkflowJobTemplateCopy, self)._build_create_dict(obj)
|
||||
field_names = set(f.name for f in obj._meta.get_fields())
|
||||
for field_name, ask_field_name in obj.get_ask_mapping().items():
|
||||
if field_name in r and field_name not in field_names:
|
||||
r.setdefault('char_prompts', {})
|
||||
r['char_prompts'][field_name] = r.pop(field_name)
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def deep_copy_permission_check_func(user, new_objs):
|
||||
for obj in new_objs:
|
||||
@ -3139,7 +3150,6 @@ class WorkflowJobTemplateLabelList(JobTemplateLabelList):
|
||||
|
||||
class WorkflowJobTemplateLaunch(RetrieveAPIView):
|
||||
|
||||
|
||||
model = models.WorkflowJobTemplate
|
||||
obj_permission_type = 'start'
|
||||
serializer_class = serializers.WorkflowJobLaunchSerializer
|
||||
@ -3156,10 +3166,15 @@ class WorkflowJobTemplateLaunch(RetrieveAPIView):
|
||||
extra_vars.setdefault(v, u'')
|
||||
if extra_vars:
|
||||
data['extra_vars'] = extra_vars
|
||||
if obj.ask_inventory_on_launch:
|
||||
data['inventory'] = obj.inventory_id
|
||||
else:
|
||||
data.pop('inventory', None)
|
||||
modified_ask_mapping = models.WorkflowJobTemplate.get_ask_mapping()
|
||||
modified_ask_mapping.pop('extra_vars')
|
||||
for field_name, ask_field_name in obj.get_ask_mapping().items():
|
||||
if not getattr(obj, ask_field_name):
|
||||
data.pop(field_name, None)
|
||||
elif field_name == 'inventory':
|
||||
data[field_name] = getattrd(obj, "%s.%s" % (field_name, 'id'), None)
|
||||
else:
|
||||
data[field_name] = getattr(obj, field_name)
|
||||
return data
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
59
awx/main/migrations/0090_v360_WFJT_prompts.py
Normal file
59
awx/main/migrations/0090_v360_WFJT_prompts.py
Normal file
@ -0,0 +1,59 @@
|
||||
# Generated by Django 2.2.2 on 2019-07-23 17:56
|
||||
|
||||
import awx.main.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0089_v360_new_job_event_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workflowjobtemplate',
|
||||
name='ask_limit_on_launch',
|
||||
field=awx.main.fields.AskForField(blank=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workflowjobtemplate',
|
||||
name='ask_scm_branch_on_launch',
|
||||
field=awx.main.fields.AskForField(blank=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workflowjobtemplate',
|
||||
name='char_prompts',
|
||||
field=awx.main.fields.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='joblaunchconfig',
|
||||
name='inventory',
|
||||
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='joblaunchconfigs', to='main.Inventory'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='schedule',
|
||||
name='inventory',
|
||||
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='schedules', to='main.Inventory'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workflowjob',
|
||||
name='inventory',
|
||||
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobs', to='main.Inventory'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workflowjobnode',
|
||||
name='inventory',
|
||||
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobnodes', to='main.Inventory'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workflowjobtemplate',
|
||||
name='inventory',
|
||||
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobtemplates', to='main.Inventory'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workflowjobtemplatenode',
|
||||
name='inventory',
|
||||
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobtemplatenodes', to='main.Inventory'),
|
||||
),
|
||||
]
|
||||
@ -1501,7 +1501,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in InventorySourceOptions._meta.fields) | set(
|
||||
['name', 'description', 'schedule', 'credentials', 'inventory']
|
||||
['name', 'description', 'credentials', 'inventory']
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@ -39,7 +39,7 @@ from awx.main.models.notifications import (
|
||||
NotificationTemplate,
|
||||
JobNotificationMixin,
|
||||
)
|
||||
from awx.main.utils import parse_yaml_or_json, getattr_dne
|
||||
from awx.main.utils import parse_yaml_or_json, getattr_dne, NullablePromptPseudoField
|
||||
from awx.main.fields import ImplicitRoleField, JSONField, AskForField
|
||||
from awx.main.models.mixins import (
|
||||
ResourceMixin,
|
||||
@ -271,7 +271,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in JobOptions._meta.fields) | set(
|
||||
['name', 'description', 'schedule', 'survey_passwords', 'labels', 'credentials',
|
||||
['name', 'description', 'survey_passwords', 'labels', 'credentials',
|
||||
'job_slice_number', 'job_slice_count']
|
||||
)
|
||||
|
||||
@ -839,25 +839,6 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
host.save()
|
||||
|
||||
|
||||
# Add on aliases for the non-related-model fields
|
||||
class NullablePromptPsuedoField(object):
|
||||
"""
|
||||
Interface for psuedo-property stored in `char_prompts` dict
|
||||
Used in LaunchTimeConfig and submodels
|
||||
"""
|
||||
def __init__(self, field_name):
|
||||
self.field_name = field_name
|
||||
|
||||
def __get__(self, instance, type=None):
|
||||
return instance.char_prompts.get(self.field_name, None)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
if value in (None, {}):
|
||||
instance.char_prompts.pop(self.field_name, None)
|
||||
else:
|
||||
instance.char_prompts[self.field_name] = value
|
||||
|
||||
|
||||
class LaunchTimeConfigBase(BaseModel):
|
||||
'''
|
||||
Needed as separate class from LaunchTimeConfig because some models
|
||||
@ -878,6 +859,7 @@ class LaunchTimeConfigBase(BaseModel):
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text=_('Inventory applied as a prompt, assuming job template prompts for inventory')
|
||||
)
|
||||
# All standard fields are stored in this dictionary field
|
||||
# This is a solution to the nullable CharField problem, specific to prompting
|
||||
@ -914,21 +896,14 @@ class LaunchTimeConfigBase(BaseModel):
|
||||
data[prompt_name] = prompt_val
|
||||
return data
|
||||
|
||||
def display_extra_vars(self):
|
||||
'''
|
||||
Hides fields marked as passwords in survey.
|
||||
'''
|
||||
if self.survey_passwords:
|
||||
extra_vars = parse_yaml_or_json(self.extra_vars).copy()
|
||||
for key, value in self.survey_passwords.items():
|
||||
if key in extra_vars:
|
||||
extra_vars[key] = value
|
||||
return extra_vars
|
||||
else:
|
||||
return self.extra_vars
|
||||
|
||||
def display_extra_data(self):
|
||||
return self.display_extra_vars()
|
||||
for field_name in JobTemplate.get_ask_mapping().keys():
|
||||
if field_name == 'extra_vars':
|
||||
continue
|
||||
try:
|
||||
LaunchTimeConfigBase._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
setattr(LaunchTimeConfigBase, field_name, NullablePromptPseudoField(field_name))
|
||||
|
||||
|
||||
class LaunchTimeConfig(LaunchTimeConfigBase):
|
||||
@ -963,14 +938,21 @@ class LaunchTimeConfig(LaunchTimeConfigBase):
|
||||
def extra_vars(self, extra_vars):
|
||||
self.extra_data = extra_vars
|
||||
|
||||
def display_extra_vars(self):
|
||||
'''
|
||||
Hides fields marked as passwords in survey.
|
||||
'''
|
||||
if hasattr(self, 'survey_passwords') and self.survey_passwords:
|
||||
extra_vars = parse_yaml_or_json(self.extra_vars).copy()
|
||||
for key, value in self.survey_passwords.items():
|
||||
if key in extra_vars:
|
||||
extra_vars[key] = value
|
||||
return extra_vars
|
||||
else:
|
||||
return self.extra_vars
|
||||
|
||||
for field_name in JobTemplate.get_ask_mapping().keys():
|
||||
if field_name == 'extra_vars':
|
||||
continue
|
||||
try:
|
||||
LaunchTimeConfig._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
setattr(LaunchTimeConfig, field_name, NullablePromptPsuedoField(field_name))
|
||||
def display_extra_data(self):
|
||||
return self.display_extra_vars()
|
||||
|
||||
|
||||
class JobLaunchConfig(LaunchTimeConfig):
|
||||
|
||||
@ -329,7 +329,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in ProjectOptions._meta.fields) | set(
|
||||
['name', 'description', 'schedule']
|
||||
['name', 'description']
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@ -19,7 +19,7 @@ from awx.main.models.notifications import (
|
||||
NotificationTemplate,
|
||||
JobNotificationMixin
|
||||
)
|
||||
from awx.main.models.base import BaseModel, CreatedModifiedModel, VarsDictProperty
|
||||
from awx.main.models.base import CreatedModifiedModel, VarsDictProperty
|
||||
from awx.main.models.rbac import (
|
||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||
ROLE_SINGLETON_SYSTEM_AUDITOR
|
||||
@ -207,11 +207,14 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
def prompts_dict(self, *args, **kwargs):
|
||||
r = super(WorkflowJobNode, self).prompts_dict(*args, **kwargs)
|
||||
# Explanation - WFJT extra_vars still break pattern, so they are not
|
||||
# put through prompts processing, but inventory is only accepted
|
||||
# put through prompts processing, but inventory and others are only accepted
|
||||
# if JT prompts for it, so it goes through this mechanism
|
||||
if self.workflow_job and self.workflow_job.inventory_id:
|
||||
# workflow job inventory takes precedence
|
||||
r['inventory'] = self.workflow_job.inventory
|
||||
if self.workflow_job:
|
||||
if self.workflow_job.inventory_id:
|
||||
# workflow job inventory takes precedence
|
||||
r['inventory'] = self.workflow_job.inventory
|
||||
if self.workflow_job.char_prompts:
|
||||
r.update(self.workflow_job.char_prompts)
|
||||
return r
|
||||
|
||||
def get_job_kwargs(self):
|
||||
@ -298,7 +301,7 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
return data
|
||||
|
||||
|
||||
class WorkflowJobOptions(BaseModel):
|
||||
class WorkflowJobOptions(LaunchTimeConfigBase):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@ -318,10 +321,11 @@ class WorkflowJobOptions(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in WorkflowJobOptions._meta.fields) | set(
|
||||
# NOTE: if other prompts are added to WFJT, put fields in WJOptions, remove inventory
|
||||
['name', 'description', 'schedule', 'survey_passwords', 'labels', 'inventory']
|
||||
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
|
||||
['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch']
|
||||
)
|
||||
r.remove('char_prompts') # needed due to copying launch config to launch config
|
||||
return r
|
||||
|
||||
def _create_workflow_nodes(self, old_node_list, user=None):
|
||||
node_links = {}
|
||||
@ -372,19 +376,18 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='workflows',
|
||||
)
|
||||
inventory = models.ForeignKey(
|
||||
'Inventory',
|
||||
related_name='%(class)ss',
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text=_('Inventory applied to all job templates in workflow that prompt for inventory.'),
|
||||
)
|
||||
ask_inventory_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
)
|
||||
ask_limit_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
)
|
||||
ask_scm_branch_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
)
|
||||
admin_role = ImplicitRoleField(parent_role=[
|
||||
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||
'organization.workflow_admin_role'
|
||||
@ -515,7 +518,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
||||
return WorkflowJob.objects.filter(workflow_job_template=self)
|
||||
|
||||
|
||||
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, LaunchTimeConfigBase):
|
||||
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('id',)
|
||||
|
||||
@ -2235,7 +2235,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),])
|
||||
# Add arguments for the source inventory script
|
||||
args.append('--source')
|
||||
args.append(self.psuedo_build_inventory(inventory_update, private_data_dir))
|
||||
args.append(self.pseudo_build_inventory(inventory_update, private_data_dir))
|
||||
if src == 'custom':
|
||||
args.append("--custom")
|
||||
args.append('-v%d' % inventory_update.verbosity)
|
||||
@ -2246,7 +2246,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
def build_inventory(self, inventory_update, private_data_dir):
|
||||
return None # what runner expects in order to not deal with inventory
|
||||
|
||||
def psuedo_build_inventory(self, inventory_update, private_data_dir):
|
||||
def pseudo_build_inventory(self, inventory_update, private_data_dir):
|
||||
"""Inventory imports are ran through a management command
|
||||
we pass the inventory in args to that command, so this is not considered
|
||||
to be "Ansible" inventory (by runner) even though it is
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
|
||||
# Python
|
||||
import pytest
|
||||
from unittest import mock
|
||||
import json
|
||||
|
||||
# AWX
|
||||
from awx.main.models.workflow import (
|
||||
@ -248,7 +250,6 @@ class TestWorkflowJobTemplate:
|
||||
test_view = WorkflowJobTemplateNodeSuccessNodesList()
|
||||
nodes = wfjt.workflow_job_template_nodes.all()
|
||||
# test cycle validation
|
||||
print(nodes[0].success_nodes.get(id=nodes[1].id).failure_nodes.get(id=nodes[2].id))
|
||||
assert test_view.is_valid_relation(nodes[2], nodes[0]) == {'Error': 'Cycle detected.'}
|
||||
|
||||
def test_always_success_failure_creation(self, wfjt, admin, get):
|
||||
@ -270,6 +271,103 @@ class TestWorkflowJobTemplate:
|
||||
wfjt2.validate_unique()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestWorkflowJobTemplatePrompts:
|
||||
"""These are tests for prompts that live on the workflow job template model
|
||||
not the node, prompts apply for entire workflow
|
||||
"""
|
||||
@pytest.fixture
|
||||
def wfjt_prompts(self):
|
||||
return WorkflowJobTemplate.objects.create(
|
||||
ask_inventory_on_launch=True,
|
||||
ask_variables_on_launch=True,
|
||||
ask_limit_on_launch=True,
|
||||
ask_scm_branch_on_launch=True
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def prompts_data(self, inventory):
|
||||
return dict(
|
||||
inventory=inventory,
|
||||
extra_vars={'foo': 'bar'},
|
||||
limit='webservers',
|
||||
scm_branch='release-3.3'
|
||||
)
|
||||
|
||||
def test_apply_workflow_job_prompts(self, workflow_job_template, wfjt_prompts, prompts_data, inventory):
|
||||
# null or empty fields used
|
||||
workflow_job = workflow_job_template.create_unified_job()
|
||||
assert workflow_job.limit is None
|
||||
assert workflow_job.inventory is None
|
||||
assert workflow_job.scm_branch is None
|
||||
|
||||
# fields from prompts used
|
||||
workflow_job = workflow_job_template.create_unified_job(**prompts_data)
|
||||
assert json.loads(workflow_job.extra_vars) == {'foo': 'bar'}
|
||||
assert workflow_job.limit == 'webservers'
|
||||
assert workflow_job.inventory == inventory
|
||||
assert workflow_job.scm_branch == 'release-3.3'
|
||||
|
||||
# non-null fields from WFJT used
|
||||
workflow_job_template.inventory = inventory
|
||||
workflow_job_template.limit = 'fooo'
|
||||
workflow_job_template.scm_branch = 'bar'
|
||||
workflow_job = workflow_job_template.create_unified_job()
|
||||
assert workflow_job.limit == 'fooo'
|
||||
assert workflow_job.inventory == inventory
|
||||
assert workflow_job.scm_branch == 'bar'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_process_workflow_job_prompts(self, inventory, workflow_job_template, wfjt_prompts, prompts_data):
|
||||
accepted, rejected, errors = workflow_job_template._accept_or_ignore_job_kwargs(**prompts_data)
|
||||
assert accepted == {}
|
||||
assert rejected == prompts_data
|
||||
assert errors
|
||||
accepted, rejected, errors = wfjt_prompts._accept_or_ignore_job_kwargs(**prompts_data)
|
||||
assert accepted == prompts_data
|
||||
assert rejected == {}
|
||||
assert not errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_set_all_the_prompts(self, post, organization, inventory, org_admin):
|
||||
r = post(
|
||||
url = reverse('api:workflow_job_template_list'),
|
||||
data = dict(
|
||||
name='My new workflow',
|
||||
organization=organization.id,
|
||||
inventory=inventory.id,
|
||||
limit='foooo',
|
||||
ask_limit_on_launch=True,
|
||||
scm_branch='bar',
|
||||
ask_scm_branch_on_launch=True
|
||||
),
|
||||
user = org_admin,
|
||||
expect = 201
|
||||
)
|
||||
wfjt = WorkflowJobTemplate.objects.get(id=r.data['id'])
|
||||
assert wfjt.char_prompts == {
|
||||
'limit': 'foooo', 'scm_branch': 'bar'
|
||||
}
|
||||
assert wfjt.ask_scm_branch_on_launch is True
|
||||
assert wfjt.ask_limit_on_launch is True
|
||||
|
||||
launch_url = r.data['related']['launch']
|
||||
with mock.patch('awx.main.queue.CallbackQueueDispatcher.dispatch', lambda self, obj: None):
|
||||
r = post(
|
||||
url = launch_url,
|
||||
data = dict(
|
||||
scm_branch = 'prompt_branch',
|
||||
limit = 'prompt_limit'
|
||||
),
|
||||
user = org_admin,
|
||||
expect=201
|
||||
)
|
||||
assert r.data['limit'] == 'prompt_limit'
|
||||
assert r.data['scm_branch'] == 'prompt_branch'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workflow_ancestors(organization):
|
||||
# Spawn order of templates grandparent -> parent -> child
|
||||
|
||||
@ -3,7 +3,7 @@ import pytest
|
||||
from awx.main.models.jobs import JobTemplate
|
||||
from awx.main.models import Inventory, CredentialType, Credential, Project
|
||||
from awx.main.models.workflow import (
|
||||
WorkflowJobTemplate, WorkflowJobTemplateNode, WorkflowJobOptions,
|
||||
WorkflowJobTemplate, WorkflowJobTemplateNode,
|
||||
WorkflowJob, WorkflowJobNode
|
||||
)
|
||||
from unittest import mock
|
||||
@ -33,11 +33,11 @@ class TestWorkflowJobInheritNodesMixin():
|
||||
def test__create_workflow_job_nodes(self, mocker, job_template_nodes):
|
||||
workflow_job_node_create = mocker.patch('awx.main.models.WorkflowJobTemplateNode.create_workflow_job_node')
|
||||
|
||||
mixin = WorkflowJobOptions()
|
||||
mixin._create_workflow_nodes(job_template_nodes)
|
||||
workflow_job = WorkflowJob()
|
||||
workflow_job._create_workflow_nodes(job_template_nodes)
|
||||
|
||||
for job_template_node in job_template_nodes:
|
||||
workflow_job_node_create.assert_any_call(workflow_job=mixin)
|
||||
workflow_job_node_create.assert_any_call(workflow_job=workflow_job)
|
||||
|
||||
class TestMapWorkflowJobNodes():
|
||||
@pytest.fixture
|
||||
@ -236,4 +236,4 @@ class TestWorkflowJobNodeJobKWARGS:
|
||||
|
||||
|
||||
def test_get_ask_mapping_integrity():
|
||||
assert list(WorkflowJobTemplate.get_ask_mapping().keys()) == ['extra_vars', 'inventory']
|
||||
assert list(WorkflowJobTemplate.get_ask_mapping().keys()) == ['extra_vars', 'inventory', 'limit', 'scm_branch']
|
||||
|
||||
@ -19,9 +19,14 @@ from functools import reduce, wraps
|
||||
from decimal import Decimal
|
||||
|
||||
# Django
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.functional import cached_property
|
||||
from django.db.models.fields.related import ForeignObjectRel, ManyToManyField
|
||||
from django.db.models.fields.related_descriptors import (
|
||||
ForwardManyToOneDescriptor,
|
||||
ManyToManyDescriptor
|
||||
)
|
||||
from django.db.models.query import QuerySet
|
||||
from django.db.models import Q
|
||||
|
||||
@ -42,7 +47,7 @@ __all__ = ['get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelc
|
||||
'get_current_apps', 'set_current_apps',
|
||||
'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity',
|
||||
'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict',
|
||||
'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
|
||||
'NullablePromptPseudoField', 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
|
||||
'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices', 'get_external_account',
|
||||
'task_manager_bulk_reschedule', 'schedule_task_manager', 'classproperty', 'create_temporary_fifo']
|
||||
|
||||
@ -435,6 +440,39 @@ def model_to_dict(obj, serializer_mapping=None):
|
||||
return attr_d
|
||||
|
||||
|
||||
class CharPromptDescriptor:
|
||||
"""Class used for identifying nullable launch config fields from class
|
||||
ex. Schedule.limit
|
||||
"""
|
||||
def __init__(self, field):
|
||||
self.field = field
|
||||
|
||||
|
||||
class NullablePromptPseudoField:
|
||||
"""
|
||||
Interface for pseudo-property stored in `char_prompts` dict
|
||||
Used in LaunchTimeConfig and submodels, defined here to avoid circular imports
|
||||
"""
|
||||
def __init__(self, field_name):
|
||||
self.field_name = field_name
|
||||
|
||||
@cached_property
|
||||
def field_descriptor(self):
|
||||
return CharPromptDescriptor(self)
|
||||
|
||||
def __get__(self, instance, type=None):
|
||||
if instance is None:
|
||||
# for inspection on class itself
|
||||
return self.field_descriptor
|
||||
return instance.char_prompts.get(self.field_name, None)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
if value in (None, {}):
|
||||
instance.char_prompts.pop(self.field_name, None)
|
||||
else:
|
||||
instance.char_prompts[self.field_name] = value
|
||||
|
||||
|
||||
def copy_model_by_class(obj1, Class2, fields, kwargs):
|
||||
'''
|
||||
Creates a new unsaved object of type Class2 using the fields from obj1
|
||||
@ -442,9 +480,10 @@ def copy_model_by_class(obj1, Class2, fields, kwargs):
|
||||
'''
|
||||
create_kwargs = {}
|
||||
for field_name in fields:
|
||||
# Foreign keys can be specified as field_name or field_name_id.
|
||||
id_field_name = '%s_id' % field_name
|
||||
if hasattr(obj1, id_field_name):
|
||||
descriptor = getattr(Class2, field_name)
|
||||
if isinstance(descriptor, ForwardManyToOneDescriptor): # ForeignKey
|
||||
# Foreign keys can be specified as field_name or field_name_id.
|
||||
id_field_name = '%s_id' % field_name
|
||||
if field_name in kwargs:
|
||||
value = kwargs[field_name]
|
||||
elif id_field_name in kwargs:
|
||||
@ -454,15 +493,29 @@ def copy_model_by_class(obj1, Class2, fields, kwargs):
|
||||
if hasattr(value, 'id'):
|
||||
value = value.id
|
||||
create_kwargs[id_field_name] = value
|
||||
elif isinstance(descriptor, CharPromptDescriptor):
|
||||
# difficult case of copying one launch config to another launch config
|
||||
new_val = None
|
||||
if field_name in kwargs:
|
||||
new_val = kwargs[field_name]
|
||||
elif hasattr(obj1, 'char_prompts'):
|
||||
if field_name in obj1.char_prompts:
|
||||
new_val = obj1.char_prompts[field_name]
|
||||
elif hasattr(obj1, field_name):
|
||||
# extremely rare case where a template spawns a launch config - sliced jobs
|
||||
new_val = getattr(obj1, field_name)
|
||||
if new_val is not None:
|
||||
create_kwargs.setdefault('char_prompts', {})
|
||||
create_kwargs['char_prompts'][field_name] = new_val
|
||||
elif isinstance(descriptor, ManyToManyDescriptor):
|
||||
continue # not copied in this method
|
||||
elif field_name in kwargs:
|
||||
if field_name == 'extra_vars' and isinstance(kwargs[field_name], dict):
|
||||
create_kwargs[field_name] = json.dumps(kwargs['extra_vars'])
|
||||
elif not isinstance(Class2._meta.get_field(field_name), (ForeignObjectRel, ManyToManyField)):
|
||||
create_kwargs[field_name] = kwargs[field_name]
|
||||
elif hasattr(obj1, field_name):
|
||||
field_obj = obj1._meta.get_field(field_name)
|
||||
if not isinstance(field_obj, ManyToManyField):
|
||||
create_kwargs[field_name] = getattr(obj1, field_name)
|
||||
create_kwargs[field_name] = getattr(obj1, field_name)
|
||||
|
||||
# Apply class-specific extra processing for origination of unified jobs
|
||||
if hasattr(obj1, '_update_unified_job_kwargs') and obj1.__class__ != Class2:
|
||||
@ -481,7 +534,10 @@ def copy_m2m_relationships(obj1, obj2, fields, kwargs=None):
|
||||
'''
|
||||
for field_name in fields:
|
||||
if hasattr(obj1, field_name):
|
||||
field_obj = obj1._meta.get_field(field_name)
|
||||
try:
|
||||
field_obj = obj1._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
continue
|
||||
if isinstance(field_obj, ManyToManyField):
|
||||
# Many to Many can be specified as field_name
|
||||
src_field_value = getattr(obj1, field_name)
|
||||
|
||||
@ -58,8 +58,9 @@ function canLaunchWithoutPrompt () {
|
||||
launchData.can_start_without_user_input &&
|
||||
!launchData.ask_inventory_on_launch &&
|
||||
!launchData.ask_variables_on_launch &&
|
||||
!launchData.survey_enabled &&
|
||||
!launchData.ask_limit_on_launch &&
|
||||
!launchData.ask_scm_branch_on_launch &&
|
||||
!launchData.survey_enabled &&
|
||||
launchData.variables_needed_to_start.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
@ -89,6 +89,34 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) {
|
||||
},
|
||||
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory',
|
||||
},
|
||||
limit: {
|
||||
label: i18n._('Limit'),
|
||||
type: 'text',
|
||||
column: 1,
|
||||
awPopOver: "<p>" + i18n._("Provide a host pattern to further constrain the list of hosts that will be managed or affected by the workflow. This limit is applied to all job template nodes that prompt for a limit. Refer to Ansible documentation for more information and examples on patterns.") + "</p>",
|
||||
dataTitle: i18n._('Limit'),
|
||||
dataPlacement: 'right',
|
||||
dataContainer: "body",
|
||||
subCheckbox: {
|
||||
variable: 'ask_limit_on_launch',
|
||||
text: i18n._('Prompt on launch')
|
||||
},
|
||||
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory',
|
||||
},
|
||||
scm_branch: {
|
||||
label: i18n._('SCM Branch'),
|
||||
type: 'text',
|
||||
column: 1,
|
||||
awPopOver: "<p>" + i18n._("Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch.") + "</p>",
|
||||
dataTitle: i18n._('SCM Branch'),
|
||||
dataPlacement: 'right',
|
||||
dataContainer: "body",
|
||||
subCheckbox: {
|
||||
variable: 'ask_scm_branch_on_launch',
|
||||
text: i18n._('Prompt on launch')
|
||||
},
|
||||
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit)',
|
||||
},
|
||||
labels: {
|
||||
label: i18n._('Labels'),
|
||||
type: 'select',
|
||||
|
||||
@ -70,9 +70,11 @@ export default [
|
||||
data[fld] = $scope[fld];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch);
|
||||
data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch);
|
||||
data.ask_limit_on_launch = Boolean($scope.ask_limit_on_launch);
|
||||
data.ask_scm_branch_on_launch = Boolean($scope.ask_scm_branch_on_launch);
|
||||
|
||||
data.extra_vars = ToJSON($scope.parseType,
|
||||
$scope.variables, true);
|
||||
|
||||
@ -54,6 +54,8 @@ export default [
|
||||
$scope.parseType = 'yaml';
|
||||
$scope.includeWorkflowMaker = false;
|
||||
$scope.ask_inventory_on_launch = workflowJobTemplateData.ask_inventory_on_launch;
|
||||
$scope.ask_limit_on_launch = workflowJobTemplateData.ask_limit_on_launch;
|
||||
$scope.ask_scm_branch_on_launch = workflowJobTemplateData.ask_scm_branch_on_launch;
|
||||
$scope.ask_variables_on_launch = (workflowJobTemplateData.ask_variables_on_launch) ? true : false;
|
||||
|
||||
if (Inventory){
|
||||
@ -91,6 +93,8 @@ export default [
|
||||
}
|
||||
|
||||
data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch);
|
||||
data.ask_limit_on_launch = Boolean($scope.ask_limit_on_launch);
|
||||
data.ask_scm_branch_on_launch = Boolean($scope.ask_scm_branch_on_launch);
|
||||
data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch);
|
||||
|
||||
data.extra_vars = ToJSON($scope.parseType,
|
||||
|
||||
@ -69,7 +69,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
|
||||
SLICE_TEMPLATE: i18n._('Slice Job Template'),
|
||||
JOB_EXPLANATION: i18n._('Explanation'),
|
||||
SOURCE_WORKFLOW_JOB: i18n._('Source Workflow'),
|
||||
INVENTORY: i18n._('Inventory')
|
||||
INVENTORY: i18n._('Inventory'),
|
||||
LIMIT: i18n._('Inventory Limit'),
|
||||
SCM_BRANCH: i18n._('SCM Branch')
|
||||
},
|
||||
details: {
|
||||
HEADER: i18n._('DETAILS'),
|
||||
|
||||
@ -140,6 +140,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIMIT -->
|
||||
<div class="WorkflowResults-resultRow" ng-show="workflow.limit">
|
||||
<label class="WorkflowResults-resultRowLabel">
|
||||
{{ strings.labels.LIMIT }}
|
||||
</label>
|
||||
<div class="WorkflowResults-resultRowText">
|
||||
{{ workflow.limit }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BRANCH -->
|
||||
<div class="WorkflowResults-resultRow" ng-show="workflow.scm_branch">
|
||||
<label class="WorkflowResults-resultRowLabel">
|
||||
{{ strings.labels.SCM_BRANCH }}
|
||||
</label>
|
||||
<div class="WorkflowResults-resultRowText">
|
||||
{{ workflow.scm_branch }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TEMPLATE DETAIL -->
|
||||
<div class="WorkflowResults-resultRow"
|
||||
ng-show="workflow.summary_fields.workflow_job_template.name">
|
||||
|
||||
@ -144,11 +144,15 @@ describe('Controller: WorkflowAdd', () => {
|
||||
description: "This is a test description",
|
||||
organization: undefined,
|
||||
inventory: undefined,
|
||||
limit: undefined,
|
||||
scm_branch: undefined,
|
||||
labels: undefined,
|
||||
variables: undefined,
|
||||
allow_simultaneous: undefined,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_scm_branch_on_launch: false,
|
||||
extra_vars: undefined
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,6 +25,7 @@ class WorkflowJobTemplateNode(HasCreate, base.Base):
|
||||
'diff_mode',
|
||||
'extra_data',
|
||||
'limit',
|
||||
'scm_branch',
|
||||
'job_tags',
|
||||
'job_type',
|
||||
'skip_tags',
|
||||
|
||||
@ -34,7 +34,12 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
|
||||
payload = PseudoNamespace(name=kwargs.get('name') or 'WorkflowJobTemplate - {}'.format(random_title()),
|
||||
description=kwargs.get('description') or random_title(10))
|
||||
|
||||
optional_fields = ("allow_simultaneous", "ask_variables_on_launch", "survey_enabled")
|
||||
optional_fields = (
|
||||
"allow_simultaneous",
|
||||
"ask_variables_on_launch", "ask_inventory_on_launch", "ask_scm_branch_on_launch", "ask_limit_on_launch",
|
||||
"limit", "scm_branch",
|
||||
"survey_enabled"
|
||||
)
|
||||
update_payload(payload, optional_fields, kwargs)
|
||||
|
||||
extra_vars = kwargs.get('extra_vars', not_provided)
|
||||
@ -48,8 +53,6 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
|
||||
|
||||
if kwargs.get('inventory'):
|
||||
payload.inventory = kwargs.get('inventory').id
|
||||
if kwargs.get('ask_inventory_on_launch'):
|
||||
payload.ask_inventory_on_launch = kwargs.get('ask_inventory_on_launch')
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ actions in the API.
|
||||
- POST to `/api/v2/job_templates/N/launch/`
|
||||
- can accept all prompt-able fields
|
||||
- POST to `/api/v2/workflow_job_templates/N/launch/`
|
||||
- can accept extra_vars and inventory
|
||||
- can accept certain fields, see `workflow.md`
|
||||
- POST to `/api/v2/system_job_templates/N/launch/`
|
||||
- can accept certain fields, with no user configuration
|
||||
|
||||
@ -174,7 +174,7 @@ job. If a user creates a node that would do this, a 400 response will be returne
|
||||
|
||||
Workflow JTs are different than other cases, because they do not have a
|
||||
template directly linked, so their prompts are a form of action-at-a-distance.
|
||||
When the node's prompts are gathered, any prompts from the workflow job
|
||||
When the node's prompts are gathered to spawn its job, any prompts from the workflow job
|
||||
will take precedence over the node's value.
|
||||
|
||||
As a special exception, `extra_vars` from a workflow will not obey JT survey
|
||||
@ -182,8 +182,7 @@ and prompting rules, both both historical and ease-of-understanding reasons.
|
||||
This behavior may change in the future.
|
||||
|
||||
Other than that exception, JT prompting rules are still adhered to when
|
||||
a job is spawned, although so far this only applies to the workflow job's
|
||||
`inventory` field.
|
||||
a job is spawned.
|
||||
|
||||
#### Job Relaunch and Re-scheduling
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ A workflow has an associated tree-graph that is composed of multiple nodes. Each
|
||||
### Workflow Create-Read-Update-Delete (CRUD)
|
||||
Like other job resources, workflow jobs are created from workflow job templates. The API exposes common fields similar to job templates, including labels, schedules, notification templates, extra variables and survey specifications. Other than that, in the API, the related workflow graph nodes can be gotten to via the related workflow_nodes field.
|
||||
|
||||
The CRUD operations against a workflow job template and its corresponding workflow jobs are almost identical to those of normal job templates and related jobs. However, from an RBAC perspective, CRUD on workflow job templates/jobs are limited to super users.
|
||||
The CRUD operations against a workflow job template and its corresponding workflow jobs are almost identical to those of normal job templates and related jobs.
|
||||
|
||||
By default, organization administrators have full control over all workflow job templates under the same organization, and they share these abilities with users who have the `workflow_admin_role` in that organization. Permissions can be further delegated to other users via the workflow job template roles.
|
||||
|
||||
@ -20,7 +20,12 @@ Workflow job template nodes are listed and created under endpoint `/workflow_job
|
||||
#### Workflow Launch Configuration
|
||||
|
||||
Workflow job templates can contain launch configuration items. So far, these only include
|
||||
`extra_vars` and `inventory`, and the `extra_vars` may have specifications via
|
||||
- `extra_vars`
|
||||
- `inventory`
|
||||
- `limit`
|
||||
- `scm_branch`
|
||||
|
||||
The `extra_vars` field may have specifications via
|
||||
a survey, in the same way that job templates work.
|
||||
|
||||
Workflow nodes may also contain the launch-time configuration for the job it will spawn.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user