diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 25fa56c8bf..bbdf13d8f0 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2184,7 +2184,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): res['cloud_credential'] = self.reverse('api:credential_detail', kwargs={'pk': cloud_cred}) net_cred = obj.network_credential if net_cred: - res['cloud_credential'] = self.reverse('api:credential_detail', kwargs={'pk': net_cred}) + res['network_credential'] = self.reverse('api:credential_detail', kwargs={'pk': net_cred}) return res @@ -2302,7 +2302,15 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO model = JobTemplate fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch', - 'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'allow_simultaneous') + 'ask_credential_on_launch', 'ask_extra_credentials_on_launch', 'survey_enabled', 'become_enabled', + 'allow_simultaneous') + + # TODO: remove in 3.3 + def get_fields(self): + ret = super(JobTemplateSerializer, self).get_fields() + if self.version == 1: + ret.pop('ask_extra_credentials_on_launch') + return ret def get_related(self, obj): res = super(JobTemplateSerializer, self).get_related(obj) @@ -2972,18 +2980,19 @@ class JobLaunchSerializer(BaseSerializer): model = JobTemplate fields = ('can_start_without_user_input', 'passwords_needed_to_start', 'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory', - 'credential', 'ask_variables_on_launch', 'ask_tags_on_launch', + 'credential', 'extra_credentials', 'ask_variables_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_limit_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch', - 'survey_enabled', 'variables_needed_to_start', + 'ask_extra_credentials_on_launch', 'survey_enabled', 'variables_needed_to_start', 'credential_needed_to_start', 'inventory_needed_to_start', 'job_template_data', 'defaults') read_only_fields = ( 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', - 'ask_inventory_on_launch', 'ask_credential_on_launch') + 'ask_inventory_on_launch', 'ask_credential_on_launch', 'ask_extra_credentials_on_launch') extra_kwargs = { 'credential': {'write_only': True,}, + 'extra_credentials': {'write_only': True, 'default': [], 'allow_empty': True}, 'limit': {'write_only': True,}, 'job_tags': {'write_only': True,}, 'skip_tags': {'write_only': True,}, @@ -2991,6 +3000,14 @@ class JobLaunchSerializer(BaseSerializer): 'inventory': {'write_only': True,} } + # TODO: remove in 3.3 + def get_fields(self): + ret = super(JobLaunchSerializer, self).get_fields() + if self.version == 1: + ret.pop('extra_credentials') + ret.pop('ask_extra_credentials_on_launch') + return ret + def get_credential_needed_to_start(self, obj): return not (obj and obj.credential) @@ -3010,6 +3027,9 @@ class JobLaunchSerializer(BaseSerializer): defaults_dict[field] = dict( name=getattrd(obj, '%s.name' % field, None), id=getattrd(obj, '%s.pk' % field, None)) + elif field == 'extra_credentials': + if self.version > 1: + defaults_dict[field] = [cred.id for cred in obj.extra_credentials.all()] else: defaults_dict[field] = getattr(obj, field) return defaults_dict @@ -3060,6 +3080,15 @@ class JobLaunchSerializer(BaseSerializer): if validation_errors: errors['variables_needed_to_start'] = validation_errors + extra_cred_kinds = [] + for cred in data.get('extra_credentials', []): + cred = Credential.objects.get(id=cred) + if cred.credential_type.pk in extra_cred_kinds: + errors['extra_credentials'] = _('Cannot assign multiple %s credentials.' % cred.credential_type.name) + if cred.credential_type.kind not in ('net', 'cloud'): + errors['extra_credentials'] = _('Extra credentials must be network or cloud.') + extra_cred_kinds.append(cred.credential_type.pk) + # Special prohibited cases for scan jobs errors.update(obj._extra_job_type_errors(data)) @@ -3073,6 +3102,7 @@ class JobLaunchSerializer(BaseSerializer): JT_skip_tags = obj.skip_tags JT_inventory = obj.inventory JT_credential = obj.credential + extra_credentials = attrs.pop('extra_credentials', None) attrs = super(JobLaunchSerializer, self).validate(attrs) obj.extra_vars = JT_extra_vars obj.limit = JT_limit @@ -3081,6 +3111,8 @@ class JobLaunchSerializer(BaseSerializer): obj.job_tags = JT_job_tags obj.inventory = JT_inventory obj.credential = JT_credential + if extra_credentials is not None: + attrs['extra_credentials'] = extra_credentials return attrs diff --git a/awx/api/views.py b/awx/api/views.py index b7fa4409f4..fb622cf997 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2526,29 +2526,41 @@ class JobTemplateLaunch(RetrieveAPIView, GenericAPIView): data['extra_vars'] = extra_vars ask_for_vars_dict = obj._ask_for_vars_dict() ask_for_vars_dict.pop('extra_vars') + if get_request_version(self.request) == 1: # TODO: remove in 3.3 + ask_for_vars_dict.pop('extra_credentials') for field in ask_for_vars_dict: if not ask_for_vars_dict[field]: data.pop(field, None) elif field == 'inventory' or field == 'credential': data[field] = getattrd(obj, "%s.%s" % (field, 'id'), None) + elif field == 'extra_credentials': + data[field] = [cred.id for cred in obj.extra_credentials.all()] else: data[field] = getattr(obj, field) return data def post(self, request, *args, **kwargs): obj = self.get_object() + ignored_fields = {} if 'credential' not in request.data and 'credential_id' in request.data: request.data['credential'] = request.data['credential_id'] if 'inventory' not in request.data and 'inventory_id' in request.data: request.data['inventory'] = request.data['inventory_id'] + if get_request_version(self.request) == 1: # TODO: remove in 3.3 + extra_creds = request.data.pop('extra_credentials', None) + if extra_creds is not None: + ignored_fields['extra_credentials'] = extra_creds + passwords = {} serializer = self.serializer_class(instance=obj, data=request.data, context={'obj': obj, 'data': request.data, 'passwords': passwords}) 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) + _accepted_or_ignored = obj._accept_or_ignore_job_kwargs(**request.data) + prompted_fields = _accepted_or_ignored[0] + ignored_fields.update(_accepted_or_ignored[1]) if 'credential' in prompted_fields and prompted_fields['credential'] != getattrd(obj, 'credential.pk', None): new_credential = get_object_or_400(Credential, pk=get_pk_from_dict(prompted_fields, 'credential')) @@ -2560,6 +2572,11 @@ class JobTemplateLaunch(RetrieveAPIView, GenericAPIView): if request.user not in new_inventory.use_role: raise PermissionDenied() + for cred in prompted_fields.get('extra_credentials', []): + new_credential = get_object_or_400(Credential, pk=cred) + if request.user not in new_credential.use_role: + raise PermissionDenied() + new_job = obj.create_unified_job(**prompted_fields) result = new_job.signal_start(**passwords) diff --git a/awx/main/access.py b/awx/main/access.py index 9030aa846b..4488b09423 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1192,7 +1192,8 @@ class JobTemplateAccess(BaseAccess): 'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_skip_tags_on_launch', - 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', + 'ask_inventory_on_launch', 'ask_credential_on_launch', + 'ask_extra_credentials_on_launch', 'survey_enabled', # These fields are ignored, but it is convenient for QA to allow clients to post them 'last_job_run', 'created', 'modified', @@ -1352,7 +1353,11 @@ class JobAccess(BaseAccess): job_fields[fd] = getattr(obj, fd) accepted_fields, ignored_fields = obj.job_template._accept_or_ignore_job_kwargs(**job_fields) for fd in ignored_fields: - if fd != 'extra_vars' and job_fields[fd] != getattr(obj.job_template, fd): + if fd == 'extra_credentials': + if set(job_fields[fd].all()) != set(getattr(obj.job_template, fd).all()): + # Job has field that is not promptable + prompts_access = False + elif fd != 'extra_vars' and job_fields[fd] != getattr(obj.job_template, fd): # Job has field that is not promptable prompts_access = False if obj.credential != obj.job_template.credential and not credential_access: diff --git a/awx/main/migrations/0043_v320_job_template_multi_credential.py b/awx/main/migrations/0043_v320_job_template_multi_credential.py index ff06b44228..a0b24ed8a1 100644 --- a/awx/main/migrations/0043_v320_job_template_multi_credential.py +++ b/awx/main/migrations/0043_v320_job_template_multi_credential.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from django.db import migrations, models from awx.main.migrations import _credentialtypes as credentialtypes -import django.db.models.deletion class Migration(migrations.Migration): @@ -23,6 +22,11 @@ class Migration(migrations.Migration): name='extra_credentials', field=models.ManyToManyField(related_name='_jobtemplate_extra_credentials_+', to='main.Credential'), ), + migrations.AddField( + model_name='jobtemplate', + name='ask_extra_credentials_on_launch', + field=models.BooleanField(default=False), + ), migrations.RunPython(credentialtypes.migrate_job_credentials), migrations.RemoveField( model_name='job', diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index fe1147ab54..f61b6d17c1 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -169,16 +169,6 @@ class JobOptions(BaseModel): ) return cred - def clean(self): - super(JobOptions, self).clean() - # extra_credentials M2M can't be accessed until a primary key exists - if self.pk: - for cred in self.extra_credentials.all(): - if cred.credential_type.kind not in ('net', 'cloud'): - raise ValidationError( - _('Extra credentials must be network or cloud.'), - ) - @property def all_credentials(self): credentials = list(self.extra_credentials.all()) @@ -269,6 +259,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour blank=True, default=False, ) + ask_extra_credentials_on_launch = models.BooleanField( + blank=True, + default=False, + ) admin_role = ImplicitRoleField( parent_role=['project.organization.admin_role', 'inventory.organization.admin_role'] ) @@ -369,7 +363,8 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour job_type=self.ask_job_type_on_launch, verbosity=self.ask_verbosity_on_launch, inventory=self.ask_inventory_on_launch, - credential=self.ask_credential_on_launch + credential=self.ask_credential_on_launch, + extra_credentials=self.ask_extra_credentials_on_launch ) def _accept_or_ignore_job_kwargs(self, **kwargs): diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 94cfa85f07..d7dcf85074 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -333,6 +333,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio unified_job_class = self._get_unified_job_class() fields = self._get_unified_job_field_names() unified_job = copy_model_by_class(self, unified_job_class, fields, kwargs) + eager_fields = kwargs.get('_eager_fields', None) if eager_fields: for fd, val in eager_fields.items(): @@ -351,7 +352,8 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio unified_job.survey_passwords = hide_password_dict unified_job.save() - # Labels coppied here + + # Labels and extra credentials copied here copy_m2m_relationships(self, unified_job, fields, kwargs=kwargs) return unified_job diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index 600f7da086..d26967dd1b 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -329,6 +329,70 @@ def test_job_launch_JT_with_validation(machine_credential, deploy_jobtemplate): assert job_obj.credential.id == machine_credential.id +@pytest.mark.django_db +@pytest.mark.parametrize('pks, error_msg', [ + ([1], 'must be network or cloud'), + ([999], 'object does not exist'), +]) +def test_job_launch_JT_with_invalid_extra_credentials(machine_credential, deploy_jobtemplate, pks, error_msg): + deploy_jobtemplate.ask_extra_credentials_on_launch = True + deploy_jobtemplate.save() + + kv = dict(extra_credentials=pks, credential=machine_credential.id) + serializer = JobLaunchSerializer( + instance=deploy_jobtemplate, data=kv, + context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) + validated = serializer.is_valid() + assert validated is False + + +@pytest.mark.django_db +def test_job_launch_JT_enforces_unique_extra_credential_kinds(machine_credential, credentialtype_aws, deploy_jobtemplate): + """ + JT launching should require that extra_credentials have distinct CredentialTypes + """ + pks = [] + for i in range(2): + aws = Credential.objects.create( + name='cred-%d' % i, + credential_type=credentialtype_aws, + inputs={ + 'username': 'test_user', + 'password': 'pas4word' + } + ) + aws.save() + pks.append(aws.pk) + + kv = dict(extra_credentials=pks, credential=machine_credential.id) + serializer = JobLaunchSerializer( + instance=deploy_jobtemplate, data=kv, + context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) + validated = serializer.is_valid() + assert validated is False + + +@pytest.mark.django_db +def test_job_launch_JT_with_extra_credentials(machine_credential, credential, net_credential, deploy_jobtemplate): + deploy_jobtemplate.ask_extra_credentials_on_launch = True + deploy_jobtemplate.save() + + kv = dict(extra_credentials=[credential.pk, net_credential.pk], credential=machine_credential.id) + serializer = JobLaunchSerializer( + instance=deploy_jobtemplate, data=kv, + context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) + validated = serializer.is_valid() + assert validated + + prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**kv) + job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) + + extra_creds = job_obj.extra_credentials.all() + assert len(extra_creds) == 2 + assert credential in extra_creds + assert net_credential in extra_creds + + @pytest.mark.django_db @pytest.mark.job_runtime_vars def test_job_launch_unprompted_vars_with_survey(mocker, survey_spec_factory, job_template_prompts, post, admin_user): diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index ab40ab1066..6c7709ca75 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -452,6 +452,145 @@ def test_scan_jt_surveys(inventory): assert "survey_enabled" in serializer.errors +@pytest.mark.django_db +def test_launch_with_extra_credentials(get, post, organization_factory, + job_template_factory, machine_credential, + credential, net_credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.ask_extra_credentials_on_launch = True + jt.save() + + resp = post( + reverse('api:job_template_launch', kwargs={'pk': jt.pk}), + dict( + credential=machine_credential.pk, + extra_credentials=[credential.pk, net_credential.pk] + ), + objs.superusers.admin, expect=201 + ) + job_pk = resp.data.get('id') + + resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) + assert resp.data.get('count') == 2 + + resp = get(reverse('api:job_template_extra_credentials_list', kwargs={'pk': jt.pk}), objs.superusers.admin) + assert resp.data.get('count') == 0 + + +@pytest.mark.django_db +def test_launch_with_extra_credentials_no_allowed(get, post, organization_factory, + job_template_factory, machine_credential, + credential, net_credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.ask_extra_credentials_on_launch = False + jt.save() + + resp = post( + reverse('api:job_template_launch', kwargs={'pk': jt.pk}), + dict( + credential=machine_credential.pk, + extra_credentials=[credential.pk, net_credential.pk] + ), + objs.superusers.admin, expect=201 + ) + assert 'extra_credentials' in resp.data['ignored_fields'].keys() + job_pk = resp.data.get('id') + + resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) + assert resp.data.get('count') == 0 + + +@pytest.mark.django_db +def test_launch_with_extra_credentials_from_jt(get, post, organization_factory, + job_template_factory, machine_credential, + credential, net_credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.ask_extra_credentials_on_launch = True + jt.extra_credentials.add(credential) + jt.extra_credentials.add(net_credential) + jt.save() + + resp = post( + reverse('api:job_template_launch', kwargs={'pk': jt.pk}), + dict( + credential=machine_credential.pk + ), + objs.superusers.admin, expect=201 + ) + job_pk = resp.data.get('id') + + resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) + assert resp.data.get('count') == 2 + + resp = get(reverse('api:job_template_extra_credentials_list', kwargs={'pk': jt.pk}), objs.superusers.admin) + assert resp.data.get('count') == 2 + + +@pytest.mark.django_db +def test_launch_with_empty_extra_credentials(get, post, organization_factory, + job_template_factory, machine_credential, + credential, net_credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.ask_extra_credentials_on_launch = True + jt.extra_credentials.add(credential) + jt.extra_credentials.add(net_credential) + jt.save() + + resp = post( + reverse('api:job_template_launch', kwargs={'pk': jt.pk}), + dict( + credential=machine_credential.pk, + extra_credentials=[], + ), + objs.superusers.admin, expect=201 + ) + job_pk = resp.data.get('id') + + resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) + assert resp.data.get('count') == 0 + + resp = get(reverse('api:job_template_extra_credentials_list', kwargs={'pk': jt.pk}), objs.superusers.admin) + assert resp.data.get('count') == 2 + + +@pytest.mark.django_db +def test_v1_launch_with_extra_credentials(get, post, organization_factory, + job_template_factory, machine_credential, + credential, net_credential): + # launch requests to `/api/v1/job_templates/N/launch/` should ignore + # `extra_credentials`, as they're only supported in v2 of the API. + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.ask_extra_credentials_on_launch = True + jt.save() + + resp = post( + reverse('api:job_template_launch', kwargs={'pk': jt.pk, 'version': 'v1'}), + dict( + credential=machine_credential.pk, + extra_credentials=[credential.pk, net_credential.pk] + ), + objs.superusers.admin, expect=201 + ) + job_pk = resp.data.get('id') + assert resp.data.get('ignored_fields').keys() == ['extra_credentials'] + + resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) + assert resp.data.get('count') == 0 + + resp = get(reverse('api:job_template_extra_credentials_list', kwargs={'pk': jt.pk}), objs.superusers.admin) + assert resp.data.get('count') == 0 + + @pytest.mark.django_db def test_jt_without_project(inventory): data = dict(name="Test", job_type="run", diff --git a/awx/main/tests/functional/models/test_job_options.py b/awx/main/tests/functional/models/test_job_options.py index 34bb7d7cae..c601413a5d 100644 --- a/awx/main/tests/functional/models/test_job_options.py +++ b/awx/main/tests/functional/models/test_job_options.py @@ -45,16 +45,3 @@ def test_clean_credential_with_custom_types(credentialtype_aws, credentialtype_n job_template.extra_credentials.add(aws) job_template.extra_credentials.add(net) job_template.full_clean() - - -@pytest.mark.django_db -def test_clean_credential_with_custom_types_xfail(credentialtype_ssh, job_template): - ssh = Credential( - name='SSH Credential', - credential_type=credentialtype_ssh - ) - ssh.save() - - with pytest.raises(ValidationError): - job_template.extra_credentials.add(ssh) - job_template.full_clean() diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index bfab902211..b56c954f9c 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -25,7 +25,7 @@ import six # Django from django.utils.translation import ugettext_lazy as _ -from django.db.models import ManyToManyField +from django.db.models.fields.related import ForeignObjectRel, ManyToManyField # Django REST Framework from rest_framework.exceptions import ParseError, PermissionDenied @@ -459,9 +459,7 @@ def copy_model_by_class(obj1, Class2, fields, kwargs): 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']) - # We can't get a hold of django.db.models.fields.related.ManyRelatedManager to compare - # so this is the next best thing. - elif kwargs[field_name].__class__.__name__ is not 'ManyRelatedManager': + 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_by_name(field_name)[0] @@ -491,6 +489,9 @@ def copy_m2m_relationships(obj1, obj2, fields, kwargs=None): src_field_value = getattr(obj1, field_name) if kwargs and field_name in kwargs: override_field_val = kwargs[field_name] + if isinstance(override_field_val, list): + getattr(obj2, field_name).add(*override_field_val) + continue if override_field_val.__class__.__name__ is 'ManyRelatedManager': src_field_value = override_field_val dest_field = getattr(obj2, field_name) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 53fe4067d7..66155aaebc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,3 +14,18 @@ [[#5741](https://github.com/ansible/ansible-tower/issues/5741)] * support sourcing inventory from a file inside of a project's source tree [[#2477](https://github.com/ansible/ansible-tower/issues/2477)] +* added support for custom cloud and network credential types, which give the + customer the ability to modify environment variables, extra vars, and + generate file-based credentials (such as file-based certificates or .ini + files) at `ansible-playbook` runtime + [[#5876](https://github.com/ansible/ansible-tower/issues/5876)] +* added support for assigning multiple cloud and network credential types on + `JobTemplates`. ``JobTemplates`` can prompt for "extra credentials" at + launch time in the same manner as promptable machine credentials + [[#5807](https://github.com/ansible/ansible-tower/issues/5807)] + [[#2913](https://github.com/ansible/ansible-tower/issues/2913)] +* custom inventory sources can now specify a ``Credential``; you + can store third-party credentials encrypted within Tower and use their + values from within your custom inventory script (by - for example - reading + an environment variable or a file's contents) + [[#5879](https://github.com/ansible/ansible-tower/issues/5879)] diff --git a/docs/custom_credential_types.md b/docs/custom_credential_types.md index 60b4e3e179..2fe1b84d03 100644 --- a/docs/custom_credential_types.md +++ b/docs/custom_credential_types.md @@ -36,6 +36,11 @@ Important Changes Engine credential. You cannot, however, create a ``Job Template`` that uses two OpenStack credentials. +* In the same manner as "promptable SSH credentials", ``Job Templates`` can now + be flagged with ``ask_extra_credentials_on_launch = true``. When this flag + is enabled, ``extra_credentials`` for a ``Job Template`` can be specified in + the launch payload. + * Custom inventory sources can now utilize a ``Credential``; you can store third-party credentials encrypted within Tower and use their values from within your custom inventory script (by - for example - reading