class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin, WebhookTemplateMixin): SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] FIELDS_TO_PRESERVE_AT_COPY = [ 'labels', 'organization', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec' ] class Meta: app_label = 'main' 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, ) notification_templates_approvals = models.ManyToManyField( "NotificationTemplate", blank=True, related_name='%(class)s_notification_templates_for_approvals') admin_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, 'organization.workflow_admin_role' ]) execute_role = ImplicitRoleField(parent_role=[ 'admin_role', 'organization.execute_role', ]) read_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, 'organization.auditor_role', 'execute_role', 'admin_role', 'approval_role', ]) approval_role = ImplicitRoleField(parent_role=[ 'organization.approval_role', 'admin_role', ]) @property def workflow_nodes(self): return self.workflow_job_template_nodes @classmethod def _get_unified_job_class(cls): return WorkflowJob @classmethod def _get_unified_jt_copy_names(cls): base_list = super(WorkflowJobTemplate, cls)._get_unified_jt_copy_names() base_list.remove('labels') return base_list | set([ 'survey_spec', 'survey_enabled', 'ask_variables_on_launch', 'organization' ]) def get_absolute_url(self, request=None): return reverse('api:workflow_job_template_detail', kwargs={'pk': self.pk}, request=request) @property def cache_timeout_blocked(self): if WorkflowJob.objects.filter(workflow_job_template=self, status__in=[ 'pending', 'waiting', 'running' ]).count() >= getattr( settings, 'SCHEDULE_MAX_JOBS', 10): logger.error( "Workflow Job template %s could not be started because there are more than %s other jobs from that template waiting to run" % (self.name, getattr(settings, 'SCHEDULE_MAX_JOBS', 10))) return True return False @property def notification_templates(self): base_notification_templates = NotificationTemplate.objects.all() error_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_errors__in=[ self ])) started_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_started__in=[ self ])) success_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_success__in=[ self ])) approval_notification_templates = list( base_notification_templates.filter( workflowjobtemplate_notification_templates_for_approvals__in=[ self ])) # Get Organization NotificationTemplates if self.organization is not None: error_notification_templates = set( error_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_errors=self. organization))) started_notification_templates = set( started_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_started=self. organization))) success_notification_templates = set( success_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_success=self. organization))) approval_notification_templates = set( approval_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_approvals=self. organization))) return dict( error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates), approvals=list(approval_notification_templates), ) def create_unified_job(self, **kwargs): workflow_job = super(WorkflowJobTemplate, self).create_unified_job(**kwargs) workflow_job.copy_nodes_from_original(original=self) return workflow_job def _accept_or_ignore_job_kwargs(self, **kwargs): exclude_errors = kwargs.pop('_exclude_errors', []) prompted_data = {} rejected_data = {} errors_dict = {} # Handle all the fields that have prompting rules # NOTE: If WFJTs prompt for other things, this logic can be combined with jobs for field_name, ask_field_name in self.get_ask_mapping().items(): if field_name == 'extra_vars': accepted_vars, rejected_vars, vars_errors = self.accept_or_ignore_variables( kwargs.get('extra_vars', {}), _exclude_errors=exclude_errors, extra_passwords=kwargs.get('survey_passwords', {})) if accepted_vars: prompted_data['extra_vars'] = accepted_vars if rejected_vars: rejected_data['extra_vars'] = rejected_vars errors_dict.update(vars_errors) continue if field_name not in kwargs: continue new_value = kwargs[field_name] old_value = getattr(self, field_name) if new_value == old_value: continue # no-op case: Counted as neither accepted or ignored elif getattr(self, ask_field_name): # accepted prompt prompted_data[field_name] = new_value else: # unprompted - template is not configured to accept field on launch rejected_data[field_name] = new_value # Not considered an error for manual launch, to support old # behavior of putting them in ignored_fields and launching anyway if 'prompts' not in exclude_errors: errors_dict[field_name] = _( 'Field is not configured to prompt on launch.').format( field_name=field_name) return prompted_data, rejected_data, errors_dict def can_start_without_user_input(self): return not bool(self.variables_needed_to_start) def node_templates_missing(self): return [ node.pk for node in self.workflow_job_template_nodes.filter( unified_job_template__isnull=True).all() ] def node_prompts_rejected(self): node_list = [] for node in self.workflow_job_template_nodes.prefetch_related( 'unified_job_template').all(): ujt_obj = node.unified_job_template if ujt_obj is None: continue prompts_dict = node.prompts_dict() accepted_fields, ignored_fields, prompts_errors = ujt_obj._accept_or_ignore_job_kwargs( **prompts_dict) if prompts_errors: node_list.append(node.pk) return node_list ''' RelatedJobsMixin ''' def _get_related_jobs(self): return WorkflowJob.objects.filter(workflow_job_template=self) def resolve_execution_environment(self): return None # EEs are not meaningful for workflows
class SurveyJobTemplateMixin(models.Model): class Meta: abstract = True survey_enabled = models.BooleanField( default=False, ) survey_spec = prevent_search(JSONField( blank=True, default={}, )) ask_variables_on_launch = AskForField( blank=True, default=False, allows_field='extra_vars' ) def survey_password_variables(self): vars = [] if self.survey_enabled and 'spec' in self.survey_spec: # Get variables that are type password for survey_element in self.survey_spec['spec']: if survey_element['type'] == 'password': vars.append(survey_element['variable']) return vars @property def variables_needed_to_start(self): vars = [] if self.survey_enabled and 'spec' in self.survey_spec: for survey_element in self.survey_spec['spec']: if survey_element['required']: vars.append(survey_element['variable']) return vars def _update_unified_job_kwargs(self, create_kwargs, kwargs): ''' Combine extra_vars with variable precedence order: JT extra_vars -> JT survey defaults -> runtime extra_vars :param create_kwargs: key-worded arguments to be updated and later used for creating unified job. :type create_kwargs: dict :param kwargs: request parameters used to override unified job template fields with runtime values. :type kwargs: dict :return: modified create_kwargs. :rtype: dict ''' # Job Template extra_vars extra_vars = self.extra_vars_dict survey_defaults = {} # transform to dict if 'extra_vars' in kwargs: runtime_extra_vars = kwargs['extra_vars'] runtime_extra_vars = parse_yaml_or_json(runtime_extra_vars) else: runtime_extra_vars = {} # Overwrite job template extra vars with survey default vars if self.survey_enabled and 'spec' in self.survey_spec: for survey_element in self.survey_spec.get("spec", []): default = survey_element.get('default') variable_key = survey_element.get('variable') if survey_element.get('type') == 'password': if variable_key in runtime_extra_vars: kw_value = runtime_extra_vars[variable_key] if kw_value == '$encrypted$': runtime_extra_vars.pop(variable_key) if default is not None: decrypted_default = default if ( survey_element['type'] == "password" and isinstance(decrypted_default, basestring) and decrypted_default.startswith('$encrypted$') ): decrypted_default = decrypt_value(get_encryption_key('value', pk=None), decrypted_default) errors = self._survey_element_validation(survey_element, {variable_key: decrypted_default}) if not errors: survey_defaults[variable_key] = default extra_vars.update(survey_defaults) # Overwrite job template extra vars with explicit job extra vars # and add on job extra vars extra_vars.update(runtime_extra_vars) create_kwargs['extra_vars'] = json.dumps(extra_vars) return create_kwargs def _survey_element_validation(self, survey_element, data, validate_required=True): # Don't apply validation to the `$encrypted$` placeholder; the decrypted # default (if any) will be validated against instead errors = [] if (survey_element['type'] == "password"): password_value = data.get(survey_element['variable']) if ( isinstance(password_value, basestring) and password_value == '$encrypted$' ): if survey_element.get('default') is None and survey_element['required']: if validate_required: errors.append("'%s' value missing" % survey_element['variable']) return errors if survey_element['variable'] not in data and survey_element['required']: if validate_required: errors.append("'%s' value missing" % survey_element['variable']) elif survey_element['type'] in ["textarea", "text", "password"]: if survey_element['variable'] in data: if type(data[survey_element['variable']]) not in (str, unicode): errors.append("Value %s for '%s' expected to be a string." % (data[survey_element['variable']], survey_element['variable'])) return errors if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']): errors.append("'%s' value %s is too small (length is %s must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(survey_element['max']): errors.append("'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'integer': if survey_element['variable'] in data: if type(data[survey_element['variable']]) != int: errors.append("Value %s for '%s' expected to be an integer." % (data[survey_element['variable']], survey_element['variable'])) return errors if 'min' in survey_element and survey_element['min'] not in ["", None] and survey_element['variable'] in data and \ data[survey_element['variable']] < int(survey_element['min']): errors.append("'%s' value %s is too small (must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in ["", None] and survey_element['variable'] in data and \ data[survey_element['variable']] > int(survey_element['max']): errors.append("'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'float': if survey_element['variable'] in data: if type(data[survey_element['variable']]) not in (float, int): errors.append("Value %s for '%s' expected to be a numeric type." % (data[survey_element['variable']], survey_element['variable'])) return errors if 'min' in survey_element and survey_element['min'] not in ["", None] and data[survey_element['variable']] < float(survey_element['min']): errors.append("'%s' value %s is too small (must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in ["", None] and data[survey_element['variable']] > float(survey_element['max']): errors.append("'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'multiselect': if survey_element['variable'] in data: if type(data[survey_element['variable']]) != list: errors.append("'%s' value is expected to be a list." % survey_element['variable']) else: choice_list = copy(survey_element['choices']) if isinstance(choice_list, basestring): choice_list = choice_list.split('\n') for val in data[survey_element['variable']]: if val not in choice_list: errors.append("Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'], choice_list)) elif survey_element['type'] == 'multiplechoice': choice_list = copy(survey_element['choices']) if isinstance(choice_list, basestring): choice_list = choice_list.split('\n') if survey_element['variable'] in data: if data[survey_element['variable']] not in choice_list: errors.append("Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']], survey_element['variable'], choice_list)) return errors def _accept_or_ignore_variables(self, data, errors=None, _exclude_errors=(), extra_passwords=None): survey_is_enabled = (self.survey_enabled and self.survey_spec) extra_vars = data.copy() if errors is None: errors = {} rejected = {} accepted = {} if survey_is_enabled: # Check for data violation of survey rules survey_errors = [] for survey_element in self.survey_spec.get("spec", []): key = survey_element.get('variable', None) value = data.get(key, None) validate_required = 'required' not in _exclude_errors if extra_passwords and key in extra_passwords and is_encrypted(value): element_errors = self._survey_element_validation(survey_element, { key: decrypt_value(get_encryption_key('value', pk=None), value) }, validate_required=validate_required) else: element_errors = self._survey_element_validation( survey_element, data, validate_required=validate_required) if element_errors: survey_errors += element_errors if key is not None and key in extra_vars: rejected[key] = extra_vars.pop(key) elif key in extra_vars: accepted[key] = extra_vars.pop(key) if survey_errors: errors['variables_needed_to_start'] = survey_errors if self.ask_variables_on_launch: # We can accept all variables accepted.update(extra_vars) extra_vars = {} if extra_vars: # Leftover extra_vars, keys provided that are not allowed rejected.update(extra_vars) # ignored variables does not block manual launch if 'prompts' not in _exclude_errors: errors['extra_vars'] = [_('Variables {list_of_keys} are not allowed on launch.').format( list_of_keys=', '.join(extra_vars.keys()))] return (accepted, rejected, errors) @staticmethod def pivot_spec(spec): ''' Utility method that will return a dictionary keyed off variable names ''' pivoted = {} for element_data in spec.get('spec', []): if 'variable' in element_data: pivoted[element_data['variable']] = element_data return pivoted def survey_variable_validation(self, data): errors = [] if not self.survey_enabled: return errors if 'name' not in self.survey_spec: errors.append("'name' missing from survey spec.") if 'description' not in self.survey_spec: errors.append("'description' missing from survey spec.") for survey_element in self.survey_spec.get("spec", []): errors += self._survey_element_validation(survey_element, data) return errors def display_survey_spec(self): ''' Hide encrypted default passwords in survey specs ''' survey_spec = deepcopy(self.survey_spec) if self.survey_spec else {} for field in survey_spec.get('spec', []): if field.get('type') == 'password': if 'default' in field and field['default']: field['default'] = '$encrypted$' return survey_spec
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookTemplateMixin): ''' A job template is a reusable job definition for applying a project (with playbook) to an inventory source with a given credential. ''' FIELDS_TO_PRESERVE_AT_COPY = [ 'labels', 'instance_groups', 'credentials', 'survey_spec' ] FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential'] SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] class Meta: app_label = 'main' ordering = ('name', ) host_config_key = prevent_search( models.CharField( max_length=1024, blank=True, default='', )) ask_diff_mode_on_launch = AskForField( blank=True, default=False, ) ask_limit_on_launch = AskForField( blank=True, default=False, ) ask_tags_on_launch = AskForField(blank=True, default=False, allows_field='job_tags') ask_skip_tags_on_launch = AskForField( blank=True, default=False, ) ask_job_type_on_launch = AskForField( blank=True, default=False, ) ask_verbosity_on_launch = AskForField( blank=True, default=False, ) ask_inventory_on_launch = AskForField( blank=True, default=False, ) ask_credential_on_launch = AskForField(blank=True, default=False, allows_field='credentials') ask_scm_branch_on_launch = AskForField(blank=True, default=False, allows_field='scm_branch') job_slice_count = models.PositiveIntegerField( blank=True, default=1, help_text=_( "The number of jobs to slice into at runtime. " "Will cause the Job Template to launch a workflow if value is greater than 1." ), ) admin_role = ImplicitRoleField(parent_role=[ 'project.organization.job_template_admin_role', 'inventory.organization.job_template_admin_role' ]) execute_role = ImplicitRoleField(parent_role=[ 'admin_role', 'project.organization.execute_role', 'inventory.organization.execute_role' ], ) read_role = ImplicitRoleField(parent_role=[ 'project.organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role' ], ) @classmethod def _get_unified_job_class(cls): return Job @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in JobOptions._meta.fields) | set([ 'name', 'description', 'survey_passwords', 'labels', 'credentials', 'job_slice_number', 'job_slice_count' ]) @property def validation_errors(self): ''' Fields needed to start, which cannot be given on launch, invalid state. ''' validation_errors = {} if self.inventory is None and not self.ask_inventory_on_launch: validation_errors['inventory'] = [ _("Job Template must provide 'inventory' or allow prompting for it." ), ] if self.project is None: validation_errors['project'] = [ _("Job Templates must have a project assigned."), ] return validation_errors @property def resources_needed_to_start(self): return [ fd for fd in ['project', 'inventory'] if not getattr(self, '{}_id'.format(fd)) ] def create_job(self, **kwargs): ''' Create a new job based on this template. ''' return self.create_unified_job(**kwargs) def get_effective_slice_ct(self, kwargs): actual_inventory = self.inventory if self.ask_inventory_on_launch and 'inventory' in kwargs: actual_inventory = kwargs['inventory'] if actual_inventory: return min(self.job_slice_count, actual_inventory.hosts.count()) else: return self.job_slice_count def create_unified_job(self, **kwargs): prevent_slicing = kwargs.pop('_prevent_slicing', False) slice_ct = self.get_effective_slice_ct(kwargs) slice_event = bool(slice_ct > 1 and (not prevent_slicing)) if slice_event: # A Slice Job Template will generate a WorkflowJob rather than a Job from awx.main.models.workflow import WorkflowJobTemplate, WorkflowJobNode kwargs[ '_unified_job_class'] = WorkflowJobTemplate._get_unified_job_class( ) kwargs['_parent_field_name'] = "job_template" kwargs.setdefault('_eager_fields', {}) kwargs['_eager_fields']['is_sliced_job'] = True elif self.job_slice_count > 1 and (not prevent_slicing): # Unique case where JT was set to slice but hosts not available kwargs.setdefault('_eager_fields', {}) kwargs['_eager_fields']['job_slice_count'] = 1 elif prevent_slicing: kwargs.setdefault('_eager_fields', {}) kwargs['_eager_fields'].setdefault('job_slice_count', 1) job = super(JobTemplate, self).create_unified_job(**kwargs) if slice_event: for idx in range(slice_ct): create_kwargs = dict(workflow_job=job, unified_job_template=self, ancestor_artifacts=dict(job_slice=idx + 1)) WorkflowJobNode.objects.create(**create_kwargs) return job def get_absolute_url(self, request=None): return reverse('api:job_template_detail', kwargs={'pk': self.pk}, request=request) def can_start_without_user_input(self, callback_extra_vars=None): ''' Return whether job template can be used to start a new job without requiring any user input. ''' variables_needed = False if callback_extra_vars: extra_vars_dict = parse_yaml_or_json(callback_extra_vars) for var in self.variables_needed_to_start: if var not in extra_vars_dict: variables_needed = True break elif self.variables_needed_to_start: variables_needed = True prompting_needed = False # The behavior of provisioning callback should mimic # that of job template launch, so prompting_needed should # not block a provisioning callback from creating/launching jobs. if callback_extra_vars is None: for ask_field_name in set(self.get_ask_mapping().values()): if getattr(self, ask_field_name): prompting_needed = True break return (not prompting_needed and not self.passwords_needed_to_start and not variables_needed) def _accept_or_ignore_job_kwargs(self, **kwargs): exclude_errors = kwargs.pop('_exclude_errors', []) prompted_data = {} rejected_data = {} accepted_vars, rejected_vars, errors_dict = self.accept_or_ignore_variables( kwargs.get('extra_vars', {}), _exclude_errors=exclude_errors, extra_passwords=kwargs.get('survey_passwords', {})) if accepted_vars: prompted_data['extra_vars'] = accepted_vars if rejected_vars: rejected_data['extra_vars'] = rejected_vars # Handle all the other fields that follow the simple prompting rule for field_name, ask_field_name in self.get_ask_mapping().items(): if field_name not in kwargs or field_name == 'extra_vars' or kwargs[ field_name] is None: continue new_value = kwargs[field_name] old_value = getattr(self, field_name) field = self._meta.get_field(field_name) if isinstance(field, models.ManyToManyField): old_value = set(old_value.all()) if getattr(self, '_deprecated_credential_launch', False): # TODO: remove this code branch when support for `extra_credentials` goes away new_value = set(kwargs[field_name]) else: new_value = set(kwargs[field_name]) - old_value if not new_value: continue if new_value == old_value: # no-op case: Fields the same as template's value # counted as neither accepted or ignored continue elif field_name == 'scm_branch' and old_value == '' and self.project and new_value == self.project.scm_branch: # special case of "not provided" for branches # job template does not provide branch, runs with default branch continue elif getattr(self, ask_field_name): # Special case where prompts can be rejected based on project setting if field_name == 'scm_branch': if not self.project: rejected_data[field_name] = new_value errors_dict[field_name] = _('Project is missing.') continue if kwargs[ 'scm_branch'] != self.project.scm_branch and not self.project.allow_override: rejected_data[field_name] = new_value errors_dict[field_name] = _( 'Project does not allow override of branch.') continue # accepted prompt prompted_data[field_name] = new_value else: # unprompted - template is not configured to accept field on launch rejected_data[field_name] = new_value # Not considered an error for manual launch, to support old # behavior of putting them in ignored_fields and launching anyway if 'prompts' not in exclude_errors: errors_dict[field_name] = _( 'Field is not configured to prompt on launch.') if ('prompts' not in exclude_errors and (not getattr(self, 'ask_credential_on_launch', False)) and self.passwords_needed_to_start): errors_dict['passwords_needed_to_start'] = _( 'Saved launch configurations cannot provide passwords needed to start.' ) needed = self.resources_needed_to_start if needed: needed_errors = [] for resource in needed: if resource in prompted_data: continue needed_errors.append( _("Job Template {} is missing or undefined.").format( resource)) if needed_errors: errors_dict['resources_needed_to_start'] = needed_errors return prompted_data, rejected_data, errors_dict @property def cache_timeout_blocked(self): if Job.objects.filter( job_template=self, status__in=[ 'pending', 'waiting', 'running' ]).count() >= getattr(settings, 'SCHEDULE_MAX_JOBS', 10): logger.error( "Job template %s could not be started because there are more than %s other jobs from that template waiting to run" % (self.name, getattr(settings, 'SCHEDULE_MAX_JOBS', 10))) return True return False def _can_update(self): return self.can_start_without_user_input() @property def notification_templates(self): # Return all notification_templates defined on the Job Template, on the Project, and on the Organization for each trigger type # TODO: Currently there is no org fk on project so this will need to be added once that is # available after the rbac pr base_notification_templates = NotificationTemplate.objects error_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_errors__in=[ self, self.project ])) started_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_started__in=[ self, self.project ])) success_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_success__in=[ self, self.project ])) # Get Organization NotificationTemplates if self.project is not None and self.project.organization is not None: error_notification_templates = set( error_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_errors=self. project.organization))) started_notification_templates = set( started_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_started=self. project.organization))) success_notification_templates = set( success_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_success=self. project.organization))) return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates)) ''' RelatedJobsMixin ''' def _get_related_jobs(self): return UnifiedJob.objects.filter(unified_job_template=self)
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin): ''' A job template is a reusable job definition for applying a project (with playbook) to an inventory source with a given credential. ''' SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] class Meta: app_label = 'main' ordering = ('name', ) host_config_key = models.CharField( max_length=1024, blank=True, default='', ) ask_diff_mode_on_launch = AskForField( blank=True, default=False, ) ask_limit_on_launch = AskForField( blank=True, default=False, ) ask_tags_on_launch = AskForField(blank=True, default=False, allows_field='job_tags') ask_skip_tags_on_launch = AskForField( blank=True, default=False, ) ask_job_type_on_launch = AskForField( blank=True, default=False, ) ask_verbosity_on_launch = AskForField( blank=True, default=False, ) ask_inventory_on_launch = AskForField( blank=True, default=False, ) ask_credential_on_launch = AskForField(blank=True, default=False, allows_field='credentials') admin_role = ImplicitRoleField(parent_role=[ 'project.organization.admin_role', 'inventory.organization.admin_role' ]) execute_role = ImplicitRoleField(parent_role=['admin_role'], ) read_role = ImplicitRoleField(parent_role=[ 'project.organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role' ], ) @classmethod def _get_unified_job_class(cls): return Job @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' ]) @property def validation_errors(self): ''' Fields needed to start, which cannot be given on launch, invalid state. ''' validation_errors = {} if self.inventory is None and not self.ask_inventory_on_launch: validation_errors['inventory'] = [ _("Job Template must provide 'inventory' or allow prompting for it." ), ] if self.project is None: validation_errors['project'] = [ _("Job types 'run' and 'check' must have assigned a project."), ] return validation_errors @property def resources_needed_to_start(self): return [ fd for fd in ['project', 'inventory'] if not getattr(self, '{}_id'.format(fd)) ] def create_job(self, **kwargs): ''' Create a new job based on this template. ''' return self.create_unified_job(**kwargs) def get_absolute_url(self, request=None): return reverse('api:job_template_detail', kwargs={'pk': self.pk}, request=request) def can_start_without_user_input(self, callback_extra_vars=None): ''' Return whether job template can be used to start a new job without requiring any user input. ''' variables_needed = False if callback_extra_vars: extra_vars_dict = parse_yaml_or_json(callback_extra_vars) for var in self.variables_needed_to_start: if var not in extra_vars_dict: variables_needed = True break elif self.variables_needed_to_start: variables_needed = True prompting_needed = False # The behavior of provisioning callback should mimic # that of job template launch, so prompting_needed should # not block a provisioning callback from creating/launching jobs. if callback_extra_vars is None: for ask_field_name in set(self.get_ask_mapping().values()): if ask_field_name == 'ask_credential_on_launch': # if ask_credential_on_launch is True, it just means it can # optionally be specified at launch time, not that it's *required* # to launch continue if getattr(self, ask_field_name): prompting_needed = True break return (not prompting_needed and not self.passwords_needed_to_start and not variables_needed) def _accept_or_ignore_job_kwargs(self, **kwargs): exclude_errors = kwargs.pop('_exclude_errors', []) prompted_data = {} rejected_data = {} accepted_vars, rejected_vars, errors_dict = self.accept_or_ignore_variables( kwargs.get('extra_vars', {}), _exclude_errors=exclude_errors, extra_passwords=kwargs.get('survey_passwords', {})) if accepted_vars: prompted_data['extra_vars'] = accepted_vars if rejected_vars: rejected_data['extra_vars'] = rejected_vars # Handle all the other fields that follow the simple prompting rule for field_name, ask_field_name in self.get_ask_mapping().items(): if field_name not in kwargs or field_name == 'extra_vars' or kwargs[ field_name] is None: continue new_value = kwargs[field_name] old_value = getattr(self, field_name) field = self._meta.get_field(field_name) if isinstance(field, models.ManyToManyField): old_value = set(old_value.all()) if getattr(self, '_deprecated_credential_launch', False): # TODO: remove this code branch when support for `extra_credentials` goes away new_value = set(kwargs[field_name]) else: new_value = set(kwargs[field_name]) - old_value if not new_value: continue if new_value == old_value: # no-op case: Fields the same as template's value # counted as neither accepted or ignored continue elif getattr(self, ask_field_name): # accepted prompt prompted_data[field_name] = new_value else: # unprompted - template is not configured to accept field on launch rejected_data[field_name] = new_value # Not considered an error for manual launch, to support old # behavior of putting them in ignored_fields and launching anyway if 'prompts' not in exclude_errors: errors_dict[field_name] = _( 'Field is not configured to prompt on launch.').format( field_name=field_name) if 'prompts' not in exclude_errors and self.passwords_needed_to_start: errors_dict['passwords_needed_to_start'] = _( 'Saved launch configurations cannot provide passwords needed to start.' ) needed = self.resources_needed_to_start if needed: needed_errors = [] for resource in needed: if resource in prompted_data: continue needed_errors.append( _("Job Template {} is missing or undefined.").format( resource)) if needed_errors: errors_dict['resources_needed_to_start'] = needed_errors return prompted_data, rejected_data, errors_dict @property def cache_timeout_blocked(self): if Job.objects.filter( job_template=self, status__in=[ 'pending', 'waiting', 'running' ]).count() > getattr(settings, 'SCHEDULE_MAX_JOBS', 10): logger.error( "Job template %s could not be started because there are more than %s other jobs from that template waiting to run" % (self.name, getattr(settings, 'SCHEDULE_MAX_JOBS', 10))) return True return False def _can_update(self): return self.can_start_without_user_input() @property def notification_templates(self): # Return all notification_templates defined on the Job Template, on the Project, and on the Organization for each trigger type # TODO: Currently there is no org fk on project so this will need to be added once that is # available after the rbac pr base_notification_templates = NotificationTemplate.objects error_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_errors__in=[ self, self.project ])) success_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_success__in=[ self, self.project ])) any_notification_templates = list( base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_any__in=[ self, self.project ])) # Get Organization NotificationTemplates if self.project is not None and self.project.organization is not None: error_notification_templates = set( error_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_errors=self. project.organization))) success_notification_templates = set( success_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_success=self. project.organization))) any_notification_templates = set(any_notification_templates + list( base_notification_templates.filter( organization_notification_templates_for_any=self.project. organization))) return dict(error=list(error_notification_templates), success=list(success_notification_templates), any=list(any_notification_templates))
class SurveyJobTemplateMixin(models.Model): class Meta: abstract = True survey_enabled = models.BooleanField(default=False, ) survey_spec = prevent_search(JSONField( blank=True, default={}, )) ask_variables_on_launch = AskForField(blank=True, default=False, allows_field='extra_vars') def survey_password_variables(self): vars = [] if self.survey_enabled and 'spec' in self.survey_spec: # Get variables that are type password for survey_element in self.survey_spec['spec']: if survey_element['type'] == 'password': vars.append(survey_element['variable']) return vars @property def variables_needed_to_start(self): vars = [] if self.survey_enabled and 'spec' in self.survey_spec: for survey_element in self.survey_spec['spec']: if survey_element['required']: vars.append(survey_element['variable']) return vars def _update_unified_job_kwargs(self, create_kwargs, kwargs): ''' Combine extra_vars with variable precedence order: JT extra_vars -> JT survey defaults -> runtime extra_vars :param create_kwargs: key-worded arguments to be updated and later used for creating unified job. :type create_kwargs: dict :param kwargs: request parameters used to override unified job template fields with runtime values. :type kwargs: dict :return: modified create_kwargs. :rtype: dict ''' # Job Template extra_vars extra_vars = self.extra_vars_dict survey_defaults = {} # transform to dict if 'extra_vars' in kwargs: runtime_extra_vars = kwargs['extra_vars'] runtime_extra_vars = parse_yaml_or_json(runtime_extra_vars) else: runtime_extra_vars = {} # Overwrite with job template extra vars with survey default vars if self.survey_enabled and 'spec' in self.survey_spec: for survey_element in self.survey_spec.get("spec", []): default = survey_element.get('default') variable_key = survey_element.get('variable') if survey_element.get('type') == 'password': if variable_key in runtime_extra_vars and default: kw_value = runtime_extra_vars[variable_key] if kw_value.startswith( '$encrypted$') and kw_value != default: runtime_extra_vars[variable_key] = default if default is not None: data = {variable_key: default} errors = self._survey_element_validation( survey_element, data) if not errors: survey_defaults[variable_key] = default extra_vars.update(survey_defaults) # Overwrite job template extra vars with explicit job extra vars # and add on job extra vars extra_vars.update(runtime_extra_vars) create_kwargs['extra_vars'] = json.dumps(extra_vars) return create_kwargs def _survey_element_validation(self, survey_element, data): errors = [] if survey_element['variable'] not in data and survey_element[ 'required']: errors.append("'%s' value missing" % survey_element['variable']) elif survey_element['type'] in ["textarea", "text", "password"]: if survey_element['variable'] in data: if type(data[survey_element['variable']]) not in (str, unicode): errors.append( "Value %s for '%s' expected to be a string." % (data[survey_element['variable']], survey_element['variable'])) return errors if 'min' in survey_element and survey_element['min'] not in [ "", None ] and len(data[survey_element['variable']]) < int( survey_element['min']): errors.append( "'%s' value %s is too small (length is %s must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in [ "", None ] and len(data[survey_element['variable']]) > int( survey_element['max']): errors.append( "'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'integer': if survey_element['variable'] in data: if type(data[survey_element['variable']]) != int: errors.append( "Value %s for '%s' expected to be an integer." % (data[survey_element['variable']], survey_element['variable'])) return errors if 'min' in survey_element and survey_element['min'] not in ["", None] and survey_element['variable'] in data and \ data[survey_element['variable']] < int(survey_element['min']): errors.append( "'%s' value %s is too small (must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in ["", None] and survey_element['variable'] in data and \ data[survey_element['variable']] > int(survey_element['max']): errors.append( "'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'float': if survey_element['variable'] in data: if type(data[survey_element['variable']]) not in (float, int): errors.append( "Value %s for '%s' expected to be a numeric type." % (data[survey_element['variable']], survey_element['variable'])) return errors if 'min' in survey_element and survey_element['min'] not in [ "", None ] and data[survey_element['variable']] < float( survey_element['min']): errors.append( "'%s' value %s is too small (must be at least %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['min'])) if 'max' in survey_element and survey_element['max'] not in [ "", None ] and data[survey_element['variable']] > float( survey_element['max']): errors.append( "'%s' value %s is too large (must be no more than %s)." % (survey_element['variable'], data[survey_element['variable']], survey_element['max'])) elif survey_element['type'] == 'multiselect': if survey_element['variable'] in data: if type(data[survey_element['variable']]) != list: errors.append("'%s' value is expected to be a list." % survey_element['variable']) else: choice_list = copy(survey_element['choices']) if isinstance(choice_list, basestring): choice_list = choice_list.split('\n') for val in data[survey_element['variable']]: if val not in choice_list: errors.append( "Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'], choice_list)) elif survey_element['type'] == 'multiplechoice': choice_list = copy(survey_element['choices']) if isinstance(choice_list, basestring): choice_list = choice_list.split('\n') if survey_element['variable'] in data: if data[survey_element['variable']] not in choice_list: errors.append( "Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']], survey_element['variable'], choice_list)) return errors def _accept_or_ignore_variables(self, data, errors=None): survey_is_enabled = (self.survey_enabled and self.survey_spec) extra_vars = data.copy() if errors is None: errors = {} rejected = {} accepted = {} if survey_is_enabled: # Check for data violation of survey rules survey_errors = [] for survey_element in self.survey_spec.get("spec", []): element_errors = self._survey_element_validation( survey_element, data) key = survey_element.get('variable', None) if element_errors: survey_errors += element_errors if key is not None and key in extra_vars: rejected[key] = extra_vars.pop(key) elif key in extra_vars: accepted[key] = extra_vars.pop(key) if survey_errors: errors['variables_needed_to_start'] = survey_errors if self.ask_variables_on_launch: # We can accept all variables accepted.update(extra_vars) extra_vars = {} if extra_vars: # Leftover extra_vars, keys provided that are not allowed rejected.update(extra_vars) # ignored variables does not block manual launch if not getattr(self, '_is_manual_launch', False): errors['extra_vars'] = [ _('Variables {list_of_keys} are not allowed on launch.'). format(list_of_keys=', '.join(extra_vars.keys())) ] return (accepted, rejected, errors)