diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 4e8256356b..3f47c148d9 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -980,12 +980,13 @@ class CredentialSerializer(BaseSerializer): ssh_key_data = serializers.WritableField(required=False, default='') ssh_key_unlock = serializers.WritableField(required=False, default='') sudo_password = serializers.WritableField(required=False, default='') + vault_password = serializers.WritableField(required=False, default='') class Meta: model = Credential fields = ('*', 'user', 'team', 'kind', 'cloud', 'username', 'password', 'ssh_key_data', 'ssh_key_unlock', - 'sudo_username', 'sudo_password') + 'sudo_username', 'sudo_password', 'vault_password') def to_native(self, obj): ret = super(CredentialSerializer, self).to_native(obj) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 272995fa95..a82edaa85d 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -164,7 +164,7 @@ class Credential(CommonModelNameNotUnique): ] PASSWORD_FIELDS = ('password', 'ssh_key_data', 'ssh_key_unlock', - 'sudo_password') + 'sudo_password', 'vault_password') class Meta: app_label = 'main' @@ -263,10 +263,14 @@ class Credential(CommonModelNameNotUnique): def needs_sudo_password(self): return self.kind == 'ssh' and self.sudo_password == 'ASK' + @property + def needs_vault_password(self): + return self.kind == 'ssh' and self.vault_password == 'ASK' + @property def passwords_needed(self): needed = [] - for field in ('password', 'sudo_password', 'ssh_key_unlock'): + for field in ('password', 'sudo_password', 'ssh_key_unlock', 'vault_password'): if getattr(self, 'needs_%s' % field): needed.append(field) return needed diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index b46beb6d27..5a95c4d648 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -176,9 +176,13 @@ class ProjectOptions(models.Model): # show up. matched = False try: - for line in file(playbook): + for n, line in enumerate(file(playbook)): if valid_re.match(line): matched = True + # Any YAML file can also be encrypted with vault; + # allow these to be used as the main playbook. + elif n == 0 and line.startswith('$ANSIBLE_VAULT;'): + matched = True except IOError: continue if not matched: diff --git a/awx/main/tasks.py b/awx/main/tasks.py index a60c936a31..74fef402eb 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -5,7 +5,7 @@ import ConfigParser import cStringIO import datetime -import distutils.version +from distutils.version import StrictVersion as Version import functools import json import logging @@ -317,6 +317,8 @@ class BaseTask(Task): instance = self.update_model(pk) status = instance.status raise RuntimeError('not starting %s task' % instance.status) + # Fetch ansible version once here to support version-dependent features. + kwargs['ansible_version'] = get_ansible_version() kwargs['private_data_file'] = self.build_private_data_file(instance, **kwargs) kwargs['passwords'] = self.build_passwords(instance, **kwargs) args = self.build_args(instance, **kwargs) @@ -377,12 +379,13 @@ class RunJob(BaseTask): def build_passwords(self, job, **kwargs): ''' - Build a dictionary of passwords for SSH private key, SSH user and sudo. + Build a dictionary of passwords for SSH private key, SSH user, sudo + and ansible-vault. ''' passwords = super(RunJob, self).build_passwords(job, **kwargs) creds = job.credential if creds: - for field in ('ssh_key_unlock', 'ssh_password', 'sudo_password'): + for field in ('ssh_key_unlock', 'ssh_password', 'sudo_password', 'vault_password'): if field == 'ssh_password': value = kwargs.get(field, decrypt_field(creds, 'password')) else: @@ -413,8 +416,7 @@ class RunJob(BaseTask): # When using Ansible >= 1.3, allow the inventory script to include host # variables inline via ['_meta']['hostvars']. try: - Version = distutils.version.StrictVersion - if Version(get_ansible_version()) >= Version('1.3'): + if Version(kwargs['ansible_version']) >= Version('1.3'): env['INVENTORY_HOSTVARS'] = str(True) except ValueError: pass @@ -461,6 +463,15 @@ class RunJob(BaseTask): args.extend(['-U', sudo_username]) if 'sudo_password' in kwargs.get('passwords', {}): args.append('--ask-sudo-pass') + + # When using Ansible >= 1.5, support prompting for a vault password. + try: + if Version(kwargs['ansible_version']) >= Version('1.5'): + if 'vault_password' in kwargs.get('passwords', {}): + args.append('--ask-vault-pass') + except ValueError: + pass + if job.forks: # FIXME: Max limit? args.append('--forks=%d' % job.forks) if job.limit: @@ -497,6 +508,7 @@ class RunJob(BaseTask): d[re.compile(r'^sudo password.*:\s*?$', re.M)] = 'sudo_password' d[re.compile(r'^SSH password:\s*?$', re.M)] = 'ssh_password' d[re.compile(r'^Password:\s*?$', re.M)] = 'ssh_password' + d[re.compile(r'^Vault password:\s*?$', re.M)] = 'vault_password' return d def pre_run_check(self, job, **kwargs): @@ -591,8 +603,7 @@ class RunProjectUpdate(BaseTask): # the git module. if scm_type == 'git' and scm_url_parts.scheme == 'ssh': try: - Version = distutils.version.StrictVersion - if Version(get_ansible_version()) >= Version('1.5'): + if Version(kwargs['ansible_version']) >= Version('1.5'): extra_vars['scm_accept_hostkey'] = 'true' except ValueError: pass diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index 2628a6b1ea..e22af72fb2 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -27,6 +27,7 @@ from awx.main.models import * from awx.main.backend import LDAPSettings from awx.main.management.commands.run_callback_receiver import run_subscriber from awx.main.management.commands.run_task_system import run_taskmanager +from awx.main.utils import get_ansible_version class BaseTestMixin(object): @@ -40,6 +41,8 @@ class BaseTestMixin(object): self._temp_project_dirs = [] self._current_auth = None self._user_passwords = {} + self.ansible_version = get_ansible_version() + self.assertNotEqual(self.ansible_version, 'unknown') # Wrap settings so we can redefine them within each test. self._wrapped = settings._wrapped settings._wrapped = UserSettingsHolder(settings._wrapped) diff --git a/awx/main/tests/tasks.py b/awx/main/tests/tasks.py index 31a21107fe..8632701500 100644 --- a/awx/main/tests/tasks.py +++ b/awx/main/tests/tasks.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Python +from distutils.version import StrictVersion as Version import os import shutil import tempfile @@ -87,6 +88,19 @@ TEST_ASYNC_NOWAIT_PLAYBOOK = ''' poll: 0 ''' +TEST_VAULT_PLAYBOOK = '''$ANSIBLE_VAULT;1.1;AES256 +35623233333035633365383330323835353564346534363762366465316263363463396162656432 +6562643539396330616265616532656466353639303338650a313466333663646431646663333739 +32623935316439343636633462373633653039646336376361386439386661366434333830383634 +6266613530626633390a363532373562353262323863343830343865303663306335643430396239 +63393963623537326366663332656132653465646332343234656237316537643135313932623237 +66313863396463343232383131633531363239396636363165646562396261626633326561313837 +32383634326230656230386237333561373630343233353239613463626538356338326633386434 +36396639313030336165366266646431306665336662663732313762663938666239663233393964 +30393733393331383132306463656636396566373961383865643562383564356363''' + +TEST_VAULT_PASSWORD = '1234' + TEST_SSH_KEY_DATA = '''-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAyQ8F5bbgjHvk4SZJsKI9OmJKMFxZqRhvx4LaqjLTKbBwRBsY 1/C00NPiZn70dKbeyV7RNVZxuzM6yd3D3lwTdbDu/eJ0x72t3ch+TdLt/aenyy10 @@ -198,6 +212,7 @@ class RunJobTest(BaseCeleryTest): 'password': '', 'sudo_username': '', 'sudo_password': '', + 'vault_password': '', } opts.update(kwargs) self.credential = Credential.objects.create(**opts) @@ -845,6 +860,56 @@ class RunJobTest(BaseCeleryTest): self.assertTrue('ssh-agent' in job.job_args) self.assertTrue('Bad passphrase' not in job.result_stdout) + def test_vault_password(self): + self.create_test_credential(vault_password=TEST_VAULT_PASSWORD) + self.create_test_project(TEST_VAULT_PLAYBOOK) + job_template = self.create_test_job_template() + job = self.create_test_job(job_template=job_template) + self.assertEqual(job.status, 'new') + self.assertFalse(job.passwords_needed_to_start) + self.assertTrue(job.signal_start()) + job = Job.objects.get(pk=job.pk) + if Version(self.ansible_version) >= Version('1.5'): + self.check_job_result(job, 'successful') + self.assertTrue('--ask-vault-pass' in job.job_args) + else: + self.check_job_result(job, 'failed') + self.assertFalse('--ask-vault-pass' in job.job_args) + + def test_vault_ask_password(self): + self.create_test_credential(vault_password='ASK') + self.create_test_project(TEST_VAULT_PLAYBOOK) + job_template = self.create_test_job_template() + job = self.create_test_job(job_template=job_template) + self.assertEqual(job.status, 'new') + self.assertTrue(job.passwords_needed_to_start) + self.assertTrue('vault_password' in job.passwords_needed_to_start) + self.assertFalse(job.signal_start()) + self.assertEqual(job.status, 'new') + self.assertTrue(job.signal_start(vault_password=TEST_VAULT_PASSWORD)) + job = Job.objects.get(pk=job.pk) + if Version(self.ansible_version) >= Version('1.5'): + self.check_job_result(job, 'successful') + self.assertTrue('--ask-vault-pass' in job.job_args) + else: + self.check_job_result(job, 'failed') + self.assertFalse('--ask-vault-pass' in job.job_args) + + def test_vault_bad_password(self): + self.create_test_credential(vault_password='not it') + self.create_test_project(TEST_VAULT_PLAYBOOK) + job_template = self.create_test_job_template() + job = self.create_test_job(job_template=job_template) + self.assertEqual(job.status, 'new') + self.assertFalse(job.passwords_needed_to_start) + self.assertTrue(job.signal_start()) + job = Job.objects.get(pk=job.pk) + self.check_job_result(job, 'failed') + if Version(self.ansible_version) >= Version('1.5'): + self.assertTrue('--ask-vault-pass' in job.job_args) + else: + self.assertFalse('--ask-vault-pass' in job.job_args) + def _test_cloud_credential_environment_variables(self, kind): if kind == 'aws': env_var1 = 'AWS_ACCESS_KEY'