diff --git a/awx/main/conf.py b/awx/main/conf.py index 33ed4d9e13e2..5a42535ca29a 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -615,6 +615,17 @@ def _load_default_license_from_file(): category=_('Jobs'), category_slug='jobs', ) +register( + 'MAX_FORKS', + field_class=fields.IntegerField, + allow_null=False, + default=0, + label=_('Maximum number of forks per job.'), + help_text=_('Saving a Job Template with more than this number of forks will result in an error. ' + 'When set to 0 (the default), no limit is applied.'), + category=_('Jobs'), + category_slug='jobs', +) register( 'LOG_AGGREGATOR_HOST', diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 4048cb135859..819defc4f87c 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -13,6 +13,7 @@ # Django from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models #from django.core.cache import cache from django.utils.encoding import smart_str @@ -293,6 +294,11 @@ def validation_errors(self): def resources_needed_to_start(self): return [fd for fd in ['project', 'inventory'] if not getattr(self, '{}_id'.format(fd))] + def clean_forks(self): + if settings.MAX_FORKS > 0 and self.forks > settings.MAX_FORKS: + raise ValidationError(_(f'Maximum number of forks ({settings.MAX_FORKS}) exceeded.')) + return self.forks + def create_job(self, **kwargs): ''' Create a new job based on this template. diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 09dc866d3063..785ed2d655a6 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1656,8 +1656,12 @@ def build_args(self, job, private_data_dir, passwords): args.append('--vault-id') args.append('{}@prompt'.format(vault_id)) - if job.forks: # FIXME: Max limit? - args.append('--forks=%d' % job.forks) + if job.forks: + if settings.MAX_FORKS > 0 and job.forks > settings.MAX_FORKS: + logger.warning(f'Maximum number of forks ({settings.MAX_FORKS}) exceeded.') + args.append('--forks=%d' % settings.MAX_FORKS) + else: + args.append('--forks=%d' % job.forks) if job.force_handlers: args.append('--force-handlers') if job.limit: diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index 6ae9e87d7e16..8ad864ee8e6f 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -118,6 +118,22 @@ def test_extra_credential_unique_type_xfail(get, post, organization_factory, job assert response.data.get('count') == 1 +@pytest.mark.django_db +def test_create_with_forks_exceeding_maximum_xfail(alice, post, project, inventory, settings): + project.use_role.members.add(alice) + inventory.use_role.members.add(alice) + settings.MAX_FORKS = 10 + response = post(reverse('api:job_template_list'), { + 'name': 'Some name', + 'project': project.id, + 'inventory': inventory.id, + 'playbook': 'helloworld.yml', + 'forks': 11, + }, alice) + assert response.status_code == 400 + assert 'Maximum number of forks (10) exceeded' in str(response.data) + + @pytest.mark.django_db def test_attach_extra_credential(get, post, organization_factory, job_template_factory, credential): objs = organization_factory("org", superusers=['admin']) diff --git a/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js b/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js index ab3aa7404cf0..3d0ad107edda 100644 --- a/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js +++ b/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js @@ -58,6 +58,10 @@ export default ['i18n', function(i18n) { type: 'text', reset: 'ANSIBLE_FACT_CACHE_TIMEOUT', }, + MAX_FORKS: { + type: 'text', + reset: 'MAX_FORKS', + }, PROJECT_UPDATE_VVV: { type: 'toggleSwitch', }, diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index 931553dac689..aa019bcbc3e8 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -233,7 +233,7 @@ angular.module('Utilities', ['RestServices', 'Utilities']) addApiErrors(form.fields[field], field); } } - if (defaultMsg) { + if (!fieldErrors && defaultMsg) { Alert(defaultMsg.hdr, defaultMsg.msg); } } else if (typeof data === 'object' && data !== null) {