From 2d1580bccbbad4527b423882a72533beac8cc6b0 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Thu, 11 Sep 2014 17:00:47 -0400 Subject: [PATCH] Implements https://trello.com/c/1NJKBOex - Add support for using proot to run jobs in isolated environment. --- awx/main/tasks.py | 85 +++++++++++++++++++++++++++++++++++-- awx/main/tests/tasks.py | 91 ++++++++++++++++++++++++++++++++++++++++ awx/settings/defaults.py | 13 ++++++ 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index f39e89ec39..16c5207586 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -14,6 +14,7 @@ import pipes import re import shutil import stat +import subprocess import tempfile import time import traceback @@ -304,6 +305,67 @@ class BaseTask(Task): args = ['ssh-agent', 'sh', '-c', cmd] return args + def should_use_proot(self, instance, **kwargs): + ''' + Return whether this task should use proot. + ''' + return False + + def check_proot_installed(self): + ''' + Check that proot is installed. + ''' + cmd = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '--version'] + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + result = proc.communicate() + return bool(proc.returncode == 0) + except (OSError, ValueError): + return False + + def build_proot_temp_dir(self, instance, **kwargs): + ''' + Create a temporary directory for proot to use. + ''' + path = tempfile.mkdtemp(prefix='ansible_tower_proot_') + os.chmod(path, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR) + return path + + def wrap_args_with_proot(self, args, cwd, **kwargs): + ''' + Wrap existing command line with proot to restrict access to: + - /etc/tower (to prevent obtaining db info or secret key) + - /var/lib/awx (except for current project) + - /var/log/tower + - /tmp (except for own tmp files) + ''' + new_args = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '-r', '/'] + hide_paths = ['/etc/tower', '/var/lib/awx', '/var/log/tower', + tempfile.gettempdir(), settings.PROJECTS_ROOT, + settings.JOBOUTPUT_ROOT] + hide_paths.extend(getattr(settings, 'AWX_PROOT_HIDE_PATHS', None) or []) + for path in sorted(set(hide_paths)): + if not os.path.exists(path): + continue + if os.path.isdir(path): + new_path = tempfile.mkdtemp(dir=kwargs['proot_temp_dir']) + os.chmod(new_path, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR) + else: + handle, new_path = tempfile.mkstemp(dir=kwargs['proot_temp_dir']) + os.close(handle) + os.chmod(new_path, stat.S_IRUSR|stat.S_IWUSR) + new_args.extend(['-b', '%s:%s' % (new_path, path)]) + show_paths = [cwd, kwargs['private_data_dir']] + show_paths.extend(getattr(settings, 'AWX_PROOT_SHOW_PATHS', None) or []) + for path in sorted(set(show_paths)): + if not os.path.exists(path): + continue + new_args.extend(['-b', '%s:%s' % (path, path)]) + new_args.extend(['-w', cwd]) + new_args.extend(args) + return new_args + def build_args(self, instance, **kwargs): raise NotImplementedError @@ -415,6 +477,12 @@ class BaseTask(Task): os.makedirs(settings.JOBOUTPUT_ROOT) stdout_filename = os.path.join(settings.JOBOUTPUT_ROOT, str(uuid.uuid1()) + ".out") stdout_handle = codecs.open(stdout_filename, 'w', encoding='utf-8') + if self.should_use_proot(instance, **kwargs): + if not self.check_proot_installed(): + raise RuntimeError('proot is not installed') + kwargs['proot_temp_dir'] = self.build_proot_temp_dir(instance, **kwargs) + args = self.wrap_args_with_proot(args, cwd, **kwargs) + safe_args = self.wrap_args_with_proot(safe_args, cwd, **kwargs) instance = self.update_model(pk, job_args=json.dumps(safe_args), job_cwd=cwd, job_env=safe_env, result_stdout_file=stdout_filename) status = self.run_pexpect(instance, args, cwd, env, kwargs['passwords'], stdout_handle) @@ -427,11 +495,16 @@ class BaseTask(Task): shutil.rmtree(kwargs['private_data_dir'], True) except OSError: pass + if kwargs.get('proot_temp_dir', ''): try: - stdout_handle.flush() - stdout_handle.close() - except Exception: + shutil.rmtree(kwargs['proot_temp_dir'], True) + except OSError: pass + try: + stdout_handle.flush() + stdout_handle.close() + except Exception: + pass instance = self.update_model(pk, status=status, result_traceback=tb, output_replacements=output_replacements) self.post_run_hook(instance, **kwargs) @@ -658,6 +731,12 @@ class RunJob(BaseTask): d[re.compile(r'^Vault password:\s*?$', re.M)] = 'vault_password' return d + def should_use_proot(self, instance, **kwargs): + ''' + Return whether this task should use proot. + ''' + return getattr(settings, 'AWX_PROOT_ENABLED', False) + def post_run_hook(self, job, **kwargs): ''' Hook for actions to run after job/task has completed. diff --git a/awx/main/tests/tasks.py b/awx/main/tests/tasks.py index 274835d8a6..be4ac655c6 100644 --- a/awx/main/tests/tasks.py +++ b/awx/main/tests/tasks.py @@ -3,6 +3,7 @@ # Python from distutils.version import StrictVersion as Version +import json import os import shutil import tempfile @@ -139,6 +140,51 @@ TEST_ASYNC_NOWAIT_PLAYBOOK = ''' poll: 0 ''' +TEST_PROOT_PLAYBOOK = ''' +- name: test proot environment + hosts: test-group + gather_facts: false + connection: local + tasks: + - name: list projects directory + command: ls -1 "{{ projects_root }}" + register: projects_ls + - name: check that only one project directory is visible + assert: + that: + - "projects_ls.stdout_lines|length == 1" + - "projects_ls.stdout_lines[0] == '{{ project_path }}'" + - name: list job output directory + command: ls -1 "{{ joboutput_root }}" + register: joboutput_ls + - name: check that we see an empty job output directory + assert: + that: + - "not joboutput_ls.stdout" + - name: check for other project path + stat: path={{ other_project_path }} + register: other_project_stat + - name: check that other project path was not found + assert: + that: + - "not other_project_stat.stat.exists" + - name: check for temp path + stat: path={{ temp_path }} + register: temp_stat + - name: check that temp path was not found + assert: + that: + - "not temp_stat.stat.exists" + - name: try to run a tower-manage command + command: tower-manage validate + ignore_errors: true + register: tower_manage_validate + - name: check that tower-manage command failed + assert: + that: + - "tower_manage_validate|failed" +''' + TEST_PLAYBOOK_WITH_ROLES = ''' - name: test with roles hosts: test-group @@ -1283,3 +1329,48 @@ class RunJobTest(BaseCeleryTest): job = Job.objects.get(pk=job.pk) self.check_job_result(job, 'successful') self.check_job_events(job, 'ok', 1, 3, has_roles=True) + + def test_run_job_with_proot(self): + # Enable proot for this test. + settings.AWX_PROOT_ENABLED = True + # Hide local settings path. + settings.AWX_PROOT_HIDE_PATHS = [os.path.join(settings.BASE_DIR, 'settings')] + # Create another project alongside the one we're using to verify it + # is hidden. + self.create_test_project(TEST_PLAYBOOK) + other_project_path = self.project.local_path + # Create a temp directory that should not be visible to the playbook. + temp_path = tempfile.mkdtemp() + self._temp_paths.append(temp_path) + # Create our test project and job template. + self.create_test_project(TEST_PROOT_PLAYBOOK) + project_path = self.project.local_path + job_template = self.create_test_job_template() + extra_vars = { + 'projects_root': settings.PROJECTS_ROOT, + 'joboutput_root': settings.JOBOUTPUT_ROOT, + 'project_path': project_path, + 'other_project_path': other_project_path, + 'temp_path': temp_path, + } + job = self.create_test_job(job_template=job_template, verbosity=3, + extra_vars=json.dumps(extra_vars)) + 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, 'successful') + + def test_run_job_with_proot_not_installed(self): + # Enable proot for this test, specify invalid proot cmd. + settings.AWX_PROOT_ENABLED = True + settings.AWX_PROOT_CMD = 'PR00T' + self.create_test_project(TEST_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.assertEqual(job.status, 'error') + diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 5adaeb0eda..88e067b62f 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -5,6 +5,7 @@ import os import sys import glob from datetime import timedelta +import tempfile # Update this module's local settings from the global settings module. from django.conf import global_settings @@ -313,6 +314,18 @@ AWX_TASK_ENV = {} # Flag to enable/disable updating hosts M2M when saving job events. CAPTURE_JOB_EVENT_HOSTS = False +# Enable proot support for running jobs (playbook runs only). +AWX_PROOT_ENABLED = False + +# Command/path to proot. +AWX_PROOT_CMD = 'proot' + +# Additional paths to hide from jobs using proot. +AWX_PROOT_HIDE_PATHS = [] + +# Additional paths to show for jobs using proot. +AWX_PROOT_SHOW_PATHS = [] + # Not possible to get list of regions without authenticating, so use this list # instead (based on docs from: # http://docs.rackspace.com/loadbalancers/api/v1.0/clb-devguide/content/Service_Access_Endpoints-d1e517.html)