diff --git a/awx/main/fields.py b/awx/main/fields.py index c5f5affb40..5d3710ed51 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -788,7 +788,8 @@ class CredentialTypeInjectorField(JSONSchemaField): 'type': 'object', 'patternProperties': { # http://docs.ansible.com/ansible/playbooks_variables.html#what-makes-a-valid-variable-name - '^[a-zA-Z_]+[a-zA-Z0-9_]*$': {'type': 'string'}, + # plus, add ability to template + r'^[a-zA-Z_\{\}]+[a-zA-Z0-9_\{\}]*$': {"anyOf": [{'type': 'string'}, {'type': 'array'}, {'$ref': '#/properties/extra_vars'}]} }, 'additionalProperties': False, }, @@ -855,27 +856,44 @@ class CredentialTypeInjectorField(JSONSchemaField): template_name = template_name.split('.')[1] setattr(valid_namespace['tower'].filename, template_name, 'EXAMPLE_FILENAME') + def validate_template_string(type_, key, tmpl): + try: + sandbox.ImmutableSandboxedEnvironment(undefined=StrictUndefined).from_string(tmpl).render(valid_namespace) + except UndefinedError as e: + raise django_exceptions.ValidationError( + _('{sub_key} uses an undefined field ({error_msg})').format(sub_key=key, error_msg=e), + code='invalid', + params={'value': value}, + ) + except SecurityError as e: + raise django_exceptions.ValidationError(_('Encountered unsafe code execution: {}').format(e)) + except TemplateSyntaxError as e: + raise django_exceptions.ValidationError( + _('Syntax error rendering template for {sub_key} inside of {type} ({error_msg})').format(sub_key=key, type=type_, error_msg=e), + code='invalid', + params={'value': value}, + ) + + def validate_extra_vars(key, node): + if isinstance(node, dict): + for k, v in node.items(): + validate_template_string("extra_vars", 'a key' if key is None else key, k) + validate_extra_vars(k if key is None else "{key}.{k}".format(key=key, k=k), v) + elif isinstance(node, list): + for i, x in enumerate(node): + validate_extra_vars("{key}[{i}]".format(key=key, i=i), x) + else: + validate_template_string("extra_vars", key, node) + for type_, injector in value.items(): if type_ == 'env': for key in injector.keys(): self.validate_env_var_allowed(key) - for key, tmpl in injector.items(): - try: - sandbox.ImmutableSandboxedEnvironment(undefined=StrictUndefined).from_string(tmpl).render(valid_namespace) - except UndefinedError as e: - raise django_exceptions.ValidationError( - _('{sub_key} uses an undefined field ({error_msg})').format(sub_key=key, error_msg=e), - code='invalid', - params={'value': value}, - ) - except SecurityError as e: - raise django_exceptions.ValidationError(_('Encountered unsafe code execution: {}').format(e)) - except TemplateSyntaxError as e: - raise django_exceptions.ValidationError( - _('Syntax error rendering template for {sub_key} inside of {type} ({error_msg})').format(sub_key=key, type=type_, error_msg=e), - code='invalid', - params={'value': value}, - ) + if type_ == 'extra_vars': + validate_extra_vars(None, injector) + else: + for key, tmpl in injector.items(): + validate_template_string(type_, key, tmpl) class AskForField(models.BooleanField): diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 871016789b..4da5d5cb51 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -528,9 +528,13 @@ class CredentialType(CommonModelNameNotUnique): if 'INVENTORY_UPDATE_ID' not in env: # awx-manage inventory_update does not support extra_vars via -e - extra_vars = {} - for var_name, tmpl in self.injectors.get('extra_vars', {}).items(): - extra_vars[var_name] = sandbox_env.from_string(tmpl).render(**namespace) + def build_extra_vars(node): + if isinstance(node, dict): + return {build_extra_vars(k): build_extra_vars(v) for k, v in node.items()} + elif isinstance(node, list): + return [build_extra_vars(x) for x in node] + else: + return sandbox_env.from_string(node).render(**namespace) def build_extra_vars_file(vars, private_dir): handle, path = tempfile.mkstemp(dir=os.path.join(private_dir, 'env')) @@ -540,6 +544,7 @@ class CredentialType(CommonModelNameNotUnique): os.chmod(path, stat.S_IRUSR) return path + extra_vars = build_extra_vars(self.injectors.get('extra_vars', {})) if extra_vars: path = build_extra_vars_file(extra_vars, private_data_dir) container_path = to_container_path(path, private_data_dir) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 9a59e091d1..bfec59b616 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -1209,6 +1209,42 @@ class TestJobCredentials(TestJobExecution): assert extra_vars["turbo_button"] == "True" return ['successful', 0] + def test_custom_environment_injectors_with_nested_extra_vars(self, private_data_dir, job, mock_me): + task = jobs.RunJob() + some_cloud = CredentialType( + kind='cloud', + name='SomeCloud', + managed=False, + inputs={'fields': [{'id': 'host', 'label': 'Host', 'type': 'string'}]}, + injectors={'extra_vars': {'auth': {'host': '{{host}}'}}}, + ) + credential = Credential(pk=1, credential_type=some_cloud, inputs={'host': 'example.com'}) + job.credentials.add(credential) + + args = task.build_args(job, private_data_dir, {}) + credential.credential_type.inject_credential(credential, {}, {}, args, private_data_dir) + extra_vars = parse_extra_vars(args, private_data_dir) + + assert extra_vars["auth"]["host"] == "example.com" + + def test_custom_environment_injectors_with_templated_extra_vars_key(self, private_data_dir, job, mock_me): + task = jobs.RunJob() + some_cloud = CredentialType( + kind='cloud', + name='SomeCloud', + managed=False, + inputs={'fields': [{'id': 'environment', 'label': 'Environment', 'type': 'string'}, {'id': 'host', 'label': 'Host', 'type': 'string'}]}, + injectors={'extra_vars': {'{{environment}}_auth': {'host': '{{host}}'}}}, + ) + credential = Credential(pk=1, credential_type=some_cloud, inputs={'environment': 'test', 'host': 'example.com'}) + job.credentials.add(credential) + + args = task.build_args(job, private_data_dir, {}) + credential.credential_type.inject_credential(credential, {}, {}, args, private_data_dir) + extra_vars = parse_extra_vars(args, private_data_dir) + + assert extra_vars["test_auth"]["host"] == "example.com" + def test_custom_environment_injectors_with_complicated_boolean_template(self, job, private_data_dir, mock_me): task = jobs.RunJob() some_cloud = CredentialType( diff --git a/docs/credentials/custom_credential_types.md b/docs/credentials/custom_credential_types.md index ed55847803..b1fba43372 100644 --- a/docs/credentials/custom_credential_types.md +++ b/docs/credentials/custom_credential_types.md @@ -172,7 +172,11 @@ of the [Jinja templating language](https://jinja.palletsprojects.com/en/2.10.x/) "THIRD_PARTY_CLOUD_API_TOKEN": "{{api_token}}" }, "extra_vars": { - "some_extra_var": "{{username}}:{{password}" + "some_extra_var": "{{username}}:{{password}}", + "auth": { + "username": "{{username}}", + "password": "{{password}}" + } } }