Merge "Handle change related reqs on push like events" into feature/zuulv3
diff --git a/doc/source/zuul.rst b/doc/source/zuul.rst
index 56cc6a8..a7dfb44 100644
--- a/doc/source/zuul.rst
+++ b/doc/source/zuul.rst
@@ -108,6 +108,10 @@
   commands.
   ``state_dir=/var/lib/zuul``
 
+**jobroot_dir**
+  Path to directory that Zuul should store temporary job files.
+  ``jobroot_dir=/tmp``
+
 **report_times**
   Boolean value (``true`` or ``false``) that determines if Zuul should
   include elapsed times for each job in the textual report.  Used by
@@ -165,6 +169,33 @@
   Path to PID lock file for the merger process.
   ``pidfile=/var/run/zuul-merger/merger.pid``
 
+executor
+""""""""
+
+The zuul-executor process configuration.
+
+**finger_port**
+  Port to use for finger log streamer.
+  ``finger_port=79``
+
+**git_dir**
+  Directory that Zuul should clone local git repositories to.
+  ``git_dir=/var/lib/zuul/git``
+
+**log_config**
+  Path to log config file for the executor process.
+  ``log_config=/etc/zuul/logging.yaml``
+
+**private_key_file**
+  SSH private key file to be used when logging into worker nodes.
+  ``private_key_file=~/.ssh/id_rsa``
+
+**user**
+  User ID for the zuul-executor process. In normal operation as a daemon,
+  the executor should be started as the ``root`` user, but it will drop
+  privileges to this user during startup.
+  ``user=zuul``
+
 .. _connection:
 
 connection ArbitraryName
diff --git a/etc/status/public_html/index.html b/etc/status/public_html/index.html
index ca5bb56..cc3d40a 100644
--- a/etc/status/public_html/index.html
+++ b/etc/status/public_html/index.html
@@ -30,8 +30,7 @@
     <script src="jquery.zuul.js"></script>
     <script src="zuul.app.js"></script>
     <script>
-        // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt
-Apache 2.0
+        // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache 2.0
         zuul_build_dom(jQuery, '#zuul_container');
         zuul_start(jQuery);
 	// @license-end
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/base.py b/tests/base.py
index 8899da4..2b0194e 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -1406,9 +1406,9 @@
                 len(self.low_queue))
         self.log.debug("releasing queued job %s (%s)" % (regex, qlen))
         for job in self.getQueue():
-            if job.name != 'executor:execute':
+            if job.name != b'executor:execute':
                 continue
-            parameters = json.loads(job.arguments)
+            parameters = json.loads(job.arguments.decode('utf8'))
             if not regex or re.match(regex, parameters.get('job')):
                 self.log.debug("releasing queued job %s" %
                                job.unique)
@@ -1859,12 +1859,20 @@
 
         # Make per test copy of Configuration.
         self.setup_config()
+        self.private_key_file = os.path.join(self.test_root, 'test_id_rsa')
+        if not os.path.exists(self.private_key_file):
+            src_private_key_file = os.path.join(FIXTURE_DIR, 'test_id_rsa')
+            shutil.copy(src_private_key_file, self.private_key_file)
+            shutil.copy('{}.pub'.format(src_private_key_file),
+                        '{}.pub'.format(self.private_key_file))
+            os.chmod(self.private_key_file, 0o0600)
         self.config.set('zuul', 'tenant_config',
                         os.path.join(FIXTURE_DIR,
                                      self.config.get('zuul', 'tenant_config')))
         self.config.set('merger', 'git_dir', self.merger_src_root)
         self.config.set('executor', 'git_dir', self.executor_src_root)
         self.config.set('zuul', 'state_dir', self.state_root)
+        self.config.set('executor', 'private_key_file', self.private_key_file)
 
         self.statsd = FakeStatsd()
         # note, use 127.0.0.1 rather than localhost to avoid getting ipv6
@@ -2197,10 +2205,17 @@
         self.fake_nodepool.stop()
         self.zk.disconnect()
         self.printHistory()
-        # we whitelist watchdog threads as they have relatively long delays
+        # We whitelist watchdog threads as they have relatively long delays
         # before noticing they should exit, but they should exit on their own.
+        # Further the pydevd threads also need to be whitelisted so debugging
+        # e.g. in PyCharm is possible without breaking shutdown.
+        whitelist = ['executor-watchdog',
+                     'pydevd.CommandThread',
+                     'pydevd.Reader',
+                     'pydevd.Writer',
+                     ]
         threads = [t for t in threading.enumerate()
-                   if t.name != 'executor-watchdog']
+                   if t.name not in whitelist]
         if len(threads) > 1:
             log_str = ""
             for thread_id, stack_frame in sys._current_frames().items():
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/hello-post.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/hello-post.yaml
new file mode 100644
index 0000000..d528be1
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/hello-post.yaml
@@ -0,0 +1,12 @@
+- hosts: all
+  tasks:
+    - name: Register hello-world.txt file.
+      stat:
+        path: "{{zuul.executor.log_root}}/hello-world.txt"
+      register: st
+
+    - name: Assert hello-world.txt file.
+      assert:
+        that:
+          - st.stat.exists
+          - st.stat.isreg
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index f9be158..02b87bd 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -72,3 +72,7 @@
     nodes:
       - name: ubuntu-xenial
         image: ubuntu-xenial
+
+- job:
+    name: hello
+    post-run: hello-post
diff --git a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
index a2d9c6f..ca734c5 100644
--- a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
@@ -2,6 +2,10 @@
     parent: python27
     name: faillocal
 
+- job:
+    parent: hello
+    name: hello-world
+
 - project:
     name: org/project
     check:
@@ -10,3 +14,4 @@
         - faillocal
         - check-vars
         - timeout
+        - hello-world
diff --git a/tests/fixtures/config/ansible/git/org_project/playbooks/hello-world.yaml b/tests/fixtures/config/ansible/git/org_project/playbooks/hello-world.yaml
new file mode 100644
index 0000000..373de02
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_project/playbooks/hello-world.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - copy:
+        content: "hello world"
+        dest: "{{zuul.executor.log_root}}/hello-world.txt"
diff --git a/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml b/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml
index 961ff06..8f858cd 100644
--- a/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml
@@ -24,6 +24,25 @@
       another_gerrit:
         verified: -1
 
+- pipeline:
+    name: common_check
+    manager: independent
+    trigger:
+      another_gerrit:
+        - event: patchset-created
+      review_gerrit:
+        - event: patchset-created
+    success:
+      review_gerrit:
+        verified: 1
+      another_gerrit:
+        verified: 1
+    failure:
+      review_gerrit:
+        verified: -1
+      another_gerrit:
+        verified: -1
+
 - job:
     name: project-test1
 
@@ -41,3 +60,16 @@
     another_check:
       jobs:
         - project-test2
+
+
+- project:
+    name: review.example.com/org/project2
+    common_check:
+      jobs:
+        - project-test1
+
+- project:
+    name: another.example.com/org/project2
+    common_check:
+      jobs:
+        - project-test2
diff --git a/tests/fixtures/config/zuul-connections-multiple-gerrits/git/org_project2/README b/tests/fixtures/config/zuul-connections-multiple-gerrits/git/org_project2/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/zuul-connections-multiple-gerrits/git/org_project2/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml b/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml
index f5bff21..38810fd 100644
--- a/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml
+++ b/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml
@@ -6,6 +6,8 @@
           - common-config
         untrusted-projects:
           - org/project1
+          - org/project2
       another_gerrit:
         untrusted-projects:
           - org/project1
+          - org/project2
diff --git a/tests/fixtures/test_id_rsa b/tests/fixtures/test_id_rsa
new file mode 100644
index 0000000..a793bd0
--- /dev/null
+++ b/tests/fixtures/test_id_rsa
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICWwIBAAKBgQCX10EQhi7hEMk1h7/fQaEj9H2DxWR0s3RXD5UI7j1Bn21tBUus
+Y0tPC5wXES4VfilXg+EuOKsE6z8x8txP1wd1+d6Hq3SWXnOcqxxv2ueAy6Gc31E7
+a2IVDYvqVsAOtxsWddvMGTj98/lexQBX6Bh+wmuba/43lq5UPepwvfgNOQIDAQAB
+AoGADMCHNlwOk9hVDanY82cPoXVnFSn+xc5MdwNYAOgBPQGmrwFC2bd9G6Zd9ZH7
+zNJLpo3s23Tm6ALZy9gZqJrmhWDZBOqeYtmkd0yUf5bCbUzNre8+gHJY8k9PAxVM
+dPr2bq8G4PyN3yC2euTht35KLjb7hD8WiF3exgI/d8oBvgECQQDFKuWmkLtkSkGo
+1KRbeBfRePbfzhGJ1yHRyO72Z1+hVXuRmtcjTfPhMikgx9dxWbpqr/RPgs7D7N8D
+JpFlsiR/AkEAxSX4LOwovklPzCZ8FyfHhkydNgDyBw8y2Xe1OO0LBN51batf9rcl
+rJBYFvulrD+seYNRCWBFpEi4KKZh4YESRwJAKmz+mYbPK9dmpYOMEjqXNXXH+YSH
+9ZcbKd8IvHCl/Ts9qakd3fTqI2z9uJYH39Yk7MwL0Agfob0Yh78GzlE01QJACheu
+g8Y3M76XCjFyKtFLgpGLfsc/nKLnjIB3U4m3BbHJuyqJyByKHjJpgAuz6IR99N6H
+GH7IMefTHame2yd7YwJAUIGRD+iOO0RJvtEHUbsz6IxrQdubNOvzm/78eyBTcbsa
+8996D18fJF6Q0/Gg0Cm65PNOpIthP3qxFkuuduUEUg==
+-----END RSA PRIVATE KEY-----
diff --git a/tests/fixtures/test_id_rsa.pub b/tests/fixtures/test_id_rsa.pub
new file mode 100644
index 0000000..bffc726
--- /dev/null
+++ b/tests/fixtures/test_id_rsa.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCX10EQhi7hEMk1h7/fQaEj9H2DxWR0s3RXD5UI7j1Bn21tBUusY0tPC5wXES4VfilXg+EuOKsE6z8x8txP1wd1+d6Hq3SWXnOcqxxv2ueAy6Gc31E7a2IVDYvqVsAOtxsWddvMGTj98/lexQBX6Bh+wmuba/43lq5UPepwvfgNOQ== Private Key For Zuul Tests DO NOT USE
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/tests/unit/test_connection.py b/tests/unit/test_connection.py
index 92270b7..142a248 100644
--- a/tests/unit/test_connection.py
+++ b/tests/unit/test_connection.py
@@ -266,6 +266,48 @@
         self.executor_server.release()
         self.waitUntilSettled()
 
+    def test_multiple_project_separate_gerrits_common_pipeline(self):
+        self.executor_server.hold_jobs_in_build = True
+
+        A = self.fake_another_gerrit.addFakeChange(
+            'org/project2', 'master', 'A')
+        self.fake_another_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertBuilds([dict(name='project-test2',
+                                changes='1,1',
+                                project='org/project2',
+                                pipeline='common_check')])
+
+        # NOTE(jamielennox): the tests back the git repo for both connections
+        # onto the same git repo on the file system. If we just create another
+        # fake change the fake_review_gerrit will try to create another 1,1
+        # change and git will fail to create the ref. Arbitrarily set it to get
+        # around the problem.
+        self.fake_review_gerrit.change_number = 50
+
+        B = self.fake_review_gerrit.addFakeChange(
+            'org/project2', 'master', 'B')
+        self.fake_review_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertBuilds([
+            dict(name='project-test2',
+                 changes='1,1',
+                 project='org/project2',
+                 pipeline='common_check'),
+            dict(name='project-test1',
+                 changes='51,1',
+                 project='org/project2',
+                 pipeline='common_check'),
+        ])
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
 
 class TestConnectionsMerger(ZuulTestCase):
     config_file = 'zuul-connections-merger.conf'
diff --git a/tests/unit/test_log_streamer.py b/tests/unit/test_log_streamer.py
new file mode 100644
index 0000000..3ea5a8e
--- /dev/null
+++ b/tests/unit/test_log_streamer.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+
+# Copyright 2017 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 socket
+import tempfile
+
+import zuul.lib.log_streamer
+import tests.base
+
+
+class TestLogStreamer(tests.base.BaseTestCase):
+
+    log = logging.getLogger("zuul.test.cloner")
+
+    def setUp(self):
+        super(TestLogStreamer, self).setUp()
+        self.host = '0.0.0.0'
+
+    def startStreamer(self, port, root=None):
+        if not root:
+            root = tempfile.gettempdir()
+        return zuul.lib.log_streamer.LogStreamer(None, self.host, port, root)
+
+    def test_start_stop(self):
+        port = 7900
+        streamer = self.startStreamer(port)
+        self.addCleanup(streamer.stop)
+
+        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.addCleanup(s.close)
+        self.assertEqual(0, s.connect_ex((self.host, port)))
+        s.close()
+
+        streamer.stop()
+
+        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.addCleanup(s.close)
+        self.assertNotEqual(0, s.connect_ex((self.host, port)))
+        s.close()
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 2624944..f5c181b 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -21,9 +21,8 @@
 import os
 import re
 import shutil
-import sys
 import time
-from unittest import (skip, skipIf)
+from unittest import skip
 
 import git
 from six.moves import urllib
@@ -510,7 +509,6 @@
         self.assertEqual(B.reported, 2)
         self.assertEqual(C.reported, 2)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_failed_change_at_head_with_queue(self):
         "Test that if a change at the head fails, queued jobs are canceled"
 
@@ -937,7 +935,6 @@
         a = source.getChange(event, refresh=True)
         self.assertTrue(source.canMerge(a, mgr.getSubmitAllowNeeds()))
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_project_merge_conflict(self):
         "Test that gate merge conflicts are handled properly"
 
@@ -989,7 +986,6 @@
             dict(name='project-test2', result='SUCCESS', changes='1,1 3,1'),
         ], ordered=False)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_delayed_merge_conflict(self):
         "Test that delayed check merge conflicts are handled properly"
 
@@ -1931,7 +1927,6 @@
         self.assertEqual(A.reported, 2)
 
     @simple_layout('layouts/no-jobs-project.yaml')
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_no_job_project(self):
         "Test that reports with no jobs don't get sent"
         A = self.fake_gerrit.addFakeChange('org/no-jobs-project',
@@ -2063,7 +2058,6 @@
         self.assertReportedStat('test-timing', '3|ms')
         self.assertReportedStat('test-gauge', '12|g')
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_stuck_job_cleanup(self):
         "Test that pending jobs are cleaned up if removed from layout"
 
@@ -2191,7 +2185,6 @@
         self.assertEqual(q1.name, 'integrated')
         self.assertEqual(q2.name, 'integrated')
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_queue_precedence(self):
         "Test that queue precedence works"
 
@@ -3878,7 +3871,6 @@
         self.assertEqual(B.data['status'], 'MERGED')
         self.assertEqual(B.reported, 0)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_check(self):
         "Test cross-repo dependencies in independent pipelines"
 
@@ -4029,11 +4021,9 @@
         self.assertEqual(self.history[0].changes, '2,1 1,1')
         self.assertEqual(len(tenant.layout.pipelines['check'].queues), 0)
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_check_reconfiguration(self):
         self._test_crd_check_reconfiguration('org/project1', 'org/project2')
 
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_undefined_project(self):
         """Test that undefined projects in dependencies are handled for
         independent pipelines"""
@@ -4043,7 +4033,6 @@
         self._test_crd_check_reconfiguration('org/project1', 'org/unknown')
 
     @simple_layout('layouts/ignore-dependencies.yaml')
-    @skipIf(sys.version_info.major > 2, 'Fails on py3')
     def test_crd_check_ignore_dependencies(self):
         "Test cross-repo dependencies can be ignored"
 
diff --git a/tests/unit/test_ssh_agent.py b/tests/unit/test_ssh_agent.py
new file mode 100644
index 0000000..c9c1ebd
--- /dev/null
+++ b/tests/unit/test_ssh_agent.py
@@ -0,0 +1,56 @@
+# 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 os
+import subprocess
+
+from tests.base import ZuulTestCase
+from zuul.executor.server import SshAgent
+
+
+class TestSshAgent(ZuulTestCase):
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def test_ssh_agent(self):
+        # Need a private key to add
+        env_copy = dict(os.environ)
+        # DISPLAY and SSH_ASKPASS will cause interactive test runners to get a
+        # surprise
+        if 'DISPLAY' in env_copy:
+            del env_copy['DISPLAY']
+        if 'SSH_ASKPASS' in env_copy:
+            del env_copy['SSH_ASKPASS']
+
+        agent = SshAgent()
+        agent.start()
+        env_copy.update(agent.env)
+
+        pub_key_file = '{}.pub'.format(self.private_key_file)
+        pub_key = None
+        with open(pub_key_file) as pub_key_f:
+            pub_key = pub_key_f.read().split('== ')[0]
+
+        agent.add(self.private_key_file)
+        keys = agent.list()
+        self.assertEqual(1, len(keys))
+        self.assertEqual(keys[0].split('== ')[0], pub_key)
+        agent.remove(self.private_key_file)
+        keys = agent.list()
+        self.assertEqual([], keys)
+        agent.stop()
+        # Agent is now dead and thus this should fail
+        with open('/dev/null') as devnull:
+            self.assertRaises(subprocess.CalledProcessError,
+                              subprocess.check_call,
+                              ['ssh-add', self.private_key_file],
+                              env=env_copy,
+                              stderr=devnull)
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 2168a7f..21b4729 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -344,6 +344,8 @@
         self.assertEqual(build.result, 'FAILURE')
         build = self.getJobFromHistory('check-vars')
         self.assertEqual(build.result, 'SUCCESS')
+        build = self.getJobFromHistory('hello-world')
+        self.assertEqual(build.result, 'SUCCESS')
         build = self.getJobFromHistory('python27')
         self.assertEqual(build.result, 'SUCCESS')
         flag_path = os.path.join(self.test_root, build.uuid + '.flag')
diff --git a/zuul/ansible/action/copy.py b/zuul/ansible/action/copy.py
index bb54430..d870c24 100644
--- a/zuul/ansible/action/copy.py
+++ b/zuul/ansible/action/copy.py
@@ -25,6 +25,6 @@
         source = self._task.args.get('src', None)
         remote_src = self._task.args.get('remote_src', False)
 
-        if not remote_src and not paths._is_safe_path(source):
+        if not remote_src and source and not paths._is_safe_path(source):
             return paths._fail_dict(source)
         return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/callback/zuul_stream.py b/zuul/ansible/callback/zuul_stream.py
index fd95e92..904316c 100644
--- a/zuul/ansible/callback/zuul_stream.py
+++ b/zuul/ansible/callback/zuul_stream.py
@@ -24,14 +24,14 @@
 
 
 def linesplit(socket):
-    buff = socket.recv(4096)
+    buff = socket.recv(4096).decode("utf-8")
     buffering = True
     while buffering:
         if "\n" in buff:
             (line, buff) = buff.split("\n", 1)
             yield line + "\n"
         else:
-            more = socket.recv(4096)
+            more = socket.recv(4096).decode("utf-8")
             if not more:
                 buffering = False
             else:
diff --git a/zuul/cmd/executor.py b/zuul/cmd/executor.py
index 1893f5a..931639f 100755
--- a/zuul/cmd/executor.py
+++ b/zuul/cmd/executor.py
@@ -24,9 +24,11 @@
 
 import logging
 import os
+import pwd
 import socket
 import sys
 import signal
+import tempfile
 
 import zuul.cmd
 import zuul.executor.server
@@ -37,6 +39,9 @@
 # Similar situation with gear and statsd.
 
 
+DEFAULT_FINGER_PORT = 79
+
+
 class Executor(zuul.cmd.ZuulApp):
 
     def parse_arguments(self):
@@ -72,15 +77,67 @@
         self.executor.stop()
         self.executor.join()
 
+    def start_log_streamer(self):
+        pipe_read, pipe_write = os.pipe()
+        child_pid = os.fork()
+        if child_pid == 0:
+            os.close(pipe_write)
+            import zuul.lib.log_streamer
+
+            self.log.info("Starting log streamer")
+            streamer = zuul.lib.log_streamer.LogStreamer(
+                self.user, '0.0.0.0', self.finger_port, self.jobroot_dir)
+
+            # Keep running until the parent dies:
+            pipe_read = os.fdopen(pipe_read)
+            pipe_read.read()
+            self.log.info("Stopping log streamer")
+            streamer.stop()
+            os._exit(0)
+        else:
+            os.close(pipe_read)
+            self.log_streamer_pid = child_pid
+
+    def change_privs(self):
+        '''
+        Drop our privileges to the zuul user.
+        '''
+        if os.getuid() != 0:
+            return
+        pw = pwd.getpwnam(self.user)
+        os.setgroups([])
+        os.setgid(pw.pw_gid)
+        os.setuid(pw.pw_uid)
+        os.umask(0o022)
+
     def main(self, daemon=True):
         # See comment at top of file about zuul imports
 
-        self.setup_logging('executor', 'log_config')
+        if self.config.has_option('executor', 'user'):
+            self.user = self.config.get('executor', 'user')
+        else:
+            self.user = 'zuul'
 
+        if self.config.has_option('zuul', 'jobroot_dir'):
+            self.jobroot_dir = os.path.expanduser(
+                self.config.get('zuul', 'jobroot_dir'))
+        else:
+            self.jobroot_dir = tempfile.gettempdir()
+
+        self.setup_logging('executor', 'log_config')
         self.log = logging.getLogger("zuul.Executor")
 
+        if self.config.has_option('executor', 'finger_port'):
+            self.finger_port = int(self.config.get('executor', 'finger_port'))
+        else:
+            self.finger_port = DEFAULT_FINGER_PORT
+
+        self.start_log_streamer()
+        self.change_privs()
+
         ExecutorServer = zuul.executor.server.ExecutorServer
         self.executor = ExecutorServer(self.config, self.connections,
+                                       jobdir_root=self.jobroot_dir,
                                        keep_jobdir=self.args.keep_jobdir)
         self.executor.start()
 
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..c93e912
--- /dev/null
+++ b/zuul/driver/bubblewrap/__init__.py
@@ -0,0 +1,173 @@
+# 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 six.moves import shlex_quote
+
+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]
+
+        self.log.debug("Bubblewrap command: %s",
+                       " ".join(shlex_quote(c) for c in 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/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py
index dcbc172..06962e5 100644
--- a/zuul/driver/gerrit/gerritconnection.py
+++ b/zuul/driver/gerrit/gerritconnection.py
@@ -725,13 +725,13 @@
         if stdin_data:
             stdin.write(stdin_data)
 
-        out = stdout.read()
+        out = stdout.read().decode('utf-8')
         self.log.debug("SSH received stdout:\n%s" % out)
 
         ret = stdout.channel.recv_exit_status()
         self.log.debug("SSH exit status: %s" % ret)
 
-        err = stderr.read()
+        err = stderr.read().decode('utf-8')
         self.log.debug("SSH received stderr:\n%s" % err)
         if ret:
             raise Exception("Gerrit error executing %s" % command)
diff --git a/zuul/driver/gerrit/gerritreporter.py b/zuul/driver/gerrit/gerritreporter.py
index a855db3..f8e8b03 100644
--- a/zuul/driver/gerrit/gerritreporter.py
+++ b/zuul/driver/gerrit/gerritreporter.py
@@ -15,7 +15,7 @@
 import logging
 import voluptuous as v
 
-
+from zuul.driver.gerrit.gerritsource import GerritSource
 from zuul.reporter import BaseReporter
 
 
@@ -25,14 +25,25 @@
     name = 'gerrit'
     log = logging.getLogger("zuul.GerritReporter")
 
-    def report(self, source, pipeline, item):
+    def report(self, pipeline, item):
         """Send a message to gerrit."""
+
+        # If the source is no GerritSource we cannot report anything here.
+        if not isinstance(item.change.project.source, GerritSource):
+            return
+
+        # For supporting several Gerrit connections we also must filter by
+        # the canonical hostname.
+        if item.change.project.source.connection.canonical_hostname != \
+                self.connection.canonical_hostname:
+            return
+
         message = self._formatItemReport(pipeline, item)
 
         self.log.debug("Report change %s, params %s, message: %s" %
                        (item.change, self.config, message))
         changeid = '%s,%s' % (item.change.number, item.change.patchset)
-        item.change._ref_sha = source.getRefSha(
+        item.change._ref_sha = item.change.project.source.getRefSha(
             item.change.project, 'refs/heads/' + item.change.branch)
 
         return self.connection.review(item.change.project.name, changeid,
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 27ece54..02c795e 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -479,8 +479,8 @@
             change.status = self._get_statuses(project, event.patch_number)
             change.reviews = self.getPullReviews(project, change.number)
             change.source_event = event
-            change.open = self.getPullOpen(project, change.number)
-            change.is_current_patchset = self.getIsCurrent(project,
+            change.open = self.getPullOpen(event.project_name, change.number)
+            change.is_current_patchset = self.getIsCurrent(event.project_name,
                                                            change.number,
                                                            event.patch_number)
         elif event.ref:
diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py
index 2d8e6cc..68c6af0 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -39,7 +39,7 @@
         if not isinstance(self._unlabels, list):
             self._unlabels = [self._unlabels]
 
-    def report(self, source, pipeline, item):
+    def report(self, pipeline, item):
         """Comment on PR and set commit status."""
         if self._create_comment:
             self.addPullComment(pipeline, item)
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/driver/smtp/smtpreporter.py b/zuul/driver/smtp/smtpreporter.py
index dd618ef..35eb69f 100644
--- a/zuul/driver/smtp/smtpreporter.py
+++ b/zuul/driver/smtp/smtpreporter.py
@@ -24,7 +24,7 @@
     name = 'smtp'
     log = logging.getLogger("zuul.SMTPReporter")
 
-    def report(self, source, pipeline, item):
+    def report(self, pipeline, item):
         """Send the compiled report message via smtp."""
         message = self._formatItemReport(pipeline, item)
 
diff --git a/zuul/driver/sql/sqlreporter.py b/zuul/driver/sql/sqlreporter.py
index d6e547d..46d538a 100644
--- a/zuul/driver/sql/sqlreporter.py
+++ b/zuul/driver/sql/sqlreporter.py
@@ -31,7 +31,7 @@
         # TODO(jeblair): document this is stored as NULL if unspecified
         self.result_score = config.get('score', None)
 
-    def report(self, source, pipeline, item):
+    def report(self, pipeline, item):
         """Create an entry into a database."""
 
         if not self.connection.tables_established:
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index eb608c9..2302412 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -31,10 +31,7 @@
 from six.moves import shlex_quote
 
 import zuul.merger.merger
-import zuul.ansible.action
-import zuul.ansible.callback
-import zuul.ansible.library
-import zuul.ansible.lookup
+import zuul.ansible
 from zuul.lib import commandsocket
 
 COMMANDS = ['stop', 'pause', 'unpause', 'graceful', 'verbose',
@@ -79,8 +76,88 @@
         self.path = None
 
 
+class SshAgent(object):
+    log = logging.getLogger("zuul.ExecutorServer")
+
+    def __init__(self):
+        self.env = {}
+        self.ssh_agent = None
+
+    def start(self):
+        if self.ssh_agent:
+            return
+        with open('/dev/null', 'r+') as devnull:
+            ssh_agent = subprocess.Popen(['ssh-agent'], close_fds=True,
+                                         stdout=subprocess.PIPE,
+                                         stderr=devnull,
+                                         stdin=devnull)
+        (output, _) = ssh_agent.communicate()
+        output = output.decode('utf8')
+        for line in output.split("\n"):
+            if '=' in line:
+                line = line.split(";", 1)[0]
+                (key, value) = line.split('=')
+                self.env[key] = value
+        self.log.info('Started SSH Agent, {}'.format(self.env))
+
+    def stop(self):
+        if 'SSH_AGENT_PID' in self.env:
+            try:
+                os.kill(int(self.env['SSH_AGENT_PID']), signal.SIGTERM)
+            except OSError:
+                self.log.exception(
+                    'Problem sending SIGTERM to agent {}'.format(self.env))
+            self.log.info('Sent SIGTERM to SSH Agent, {}'.format(self.env))
+            self.env = {}
+
+    def add(self, key_path):
+        env = os.environ.copy()
+        env.update(self.env)
+        key_path = os.path.expanduser(key_path)
+        self.log.debug('Adding SSH Key {}'.format(key_path))
+        output = ''
+        try:
+            output = subprocess.check_output(['ssh-add', key_path], env=env,
+                                             stderr=subprocess.PIPE)
+        except subprocess.CalledProcessError:
+            self.log.error('ssh-add failed: {}'.format(output))
+            raise
+        self.log.info('Added SSH Key {}'.format(key_path))
+
+    def remove(self, key_path):
+        env = os.environ.copy()
+        env.update(self.env)
+        key_path = os.path.expanduser(key_path)
+        self.log.debug('Removing SSH Key {}'.format(key_path))
+        subprocess.check_output(['ssh-add', '-d', key_path], env=env,
+                                stderr=subprocess.PIPE)
+        self.log.info('Removed SSH Key {}'.format(key_path))
+
+    def list(self):
+        if 'SSH_AUTH_SOCK' not in self.env:
+            return None
+        env = os.environ.copy()
+        env.update(self.env)
+        result = []
+        for line in subprocess.Popen(['ssh-add', '-L'], env=env,
+                                     stdout=subprocess.PIPE).stdout:
+            line = line.decode('utf8')
+            if line.strip() == 'The agent has no identities.':
+                break
+            result.append(line.strip())
+        return result
+
+
 class JobDir(object):
-    def __init__(self, root=None, keep=False):
+    def __init__(self, root, keep, build_uuid):
+        '''
+        :param str root: Root directory for the individual job directories.
+            Can be None to use the default system temp root directory.
+        :param bool keep: If True, do not delete the job directory.
+        :param str build_uuid: The unique build UUID. If supplied, this will
+            be used as the temp job directory name. Using this will help the
+            log streaming daemon find job logs.
+        '''
         # root
         #   ansible
         #     trusted.cfg
@@ -89,7 +166,12 @@
         #     src
         #     logs
         self.keep = keep
-        self.root = tempfile.mkdtemp(dir=root)
+        if root:
+            tmpdir = root
+        else:
+            tmpdir = tempfile.gettempdir()
+        self.root = os.path.join(tmpdir, build_uuid)
+        os.mkdir(self.root, 0o700)
         # Work
         self.work_root = os.path.join(self.root, 'work')
         os.makedirs(self.work_root)
@@ -168,7 +250,7 @@
         self.event = threading.Event()
 
     def __eq__(self, other):
-        if (other.connection_name == self.connection_name and
+        if (other and other.connection_name == self.connection_name and
             other.project_name == self.project_name):
             return True
         return False
@@ -222,6 +304,8 @@
 def _copy_ansible_files(python_module, target_dir):
         library_path = os.path.dirname(os.path.abspath(python_module.__file__))
         for fn in os.listdir(library_path):
+            if fn == "__pycache__":
+                continue
             full_path = os.path.join(library_path, fn)
             if os.path.isdir(full_path):
                 shutil.copytree(full_path, os.path.join(target_dir, fn))
@@ -265,6 +349,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
@@ -281,25 +372,27 @@
         path = os.path.join(state_dir, 'executor.socket')
         self.command_socket = commandsocket.CommandSocket(path)
         ansible_dir = os.path.join(state_dir, 'ansible')
-        self.library_dir = os.path.join(ansible_dir, 'library')
-        if not os.path.exists(self.library_dir):
-            os.makedirs(self.library_dir)
-        self.action_dir = os.path.join(ansible_dir, 'action')
-        if not os.path.exists(self.action_dir):
-            os.makedirs(self.action_dir)
+        self.ansible_dir = ansible_dir
 
-        self.callback_dir = os.path.join(ansible_dir, 'callback')
-        if not os.path.exists(self.callback_dir):
-            os.makedirs(self.callback_dir)
+        zuul_dir = os.path.join(ansible_dir, 'zuul')
+        plugin_dir = os.path.join(zuul_dir, 'ansible')
 
-        self.lookup_dir = os.path.join(ansible_dir, 'lookup')
-        if not os.path.exists(self.lookup_dir):
-            os.makedirs(self.lookup_dir)
+        if not os.path.exists(plugin_dir):
+            os.makedirs(plugin_dir)
 
-        _copy_ansible_files(zuul.ansible.library, self.library_dir)
-        _copy_ansible_files(zuul.ansible.action, self.action_dir)
-        _copy_ansible_files(zuul.ansible.callback, self.callback_dir)
-        _copy_ansible_files(zuul.ansible.lookup, self.lookup_dir)
+        self.library_dir = os.path.join(plugin_dir, 'library')
+        self.action_dir = os.path.join(plugin_dir, 'action')
+        self.callback_dir = os.path.join(plugin_dir, 'callback')
+        self.lookup_dir = os.path.join(plugin_dir, 'lookup')
+
+        _copy_ansible_files(zuul.ansible, plugin_dir)
+
+        # We're copying zuul.ansible.* into a directory we are going
+        # to add to pythonpath, so our plugins can "import
+        # zuul.ansible".  But we're not installing all of zuul, so
+        # create a __init__.py file for the stub "zuul" module.
+        with open(os.path.join(zuul_dir, '__init__.py'), 'w'):
+            pass
 
         self.job_workers = {}
 
@@ -511,6 +604,8 @@
         self.proc_lock = threading.Lock()
         self.running = False
         self.aborted = False
+        self.thread = None
+        self.ssh_agent = None
 
         if self.executor_server.config.has_option(
             'executor', 'private_key_file'):
@@ -518,8 +613,11 @@
                 'executor', 'private_key_file')
         else:
             self.private_key_file = '~/.ssh/id_rsa'
+        self.ssh_agent = SshAgent()
 
     def run(self):
+        self.ssh_agent.start()
+        self.ssh_agent.add(self.private_key_file)
         self.running = True
         self.thread = threading.Thread(target=self.execute)
         self.thread.start()
@@ -527,12 +625,14 @@
     def stop(self):
         self.aborted = True
         self.abortRunningProc()
-        self.thread.join()
+        if self.thread:
+            self.thread.join()
 
     def execute(self):
         try:
-            self.jobdir = JobDir(root=self.executor_server.jobdir_root,
-                                 keep=self.executor_server.keep_jobdir)
+            self.jobdir = JobDir(self.executor_server.jobdir_root,
+                                 self.executor_server.keep_jobdir,
+                                 str(self.job.unique))
             self._execute()
         except Exception:
             self.log.exception("Exception while executing job")
@@ -547,6 +647,11 @@
                 self.executor_server.finishJob(self.job.unique)
             except Exception:
                 self.log.exception("Error finalizing job thread:")
+            if self.ssh_agent:
+                try:
+                    self.ssh_agent.stop()
+                except Exception:
+                    self.log.exception("Error stopping SSH agent:")
 
     def _execute(self):
         self.log.debug("Job %s: beginning" % (self.job.unique,))
@@ -1028,12 +1133,26 @@
 
     def runAnsible(self, cmd, timeout, trusted=False):
         env_copy = os.environ.copy()
+        env_copy.update(self.ssh_agent.env)
         env_copy['LOGNAME'] = 'zuul'
+        pythonpath = env_copy.get('PYTHONPATH')
+        if pythonpath:
+            pythonpath = [pythonpath]
+        else:
+            pythonpath = []
+        pythonpath = [self.executor_server.ansible_dir] + pythonpath
+        env_copy['PYTHONPATH'] = os.path.pathsep.join(pythonpath)
 
         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
 
@@ -1042,7 +1161,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 9908fff..79d78f4 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:
diff --git a/zuul/lib/log_streamer.py b/zuul/lib/log_streamer.py
new file mode 100644
index 0000000..6aa51a6
--- /dev/null
+++ b/zuul/lib/log_streamer.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2016 IBM Corp.
+# Copyright 2017 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 os
+import os.path
+import pwd
+import re
+import select
+import socket
+import threading
+import time
+
+try:
+    import SocketServer as ss  # python 2.x
+except ImportError:
+    import socketserver as ss  # python 3
+
+
+class Log(object):
+
+    def __init__(self, path):
+        self.path = path
+        self.file = open(path)
+        self.stat = os.stat(path)
+        self.size = self.stat.st_size
+
+
+class RequestHandler(ss.BaseRequestHandler):
+    '''
+    Class to handle a single log streaming request.
+
+    The log streaming code was blatantly stolen from zuul_console.py. Only
+    the (class/method/attribute) names were changed to protect the innocent.
+    '''
+
+    def handle(self):
+        build_uuid = self.request.recv(1024).decode("utf-8")
+        build_uuid = build_uuid.rstrip()
+
+        # validate build ID
+        if not re.match("[0-9A-Fa-f]+$", build_uuid):
+            msg = 'Build ID %s is not valid' % build_uuid
+            self.request.sendall(msg.encode("utf-8"))
+            return
+
+        job_dir = os.path.join(self.server.jobdir_root, build_uuid)
+        if not os.path.exists(job_dir):
+            msg = 'Build ID %s not found' % build_uuid
+            self.request.sendall(msg.encode("utf-8"))
+            return
+
+        # check if log file exists
+        log_file = os.path.join(job_dir, 'ansible', 'ansible_log.txt')
+        if not os.path.exists(log_file):
+            msg = 'Log not found for build ID %s' % build_uuid
+            self.request.sendall(msg.encode("utf-8"))
+            return
+
+        self.stream_log(log_file)
+
+    def stream_log(self, log_file):
+        log = None
+        while True:
+            if log is not None:
+                try:
+                    log.file.close()
+                except:
+                    pass
+            while True:
+                log = self.chunk_log(log_file)
+                if log:
+                    break
+                time.sleep(0.5)
+            while True:
+                if self.follow_log(log):
+                    break
+                else:
+                    return
+
+    def chunk_log(self, log_file):
+        try:
+            log = Log(log_file)
+        except Exception:
+            return
+        while True:
+            chunk = log.file.read(4096)
+            if not chunk:
+                break
+            self.request.send(chunk.encode('utf-8'))
+        return log
+
+    def follow_log(self, log):
+        while True:
+            # As long as we have unread data, keep reading/sending
+            while True:
+                chunk = log.file.read(4096)
+                if chunk:
+                    self.request.send(chunk.encode('utf-8'))
+                else:
+                    break
+
+            # At this point, we are waiting for more data to be written
+            time.sleep(0.5)
+
+            # Check to see if the remote end has sent any data, if so,
+            # discard
+            r, w, e = select.select([self.request], [], [self.request], 0)
+            if self.request in e:
+                return False
+            if self.request in r:
+                ret = self.request.recv(1024)
+                # Discard anything read, if input is eof, it has
+                # disconnected.
+                if not ret:
+                    return False
+
+            # See if the file has been truncated
+            try:
+                st = os.stat(log.path)
+                if (st.st_ino != log.stat.st_ino or
+                    st.st_size < log.size):
+                    return True
+            except Exception:
+                return True
+            log.size = st.st_size
+
+
+class CustomForkingTCPServer(ss.ForkingTCPServer):
+    '''
+    Custom version that allows us to drop privileges after port binding.
+    '''
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user')
+        self.jobdir_root = kwargs.pop('jobdir_root')
+        # For some reason, setting custom attributes does not work if we
+        # call the base class __init__ first. Wha??
+        ss.ForkingTCPServer.__init__(self, *args, **kwargs)
+
+    def change_privs(self):
+        '''
+        Drop our privileges to the zuul user.
+        '''
+        if os.getuid() != 0:
+            return
+        pw = pwd.getpwnam(self.user)
+        os.setgroups([])
+        os.setgid(pw.pw_gid)
+        os.setuid(pw.pw_uid)
+        os.umask(0o022)
+
+    def server_bind(self):
+        self.allow_reuse_address = True
+        ss.ForkingTCPServer.server_bind(self)
+        if self.user:
+            self.change_privs()
+
+    def server_close(self):
+        '''
+        Overridden from base class to shutdown the socket immediately.
+        '''
+        try:
+            self.socket.shutdown(socket.SHUT_RD)
+            self.socket.close()
+        except socket.error as e:
+            # If it's already closed, don't error.
+            if e.errno == socket.EBADF:
+                return
+            raise
+
+
+class LogStreamer(object):
+    '''
+    Class implementing log streaming over the finger daemon port.
+    '''
+
+    def __init__(self, user, host, port, jobdir_root):
+        self.server = CustomForkingTCPServer((host, port),
+                                             RequestHandler,
+                                             user=user,
+                                             jobdir_root=jobdir_root)
+
+        # We start the actual serving within a thread so we can return to
+        # the owner.
+        self.thd = threading.Thread(target=self.server.serve_forever)
+        self.thd.daemon = True
+        self.thd.start()
+
+    def stop(self):
+        if self.thd.isAlive():
+            self.server.shutdown()
+            self.server.server_close()
diff --git a/zuul/manager/__init__.py b/zuul/manager/__init__.py
index d4024ec..93522f0 100644
--- a/zuul/manager/__init__.py
+++ b/zuul/manager/__init__.py
@@ -146,20 +146,17 @@
 
     def reportStart(self, item):
         if not self.pipeline._disabled:
-            source = item.change.project.source
             try:
                 self.log.info("Reporting start, action %s item %s" %
                               (self.pipeline.start_actions, item))
-                ret = self.sendReport(self.pipeline.start_actions,
-                                      source, item)
+                ret = self.sendReport(self.pipeline.start_actions, item)
                 if ret:
                     self.log.error("Reporting item start %s received: %s" %
                                    (item, ret))
             except:
                 self.log.exception("Exception while reporting start:")
 
-    def sendReport(self, action_reporters, source, item,
-                   message=None):
+    def sendReport(self, action_reporters, item, message=None):
         """Sends the built message off to configured reporters.
 
         Takes the action_reporters, item, message and extra options and
@@ -168,7 +165,7 @@
         report_errors = []
         if len(action_reporters) > 0:
             for reporter in action_reporters:
-                ret = reporter.report(source, self.pipeline, item)
+                ret = reporter.report(self.pipeline, item)
                 if ret:
                     report_errors.append(ret)
             if len(report_errors) == 0:
@@ -727,7 +724,6 @@
 
     def _reportItem(self, item):
         self.log.debug("Reporting change %s" % item.change)
-        source = item.change.project.source
         ret = True  # Means error as returned by trigger.report
         if item.getConfigError():
             self.log.debug("Invalid config for change %s" % item.change)
@@ -762,7 +758,7 @@
             try:
                 self.log.info("Reporting item %s, actions: %s" %
                               (item, actions))
-                ret = self.sendReport(actions, source, item)
+                ret = self.sendReport(actions, item)
                 if ret:
                     self.log.error("Reporting item %s received: %s" %
                                    (item, ret))
diff --git a/zuul/model.py b/zuul/model.py
index 28b214d..ee1fede 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -929,7 +929,7 @@
     def inheritFrom(self, other):
         for jobname, jobs in other.jobs.items():
             if jobname in self.jobs:
-                self.jobs[jobname].append(jobs)
+                self.jobs[jobname].extend(jobs)
             else:
                 self.jobs[jobname] = jobs
 
diff --git a/zuul/reporter/__init__.py b/zuul/reporter/__init__.py
index 582265d..9c8e953 100644
--- a/zuul/reporter/__init__.py
+++ b/zuul/reporter/__init__.py
@@ -37,7 +37,7 @@
         self._action = action
 
     @abc.abstractmethod
-    def report(self, source, pipeline, item):
+    def report(self, pipeline, item):
         """Send the compiled report message."""
 
     def getSubmitAllowNeeds(self):