mirror of
https://github.com/ansible/awx.git
synced 2026-06-26 17:08:03 -02:30
Feature: saved launchtime configurations
Consolidate prompts accept/reject logic in unified models Break out accept/reject logic for variables Surface new promptable fields on WFJT nodes, schedules Make schedules and workflows accurately reject variables that are not allowed by the prompting rules or the survey rules on the template Validate against unallowed extra_data in system job schedules Prevent schedule or WFJT node POST/PATCH with unprompted data Move system job days validation to new mechanism Add new psuedo-field for WFJT node credential Add validation for node related credentials Add related config model to unified job Use JobLaunchConfig model for launch RBAC check Support credential overwrite behavior with multi-creds change modern manual launch to use merge behavior Refactor JobLaunchSerializer, self.instance=None Modularize job launch view to create "modern" data Auto-create config object with every job Add create schedule endpoint for jobs
This commit is contained in:
261
awx/api/views.py
261
awx/api/views.py
@@ -607,6 +607,43 @@ class ScheduleDetail(RetrieveUpdateDestroyAPIView):
|
||||
new_in_148 = True
|
||||
|
||||
|
||||
class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
|
||||
|
||||
model = Credential
|
||||
serializer_class = CredentialSerializer
|
||||
relationship = 'credentials'
|
||||
|
||||
def is_valid_relation(self, parent, sub, created=False):
|
||||
if not parent.unified_job_template:
|
||||
return {"msg": _("Cannot assign credential when related template is null.")}
|
||||
elif self.relationship not in parent.unified_job_template.ask_mapping:
|
||||
return {"msg": _("Related template cannot accept credentials on launch.")}
|
||||
elif sub.passwords_needed:
|
||||
return {"msg": _("Credential that requires user input on launch "
|
||||
"cannot be used in saved launch configuration.")}
|
||||
|
||||
ask_field_name = parent.unified_job_template.ask_mapping[self.relationship]
|
||||
|
||||
if not getattr(parent, ask_field_name):
|
||||
return {"msg": _("Related template is not configured to accept credentials on launch.")}
|
||||
elif sub.kind != 'vault' and parent.credentials.filter(credential_type__kind=sub.kind).exists():
|
||||
return {"msg": _("This launch configuration already provides a {credential_type} credential.".format(
|
||||
credential_type=sub.kind))}
|
||||
elif sub.pk in parent.unified_job_template.credentials.values_list('pk', flat=True):
|
||||
return {"msg": _("Related template already uses {credential_type} credential.".format(
|
||||
credential_type=sub.name))}
|
||||
|
||||
# None means there were no validation errors
|
||||
return None
|
||||
|
||||
|
||||
class ScheduleCredentialsList(LaunchConfigCredentialsBase):
|
||||
|
||||
parent_model = Schedule
|
||||
new_in_330 = True
|
||||
new_in_api_v2 = True
|
||||
|
||||
|
||||
class ScheduleUnifiedJobsList(SubListAPIView):
|
||||
|
||||
model = UnifiedJob
|
||||
@@ -2704,16 +2741,21 @@ class JobTemplateLaunch(RetrieveAPIView):
|
||||
return data
|
||||
extra_vars = data.pop('extra_vars', None) or {}
|
||||
if obj:
|
||||
for p in obj.passwords_needed_to_start:
|
||||
data[p] = u''
|
||||
needed_passwords = obj.passwords_needed_to_start
|
||||
if needed_passwords:
|
||||
data['credential_passwords'] = {}
|
||||
for p in needed_passwords:
|
||||
data['credential_passwords'][p] = u''
|
||||
else:
|
||||
data.pop('credential_passwords')
|
||||
for v in obj.variables_needed_to_start:
|
||||
extra_vars.setdefault(v, u'')
|
||||
if extra_vars:
|
||||
data['extra_vars'] = extra_vars
|
||||
ask_for_vars_dict = obj._ask_for_vars_dict()
|
||||
ask_for_vars_dict.pop('extra_vars')
|
||||
for field in ask_for_vars_dict:
|
||||
if not ask_for_vars_dict[field]:
|
||||
modified_ask_mapping = JobTemplate.ask_mapping.copy()
|
||||
modified_ask_mapping.pop('extra_vars')
|
||||
for field, ask_field_name in modified_ask_mapping.items():
|
||||
if not getattr(obj, ask_field_name):
|
||||
data.pop(field, None)
|
||||
elif field == 'inventory':
|
||||
data[field] = getattrd(obj, "%s.%s" % (field, 'id'), None)
|
||||
@@ -2723,39 +2765,41 @@ class JobTemplateLaunch(RetrieveAPIView):
|
||||
data[field] = getattr(obj, field)
|
||||
return data
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
def modernize_launch_payload(self, data, obj):
|
||||
'''
|
||||
Steps to do simple translations of request data to support
|
||||
old field structure to launch endpoint
|
||||
TODO: delete this method with future API version changes
|
||||
'''
|
||||
ignored_fields = {}
|
||||
modern_data = data.copy()
|
||||
|
||||
for fd in ('credential', 'vault_credential', 'inventory'):
|
||||
id_fd = '{}_id'.format(fd)
|
||||
if fd not in request.data and id_fd in request.data:
|
||||
request.data[fd] = request.data[id_fd]
|
||||
if fd not in modern_data and id_fd in modern_data:
|
||||
modern_data[fd] = modern_data[id_fd]
|
||||
|
||||
# This block causes `extra_credentials` to _always_ be ignored for
|
||||
# the launch endpoint if we're accessing `/api/v1/`
|
||||
if get_request_version(self.request) == 1 and 'extra_credentials' in request.data:
|
||||
if hasattr(request.data, '_mutable') and not request.data._mutable:
|
||||
request.data._mutable = True
|
||||
extra_creds = request.data.pop('extra_credentials', None)
|
||||
if get_request_version(self.request) == 1 and 'extra_credentials' in modern_data:
|
||||
extra_creds = modern_data.pop('extra_credentials', None)
|
||||
if extra_creds is not None:
|
||||
ignored_fields['extra_credentials'] = extra_creds
|
||||
|
||||
# Automatically convert legacy launch credential arguments into a list of `.credentials`
|
||||
if 'credentials' in request.data and (
|
||||
'credential' in request.data or
|
||||
'vault_credential' in request.data or
|
||||
'extra_credentials' in request.data
|
||||
if 'credentials' in modern_data and (
|
||||
'credential' in modern_data or
|
||||
'vault_credential' in modern_data or
|
||||
'extra_credentials' in modern_data
|
||||
):
|
||||
return Response(dict(
|
||||
error=_("'credentials' cannot be used in combination with 'credential', 'vault_credential', or 'extra_credentials'.")), # noqa
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise ParseError({"error": _(
|
||||
"'credentials' cannot be used in combination with 'credential', 'vault_credential', or 'extra_credentials'."
|
||||
)})
|
||||
|
||||
if (
|
||||
'credential' in request.data or
|
||||
'vault_credential' in request.data or
|
||||
'extra_credentials' in request.data
|
||||
'credential' in modern_data or
|
||||
'vault_credential' in modern_data or
|
||||
'extra_credentials' in modern_data
|
||||
):
|
||||
# make a list of the current credentials
|
||||
existing_credentials = obj.credentials.all()
|
||||
@@ -2765,49 +2809,58 @@ class JobTemplateLaunch(RetrieveAPIView):
|
||||
('vault_credential', lambda cred: cred.credential_type.kind != 'vault'),
|
||||
('extra_credentials', lambda cred: cred.credential_type.kind not in ('cloud', 'net'))
|
||||
):
|
||||
if key in request.data:
|
||||
if key in modern_data:
|
||||
# if a specific deprecated key is specified, remove all
|
||||
# credentials of _that_ type from the list of current
|
||||
# credentials
|
||||
existing_credentials = filter(conditional, existing_credentials)
|
||||
prompted_value = request.data.pop(key)
|
||||
prompted_value = modern_data.pop(key)
|
||||
|
||||
# add the deprecated credential specified in the request
|
||||
if not isinstance(prompted_value, Iterable):
|
||||
prompted_value = [prompted_value]
|
||||
|
||||
# If user gave extra_credentials, special case to use exactly
|
||||
# the given list without merging with JT credentials
|
||||
if key == 'extra_credentials' and prompted_value:
|
||||
obj._deprecated_credential_launch = True
|
||||
new_credentials.extend(prompted_value)
|
||||
|
||||
# combine the list of "new" and the filtered list of "old"
|
||||
new_credentials.extend([cred.pk for cred in existing_credentials])
|
||||
if new_credentials:
|
||||
request.data['credentials'] = new_credentials
|
||||
modern_data['credentials'] = new_credentials
|
||||
|
||||
passwords = {}
|
||||
serializer = self.serializer_class(instance=obj, data=request.data, context={'obj': obj, 'data': request.data, 'passwords': passwords})
|
||||
# credential passwords were historically provided as top-level attributes
|
||||
if 'credential_passwords' not in modern_data:
|
||||
modern_data['credential_passwords'] = data.copy()
|
||||
|
||||
return (modern_data, ignored_fields)
|
||||
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
print request.data
|
||||
|
||||
try:
|
||||
modern_data, ignored_fields = self.modernize_launch_payload(
|
||||
data=request.data, obj=obj
|
||||
)
|
||||
except ParseError as exc:
|
||||
print ' args ' + str(exc.args)
|
||||
return Response(exc.detail, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = self.serializer_class(data=modern_data, context={'template': obj})
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
_accepted_or_ignored = obj._accept_or_ignore_job_kwargs(**request.data)
|
||||
prompted_fields = _accepted_or_ignored[0]
|
||||
ignored_fields.update(_accepted_or_ignored[1])
|
||||
ignored_fields.update(serializer._ignored_fields)
|
||||
|
||||
fd = 'inventory'
|
||||
if fd in prompted_fields and prompted_fields[fd] != getattrd(obj, '{}.pk'.format(fd), None):
|
||||
new_res = get_object_or_400(Inventory, pk=get_pk_from_dict(prompted_fields, fd))
|
||||
use_role = getattr(new_res, 'use_role')
|
||||
if request.user not in use_role:
|
||||
raise PermissionDenied()
|
||||
if not request.user.can_access(JobLaunchConfig, 'add', serializer.validated_data, template=obj):
|
||||
raise PermissionDenied()
|
||||
|
||||
# For credentials that are _added_ via launch parameters, ensure the
|
||||
# launching user has access
|
||||
current_credentials = set(obj.credentials.values_list('id', flat=True))
|
||||
for new_cred in Credential.objects.filter(id__in=prompted_fields.get('credentials', [])):
|
||||
if new_cred.pk not in current_credentials and request.user not in new_cred.use_role:
|
||||
raise PermissionDenied(_(
|
||||
"You do not have access to credential {}".format(new_cred.name)
|
||||
))
|
||||
|
||||
new_job = obj.create_unified_job(**prompted_fields)
|
||||
passwords = serializer.validated_data.pop('credential_passwords', {})
|
||||
new_job = obj.create_unified_job(**serializer.validated_data)
|
||||
result = new_job.signal_start(**passwords)
|
||||
|
||||
if not result:
|
||||
@@ -2817,11 +2870,35 @@ class JobTemplateLaunch(RetrieveAPIView):
|
||||
else:
|
||||
data = OrderedDict()
|
||||
data['job'] = new_job.id
|
||||
data['ignored_fields'] = ignored_fields
|
||||
data['ignored_fields'] = self.sanitize_for_response(ignored_fields)
|
||||
data.update(JobSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job))
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def sanitize_for_response(self, data):
|
||||
'''
|
||||
Model objects cannot be serialized by DRF,
|
||||
this replaces objects with their ids for inclusion in response
|
||||
'''
|
||||
|
||||
def display_value(val):
|
||||
if hasattr(val, 'id'):
|
||||
return val.id
|
||||
else:
|
||||
return val
|
||||
|
||||
sanitized_data = {}
|
||||
for field_name, value in data.items():
|
||||
if isinstance(value, (set, list)):
|
||||
sanitized_data[field_name] = []
|
||||
for sub_value in value:
|
||||
sanitized_data[field_name].append(display_value(sub_value))
|
||||
else:
|
||||
sanitized_data[field_name] = display_value(value)
|
||||
|
||||
return sanitized_data
|
||||
|
||||
|
||||
class JobTemplateSchedulesList(SubListCreateAPIView):
|
||||
|
||||
view_name = _("Job Template Schedules")
|
||||
@@ -3238,10 +3315,20 @@ class WorkflowJobNodeDetail(WorkflowsEnforcementMixin, RetrieveAPIView):
|
||||
new_in_310 = True
|
||||
|
||||
|
||||
class WorkflowJobNodeCredentialsList(SubListAPIView):
|
||||
|
||||
model = Credential
|
||||
serializer_class = CredentialSerializer
|
||||
parent_model = WorkflowJobNode
|
||||
relationship = 'credentials'
|
||||
new_in_330 = True
|
||||
new_in_api_v2 = True
|
||||
|
||||
|
||||
class WorkflowJobTemplateNodeList(WorkflowsEnforcementMixin, ListCreateAPIView):
|
||||
|
||||
model = WorkflowJobTemplateNode
|
||||
serializer_class = WorkflowJobTemplateNodeListSerializer
|
||||
serializer_class = WorkflowJobTemplateNodeSerializer
|
||||
new_in_310 = True
|
||||
|
||||
|
||||
@@ -3251,21 +3338,18 @@ class WorkflowJobTemplateNodeDetail(WorkflowsEnforcementMixin, RetrieveUpdateDes
|
||||
serializer_class = WorkflowJobTemplateNodeDetailSerializer
|
||||
new_in_310 = True
|
||||
|
||||
def update_raw_data(self, data):
|
||||
for fd in ['job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags']:
|
||||
data[fd] = None
|
||||
try:
|
||||
obj = self.get_object()
|
||||
data.update(obj.char_prompts)
|
||||
except Exception:
|
||||
pass
|
||||
return super(WorkflowJobTemplateNodeDetail, self).update_raw_data(data)
|
||||
|
||||
class WorkflowJobTemplateNodeCredentialsList(LaunchConfigCredentialsBase):
|
||||
|
||||
parent_model = WorkflowJobTemplateNode
|
||||
new_in_330 = True
|
||||
new_in_api_v2 = True
|
||||
|
||||
|
||||
class WorkflowJobTemplateNodeChildrenBaseList(WorkflowsEnforcementMixin, EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = WorkflowJobTemplateNode
|
||||
serializer_class = WorkflowJobTemplateNodeListSerializer
|
||||
serializer_class = WorkflowJobTemplateNodeSerializer
|
||||
always_allow_superuser = True
|
||||
parent_model = WorkflowJobTemplateNode
|
||||
relationship = ''
|
||||
@@ -3447,7 +3531,7 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView):
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
prompted_fields, ignored_fields = obj._accept_or_ignore_job_kwargs(**request.data)
|
||||
prompted_fields, ignored_fields, errors = obj._accept_or_ignore_job_kwargs(**request.data)
|
||||
|
||||
new_job = obj.create_unified_job(**prompted_fields)
|
||||
new_job.signal_start()
|
||||
@@ -3489,17 +3573,12 @@ class WorkflowJobRelaunch(WorkflowsEnforcementMixin, GenericAPIView):
|
||||
class WorkflowJobTemplateWorkflowNodesList(WorkflowsEnforcementMixin, SubListCreateAPIView):
|
||||
|
||||
model = WorkflowJobTemplateNode
|
||||
serializer_class = WorkflowJobTemplateNodeListSerializer
|
||||
serializer_class = WorkflowJobTemplateNodeSerializer
|
||||
parent_model = WorkflowJobTemplate
|
||||
relationship = 'workflow_job_template_nodes'
|
||||
parent_key = 'workflow_job_template'
|
||||
new_in_310 = True
|
||||
|
||||
def update_raw_data(self, data):
|
||||
for fd in ['job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags']:
|
||||
data[fd] = None
|
||||
return super(WorkflowJobTemplateWorkflowNodesList, self).update_raw_data(data)
|
||||
|
||||
def get_queryset(self):
|
||||
return super(WorkflowJobTemplateWorkflowNodesList, self).get_queryset().order_by('id')
|
||||
|
||||
@@ -3936,6 +4015,52 @@ class JobRelaunch(RetrieveAPIView):
|
||||
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
|
||||
class JobCreateSchedule(RetrieveAPIView):
|
||||
|
||||
model = Job
|
||||
obj_permission_type = 'start'
|
||||
serializer_class = JobCreateScheduleSerializer
|
||||
new_in_330 = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
|
||||
if not obj.can_schedule:
|
||||
return Response({"error": _('Information needed to schedule this job is missing.')},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
config = obj.launch_config
|
||||
if not request.user.can_access(JobLaunchConfig, 'add', {'reference_obj': obj}):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Make up a name for the schedule, guarentee that it is unique
|
||||
name = 'Auto-generated schedule from job {}'.format(obj.id)
|
||||
existing_names = Schedule.objects.filter(name__startswith=name).values_list('name', flat=True)
|
||||
if name in existing_names:
|
||||
idx = 1
|
||||
alt_name = '{} - number {}'.format(name, idx)
|
||||
while alt_name in existing_names:
|
||||
idx += 1
|
||||
alt_name = '{} - number {}'.format(name, idx)
|
||||
name = alt_name
|
||||
|
||||
schedule = Schedule.objects.create(
|
||||
name=name,
|
||||
unified_job_template=obj.unified_job_template,
|
||||
enabled=False,
|
||||
rrule='{}Z RRULE:FREQ=MONTHLY;INTERVAL=1'.format(now().strftime('DTSTART:%Y%m%dT%H%M%S')),
|
||||
extra_data=config.extra_data,
|
||||
survey_passwords=config.survey_passwords,
|
||||
inventory=config.inventory,
|
||||
char_prompts=config.char_prompts
|
||||
)
|
||||
schedule.credentials.add(*config.credentials.all())
|
||||
|
||||
data = ScheduleSerializer(schedule, context=self.get_serializer_context()).data
|
||||
headers = {'Location': schedule.get_absolute_url(request=request)}
|
||||
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
|
||||
class JobNotificationsList(SubListAPIView):
|
||||
|
||||
model = Notification
|
||||
|
||||
Reference in New Issue
Block a user