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>
This commit is contained in:
Clint Byrum 2017-04-04 16:20:00 -07:00 committed by James E. Blair
parent 50c69d8957
commit 5870ccae62
7 changed files with 294 additions and 1 deletions

View File

@ -26,6 +26,7 @@ console_scripts =
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

View File

@ -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)

View File

@ -254,3 +254,27 @@ class ReporterInterface(object):
"""
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

View File

@ -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()

View File

@ -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

View File

@ -337,6 +337,13 @@ class ExecutorServer(object):
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 @@ class ExecutorServer(object):
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 @@ class AnsibleJob(object):
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 @@ class AnsibleJob(object):
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,

View File

@ -22,6 +22,8 @@ import zuul.driver.github
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 @@ class ConnectionRegistry(object):
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: