diff --git a/awx/main/fields.py b/awx/main/fields.py index 777836ebf3..9bb257faba 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -695,11 +695,10 @@ class CredentialTypeInjectorField(JSONSchemaField): 'properties': { 'file': { 'type': 'object', - 'properties': { - 'template': {'type': 'string'}, + 'patternProperties': { + '^template(\.[a-zA-Z_]+[a-zA-Z0-9_]*)?$': {'type': 'string'}, }, 'additionalProperties': False, - 'required': ['template'], }, 'env': { 'type': 'object', @@ -749,8 +748,22 @@ class CredentialTypeInjectorField(JSONSchemaField): class TowerNamespace: filename = None - valid_namespace['tower'] = TowerNamespace() + + # ensure either single file or multi-file syntax is used (but not both) + template_names = [x for x in value.get('file', {}).keys() if x.startswith('template')] + if 'template' in template_names and len(template_names) > 1: + raise django_exceptions.ValidationError( + _('Must use multi-file syntax when injecting multiple files'), + code='invalid', + params={'value': value}, + ) + if 'template' not in template_names: + valid_namespace['tower'].filename = TowerNamespace() + for template_name in template_names: + template_name = template_name.split('.')[1] + setattr(valid_namespace['tower'].filename, template_name, 'EXAMPLE') + for type_, injector in value.items(): for key, tmpl in injector.items(): try: diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index face2befdb..86c3930299 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -594,7 +594,7 @@ class CredentialType(CommonModelNameNotUnique): return class TowerNamespace: - filename = None + pass tower_namespace = TowerNamespace() @@ -622,17 +622,25 @@ class CredentialType(CommonModelNameNotUnique): if len(value): namespace[field_name] = value - file_tmpl = self.injectors.get('file', {}).get('template') - if file_tmpl is not None: - # If a file template is provided, render the file and update the - # special `tower` template namespace so the filename can be - # referenced in other injectors + file_tmpls = self.injectors.get('file', {}) + # If any file templates are provided, render the files and update the + # special `tower` template namespace so the filename can be + # referenced in other injectors + for file_label, file_tmpl in file_tmpls.items(): data = Template(file_tmpl).render(**namespace) _, path = tempfile.mkstemp(dir=private_data_dir) with open(path, 'w') as f: f.write(data) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) - namespace['tower'].filename = path + + # determine if filename indicates single file or many + if file_label.find('.') == -1: + tower_namespace.filename = path + else: + if not hasattr(tower_namespace, 'filename'): + tower_namespace.filename = TowerNamespace() + file_label = file_label.split('.')[1] + setattr(tower_namespace.filename, file_label, path) for env_var, tmpl in self.injectors.get('env', {}).items(): if env_var.startswith('ANSIBLE_') or env_var in self.ENV_BLACKLIST: diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 1fe909c092..37609cd222 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -107,8 +107,11 @@ def test_cred_type_input_schema_validity(input_, valid): ({}, True), ({'invalid-injector': {}}, False), ({'file': 123}, False), - ({'file': {}}, False), + ({'file': {}}, True), ({'file': {'template': '{{username}}'}}, True), + ({'file': {'template.username': '{{username}}'}}, True), + ({'file': {'template.username': '{{username}}', 'template.password': '{{pass}}'}}, True), + ({'file': {'template': '{{username}}', 'template.password': '{{pass}}'}}, False), ({'file': {'foo': 'bar'}}, False), ({'env': 123}, False), ({'env': {}}, True), diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 2c42df686a..c80dbda6f9 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -1227,6 +1227,50 @@ class TestJobCredentials(TestJobExecution): self.run_pexpect.side_effect = run_pexpect_side_effect self.task.run(self.pk) + def test_custom_environment_injectors_with_files(self): + some_cloud = CredentialType( + kind='cloud', + name='SomeCloud', + managed_by_tower=False, + inputs={ + 'fields': [{ + 'id': 'cert', + 'label': 'Certificate', + 'type': 'string' + }, { + 'id': 'key', + 'label': 'Key', + 'type': 'string' + }] + }, + injectors={ + 'file': { + 'template.cert': '[mycert]\n{{cert}}', + 'template.key': '[mykey]\n{{key}}' + }, + 'env': { + 'MY_CERT_INI_FILE': '{{tower.filename.cert}}', + 'MY_KEY_INI_FILE': '{{tower.filename.key}}' + } + } + ) + credential = Credential( + pk=1, + credential_type=some_cloud, + inputs = {'cert': 'CERT123', 'key': 'KEY123'} + ) + self.instance.credentials.add(credential) + self.task.run(self.pk) + + def run_pexpect_side_effect(*args, **kwargs): + args, cwd, env, stdout = args + assert open(env['MY_CERT_INI_FILE'], 'rb').read() == '[mycert]\nCERT123' + assert open(env['MY_KEY_INI_FILE'], 'rb').read() == '[mykey]\nKEY123' + return ['successful', 0] + + self.run_pexpect.side_effect = run_pexpect_side_effect + self.task.run(self.pk) + def test_multi_cloud(self): gce = CredentialType.defaults['gce']() gce_credential = Credential( diff --git a/docs/custom_credential_types.md b/docs/custom_credential_types.md index c1b5387565..33426c2011 100644 --- a/docs/custom_credential_types.md +++ b/docs/custom_credential_types.md @@ -194,7 +194,8 @@ certificate/key data: } } - +Note that the single and multi-file syntax cannot be mixed within the same +``Credential Type``. Job and Job Template Credential Assignment ------------------------------------------ @@ -326,6 +327,8 @@ When verifying acceptance we should ensure the following statements are true: * Custom `Credential Types` should support injecting both single and multiple files. (Furthermore, the new syntax for injecting multiple files should work properly even if only a single file is injected). +* Users should not be able to use the syntax for injecting single and + multiple files in the same custom credential. * The default `Credential Types` included with Tower in 3.2 should be non-editable/readonly and cannot be deleted by any user. * Stored `Credential` values for _all_ types should be consistent before and