Add support for bwrap

This will be the minimum "batteries included" bubblwrap driver. It does
not do any MAC configuration, since these vary by system. Operators
may wish to wrap it further in a MAC wrapper driver.

Because we set bubblewrap as the default wrapper, test_playbooks tests
it. However, it lacks a negative test, so we won't know if we're not
actually containing things.

Users who don't have bubblewrap or don't wish to use it can set the
untrusted_wrapper to 'nullwrap' which will just execute things as
they're done before this change.

Change-Id: I84dd7c8cc55d2110b58609784007ffda0d135716
Story: 2000910
Task: 3540
Signed-off-by: Paul Belanger <pabelanger@redhat.com>
diff --git a/setup.cfg b/setup.cfg
index 9ee64f3..5ae0903 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,7 @@
     zuul = zuul.cmd.client:main
     zuul-cloner = zuul.cmd.cloner:main
     zuul-executor = zuul.cmd.executor:main
+    zuul-bwrap = zuul.driver.bubblewrap:main
 
 [build_sphinx]
 source-dir = doc/source
diff --git a/tests/unit/test_bubblewrap.py b/tests/unit/test_bubblewrap.py
new file mode 100644
index 0000000..b274944
--- /dev/null
+++ b/tests/unit/test_bubblewrap.py
@@ -0,0 +1,54 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import fixtures
+import logging
+import subprocess
+import tempfile
+import testtools
+
+from zuul.driver import bubblewrap
+from zuul.executor.server import SshAgent
+
+
+class TestBubblewrap(testtools.TestCase):
+    def setUp(self):
+        super(TestBubblewrap, self).setUp()
+        self.log_fixture = self.useFixture(
+            fixtures.FakeLogger(level=logging.DEBUG))
+        self.useFixture(fixtures.NestedTempfile())
+
+    def test_bubblewrap_wraps(self):
+        bwrap = bubblewrap.BubblewrapDriver()
+        work_dir = tempfile.mkdtemp()
+        ansible_dir = tempfile.mkdtemp()
+        ssh_agent = SshAgent()
+        self.addCleanup(ssh_agent.stop)
+        ssh_agent.start()
+        po = bwrap.getPopen(work_dir=work_dir,
+                            ansible_dir=ansible_dir,
+                            ssh_auth_sock=ssh_agent.env['SSH_AUTH_SOCK'])
+        self.assertTrue(po.passwd_r > 2)
+        self.assertTrue(po.group_r > 2)
+        self.assertTrue(work_dir in po.command)
+        self.assertTrue(ansible_dir in po.command)
+        # Now run /usr/bin/id to verify passwd/group entries made it in
+        true_proc = po(['/usr/bin/id'], stdout=subprocess.PIPE,
+                       stderr=subprocess.PIPE)
+        (output, errs) = true_proc.communicate()
+        # Make sure it printed things on stdout
+        self.assertTrue(len(output.strip()))
+        # And that it did not print things on stderr
+        self.assertEqual(0, len(errs.strip()))
+        # Make sure the _r's are closed
+        self.assertIsNone(po.passwd_r)
+        self.assertIsNone(po.group_r)
diff --git a/zuul/driver/__init__.py b/zuul/driver/__init__.py
index 671996a..0c3105d 100644
--- a/zuul/driver/__init__.py
+++ b/zuul/driver/__init__.py
@@ -254,3 +254,27 @@
 
         """
         pass
+
+
+@six.add_metaclass(abc.ABCMeta)
+class WrapperInterface(object):
+    """The wrapper interface to be implmeneted by a driver.
+
+    A driver which wraps execution of commands executed by Zuul should
+    implement this interface.
+
+    """
+
+    @abc.abstractmethod
+    def getPopen(self, **kwargs):
+        """Create and return a subprocess.Popen factory wrapped however the
+        driver sees fit.
+
+        This method is required by the interface
+
+        :arg dict kwargs: key/values for use by driver as needed
+
+        :returns: a callable that takes the same args as subprocess.Popen
+        :rtype: Callable
+        """
+        pass
diff --git a/zuul/driver/bubblewrap/__init__.py b/zuul/driver/bubblewrap/__init__.py
new file mode 100644
index 0000000..6426fbb
--- /dev/null
+++ b/zuul/driver/bubblewrap/__init__.py
@@ -0,0 +1,168 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2013 OpenStack Foundation
+# Copyright 2016 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import argparse
+import grp
+import logging
+import os
+import pwd
+import subprocess
+import sys
+
+from zuul.driver import (Driver, WrapperInterface)
+
+
+class WrappedPopen(object):
+    def __init__(self, command, passwd_r, group_r):
+        self.command = command
+        self.passwd_r = passwd_r
+        self.group_r = group_r
+
+    def __call__(self, args, *sub_args, **kwargs):
+        try:
+            args = self.command + args
+            if kwargs.get('close_fds') or sys.version_info.major >= 3:
+                # The default in py3 is close_fds=True, so we need to pass
+                # our open fds in. However, this can only work right in
+                # py3.2 or later due to the lack of 'pass_fds' in prior
+                # versions. So until we are py3 only we can only bwrap
+                # things that are close_fds=False
+                pass_fds = list(kwargs.get('pass_fds', []))
+                for fd in (self.passwd_r, self.group_r):
+                    if fd not in pass_fds:
+                        pass_fds.append(fd)
+                kwargs['pass_fds'] = pass_fds
+            proc = subprocess.Popen(args, *sub_args, **kwargs)
+        finally:
+            self.__del__()
+        return proc
+
+    def __del__(self):
+        if self.passwd_r:
+            try:
+                os.close(self.passwd_r)
+            except OSError:
+                pass
+            self.passwd_r = None
+        if self.group_r:
+            try:
+                os.close(self.group_r)
+            except OSError:
+                pass
+            self.group_r = None
+
+
+class BubblewrapDriver(Driver, WrapperInterface):
+    name = 'bubblewrap'
+    log = logging.getLogger("zuul.BubblewrapDriver")
+
+    bwrap_command = [
+        'bwrap',
+        '--dir', '/tmp',
+        '--tmpfs', '/tmp',
+        '--dir', '/var',
+        '--dir', '/var/tmp',
+        '--dir', '/run/user/{uid}',
+        '--ro-bind', '/usr', '/usr',
+        '--ro-bind', '/lib', '/lib',
+        '--ro-bind', '/lib64', '/lib64',
+        '--ro-bind', '/bin', '/bin',
+        '--ro-bind', '/sbin', '/sbin',
+        '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf',
+        '--ro-bind', '{ansible_dir}', '{ansible_dir}',
+        '--ro-bind', '{ssh_auth_sock}', '{ssh_auth_sock}',
+        '--dir', '{work_dir}',
+        '--bind', '{work_dir}', '{work_dir}',
+        '--dev', '/dev',
+        '--dir', '{user_home}',
+        '--chdir', '/',
+        '--unshare-all',
+        '--share-net',
+        '--uid', '{uid}',
+        '--gid', '{gid}',
+        '--file', '{uid_fd}', '/etc/passwd',
+        '--file', '{gid_fd}', '/etc/group',
+    ]
+
+    def reconfigure(self, tenant):
+        pass
+
+    def stop(self):
+        pass
+
+    def getPopen(self, **kwargs):
+        # Set zuul_dir if it was not passed in
+        if 'zuul_dir' in kwargs:
+            zuul_dir = kwargs['zuul_dir']
+        else:
+            zuul_python_dir = os.path.dirname(sys.executable)
+            # We want the dir directly above bin to get the whole venv
+            zuul_dir = os.path.normpath(os.path.join(zuul_python_dir, '..'))
+
+        bwrap_command = list(self.bwrap_command)
+        if not zuul_dir.startswith('/usr'):
+            bwrap_command.extend(['--ro-bind', zuul_dir, zuul_dir])
+
+        # Need users and groups
+        uid = os.getuid()
+        passwd = pwd.getpwuid(uid)
+        passwd_bytes = b':'.join(
+            ['{}'.format(x).encode('utf8') for x in passwd])
+        (passwd_r, passwd_w) = os.pipe()
+        os.write(passwd_w, passwd_bytes)
+        os.close(passwd_w)
+
+        gid = os.getgid()
+        group = grp.getgrgid(gid)
+        group_bytes = b':'.join(
+            ['{}'.format(x).encode('utf8') for x in group])
+        group_r, group_w = os.pipe()
+        os.write(group_w, group_bytes)
+        os.close(group_w)
+
+        kwargs = dict(kwargs)  # Don't update passed in dict
+        kwargs['uid'] = uid
+        kwargs['gid'] = gid
+        kwargs['uid_fd'] = passwd_r
+        kwargs['gid_fd'] = group_r
+        kwargs['user_home'] = passwd.pw_dir
+        command = [x.format(**kwargs) for x in bwrap_command]
+
+        wrapped_popen = WrappedPopen(command, passwd_r, group_r)
+
+        return wrapped_popen
+
+
+def main(args=None):
+    driver = BubblewrapDriver()
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('work_dir')
+    parser.add_argument('ansible_dir')
+    parser.add_argument('run_args', nargs='+')
+    cli_args = parser.parse_args()
+
+    ssh_auth_sock = os.environ.get('SSH_AUTH_SOCK')
+
+    popen = driver.getPopen(work_dir=cli_args.work_dir,
+                            ansible_dir=cli_args.ansible_dir,
+                            ssh_auth_sock=ssh_auth_sock)
+    x = popen(cli_args.run_args)
+    x.wait()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/zuul/driver/nullwrap/__init__.py b/zuul/driver/nullwrap/__init__.py
new file mode 100644
index 0000000..ebcd1da
--- /dev/null
+++ b/zuul/driver/nullwrap/__init__.py
@@ -0,0 +1,28 @@
+# Copyright 2012 Hewlett-Packard Development Company, L.P.
+# Copyright 2013 OpenStack Foundation
+# Copyright 2016 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import subprocess
+
+from zuul.driver import (Driver, WrapperInterface)
+
+
+class NullwrapDriver(Driver, WrapperInterface):
+    name = 'nullwrap'
+    log = logging.getLogger("zuul.NullwrapDriver")
+
+    def getPopen(self, **kwargs):
+        return subprocess.Popen
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index 14bb588..927307e 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -337,6 +337,13 @@
         else:
             self.merge_name = None
 
+        if self.config.has_option('executor', 'untrusted_wrapper'):
+            untrusted_wrapper_name = self.config.get(
+                'executor', 'untrusted_wrapper').split()
+        else:
+            untrusted_wrapper_name = 'bubblewrap'
+        self.untrusted_wrapper = connections.drivers[untrusted_wrapper_name]
+
         self.connections = connections
         # This merger and its git repos are used to maintain
         # up-to-date copies of all the repos that are used by jobs, as
@@ -353,6 +360,7 @@
         path = os.path.join(state_dir, 'executor.socket')
         self.command_socket = commandsocket.CommandSocket(path)
         ansible_dir = os.path.join(state_dir, 'ansible')
+        self.ansible_dir = ansible_dir
         self.library_dir = os.path.join(ansible_dir, 'library')
         if not os.path.exists(self.library_dir):
             os.makedirs(self.library_dir)
@@ -1120,8 +1128,14 @@
 
         if trusted:
             config_file = self.jobdir.trusted_config
+            popen = subprocess.Popen
         else:
             config_file = self.jobdir.untrusted_config
+            driver = self.executor_server.untrusted_wrapper
+            popen = driver.getPopen(
+                work_dir=self.jobdir.root,
+                ansible_dir=self.executor_server.ansible_dir,
+                ssh_auth_sock=env_copy.get('SSH_AUTH_SOCK'))
 
         env_copy['ANSIBLE_CONFIG'] = config_file
 
@@ -1130,7 +1144,7 @@
                 return (self.RESULT_ABORTED, None)
             self.log.debug("Ansible command: ANSIBLE_CONFIG=%s %s",
                            config_file, " ".join(shlex_quote(c) for c in cmd))
-            self.proc = subprocess.Popen(
+            self.proc = popen(
                 cmd,
                 cwd=self.jobdir.work_root,
                 stdout=subprocess.PIPE,
diff --git a/zuul/lib/connections.py b/zuul/lib/connections.py
index 720299a..ba245bc 100644
--- a/zuul/lib/connections.py
+++ b/zuul/lib/connections.py
@@ -22,6 +22,8 @@
 import zuul.driver.smtp
 import zuul.driver.timer
 import zuul.driver.sql
+import zuul.driver.bubblewrap
+import zuul.driver.nullwrap
 from zuul.connection import BaseConnection
 from zuul.driver import SourceInterface
 
@@ -46,6 +48,8 @@
         self.registerDriver(zuul.driver.smtp.SMTPDriver())
         self.registerDriver(zuul.driver.timer.TimerDriver())
         self.registerDriver(zuul.driver.sql.SQLDriver())
+        self.registerDriver(zuul.driver.bubblewrap.BubblewrapDriver())
+        self.registerDriver(zuul.driver.nullwrap.NullwrapDriver())
 
     def registerDriver(self, driver):
         if driver.name in self.drivers: