Merge "Catch and log url pattern formatting errors" into feature/zuulv3
diff --git a/doc/source/developer/testing.rst b/doc/source/developer/testing.rst
index 4a813d0..057ab7e 100644
--- a/doc/source/developer/testing.rst
+++ b/doc/source/developer/testing.rst
@@ -9,6 +9,8 @@
 access to a number of attributes useful for manipulating or inspecting
 the environment being simulated in the test:
 
+.. autofunction:: tests.base.simple_layout
+
 .. autoclass:: tests.base.ZuulTestCase
    :members:
 
diff --git a/requirements.txt b/requirements.txt
index c7e059a..974b77f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,7 +9,7 @@
 python-daemon>=2.0.4,<2.1.0
 extras
 statsd>=1.0.0,<3.0
-voluptuous>=0.7
+voluptuous>=0.10.2
 gear>=0.5.7,<1.0.0
 apscheduler>=3.0
 PrettyTable>=0.6,<0.8
diff --git a/tests/base.py b/tests/base.py
index 1f447da..6d3df8b 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -28,13 +28,17 @@
 import select
 import shutil
 from six.moves import reload_module
-from six import StringIO
+try:
+    from cStringIO import StringIO
+except Exception:
+    from six import StringIO
 import socket
 import string
 import subprocess
 import sys
 import tempfile
 import threading
+import traceback
 import time
 import uuid
 
@@ -97,6 +101,31 @@
     raise Exception("Timeout waiting for %s" % purpose)
 
 
+def simple_layout(path, driver='gerrit'):
+    """Specify a layout file for use by a test method.
+
+    :arg str path: The path to the layout file.
+    :arg str driver: The source driver to use, defaults to gerrit.
+
+    Some tests require only a very simple configuration.  For those,
+    establishing a complete config directory hierachy is too much
+    work.  In those cases, you can add a simple zuul.yaml file to the
+    test fixtures directory (in fixtures/layouts/foo.yaml) and use
+    this decorator to indicate the test method should use that rather
+    than the tenant config file specified by the test class.
+
+    The decorator will cause that layout file to be added to a
+    config-project called "common-config" and each "project" instance
+    referenced in the layout file will have a git repo automatically
+    initialized.
+    """
+
+    def decorator(test):
+        test.__simple_layout__ = (path, driver)
+        return test
+    return decorator
+
+
 class ChangeReference(git.Reference):
     _common_path_default = "refs/changes"
     _points_to_commits_only = True
@@ -769,6 +798,11 @@
                 build.release()
         super(RecordingExecutorServer, self).stopJob(job)
 
+    def stop(self):
+        for build in self.running_builds:
+            build.release()
+        super(RecordingExecutorServer, self).stop()
+
 
 class RecordingAnsibleJob(zuul.executor.server.AnsibleJob):
     def doMergeChanges(self, items):
@@ -996,6 +1030,7 @@
                     provider='test-provider',
                     region='test-region',
                     az='test-az',
+                    interface_ip='127.0.0.1',
                     public_ipv4='127.0.0.1',
                     private_ipv4=None,
                     public_ipv6=None,
@@ -1042,7 +1077,7 @@
 
 
 class ChrootedKazooFixture(fixtures.Fixture):
-    def __init__(self):
+    def __init__(self, test_id):
         super(ChrootedKazooFixture, self).__init__()
 
         zk_host = os.environ.get('NODEPOOL_ZK_HOST', 'localhost')
@@ -1059,15 +1094,19 @@
         else:
             self.zookeeper_port = int(port)
 
+        self.test_id = test_id
+
     def _setUp(self):
         # Make sure the test chroot paths do not conflict
         random_bits = ''.join(random.choice(string.ascii_lowercase +
                                             string.ascii_uppercase)
                               for x in range(8))
 
-        rand_test_path = '%s_%s' % (random_bits, os.getpid())
+        rand_test_path = '%s_%s_%s' % (random_bits, os.getpid(), self.test_id)
         self.zookeeper_chroot = "/nodepool_test/%s" % rand_test_path
 
+        self.addCleanup(self._cleanup)
+
         # Ensure the chroot path exists and clean up any pre-existing znodes.
         _tmp_client = kazoo.client.KazooClient(
             hosts='%s:%s' % (self.zookeeper_host, self.zookeeper_port))
@@ -1080,8 +1119,6 @@
         _tmp_client.stop()
         _tmp_client.close()
 
-        self.addCleanup(self._cleanup)
-
     def _cleanup(self):
         '''Remove the chroot path.'''
         # Need a non-chroot'ed client to remove the chroot path
@@ -1090,6 +1127,7 @@
         _tmp_client.start()
         _tmp_client.delete(self.zookeeper_chroot, recursive=True)
         _tmp_client.stop()
+        _tmp_client.close()
 
 
 class MySQLSchemaFixture(fixtures.Fixture):
@@ -1131,7 +1169,7 @@
 
 class BaseTestCase(testtools.TestCase):
     log = logging.getLogger("zuul.test")
-    wait_timeout = 20
+    wait_timeout = 30
 
     def attachLogs(self, *args):
         def reader():
@@ -1182,6 +1220,12 @@
         logger.setLevel(logging.DEBUG)
         logger.addHandler(handler)
 
+        # Make sure we don't carry old handlers around in process state
+        # which slows down test runs
+        self.addCleanup(logger.removeHandler, handler)
+        self.addCleanup(handler.close)
+        self.addCleanup(handler.flush)
+
         # NOTE(notmorgan): Extract logging overrides for specific
         # libraries from the OS_LOG_DEFAULTS env and create loggers
         # for each. This is used to limit the output during test runs
@@ -1222,7 +1266,8 @@
         be loaded).  It defaults to the value specified in
         `config_file` but can be overidden by subclasses to obtain a
         different tenant/project layout while using the standard main
-        configuration.
+        configuration.  See also the :py:func:`simple_layout`
+        decorator.
 
     :cvar bool create_project_keys: Indicates whether Zuul should
         auto-generate keys for each project, or whether the test
@@ -1300,23 +1345,6 @@
         self.config.set('executor', 'git_dir', self.executor_src_root)
         self.config.set('zuul', 'state_dir', self.state_root)
 
-        # For each project in config:
-        # TODOv3(jeblair): remove these and replace with new git
-        # filesystem fixtures
-        self.init_repo("org/project3")
-        self.init_repo("org/project4")
-        self.init_repo("org/project5")
-        self.init_repo("org/project6")
-        self.init_repo("org/one-job-project")
-        self.init_repo("org/nonvoting-project")
-        self.init_repo("org/templated-project")
-        self.init_repo("org/layered-project")
-        self.init_repo("org/node-project")
-        self.init_repo("org/conflict-project")
-        self.init_repo("org/noop-project")
-        self.init_repo("org/experimental-project")
-        self.init_repo("org/no-jobs-project")
-
         self.statsd = FakeStatsd()
         # note, use 127.0.0.1 rather than localhost to avoid getting ipv6
         # see: https://github.com/jsocol/pystatsd/issues/61
@@ -1339,6 +1367,9 @@
 
         self.sched = zuul.scheduler.Scheduler(self.config)
 
+        self.webapp = zuul.webapp.WebApp(
+            self.sched, port=0, listen_address='127.0.0.1')
+
         self.event_queues = [
             self.sched.result_event_queue,
             self.sched.trigger_event_queue,
@@ -1346,7 +1377,7 @@
         ]
 
         self.configure_connections()
-        self.sched.registerConnections(self.connections)
+        self.sched.registerConnections(self.connections, self.webapp)
 
         def URLOpenerFactory(*args, **kw):
             if isinstance(args[0], urllib.request.Request):
@@ -1386,23 +1417,20 @@
         self.sched.setNodepool(self.nodepool)
         self.sched.setZooKeeper(self.zk)
 
-        self.webapp = zuul.webapp.WebApp(
-            self.sched, port=0, listen_address='127.0.0.1')
         self.rpc = zuul.rpclistener.RPCListener(self.config, self.sched)
 
         self.sched.start()
         self.webapp.start()
         self.rpc.start()
         self.executor_client.gearman.waitForServer()
+        # Cleanups are run in reverse order
+        self.addCleanup(self.assertCleanShutdown)
         self.addCleanup(self.shutdown)
+        self.addCleanup(self.assertFinalState)
 
         self.sched.reconfigure(self.config)
         self.sched.resume()
 
-    def tearDown(self):
-        super(ZuulTestCase, self).tearDown()
-        self.assertFinalState()
-
     def configure_connections(self):
         # Set up gerrit related fakes
         # Set a changes database so multiple FakeGerrit's can report back to
@@ -1444,19 +1472,74 @@
         # obeys the config_file and tenant_config_file attributes.
         self.config = ConfigParser.ConfigParser()
         self.config.read(os.path.join(FIXTURE_DIR, self.config_file))
-        if hasattr(self, 'tenant_config_file'):
-            self.config.set('zuul', 'tenant_config', self.tenant_config_file)
-            git_path = os.path.join(
-                os.path.dirname(
-                    os.path.join(FIXTURE_DIR, self.tenant_config_file)),
-                'git')
-            if os.path.exists(git_path):
-                for reponame in os.listdir(git_path):
-                    project = reponame.replace('_', '/')
-                    self.copyDirToRepo(project,
-                                       os.path.join(git_path, reponame))
+
+        if not self.setupSimpleLayout():
+            if hasattr(self, 'tenant_config_file'):
+                self.config.set('zuul', 'tenant_config',
+                                self.tenant_config_file)
+                git_path = os.path.join(
+                    os.path.dirname(
+                        os.path.join(FIXTURE_DIR, self.tenant_config_file)),
+                    'git')
+                if os.path.exists(git_path):
+                    for reponame in os.listdir(git_path):
+                        project = reponame.replace('_', '/')
+                        self.copyDirToRepo(project,
+                                           os.path.join(git_path, reponame))
         self.setupAllProjectKeys()
 
+    def setupSimpleLayout(self):
+        # If the test method has been decorated with a simple_layout,
+        # use that instead of the class tenant_config_file.  Set up a
+        # single config-project with the specified layout, and
+        # initialize repos for all of the 'project' entries which
+        # appear in the layout.
+        test_name = self.id().split('.')[-1]
+        test = getattr(self, test_name)
+        if hasattr(test, '__simple_layout__'):
+            path, driver = getattr(test, '__simple_layout__')
+        else:
+            return False
+
+        files = {}
+        path = os.path.join(FIXTURE_DIR, path)
+        with open(path) as f:
+            data = f.read()
+            layout = yaml.safe_load(data)
+            files['zuul.yaml'] = data
+        untrusted_projects = []
+        for item in layout:
+            if 'project' in item:
+                name = item['project']['name']
+                untrusted_projects.append(name)
+                self.init_repo(name)
+                self.addCommitToRepo(name, 'initial commit',
+                                     files={'README': ''},
+                                     branch='master', tag='init')
+            if 'job' in item:
+                jobname = item['job']['name']
+                files['playbooks/%s.yaml' % jobname] = ''
+
+        root = os.path.join(self.test_root, "config")
+        if not os.path.exists(root):
+            os.makedirs(root)
+        f = tempfile.NamedTemporaryFile(dir=root, delete=False)
+        config = [{'tenant':
+                   {'name': 'tenant-one',
+                    'source': {driver:
+                               {'config-projects': ['common-config'],
+                                'untrusted-projects': untrusted_projects}}}}]
+        f.write(yaml.dump(config))
+        f.close()
+        self.config.set('zuul', 'tenant_config',
+                        os.path.join(FIXTURE_DIR, f.name))
+
+        self.init_repo('common-config')
+        self.addCommitToRepo('common-config', 'add content from fixture',
+                             files, branch='master', tag='init')
+
+        return True
+
     def setupAllProjectKeys(self):
         if self.create_project_keys:
             return
@@ -1467,9 +1550,9 @@
         for tenant in tenant_config:
             sources = tenant['tenant']['source']
             for source, conf in sources.items():
-                for project in conf.get('config-repos', []):
+                for project in conf.get('config-projects', []):
                     self.setupProjectKeys(source, project)
-                for project in conf.get('project-repos', []):
+                for project in conf.get('untrusted-projects', []):
                     self.setupProjectKeys(source, project)
 
     def setupProjectKeys(self, source, project):
@@ -1490,7 +1573,8 @@
                 o.write(i.read())
 
     def setupZK(self):
-        self.zk_chroot_fixture = self.useFixture(ChrootedKazooFixture())
+        self.zk_chroot_fixture = self.useFixture(
+            ChrootedKazooFixture(self.id()))
         self.zk_config = '%s:%s%s' % (
             self.zk_chroot_fixture.zookeeper_host,
             self.zk_chroot_fixture.zookeeper_port,
@@ -1540,6 +1624,9 @@
                     self.assertEqual(test_key, f.read())
 
     def assertFinalState(self):
+        self.log.debug("Assert final state")
+        # Make sure no jobs are running
+        self.assertEqual({}, self.executor_server.job_workers)
         # Make sure that git.Repo objects have been garbage collected.
         repos = []
         gc.collect()
@@ -1575,12 +1662,23 @@
         self.gearman_server.shutdown()
         self.fake_nodepool.stop()
         self.zk.disconnect()
-        threads = threading.enumerate()
-        if len(threads) > 1:
-            self.log.error("More than one thread is running: %s" % threads)
         self.printHistory()
+        # we whitelist watchdog threads as they have relatively long delays
+        # before noticing they should exit, but they should exit on their own.
+        threads = [t for t in threading.enumerate()
+                   if t.name != 'executor-watchdog']
+        if len(threads) > 1:
+            log_str = ""
+            for thread_id, stack_frame in sys._current_frames().items():
+                log_str += "Thread: %s\n" % thread_id
+                log_str += "".join(traceback.format_stack(stack_frame))
+            self.log.debug(log_str)
+            raise Exception("More than one thread is running: %s" % threads)
 
-    def init_repo(self, project):
+    def assertCleanShutdown(self):
+        pass
+
+    def init_repo(self, project, tag=None):
         parts = project.split('/')
         path = os.path.join(self.upstream_root, *parts[:-1])
         if not os.path.exists(path):
@@ -1594,6 +1692,8 @@
 
         repo.index.commit('initial commit')
         master = repo.create_head('master')
+        if tag:
+            repo.create_tag(tag)
 
         repo.head.reference = master
         zuul.merger.merger.reset_repo_to_head(repo)
@@ -1627,11 +1727,15 @@
         commit = repo.index.commit('Creating a fake commit')
         return commit.hexsha
 
-    def orderedRelease(self):
+    def orderedRelease(self, count=None):
         # Run one build at a time to ensure non-race order:
+        i = 0
         while len(self.builds):
             self.release(self.builds[0])
             self.waitUntilSettled()
+            i += 1
+            if count is not None and i >= count:
+                break
 
     def release(self, job):
         if isinstance(job, FakeBuild):
@@ -1670,7 +1774,9 @@
 
     def areAllBuildsWaiting(self):
         builds = self.executor_client.builds.values()
+        seen_builds = set()
         for build in builds:
+            seen_builds.add(build.uuid)
             client_job = None
             for conn in self.executor_client.gearman.active_connections:
                 for j in conn.related_jobs.values():
@@ -1708,6 +1814,11 @@
             else:
                 self.log.debug("%s is unassigned" % server_job)
                 return False
+        for (build_uuid, job_worker) in \
+            self.executor_server.job_workers.items():
+            if build_uuid not in seen_builds:
+                self.log.debug("%s is not finalized" % build_uuid)
+                return False
         return True
 
     def areAllNodeRequestsComplete(self):
@@ -1911,9 +2022,12 @@
     name: openstack
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - %s
-        """ % path)
+        untrusted-projects:
+          - org/project
+          - org/project1
+          - org/project2\n""" % path)
         f.close()
         self.config.set('zuul', 'tenant_config',
                         os.path.join(FIXTURE_DIR, f.name))
@@ -1944,23 +2058,37 @@
             repo.create_tag(tag)
         return before
 
-    def commitLayoutUpdate(self, orig_name, source_name):
-        source_path = os.path.join(self.test_root, 'upstream',
-                                   source_name)
-        to_copy = ['zuul.yaml']
-        for playbook in os.listdir(os.path.join(source_path, 'playbooks')):
-            to_copy.append('playbooks/{}'.format(playbook))
-        commit_data = {}
-        for source_file in to_copy:
-            source_file_path = os.path.join(source_path, source_file)
-            with open(source_file_path, 'r') as nt:
-                commit_data[source_file] = nt.read()
+    def commitConfigUpdate(self, project_name, source_name):
+        """Commit an update to zuul.yaml
+
+        This overwrites the zuul.yaml in the specificed project with
+        the contents specified.
+
+        :arg str project_name: The name of the project containing
+            zuul.yaml (e.g., common-config)
+
+        :arg str source_name: The path to the file (underneath the
+            test fixture directory) whose contents should be used to
+            replace zuul.yaml.
+        """
+
+        source_path = os.path.join(FIXTURE_DIR, source_name)
+        files = {}
+        with open(source_path, 'r') as f:
+            data = f.read()
+            layout = yaml.safe_load(data)
+            files['zuul.yaml'] = data
+        for item in layout:
+            if 'job' in item:
+                jobname = item['job']['name']
+                files['playbooks/%s.yaml' % jobname] = ''
         before = self.addCommitToRepo(
-            orig_name, 'Pulling content from %s' % source_name,
-            commit_data)
+            project_name, 'Pulling content from %s' % source_name,
+            files)
         return before
 
     def addEvent(self, connection, event):
+
         """Inject a Fake (Gerrit) event.
 
         This method accepts a JSON-encoded event and simulates Zuul
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index 0980bc1..f9be158 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     allow-secrets: true
     trigger:
       gerrit:
@@ -17,7 +16,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -50,6 +48,7 @@
         Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
         +150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
 
+
 - job:
     name: python27
     pre-run: pre
diff --git a/tests/fixtures/config/ansible/main.yaml b/tests/fixtures/config/ansible/main.yaml
index 8df99f4..9ccece9 100644
--- a/tests/fixtures/config/ansible/main.yaml
+++ b/tests/fixtures/config/ansible/main.yaml
@@ -2,8 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
-        project-repos:
+        untrusted-projects:
           - org/project
           - bare-role
diff --git a/tests/fixtures/config/broken/git/common-config/zuul.yaml b/tests/fixtures/config/broken/git/common-config/zuul.yaml
index 6abb87f..162a982 100644
--- a/tests/fixtures/config/broken/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/broken/git/common-config/zuul.yaml
@@ -1,8 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source:
-      gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/broken/main.yaml b/tests/fixtures/config/broken/main.yaml
index a22ed5c..9d01f54 100644
--- a/tests/fixtures/config/broken/main.yaml
+++ b/tests/fixtures/config/broken/main.yaml
@@ -2,5 +2,5 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
diff --git a/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml b/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
index 60f3651..cdf989e 100644
--- a/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
@@ -2,7 +2,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/dependency-graph/main.yaml b/tests/fixtures/config/dependency-graph/main.yaml
index d9868fa..208e274 100644
--- a/tests/fixtures/config/dependency-graph/main.yaml
+++ b/tests/fixtures/config/dependency-graph/main.yaml
@@ -2,7 +2,7 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
-        project-repos:
+        untrusted-projects:
           - org/project
diff --git a/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml b/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
index 5005108..60d7363 100755
--- a/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
@@ -2,7 +2,6 @@
     name: dup1
     manager: independent
     success-message: Build succeeded (dup1).
-    source: gerrit
     trigger:
       gerrit:
         - event: change-restored
@@ -17,7 +16,6 @@
     name: dup2
     manager: independent
     success-message: Build succeeded (dup2).
-    source: gerrit
     trigger:
       gerrit:
         - event: change-restored
diff --git a/tests/fixtures/config/duplicate-pipeline/main.yaml b/tests/fixtures/config/duplicate-pipeline/main.yaml
index ba2d8f5..d28df0d 100755
--- a/tests/fixtures/config/duplicate-pipeline/main.yaml
+++ b/tests/fixtures/config/duplicate-pipeline/main.yaml
@@ -2,5 +2,7 @@
     name: tenant-duplicate
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/git-driver/git/common-config/zuul.yaml b/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
index 0e332e4..8fe8749 100644
--- a/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/git-driver/main.yaml b/tests/fixtures/config/git-driver/main.yaml
index 5b9b3d9..2a2b204 100644
--- a/tests/fixtures/config/git-driver/main.yaml
+++ b/tests/fixtures/config/git-driver/main.yaml
@@ -2,8 +2,8 @@
     name: tenant-one
     source:
       git:
-        config-repos:
+        config-projects:
           - common-config
       gerrit:
-        project-repos:
+        untrusted-projects:
           - org/project
diff --git a/tests/fixtures/config/in-repo/git/common-config/zuul.yaml b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
index 55169ce..1fdaf2e 100644
--- a/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: tenant-one-gate
     manager: dependent
     success-message: Build succeeded (tenant-one-gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/in-repo/main.yaml b/tests/fixtures/config/in-repo/main.yaml
index d9868fa..208e274 100644
--- a/tests/fixtures/config/in-repo/main.yaml
+++ b/tests/fixtures/config/in-repo/main.yaml
@@ -2,7 +2,7 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
-        project-repos:
+        untrusted-projects:
           - org/project
diff --git a/tests/fixtures/config/merges/git/common-config/zuul.yaml b/tests/fixtures/config/merges/git/common-config/zuul.yaml
index ab4e24c..1309b3f 100644
--- a/tests/fixtures/config/merges/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/merges/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/merges/main.yaml b/tests/fixtures/config/merges/main.yaml
index a22ed5c..3ec47ea 100644
--- a/tests/fixtures/config/merges/main.yaml
+++ b/tests/fixtures/config/merges/main.yaml
@@ -2,5 +2,11 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project-cherry-pick
+          - org/project-merge
+          - org/project-merge-branches
+          - org/project-merge-resolve
+
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
index d18ed46..ba91fb5 100644
--- a/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/multi-tenant-semaphore/main.yaml b/tests/fixtures/config/multi-tenant-semaphore/main.yaml
index b1c47b1..59422a0 100644
--- a/tests/fixtures/config/multi-tenant-semaphore/main.yaml
+++ b/tests/fixtures/config/multi-tenant-semaphore/main.yaml
@@ -2,14 +2,20 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
           - tenant-one-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
 
 - tenant:
     name: tenant-two
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
           - tenant-two-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml b/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
index 004f2df..ec9c6dd 100644
--- a/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml b/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml
index 5769cf5..63a19e2 100644
--- a/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml
@@ -2,7 +2,6 @@
     name: tenant-one-gate
     manager: dependent
     success-message: Build succeeded (tenant-one-gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml b/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml
index 19782ce..4feb9f5 100644
--- a/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml
@@ -2,7 +2,6 @@
     name: tenant-two-gate
     manager: dependent
     success-message: Build succeeded (tenant-two-gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/multi-tenant/main.yaml b/tests/fixtures/config/multi-tenant/main.yaml
index b1c47b1..3ae7756 100644
--- a/tests/fixtures/config/multi-tenant/main.yaml
+++ b/tests/fixtures/config/multi-tenant/main.yaml
@@ -2,14 +2,18 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
           - tenant-one-config
+        untrusted-projects:
+          - org/project1
 
 - tenant:
     name: tenant-two
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
           - tenant-two-config
+        untrusted-projects:
+          - org/project2
diff --git a/tests/fixtures/config/one-job-project/git/common-config/playbooks/one-job-project-merge.yaml b/tests/fixtures/config/one-job-project/git/common-config/playbooks/one-job-project-merge.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/one-job-project/git/common-config/playbooks/one-job-project-merge.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/one-job-project/git/common-config/playbooks/one-job-project-post.yaml b/tests/fixtures/config/one-job-project/git/common-config/playbooks/one-job-project-post.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/one-job-project/git/common-config/playbooks/one-job-project-post.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/one-job-project/git/org_one-job-project/README b/tests/fixtures/config/one-job-project/git/org_one-job-project/README
deleted file mode 100644
index 9daeafb..0000000
--- a/tests/fixtures/config/one-job-project/git/org_one-job-project/README
+++ /dev/null
@@ -1 +0,0 @@
-test
diff --git a/tests/fixtures/config/one-job-project/main.yaml b/tests/fixtures/config/one-job-project/main.yaml
deleted file mode 100644
index a22ed5c..0000000
--- a/tests/fixtures/config/one-job-project/main.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-- tenant:
-    name: tenant-one
-    source:
-      gerrit:
-        config-repos:
-          - common-config
diff --git a/tests/fixtures/config/openstack/git/project-config/zuul.yaml b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
index 760adb8..5d0c774 100644
--- a/tests/fixtures/config/openstack/git/project-config/zuul.yaml
+++ b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
@@ -2,7 +2,6 @@
     name: check
     manager: independent
     success-message: Build succeeded (check).
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -17,7 +16,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/openstack/main.yaml b/tests/fixtures/config/openstack/main.yaml
index 95a0952..f794093 100644
--- a/tests/fixtures/config/openstack/main.yaml
+++ b/tests/fixtures/config/openstack/main.yaml
@@ -2,5 +2,8 @@
     name: openstack
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - project-config
+        untrusted-projects:
+          - openstack/nova
+          - openstack/keystone
\ No newline at end of file
diff --git a/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
index 78d2a18..1a5baed 100644
--- a/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: pipeline
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -18,7 +17,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/email/main.yaml b/tests/fixtures/config/requirements/email/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/email/main.yaml
+++ b/tests/fixtures/config/requirements/email/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml
index 1e84e18..fa230de 100644
--- a/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: pipeline
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -19,7 +18,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/newer-than/main.yaml b/tests/fixtures/config/requirements/newer-than/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/newer-than/main.yaml
+++ b/tests/fixtures/config/requirements/newer-than/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml
index efbd79a..14541b6 100644
--- a/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: pipeline
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -19,7 +18,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/older-than/main.yaml b/tests/fixtures/config/requirements/older-than/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/older-than/main.yaml
+++ b/tests/fixtures/config/requirements/older-than/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml
index 7212944..61f3819 100644
--- a/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: pipeline
     manager: independent
-    source: gerrit
     reject:
       approval:
         - username: jenkins
@@ -18,7 +17,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/reject-username/main.yaml b/tests/fixtures/config/requirements/reject-username/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/reject-username/main.yaml
+++ b/tests/fixtures/config/requirements/reject-username/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
index 9f5b125..32a7582 100644
--- a/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: pipeline
     manager: independent
-    source: gerrit
     require:
       approval:
         - username: jenkins
@@ -26,7 +25,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/reject/main.yaml b/tests/fixtures/config/requirements/reject/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/reject/main.yaml
+++ b/tests/fixtures/config/requirements/reject/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
index 01ceb46..ffc3453 100644
--- a/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: current-check
     manager: independent
-    source: gerrit
     require:
       current-patchset: true
     trigger:
@@ -18,7 +17,6 @@
 - pipeline:
     name: open-check
     manager: independent
-    source: gerrit
     require:
       open: true
     trigger:
@@ -35,7 +33,6 @@
 - pipeline:
     name: status-check
     manager: independent
-    source: gerrit
     require:
       status: NEW
     trigger:
diff --git a/tests/fixtures/config/requirements/state/main.yaml b/tests/fixtures/config/requirements/state/main.yaml
index a22ed5c..99756fb 100644
--- a/tests/fixtures/config/requirements/state/main.yaml
+++ b/tests/fixtures/config/requirements/state/main.yaml
@@ -2,5 +2,9 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - current-project
+          - open-project
+          - status-project
diff --git a/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
index 9789e71..bc1083a 100644
--- a/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: pipeline
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -18,7 +17,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/username/main.yaml b/tests/fixtures/config/requirements/username/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/username/main.yaml
+++ b/tests/fixtures/config/requirements/username/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
index 7989363..7d9164d 100644
--- a/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
@@ -5,7 +5,6 @@
       approval:
         - username: jenkins
           verified: 1
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -19,7 +18,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/vote1/main.yaml b/tests/fixtures/config/requirements/vote1/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/vote1/main.yaml
+++ b/tests/fixtures/config/requirements/vote1/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
index 9348afb..7308c8a 100644
--- a/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
@@ -7,7 +7,6 @@
           verified:
             - 1
             - 2
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -21,7 +20,6 @@
 - pipeline:
     name: trigger
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/requirements/vote2/main.yaml b/tests/fixtures/config/requirements/vote2/main.yaml
index a22ed5c..950b117 100644
--- a/tests/fixtures/config/requirements/vote2/main.yaml
+++ b/tests/fixtures/config/requirements/vote2/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-test1.yaml b/tests/fixtures/config/semaphore/git/common-config/playbooks/project-test1.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-test1.yaml
rename to tests/fixtures/config/semaphore/git/common-config/playbooks/project-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test1.yaml b/tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-one-test1.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test1.yaml
rename to tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-one-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test2.yaml b/tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-one-test2.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test2.yaml
rename to tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-one-test2.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test1.yaml b/tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-two-test1.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test1.yaml
rename to tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-two-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test2.yaml b/tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-two-test2.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test2.yaml
rename to tests/fixtures/config/semaphore/git/common-config/playbooks/semaphore-two-test2.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/zuul.yaml b/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
similarity index 85%
rename from tests/fixtures/config/single-tenant/git/layout-semaphore/zuul.yaml
rename to tests/fixtures/config/semaphore/git/common-config/zuul.yaml
index f935112..9d1cacf 100644
--- a/tests/fixtures/config/single-tenant/git/layout-semaphore/zuul.yaml
+++ b/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -12,6 +11,14 @@
       gerrit:
         verified: -1
 
+# TODOv3(jeblair, tobiash): make semaphore definitions required, which
+# will cause these tests to fail until we define test-semaphore
+# here.
+
+- semaphore:
+    name: test-semaphore-two
+    max: 2
+
 - job:
     name: project-test1
 
@@ -46,7 +53,3 @@
         - project-test1
         - semaphore-two-test1
         - semaphore-two-test2
-
-- semaphore:
-    name: test-semaphore-two
-    max: 2
diff --git a/tests/fixtures/config/single-tenant/git/org_noop-project/README b/tests/fixtures/config/semaphore/git/org_project/README
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/org_noop-project/README
rename to tests/fixtures/config/semaphore/git/org_project/README
diff --git a/tests/fixtures/config/single-tenant/git/org_unknown/README b/tests/fixtures/config/semaphore/git/org_project1/README
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/org_unknown/README
rename to tests/fixtures/config/semaphore/git/org_project1/README
diff --git a/tests/fixtures/config/semaphore/main.yaml b/tests/fixtures/config/semaphore/main.yaml
new file mode 100644
index 0000000..5f57245
--- /dev/null
+++ b/tests/fixtures/config/semaphore/main.yaml
@@ -0,0 +1,9 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
+          - org/project1
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml b/tests/fixtures/config/semaphore/zuul-reconfiguration.yaml
similarity index 93%
rename from tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml
rename to tests/fixtures/config/semaphore/zuul-reconfiguration.yaml
index 0e332e4..8fe8749 100644
--- a/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml
+++ b/tests/fixtures/config/semaphore/zuul-reconfiguration.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/single-tenant/git/common-config/playbooks/experimental-project-test.yaml b/tests/fixtures/config/single-tenant/git/common-config/playbooks/experimental-project-test.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/common-config/playbooks/experimental-project-test.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml b/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
index dff18de..34bd9cd 100644
--- a/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -37,24 +35,11 @@
 - pipeline:
     name: post
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: ref-updated
           ref: ^(?!refs/).*$
 
-- pipeline:
-    name: experimental
-    manager: independent
-    source: gerrit
-    trigger:
-      gerrit:
-        - event: patchset-created
-    success:
-      gerrit: {}
-    failure:
-      gerrit: {}
-
 - job:
     name: project-merge
     hold-following-changes: true
@@ -87,20 +72,6 @@
     queue-name: integration
 
 - job:
-    name: experimental-project-test
-
-- job:
-    name: nonvoting-project-merge
-    hold-following-changes: true
-
-- job:
-    name: nonvoting-project-test1
-
-- job:
-    name: nonvoting-project-test2
-    voting: false
-
-- job:
     name: project-testfile
     files:
       - .*-requires
@@ -170,68 +141,3 @@
             dependencies: project-merge
         - project1-project2-integration:
             dependencies: project-merge
-
-- project:
-    name: org/project3
-    check:
-      jobs:
-        - project-merge
-        - project-test1:
-            dependencies: project-merge
-        - project-test2:
-            dependencies: project-merge
-        - project1-project2-integration:
-            dependencies: project-merge
-    gate:
-      queue: integrated
-      jobs:
-        - project-merge
-        - project-test1:
-            dependencies: project-merge
-        - project-test2:
-            dependencies: project-merge
-        - project1-project2-integration:
-            dependencies: project-merge
-    post:
-      jobs:
-        - project-post
-
-- project:
-    name: org/experimental-project
-    experimental:
-      jobs:
-        - project-merge
-        - experimental-project-test:
-            dependencies: project-merge
-
-- project:
-    name: org/noop-project
-    check:
-      jobs:
-        - noop
-    gate:
-      jobs:
-        - noop
-
-- project:
-    name: org/nonvoting-project
-    check:
-      jobs:
-        - nonvoting-project-merge
-        - nonvoting-project-test1:
-            dependencies: nonvoting-project-merge
-        - nonvoting-project-test2:
-            dependencies: nonvoting-project-merge
-    gate:
-      jobs:
-        - nonvoting-project-merge
-        - nonvoting-project-test1:
-            dependencies: nonvoting-project-merge
-        - nonvoting-project-test2:
-            dependencies: nonvoting-project-merge
-
-- project:
-    name: org/no-jobs-project
-    check:
-      jobs:
-        - project-testfile
diff --git a/tests/fixtures/config/single-tenant/git/layout-disabled-at/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-disabled-at/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-disabled-at/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-dont-ignore-ref-deletes/playbooks/project-post.yaml b/tests/fixtures/config/single-tenant/git/layout-dont-ignore-ref-deletes/playbooks/project-post.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-dont-ignore-ref-deletes/playbooks/project-post.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-footer-message/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-footer-message/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-footer-message/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-bitrot-stable-old.yaml b/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-bitrot-stable-old.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-bitrot-stable-old.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-bitrot-stable-older.yaml b/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-bitrot-stable-older.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-idle/playbooks/project-bitrot-stable-older.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-irrelevant-starts-empty.yaml b/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-irrelevant-starts-empty.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-irrelevant-starts-empty.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-irrelevant-starts-full.yaml b/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-irrelevant-starts-full.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-irrelevant-starts-full.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-nomatch-starts-empty.yaml b/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-nomatch-starts-empty.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-nomatch-starts-empty.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-nomatch-starts-full.yaml b/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-nomatch-starts-full.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-inheritance/playbooks/project-test-nomatch-starts-full.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-irrelevant-files/playbooks/project-test-irrelevant-files.yaml b/tests/fixtures/config/single-tenant/git/layout-irrelevant-files/playbooks/project-test-irrelevant-files.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-irrelevant-files/playbooks/project-test-irrelevant-files.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-merge.yaml b/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-merge.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-merge.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-test2.yaml b/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-test2.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-test2.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-testfile.yaml b/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-testfile.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/playbooks/project-testfile.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-no-jobs/playbooks/gate-noop.yaml b/tests/fixtures/config/single-tenant/git/layout-no-jobs/playbooks/gate-noop.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-no-jobs/playbooks/gate-noop.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-bitrot-stable-old.yaml b/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-bitrot-stable-old.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-bitrot-stable-old.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-bitrot-stable-older.yaml b/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-bitrot-stable-older.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-bitrot-stable-older.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-no-timer/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-merge.yaml b/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-merge.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-merge.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-test2.yaml b/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-test2.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-rate-limit/playbooks/project-test2.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-merge.yaml b/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-merge.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-merge.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-test2.yaml b/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-test2.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/playbooks/project-test2.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/experimental-project-test.yaml b/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/experimental-project-test.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/experimental-project-test.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-merge.yaml b/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-merge.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-merge.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-test2.yaml b/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-test2.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-smtp/playbooks/project-test2.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/integration.yaml b/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/integration.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/integration.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/merge.yaml b/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/merge.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/merge.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/test1.yaml b/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/test2.yaml b/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/test2.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-tags/playbooks/test2.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-tags/zuul.yaml b/tests/fixtures/config/single-tenant/git/layout-tags/zuul.yaml
deleted file mode 100644
index 07f0657..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-tags/zuul.yaml
+++ /dev/null
@@ -1,55 +0,0 @@
-- pipeline:
-    name: check
-    manager: independent
-    source: gerrit
-    trigger:
-      gerrit:
-        - event: patchset-created
-    success:
-      gerrit:
-        verified: 1
-    failure:
-      gerrit:
-        verified: -1
-
-- job:
-    name: merge
-    failure-message: Unable to merge change
-    hold-following-changes: true
-    tags:
-      - merge
-
-- job:
-    name: test1
-
-- job:
-    name: test2
-
-- job:
-    name: integration
-
-- project:
-    name: org/project1
-    check:
-      jobs:
-        - merge:
-            tags:
-              - extratag
-        - test1:
-            dependencies: merge
-        - test2:
-            dependencies: merge
-        - integration:
-            dependencies: merge
-
-- project:
-    name: org/project2
-    check:
-      jobs:
-        - merge
-        - test1:
-            dependencies: merge
-        - test2:
-            dependencies: merge
-        - integration:
-            dependencies: merge
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer-smtp/playbooks/project-bitrot-stable-old.yaml b/tests/fixtures/config/single-tenant/git/layout-timer-smtp/playbooks/project-bitrot-stable-old.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-timer-smtp/playbooks/project-bitrot-stable-old.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer-smtp/playbooks/project-bitrot-stable-older.yaml b/tests/fixtures/config/single-tenant/git/layout-timer-smtp/playbooks/project-bitrot-stable-older.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-timer-smtp/playbooks/project-bitrot-stable-older.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-bitrot-stable-old.yaml b/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-bitrot-stable-old.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-bitrot-stable-old.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-bitrot-stable-older.yaml b/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-bitrot-stable-older.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-bitrot-stable-older.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-test1.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-test1.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-test2.yaml b/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-test2.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-timer/playbooks/project-test2.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/org_delete-project/README b/tests/fixtures/config/single-tenant/git/org_delete-project/README
deleted file mode 100644
index 9daeafb..0000000
--- a/tests/fixtures/config/single-tenant/git/org_delete-project/README
+++ /dev/null
@@ -1 +0,0 @@
-test
diff --git a/tests/fixtures/config/single-tenant/git/org_experimental-project/README b/tests/fixtures/config/single-tenant/git/org_experimental-project/README
deleted file mode 100644
index 9daeafb..0000000
--- a/tests/fixtures/config/single-tenant/git/org_experimental-project/README
+++ /dev/null
@@ -1 +0,0 @@
-test
diff --git a/tests/fixtures/config/single-tenant/git/org_no-jobs-project/README b/tests/fixtures/config/single-tenant/git/org_no-jobs-project/README
deleted file mode 100644
index 44f3bac..0000000
--- a/tests/fixtures/config/single-tenant/git/org_no-jobs-project/README
+++ /dev/null
@@ -1 +0,0 @@
-staypuft
diff --git a/tests/fixtures/config/single-tenant/git/org_nonvoting-project/README b/tests/fixtures/config/single-tenant/git/org_nonvoting-project/README
deleted file mode 100644
index 2cc3865..0000000
--- a/tests/fixtures/config/single-tenant/git/org_nonvoting-project/README
+++ /dev/null
@@ -1 +0,0 @@
-dont tread on me
diff --git a/tests/fixtures/config/single-tenant/git/org_project3/README b/tests/fixtures/config/single-tenant/git/org_project3/README
deleted file mode 100644
index 234496b..0000000
--- a/tests/fixtures/config/single-tenant/git/org_project3/README
+++ /dev/null
@@ -1 +0,0 @@
-third
diff --git a/tests/fixtures/config/single-tenant/main.yaml b/tests/fixtures/config/single-tenant/main.yaml
index d9868fa..83ed092 100644
--- a/tests/fixtures/config/single-tenant/main.yaml
+++ b/tests/fixtures/config/single-tenant/main.yaml
@@ -2,7 +2,9 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
-        project-repos:
+        untrusted-projects:
           - org/project
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml b/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
index 36c7602..dd80d08 100644
--- a/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/sql-driver/main.yaml b/tests/fixtures/config/sql-driver/main.yaml
index d9868fa..208e274 100644
--- a/tests/fixtures/config/sql-driver/main.yaml
+++ b/tests/fixtures/config/sql-driver/main.yaml
@@ -2,7 +2,7 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
-        project-repos:
+        untrusted-projects:
           - org/project
diff --git a/tests/fixtures/config/success-url/git/common-config/zuul.yaml b/tests/fixtures/config/success-url/git/common-config/zuul.yaml
index b3ecf6d..7082b8c 100644
--- a/tests/fixtures/config/success-url/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/success-url/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/success-url/main.yaml b/tests/fixtures/config/success-url/main.yaml
index a22ed5c..0027ae1 100644
--- a/tests/fixtures/config/success-url/main.yaml
+++ b/tests/fixtures/config/success-url/main.yaml
@@ -2,5 +2,7 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/docs
diff --git a/tests/fixtures/config/templated-project/git/common-config/zuul.yaml b/tests/fixtures/config/templated-project/git/common-config/zuul.yaml
index 8d2c8a0..251a3cd 100644
--- a/tests/fixtures/config/templated-project/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/templated-project/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -37,7 +35,6 @@
 - pipeline:
     name: post
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: ref-updated
diff --git a/tests/fixtures/config/templated-project/main.yaml b/tests/fixtures/config/templated-project/main.yaml
index a22ed5c..e59b396 100644
--- a/tests/fixtures/config/templated-project/main.yaml
+++ b/tests/fixtures/config/templated-project/main.yaml
@@ -2,5 +2,8 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/templated-project
+          - org/layered-project
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 302dfcf..961ff06 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
@@ -1,7 +1,6 @@
 - pipeline:
     name: review_check
     manager: independent
-    source: review_gerrit
     trigger:
       review_gerrit:
         - event: patchset-created
@@ -15,7 +14,6 @@
 - pipeline:
     name: another_check
     manager: independent
-    source: another_gerrit
     trigger:
       another_gerrit:
         - event: patchset-created
@@ -33,10 +31,13 @@
     name: project-test2
 
 - project:
-    name: org/project1
+    name: review.example.com/org/project1
     review_check:
       jobs:
         - project-test1
+
+- project:
+    name: another.example.com/org/project1
     another_check:
       jobs:
         - project-test2
diff --git a/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml b/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml
index 730cc7e..f5bff21 100644
--- a/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml
+++ b/tests/fixtures/config/zuul-connections-multiple-gerrits/main.yaml
@@ -2,5 +2,10 @@
     name: tenant-one
     source:
       review_gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project1
+      another_gerrit:
+        untrusted-projects:
+          - org/project1
diff --git a/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml b/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml
index 114a4a3..adc61a3 100644
--- a/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: review_gerrit
     trigger:
       review_gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/zuul-connections-same-gerrit/main.yaml b/tests/fixtures/config/zuul-connections-same-gerrit/main.yaml
index 90297fb..9b2fc83 100644
--- a/tests/fixtures/config/zuul-connections-same-gerrit/main.yaml
+++ b/tests/fixtures/config/zuul-connections-same-gerrit/main.yaml
@@ -2,7 +2,7 @@
     name: tenant-one
     source:
       review_gerrit:
-        config-repos:
+        config-projects:
           - common-config
-        project-repos:
+        untrusted-projects:
           - org/project
diff --git a/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml b/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml
index 8d63576..2b21c9b 100644
--- a/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     require:
       approval:
         - verified: -1
@@ -21,7 +20,6 @@
 - pipeline:
     name: gate
     manager: dependent
-    source: gerrit
     require:
       approval:
         - verified: 1
diff --git a/tests/fixtures/config/zuultrigger/parent-change-enqueued/main.yaml b/tests/fixtures/config/zuultrigger/parent-change-enqueued/main.yaml
index a22ed5c..208e274 100644
--- a/tests/fixtures/config/zuultrigger/parent-change-enqueued/main.yaml
+++ b/tests/fixtures/config/zuultrigger/parent-change-enqueued/main.yaml
@@ -2,5 +2,7 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml b/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml
index eb6bf1c..48fdffe 100644
--- a/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: gate
     manager: dependent
     failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -37,7 +35,6 @@
 - pipeline:
     name: merge-check
     manager: independent
-    source: gerrit
     ignore-dependencies: true
     trigger:
       zuul:
diff --git a/tests/fixtures/config/zuultrigger/project-change-merged/main.yaml b/tests/fixtures/config/zuultrigger/project-change-merged/main.yaml
index a22ed5c..9d01f54 100644
--- a/tests/fixtures/config/zuultrigger/project-change-merged/main.yaml
+++ b/tests/fixtures/config/zuultrigger/project-change-merged/main.yaml
@@ -2,5 +2,5 @@
     name: tenant-one
     source:
       gerrit:
-        config-repos:
+        config-projects:
           - common-config
diff --git a/tests/fixtures/config/single-tenant/git/layout-disabled-at/zuul.yaml b/tests/fixtures/layouts/disable_at.yaml
similarity index 95%
rename from tests/fixtures/config/single-tenant/git/layout-disabled-at/zuul.yaml
rename to tests/fixtures/layouts/disable_at.yaml
index bdc19ac..2956ebf 100644
--- a/tests/fixtures/config/single-tenant/git/layout-disabled-at/zuul.yaml
+++ b/tests/fixtures/layouts/disable_at.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/single-tenant/git/layout-dont-ignore-ref-deletes/zuul.yaml b/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
similarity index 93%
rename from tests/fixtures/config/single-tenant/git/layout-dont-ignore-ref-deletes/zuul.yaml
rename to tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
index 334d9ac..aee5ac6 100644
--- a/tests/fixtures/config/single-tenant/git/layout-dont-ignore-ref-deletes/zuul.yaml
+++ b/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: post
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: ref-updated
diff --git a/tests/fixtures/config/single-tenant/git/layout-footer-message/zuul.yaml b/tests/fixtures/layouts/footer-message.yaml
similarity index 97%
rename from tests/fixtures/config/single-tenant/git/layout-footer-message/zuul.yaml
rename to tests/fixtures/layouts/footer-message.yaml
index c698378..1261902 100644
--- a/tests/fixtures/config/single-tenant/git/layout-footer-message/zuul.yaml
+++ b/tests/fixtures/layouts/footer-message.yaml
@@ -2,7 +2,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
     footer-message: For CI problems and help debugging, contact ci@example.org
     trigger:
@@ -29,6 +28,7 @@
 - job:
     name: project-test1
 #    success-url: http://logs.exxxample.com/{change.number}/{change.patchset}/{pipeline.name}/{job.name}
+
 - project:
     name: org/project
     gate:
diff --git a/tests/fixtures/config/single-tenant/git/layout-idle/zuul.yaml b/tests/fixtures/layouts/idle.yaml
similarity index 95%
rename from tests/fixtures/config/single-tenant/git/layout-idle/zuul.yaml
rename to tests/fixtures/layouts/idle.yaml
index d1fa04b..ff33842 100644
--- a/tests/fixtures/config/single-tenant/git/layout-idle/zuul.yaml
+++ b/tests/fixtures/layouts/idle.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: periodic
     manager: independent
-    source: gerrit
     trigger:
       timer:
         - time: '* * * * * */1'
diff --git a/tests/fixtures/config/single-tenant/git/layout-ignore-dependencies/zuul.yaml b/tests/fixtures/layouts/ignore-dependencies.yaml
similarity index 97%
rename from tests/fixtures/config/single-tenant/git/layout-ignore-dependencies/zuul.yaml
rename to tests/fixtures/layouts/ignore-dependencies.yaml
index 4010372..02aea36 100644
--- a/tests/fixtures/config/single-tenant/git/layout-ignore-dependencies/zuul.yaml
+++ b/tests/fixtures/layouts/ignore-dependencies.yaml
@@ -2,7 +2,6 @@
     name: check
     manager: independent
     ignore-dependencies: true
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/single-tenant/git/layout-inheritance/zuul.yaml b/tests/fixtures/layouts/inheritance.yaml
similarity index 97%
rename from tests/fixtures/config/single-tenant/git/layout-inheritance/zuul.yaml
rename to tests/fixtures/layouts/inheritance.yaml
index ab8c9a5..65dddab 100644
--- a/tests/fixtures/config/single-tenant/git/layout-inheritance/zuul.yaml
+++ b/tests/fixtures/layouts/inheritance.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/single-tenant/git/layout-irrelevant-files/zuul.yaml b/tests/fixtures/layouts/irrelevant-files.yaml
similarity index 95%
rename from tests/fixtures/config/single-tenant/git/layout-irrelevant-files/zuul.yaml
rename to tests/fixtures/layouts/irrelevant-files.yaml
index 5d72fc0..3d086dc 100644
--- a/tests/fixtures/config/single-tenant/git/layout-irrelevant-files/zuul.yaml
+++ b/tests/fixtures/layouts/irrelevant-files.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/zuul.yaml b/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
similarity index 96%
rename from tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/zuul.yaml
rename to tests/fixtures/layouts/live-reconfiguration-del-project.yaml
index a6d6599..299c612 100644
--- a/tests/fixtures/config/single-tenant/git/layout-live-reconfiguration-del-project/zuul.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml b/tests/fixtures/layouts/no-jobs-project.yaml
similarity index 68%
copy from tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml
copy to tests/fixtures/layouts/no-jobs-project.yaml
index 0e332e4..803e5a0 100644
--- a/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml
+++ b/tests/fixtures/layouts/no-jobs-project.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -13,10 +12,12 @@
         verified: -1
 
 - job:
-    name: project-test1
+    name: project-testfile
+    files:
+      - .*-requires
 
 - project:
-    name: org/project
+    name: org/no-jobs-project
     check:
       jobs:
-        - project-test1
+        - project-testfile
diff --git a/tests/fixtures/config/single-tenant/git/layout-no-jobs/zuul.yaml b/tests/fixtures/layouts/no-jobs.yaml
similarity index 93%
rename from tests/fixtures/config/single-tenant/git/layout-no-jobs/zuul.yaml
rename to tests/fixtures/layouts/no-jobs.yaml
index 5894440..66193b0 100644
--- a/tests/fixtures/config/single-tenant/git/layout-no-jobs/zuul.yaml
+++ b/tests/fixtures/layouts/no-jobs.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,8 +15,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source:
-      gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/single-tenant/git/layout-no-timer/zuul.yaml b/tests/fixtures/layouts/no-timer.yaml
similarity index 95%
rename from tests/fixtures/config/single-tenant/git/layout-no-timer/zuul.yaml
rename to tests/fixtures/layouts/no-timer.yaml
index ab919a4..c8ced62 100644
--- a/tests/fixtures/config/single-tenant/git/layout-no-timer/zuul.yaml
+++ b/tests/fixtures/layouts/no-timer.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -17,7 +16,6 @@
     manager: independent
     # Trigger is required, set it to one that is a noop
     # during tests that check the timer trigger.
-    source: gerrit
     trigger:
       gerrit:
         - event: ref-updated
diff --git a/tests/fixtures/layouts/nonvoting-job.yaml b/tests/fixtures/layouts/nonvoting-job.yaml
new file mode 100644
index 0000000..fee5043
--- /dev/null
+++ b/tests/fixtures/layouts/nonvoting-job.yaml
@@ -0,0 +1,41 @@
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+- job:
+    name: nonvoting-project-merge
+    hold-following-changes: true
+
+- job:
+    name: nonvoting-project-test1
+
+- job:
+    name: nonvoting-project-test2
+    voting: false
+
+- project:
+    name: org/nonvoting-project
+    gate:
+      jobs:
+        - nonvoting-project-merge
+        - nonvoting-project-test1:
+            dependencies: nonvoting-project-merge
+        - nonvoting-project-test2:
+            dependencies: nonvoting-project-merge
diff --git a/tests/fixtures/layouts/nonvoting-pipeline.yaml b/tests/fixtures/layouts/nonvoting-pipeline.yaml
new file mode 100644
index 0000000..be5d5af
--- /dev/null
+++ b/tests/fixtures/layouts/nonvoting-pipeline.yaml
@@ -0,0 +1,25 @@
+- pipeline:
+    name: experimental
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit: {}
+    failure:
+      gerrit: {}
+
+- job:
+    name: project-merge
+    hold-following-changes: true
+
+- job:
+    name: experimental-project-test
+
+- project:
+    name: org/experimental-project
+    experimental:
+      jobs:
+        - project-merge
+        - experimental-project-test:
+            dependencies: project-merge
diff --git a/tests/fixtures/layouts/noop-job.yaml b/tests/fixtures/layouts/noop-job.yaml
new file mode 100644
index 0000000..8081216
--- /dev/null
+++ b/tests/fixtures/layouts/noop-job.yaml
@@ -0,0 +1,26 @@
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+- project:
+    name: org/noop-project
+    gate:
+      jobs:
+        - noop
diff --git a/tests/fixtures/config/one-job-project/git/common-config/zuul.yaml b/tests/fixtures/layouts/one-job-project.yaml
similarity index 94%
rename from tests/fixtures/config/one-job-project/git/common-config/zuul.yaml
rename to tests/fixtures/layouts/one-job-project.yaml
index 4579062..b293269 100644
--- a/tests/fixtures/config/one-job-project/git/common-config/zuul.yaml
+++ b/tests/fixtures/layouts/one-job-project.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -37,7 +35,6 @@
 - pipeline:
     name: post
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: ref-updated
diff --git a/tests/fixtures/config/single-tenant/git/layout-rate-limit/zuul.yaml b/tests/fixtures/layouts/rate-limit.yaml
similarity index 97%
rename from tests/fixtures/config/single-tenant/git/layout-rate-limit/zuul.yaml
rename to tests/fixtures/layouts/rate-limit.yaml
index c4e00f6..283354e 100644
--- a/tests/fixtures/config/single-tenant/git/layout-rate-limit/zuul.yaml
+++ b/tests/fixtures/layouts/rate-limit.yaml
@@ -2,7 +2,6 @@
     name: gate
     manager: dependent
     failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/zuul.yaml b/tests/fixtures/layouts/repo-deleted.yaml
similarity index 96%
rename from tests/fixtures/config/single-tenant/git/layout-repo-deleted/zuul.yaml
rename to tests/fixtures/layouts/repo-deleted.yaml
index 5851d75..a33da77 100644
--- a/tests/fixtures/config/single-tenant/git/layout-repo-deleted/zuul.yaml
+++ b/tests/fixtures/layouts/repo-deleted.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -16,7 +15,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
diff --git a/tests/fixtures/config/single-tenant/git/layout-smtp/zuul.yaml b/tests/fixtures/layouts/smtp.yaml
similarity index 93%
rename from tests/fixtures/config/single-tenant/git/layout-smtp/zuul.yaml
rename to tests/fixtures/layouts/smtp.yaml
index be90d48..8f53d02 100644
--- a/tests/fixtures/config/single-tenant/git/layout-smtp/zuul.yaml
+++ b/tests/fixtures/layouts/smtp.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -22,7 +21,6 @@
     name: gate
     manager: dependent
     success-message: Build succeeded (gate).
-    source: gerrit
     trigger:
       gerrit:
         - event: comment-added
@@ -60,9 +58,6 @@
 - job:
     name: project-test2
 
-- job:
-    name: experimental-project-test
-
 - project:
     name: org/project
     check:
diff --git a/tests/fixtures/layouts/tags.yaml b/tests/fixtures/layouts/tags.yaml
new file mode 100644
index 0000000..422eca2
--- /dev/null
+++ b/tests/fixtures/layouts/tags.yaml
@@ -0,0 +1,31 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- job:
+    name: merge
+    tags:
+      - merge
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - merge:
+            tags:
+              - extratag
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - merge
diff --git a/tests/fixtures/layouts/three-projects.yaml b/tests/fixtures/layouts/three-projects.yaml
new file mode 100644
index 0000000..5d10276
--- /dev/null
+++ b/tests/fixtures/layouts/three-projects.yaml
@@ -0,0 +1,112 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+- job:
+    name: project-merge
+    hold-following-changes: true
+
+- job:
+    name: project-test1
+
+- job:
+    name: project-test2
+
+- job:
+    name: project1-project2-integration
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - project-merge
+        - project-test1:
+            dependencies: project-merge
+        - project-test2:
+            dependencies: project-merge
+        - project1-project2-integration:
+            dependencies: project-merge
+    gate:
+      queue: integrated
+      jobs:
+        - project-merge
+        - project-test1:
+            dependencies: project-merge
+        - project-test2:
+            dependencies: project-merge
+        - project1-project2-integration:
+            dependencies: project-merge
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - project-merge
+        - project-test1:
+            dependencies: project-merge
+        - project-test2:
+            dependencies: project-merge
+        - project1-project2-integration:
+            dependencies: project-merge
+    gate:
+      queue: integrated
+      jobs:
+        - project-merge
+        - project-test1:
+            dependencies: project-merge
+        - project-test2:
+            dependencies: project-merge
+        - project1-project2-integration:
+            dependencies: project-merge
+
+- project:
+    name: org/project3
+    check:
+      jobs:
+        - project-merge
+        - project-test1:
+            dependencies: project-merge
+        - project-test2:
+            dependencies: project-merge
+        - project1-project2-integration:
+            dependencies: project-merge
+    gate:
+      queue: integrated
+      jobs:
+        - project-merge
+        - project-test1:
+            dependencies: project-merge
+        - project-test2:
+            dependencies: project-merge
+        - project1-project2-integration:
+            dependencies: project-merge
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer-smtp/zuul.yaml b/tests/fixtures/layouts/timer-smtp.yaml
similarity index 96%
rename from tests/fixtures/config/single-tenant/git/layout-timer-smtp/zuul.yaml
rename to tests/fixtures/layouts/timer-smtp.yaml
index 2a2eca5..66e9aaf 100644
--- a/tests/fixtures/config/single-tenant/git/layout-timer-smtp/zuul.yaml
+++ b/tests/fixtures/layouts/timer-smtp.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: periodic
     manager: independent
-    source: gerrit
     trigger:
       timer:
         - time: '* * * * * */1'
diff --git a/tests/fixtures/config/single-tenant/git/layout-timer/zuul.yaml b/tests/fixtures/layouts/timer.yaml
similarity index 94%
rename from tests/fixtures/config/single-tenant/git/layout-timer/zuul.yaml
rename to tests/fixtures/layouts/timer.yaml
index 8072644..95199e7 100644
--- a/tests/fixtures/config/single-tenant/git/layout-timer/zuul.yaml
+++ b/tests/fixtures/layouts/timer.yaml
@@ -1,7 +1,6 @@
 - pipeline:
     name: check
     manager: independent
-    source: gerrit
     trigger:
       gerrit:
         - event: patchset-created
@@ -15,7 +14,6 @@
 - pipeline:
     name: periodic
     manager: independent
-    source: gerrit
     trigger:
       timer:
         - time: '* * * * * */1'
diff --git a/tests/nodepool/test_nodepool_integration.py b/tests/nodepool/test_nodepool_integration.py
index 2c9a9b3..9c87a10 100644
--- a/tests/nodepool/test_nodepool_integration.py
+++ b/tests/nodepool/test_nodepool_integration.py
@@ -28,9 +28,10 @@
     # fake scheduler.
 
     def setUp(self):
-        super(BaseTestCase, self).setUp()
+        super(TestNodepoolIntegration, self).setUp()
 
         self.zk = zuul.zk.ZooKeeper()
+        self.addCleanup(self.zk.disconnect)
         self.zk.connect('localhost:2181')
         self.hostname = socket.gethostname()
 
diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py
index ee9a0b0..db32938 100644
--- a/tests/unit/test_connection.py
+++ b/tests/unit/test_connection.py
@@ -147,9 +147,6 @@
 
     def test_multiple_sql_connections(self):
         "Test putting results in different databases"
-        self.updateConfigLayout(
-            'tests/fixtures/layout-sql-reporter.yaml')
-
         # Add a successful result
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
diff --git a/tests/unit/test_gerrit.py b/tests/unit/test_gerrit.py
index 999e55d..a369aff 100644
--- a/tests/unit/test_gerrit.py
+++ b/tests/unit/test_gerrit.py
@@ -22,6 +22,7 @@
 
 import tests.base
 from tests.base import BaseTestCase
+from zuul.driver.gerrit import GerritDriver
 from zuul.driver.gerrit.gerritconnection import GerritConnection
 
 FIXTURE_DIR = os.path.join(tests.base.FIXTURE_DIR, 'gerrit')
@@ -53,7 +54,8 @@
             'user': 'gerrit',
             'server': 'localhost',
         }
-        gerrit = GerritConnection(None, 'review_gerrit', gerrit_config)
+        driver = GerritDriver()
+        gerrit = GerritConnection(driver, 'review_gerrit', gerrit_config)
 
         calls, values = read_fixtures(files)
         _ssh_mock.side_effect = values
diff --git a/tests/unit/test_git_driver.py b/tests/unit/test_git_driver.py
index 4d75944..1cfadf4 100644
--- a/tests/unit/test_git_driver.py
+++ b/tests/unit/test_git_driver.py
@@ -27,10 +27,10 @@
         tenant = self.sched.abide.tenants.get('tenant-one')
         # Check that we have the git source for common-config and the
         # gerrit source for the project.
-        self.assertEqual('git', tenant.config_repos[0][0].name)
-        self.assertEqual('common-config', tenant.config_repos[0][1].name)
-        self.assertEqual('gerrit', tenant.project_repos[0][0].name)
-        self.assertEqual('org/project', tenant.project_repos[0][1].name)
+        self.assertEqual('git', tenant.config_projects[0].source.name)
+        self.assertEqual('common-config', tenant.config_projects[0].name)
+        self.assertEqual('gerrit', tenant.untrusted_projects[0].source.name)
+        self.assertEqual('org/project', tenant.untrusted_projects[0].name)
 
         # The configuration for this test is accessed via the git
         # driver (in common-config), rather than the gerrit driver, so
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index 2167a3b..d8480ea 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -27,20 +27,22 @@
 from tests.base import BaseTestCase, FIXTURE_DIR
 
 
-class FakeSource(object):
-    def __init__(self, name):
-        self.name = name
+class Dummy(object):
+    def __init__(self, **kw):
+        for k, v in kw.items():
+            setattr(self, k, v)
 
 
 class TestJob(BaseTestCase):
-
     def setUp(self):
         super(TestJob, self).setUp()
+        self.connection = Dummy(connection_name='dummy_connection')
+        self.source = Dummy(canonical_hostname='git.example.com',
+                            connection=self.connection)
         self.tenant = model.Tenant('tenant')
         self.layout = model.Layout()
-        self.project = model.Project('project', 'connection')
-        self.source = FakeSource('connection')
-        self.tenant.addProjectRepo(self.source, self.project)
+        self.project = model.Project('project', self.source)
+        self.tenant.addUntrustedProject(self.project)
         self.pipeline = model.Pipeline('gate', self.layout)
         self.layout.addPipeline(self.pipeline)
         self.queue = model.ChangeQueue(self.pipeline)
@@ -162,7 +164,8 @@
         pipeline = model.Pipeline('gate', layout)
         layout.addPipeline(pipeline)
         queue = model.ChangeQueue(pipeline)
-        project = model.Project('project', None)
+        project = model.Project('project', self.source)
+        tenant.addUntrustedProject(project)
 
         base = configloader.JobParser.fromYaml(tenant, layout, {
             '_source_context': self.context,
@@ -429,6 +432,7 @@
     def test_job_inheritance_job_tree(self):
         tenant = model.Tenant('tenant')
         layout = model.Layout()
+        tenant.addUntrustedProject(self.project)
 
         pipeline = model.Pipeline('gate', layout)
         layout.addPipeline(pipeline)
@@ -508,7 +512,8 @@
         pipeline = model.Pipeline('gate', layout)
         layout.addPipeline(pipeline)
         queue = model.ChangeQueue(pipeline)
-        project = model.Project('project', None)
+        project = model.Project('project', self.source)
+        tenant.addUntrustedProject(project)
 
         base = configloader.JobParser.fromYaml(tenant, layout, {
             '_source_context': self.context,
@@ -554,7 +559,7 @@
     def test_job_source_project(self):
         tenant = model.Tenant('tenant')
         layout = model.Layout()
-        base_project = model.Project('base_project', None)
+        base_project = model.Project('base_project', self.source)
         base_context = model.SourceContext(base_project, 'master',
                                            'test', True)
 
@@ -565,7 +570,7 @@
         })
         layout.addJob(base)
 
-        other_project = model.Project('other_project', None)
+        other_project = model.Project('other_project', self.source)
         other_context = model.SourceContext(other_project, 'master',
                                             'test', True)
         base2 = configloader.JobParser.fromYaml(tenant, layout, {
@@ -588,7 +593,8 @@
         })
         self.layout.addJob(job)
 
-        project2 = model.Project('project2', None)
+        project2 = model.Project('project2', self.source)
+        self.tenant.addUntrustedProject(project2)
         context2 = model.SourceContext(project2, 'master',
                                        'test', True)
 
@@ -778,3 +784,137 @@
         graph.addJob(jobs[3])
         jobs[6].dependencies = frozenset([jobs[2].name])
         graph.addJob(jobs[6])
+
+
+class TestTenant(BaseTestCase):
+    def test_add_project(self):
+        tenant = model.Tenant('tenant')
+        connection1 = Dummy(connection_name='dummy_connection1')
+        source1 = Dummy(canonical_hostname='git1.example.com',
+                        name='dummy',  # TODOv3(jeblair): remove
+                        connection=connection1)
+
+        source1_project1 = model.Project('project1', source1)
+        tenant.addConfigProject(source1_project1)
+        d = {'project1':
+             {'git1.example.com': source1_project1}}
+        self.assertEqual(d, tenant.projects)
+        self.assertEqual((True, source1_project1),
+                         tenant.getProject('project1'))
+        self.assertEqual((True, source1_project1),
+                         tenant.getProject('git1.example.com/project1'))
+
+        source1_project2 = model.Project('project2', source1)
+        tenant.addUntrustedProject(source1_project2)
+        d = {'project1':
+             {'git1.example.com': source1_project1},
+             'project2':
+             {'git1.example.com': source1_project2}}
+        self.assertEqual(d, tenant.projects)
+        self.assertEqual((False, source1_project2),
+                         tenant.getProject('project2'))
+        self.assertEqual((False, source1_project2),
+                         tenant.getProject('git1.example.com/project2'))
+
+        connection2 = Dummy(connection_name='dummy_connection2')
+        source2 = Dummy(canonical_hostname='git2.example.com',
+                        name='dummy',  # TODOv3(jeblair): remove
+                        connection=connection2)
+
+        source2_project1 = model.Project('project1', source2)
+        tenant.addUntrustedProject(source2_project1)
+        d = {'project1':
+             {'git1.example.com': source1_project1,
+              'git2.example.com': source2_project1},
+             'project2':
+             {'git1.example.com': source1_project2}}
+        self.assertEqual(d, tenant.projects)
+        with testtools.ExpectedException(
+                Exception,
+                "Project name 'project1' is ambiguous"):
+            tenant.getProject('project1')
+        self.assertEqual((False, source1_project2),
+                         tenant.getProject('project2'))
+        self.assertEqual((True, source1_project1),
+                         tenant.getProject('git1.example.com/project1'))
+        self.assertEqual((False, source2_project1),
+                         tenant.getProject('git2.example.com/project1'))
+
+        source2_project2 = model.Project('project2', source2)
+        tenant.addConfigProject(source2_project2)
+        d = {'project1':
+             {'git1.example.com': source1_project1,
+              'git2.example.com': source2_project1},
+             'project2':
+             {'git1.example.com': source1_project2,
+              'git2.example.com': source2_project2}}
+        self.assertEqual(d, tenant.projects)
+        with testtools.ExpectedException(
+                Exception,
+                "Project name 'project1' is ambiguous"):
+            tenant.getProject('project1')
+        with testtools.ExpectedException(
+                Exception,
+                "Project name 'project2' is ambiguous"):
+            tenant.getProject('project2')
+        self.assertEqual((True, source1_project1),
+                         tenant.getProject('git1.example.com/project1'))
+        self.assertEqual((False, source2_project1),
+                         tenant.getProject('git2.example.com/project1'))
+        self.assertEqual((False, source1_project2),
+                         tenant.getProject('git1.example.com/project2'))
+        self.assertEqual((True, source2_project2),
+                         tenant.getProject('git2.example.com/project2'))
+
+        source1_project2b = model.Project('subpath/project2', source1)
+        tenant.addConfigProject(source1_project2b)
+        d = {'project1':
+             {'git1.example.com': source1_project1,
+              'git2.example.com': source2_project1},
+             'project2':
+             {'git1.example.com': source1_project2,
+              'git2.example.com': source2_project2},
+             'subpath/project2':
+             {'git1.example.com': source1_project2b}}
+        self.assertEqual(d, tenant.projects)
+        self.assertEqual((False, source1_project2),
+                         tenant.getProject('git1.example.com/project2'))
+        self.assertEqual((True, source2_project2),
+                         tenant.getProject('git2.example.com/project2'))
+        self.assertEqual((True, source1_project2b),
+                         tenant.getProject('subpath/project2'))
+        self.assertEqual(
+            (True, source1_project2b),
+            tenant.getProject('git1.example.com/subpath/project2'))
+
+        source2_project2b = model.Project('subpath/project2', source2)
+        tenant.addConfigProject(source2_project2b)
+        d = {'project1':
+             {'git1.example.com': source1_project1,
+              'git2.example.com': source2_project1},
+             'project2':
+             {'git1.example.com': source1_project2,
+              'git2.example.com': source2_project2},
+             'subpath/project2':
+             {'git1.example.com': source1_project2b,
+              'git2.example.com': source2_project2b}}
+        self.assertEqual(d, tenant.projects)
+        self.assertEqual((False, source1_project2),
+                         tenant.getProject('git1.example.com/project2'))
+        self.assertEqual((True, source2_project2),
+                         tenant.getProject('git2.example.com/project2'))
+        with testtools.ExpectedException(
+                Exception,
+                "Project name 'subpath/project2' is ambiguous"):
+            tenant.getProject('subpath/project2')
+        self.assertEqual(
+            (True, source1_project2b),
+            tenant.getProject('git1.example.com/subpath/project2'))
+        self.assertEqual(
+            (True, source2_project2b),
+            tenant.getProject('git2.example.com/subpath/project2'))
+
+        with testtools.ExpectedException(
+                Exception,
+                "Project project1 is already in project index"):
+            tenant._addProject(source1_project1)
diff --git a/tests/unit/test_nodepool.py b/tests/unit/test_nodepool.py
index 0a55f9f..ba7523c 100644
--- a/tests/unit/test_nodepool.py
+++ b/tests/unit/test_nodepool.py
@@ -27,15 +27,17 @@
     # scheduler.
 
     def setUp(self):
-        super(BaseTestCase, self).setUp()
+        super(TestNodepool, self).setUp()
 
-        self.zk_chroot_fixture = self.useFixture(ChrootedKazooFixture())
+        self.zk_chroot_fixture = self.useFixture(
+            ChrootedKazooFixture(self.id()))
         self.zk_config = '%s:%s%s' % (
             self.zk_chroot_fixture.zookeeper_host,
             self.zk_chroot_fixture.zookeeper_port,
             self.zk_chroot_fixture.zookeeper_chroot)
 
         self.zk = zuul.zk.ZooKeeper()
+        self.addCleanup(self.zk.disconnect)
         self.zk.connect(self.zk_config)
         self.hostname = 'nodepool-test-hostname'
 
@@ -48,6 +50,7 @@
             self.zk_chroot_fixture.zookeeper_host,
             self.zk_chroot_fixture.zookeeper_port,
             self.zk_chroot_fixture.zookeeper_chroot)
+        self.addCleanup(self.fake_nodepool.stop)
 
     def waitForRequests(self):
         # Wait until all requests are complete.
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 43a8ddf..3c38045 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -14,6 +14,7 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import gc
 import json
 import textwrap
 
@@ -35,6 +36,7 @@
 from tests.base import (
     ZuulTestCase,
     repack_repo,
+    simple_layout,
 )
 
 
@@ -905,7 +907,8 @@
         # TODO: move to test_gerrit (this is a unit test!)
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         tenant = self.sched.abide.tenants.get('tenant-one')
-        source = tenant.layout.pipelines['gate'].source
+        (trusted, project) = tenant.getProject('org/project')
+        source = project.source
 
         # TODO(pabelanger): As we add more source / trigger APIs we should make
         # it easier for users to create events for testing.
@@ -1103,12 +1106,9 @@
         self.assertEqual(len(self.history), 0)
         self.assertNotIn('project-post', job_names)
 
+    @simple_layout('layouts/dont-ignore-ref-deletes.yaml')
     def test_post_ignore_deletes_negative(self):
         "Test that deleting refs does trigger post jobs"
-
-        self.updateConfigLayout('layout-dont-ignore-ref-deletes')
-        self.sched.reconfigure(self.config)
-
         e = {
             "type": "ref-updated",
             "submitter": {
@@ -1245,16 +1245,15 @@
         # aborted jobs.
 
         self.executor_server.hold_jobs_in_build = True
-        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
-        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
-        C = self.fake_gerrit.addFakeChange('org/project1', 'master', 'C')
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
         A.addApproval('code-review', 2)
         B.addApproval('code-review', 2)
         C.addApproval('code-review', 2)
 
         self.executor_server.failJob('project-test1', A)
         self.executor_server.failJob('project-test2', A)
-        self.executor_server.failJob('project1-project2-integration', A)
 
         self.fake_gerrit.addEvent(A.addApproval('approved', 1))
         self.fake_gerrit.addEvent(B.addApproval('approved', 1))
@@ -1273,29 +1272,26 @@
         self.executor_server.release('.*-merge')
         self.waitUntilSettled()
 
-        self.assertEqual(len(self.builds), 9)
+        self.assertEqual(len(self.builds), 6)
         self.assertEqual(self.builds[0].name, 'project-test1')
         self.assertEqual(self.builds[1].name, 'project-test2')
-        self.assertEqual(self.builds[2].name, 'project1-project2-integration')
-        self.assertEqual(self.builds[3].name, 'project-test1')
-        self.assertEqual(self.builds[4].name, 'project-test2')
-        self.assertEqual(self.builds[5].name, 'project1-project2-integration')
-        self.assertEqual(self.builds[6].name, 'project-test1')
-        self.assertEqual(self.builds[7].name, 'project-test2')
-        self.assertEqual(self.builds[8].name, 'project1-project2-integration')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'project-test2')
+        self.assertEqual(self.builds[4].name, 'project-test1')
+        self.assertEqual(self.builds[5].name, 'project-test2')
 
         self.release(self.builds[0])
         self.waitUntilSettled()
 
-        self.assertEqual(len(self.builds), 3)  # test2,integration, merge for B
-        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 6)
+        self.assertEqual(len(self.builds), 2)  # test2, merge for B
+        self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 4)
 
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
 
         self.assertEqual(len(self.builds), 0)
-        self.assertEqual(len(self.history), 20)
+        self.assertEqual(len(self.history), 15)
 
         self.assertEqual(A.data['status'], 'NEW')
         self.assertEqual(B.data['status'], 'MERGED')
@@ -1304,6 +1300,7 @@
         self.assertEqual(B.reported, 2)
         self.assertEqual(C.reported, 2)
 
+    @simple_layout('layouts/nonvoting-job.yaml')
     def test_nonvoting_job(self):
         "Test that non-voting jobs don't vote."
 
@@ -1365,10 +1362,11 @@
         self.assertEqual(self.getJobFromHistory('project-test2').result,
                          'FAILURE')
 
+    @simple_layout('layouts/three-projects.yaml')
     def test_dependent_behind_dequeue(self):
         # This particular test does a large amount of merges and needs a little
         # more time to complete
-        self.wait_timeout = 90
+        self.wait_timeout = 120
         "test that dependent changes behind dequeued changes work"
         # This complicated test is a reproduction of a real life bug
         self.sched.reconfigure(self.config)
@@ -1497,17 +1495,17 @@
         # https://bugs.executepad.net/zuul/+bug/1078946
         # This test assumes the repo is already cloned; make sure it is
         tenant = self.sched.abide.tenants.get('tenant-one')
-        url = self.fake_gerrit.getGitUrl(
-            tenant.layout.project_configs.get('org/project1'))
-        self.merge_server.merger.addProject('org/project1', url)
-        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        trusted, project = tenant.getProject('org/project')
+        url = self.fake_gerrit.getGitUrl(project)
+        self.merge_server.merger.addProject('org/project', url)
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addPatchset(large=True)
-        path = os.path.join(self.upstream_root, "org/project1")
+        path = os.path.join(self.upstream_root, "org/project")
         repack_repo(path)
-        path = os.path.join(self.merger_src_root, "org/project1")
+        path = os.path.join(self.merger_src_root, "org/project")
         if os.path.exists(path):
             repack_repo(path)
-        path = os.path.join(self.executor_src_root, "org/project1")
+        path = os.path.join(self.executor_src_root, "org/project")
         if os.path.exists(path):
             repack_repo(path)
 
@@ -1744,11 +1742,13 @@
 
     def test_abandoned_not_timer(self):
         "Test that an abandoned change does not cancel timer jobs"
-
+        # This test can not use simple_layout because it must start
+        # with a configuration which does not include a
+        # timer-triggered job so that we have an opportunity to set
+        # the hold flag before the first job.
         self.executor_server.hold_jobs_in_build = True
-
         # Start timer trigger - also org/project
-        self.updateConfigLayout('layout-idle')
+        self.commitConfigUpdate('common-config', 'layouts/idle.yaml')
         self.sched.reconfigure(self.config)
         # The pipeline triggers every second, so we should have seen
         # several by now.
@@ -1757,9 +1757,9 @@
         # Stop queuing timer triggered jobs so that the assertions
         # below don't race against more jobs being queued.
         # Must be in same repo, so overwrite config with another one
-        self.commitLayoutUpdate('layout-idle', 'layout-no-timer')
-
+        self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
+
         self.assertEqual(len(self.builds), 2, "Two timer jobs")
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
@@ -1895,6 +1895,7 @@
         self.assertEqual(len(self.history), 10)
         self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1)
 
+    @simple_layout('layouts/noop-job.yaml')
     def test_noop_job(self):
         "Test that the internal noop job works"
         A = self.fake_gerrit.addFakeChange('org/noop-project', 'master', 'A')
@@ -1908,6 +1909,7 @@
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2)
 
+    @simple_layout('layouts/no-jobs-project.yaml')
     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',
@@ -2051,7 +2053,7 @@
         # The assertion is that we have one job in the queue, project-merge
         self.assertEqual(len(self.gearman_server.getQueue()), 1)
 
-        self.commitLayoutUpdate('common-config', 'layout-no-jobs')
+        self.commitConfigUpdate('common-config', 'layouts/no-jobs.yaml')
         self.sched.reconfigure(self.config)
         self.waitUntilSettled()
 
@@ -2112,9 +2114,6 @@
 
     def _test_irrelevant_files_jobs(self, should_skip):
         "Test that jobs with irrelevant-files filter run only when appropriate"
-        self.updateConfigLayout('layout-irrelevant-files')
-        self.sched.reconfigure(self.config)
-
         if should_skip:
             files = {'ignoreme': 'ignored\n'}
         else:
@@ -2135,16 +2134,16 @@
         else:
             self.assertIn(change.data['number'], tested_change_ids)
 
+    @simple_layout('layouts/irrelevant-files.yaml')
     def test_irrelevant_files_match_skips_job(self):
         self._test_irrelevant_files_jobs(should_skip=True)
 
+    @simple_layout('layouts/irrelevant-files.yaml')
     def test_irrelevant_files_no_match_runs_job(self):
         self._test_irrelevant_files_jobs(should_skip=False)
 
+    @simple_layout('layouts/inheritance.yaml')
     def test_inherited_jobs_keep_matchers(self):
-        self.updateConfigLayout('layout-inheritance')
-        self.sched.reconfigure(self.config)
-
         files = {'ignoreme': 'ignored\n'}
 
         change = self.fake_gerrit.addFakeChange('org/project',
@@ -2168,9 +2167,8 @@
     def test_queue_names(self):
         "Test shared change queue names"
         tenant = self.sched.abide.tenants.get('tenant-one')
-        source = tenant.layout.pipelines['gate'].source
-        project1 = source.getProject('org/project1')
-        project2 = source.getProject('org/project2')
+        (trusted, project1) = tenant.getProject('org/project1')
+        (trusted, project2) = tenant.getProject('org/project2')
         q1 = tenant.layout.pipelines['gate'].getQueue(project1)
         q2 = tenant.layout.pipelines['gate'].getQueue(project2)
         self.assertEqual(q1.name, 'integrated')
@@ -2266,246 +2264,6 @@
         self.assertEqual('https://server/job/project-test2/0/',
                          status_jobs[2]['report_url'])
 
-    def test_semaphore_one(self):
-        "Test semaphores with max=1 (mutex)"
-        self.updateConfigLayout('layout-semaphore')
-        self.sched.reconfigure(self.config)
-
-        self.waitUntilSettled()
-        tenant = self.sched.abide.tenants.get('openstack')
-
-        self.executor_server.hold_jobs_in_build = True
-
-        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
-        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 3)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'semaphore-one-test1')
-        self.assertEqual(self.builds[2].name, 'project-test1')
-
-        self.executor_server.release('semaphore-one-test1')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 3)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'semaphore-one-test2')
-        self.assertTrue('test-semaphore' in
-                        tenant.semaphore_handler.semaphores)
-
-        self.executor_server.release('semaphore-one-test2')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 3)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'semaphore-one-test1')
-        self.assertTrue('test-semaphore' in
-                        tenant.semaphore_handler.semaphores)
-
-        self.executor_server.release('semaphore-one-test1')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 3)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'semaphore-one-test2')
-        self.assertTrue('test-semaphore' in
-                        tenant.semaphore_handler.semaphores)
-
-        self.executor_server.release('semaphore-one-test2')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 2)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.executor_server.hold_jobs_in_build = False
-        self.executor_server.release()
-
-        self.waitUntilSettled()
-        self.assertEqual(len(self.builds), 0)
-
-        self.assertEqual(A.reported, 1)
-        self.assertEqual(B.reported, 1)
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
-    def test_semaphore_two(self):
-        "Test semaphores with max>1"
-        self.updateConfigLayout('layout-semaphore')
-        self.sched.reconfigure(self.config)
-
-        self.waitUntilSettled()
-        tenant = self.sched.abide.tenants.get('openstack')
-
-        self.executor_server.hold_jobs_in_build = True
-        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
-        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
-        self.assertFalse('test-semaphore-two' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
-        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 4)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'semaphore-two-test1')
-        self.assertEqual(self.builds[2].name, 'semaphore-two-test2')
-        self.assertEqual(self.builds[3].name, 'project-test1')
-        self.assertTrue('test-semaphore-two' in
-                        tenant.semaphore_handler.semaphores)
-        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
-            'test-semaphore-two', [])), 2)
-
-        self.executor_server.release('semaphore-two-test1')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 4)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'semaphore-two-test2')
-        self.assertEqual(self.builds[2].name, 'project-test1')
-        self.assertEqual(self.builds[3].name, 'semaphore-two-test1')
-        self.assertTrue('test-semaphore-two' in
-                        tenant.semaphore_handler.semaphores)
-        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
-            'test-semaphore-two', [])), 2)
-
-        self.executor_server.release('semaphore-two-test2')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 4)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'semaphore-two-test1')
-        self.assertEqual(self.builds[3].name, 'semaphore-two-test2')
-        self.assertTrue('test-semaphore-two' in
-                        tenant.semaphore_handler.semaphores)
-        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
-            'test-semaphore-two', [])), 2)
-
-        self.executor_server.release('semaphore-two-test1')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 3)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'semaphore-two-test2')
-        self.assertTrue('test-semaphore-two' in
-                        tenant.semaphore_handler.semaphores)
-        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
-            'test-semaphore-two', [])), 1)
-
-        self.executor_server.release('semaphore-two-test2')
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 2)
-        self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertFalse('test-semaphore-two' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.executor_server.hold_jobs_in_build = False
-        self.executor_server.release()
-
-        self.waitUntilSettled()
-        self.assertEqual(len(self.builds), 0)
-
-        self.assertEqual(A.reported, 1)
-        self.assertEqual(B.reported, 1)
-
-    def test_semaphore_abandon(self):
-        "Test abandon with job semaphores"
-        self.updateConfigLayout('layout-semaphore')
-        self.sched.reconfigure(self.config)
-
-        self.waitUntilSettled()
-        tenant = self.sched.abide.tenants.get('openstack')
-
-        self.executor_server.hold_jobs_in_build = True
-
-        tenant = self.sched.abide.tenants.get('openstack')
-        check_pipeline = tenant.layout.pipelines['check']
-
-        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
-        self.waitUntilSettled()
-
-        self.assertTrue('test-semaphore' in
-                        tenant.semaphore_handler.semaphores)
-
-        self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
-        self.waitUntilSettled()
-
-        # The check pipeline should be empty
-        items = check_pipeline.getAllItems()
-        self.assertEqual(len(items), 0)
-
-        # The semaphore should be released
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.executor_server.hold_jobs_in_build = False
-        self.executor_server.release()
-        self.waitUntilSettled()
-
-    def test_semaphore_reconfigure(self):
-        "Test reconfigure with job semaphores"
-        self.updateConfigLayout('layout-semaphore')
-        self.sched.reconfigure(self.config)
-
-        self.waitUntilSettled()
-        tenant = self.sched.abide.tenants.get('openstack')
-
-        self.executor_server.hold_jobs_in_build = True
-
-        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
-        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
-        self.waitUntilSettled()
-
-        self.assertTrue('test-semaphore' in
-                        tenant.semaphore_handler.semaphores)
-
-        # reconfigure without layout change
-        self.sched.reconfigure(self.config)
-        self.waitUntilSettled()
-        tenant = self.sched.abide.tenants.get('openstack')
-
-        # semaphore still must be held
-        self.assertTrue('test-semaphore' in
-                        tenant.semaphore_handler.semaphores)
-
-        self.updateConfigLayout('layout-semaphore-reconfiguration')
-        self.sched.reconfigure(self.config)
-        self.waitUntilSettled()
-        tenant = self.sched.abide.tenants.get('openstack')
-
-        self.executor_server.release('project-test1')
-        self.waitUntilSettled()
-
-        # There should be no builds anymore
-        self.assertEqual(len(self.builds), 0)
-
-        # The semaphore should be released
-        self.assertFalse('test-semaphore' in
-                         tenant.semaphore_handler.semaphores)
-
     def test_live_reconfiguration(self):
         "Test that live reconfiguration works"
         self.executor_server.hold_jobs_in_build = True
@@ -2828,8 +2586,9 @@
         self.assertEqual(len(self.builds), 5)
 
         # This layout defines only org/project, not org/project1
-        self.commitLayoutUpdate('common-config',
-                                'layout-live-reconfiguration-del-project')
+        self.commitConfigUpdate(
+            'common-config',
+            'layouts/live-reconfiguration-del-project.yaml')
         self.sched.reconfigure(self.config)
         self.waitUntilSettled()
 
@@ -2879,10 +2638,8 @@
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2)
 
+    @simple_layout('layouts/repo-deleted.yaml')
     def test_repo_deleted(self):
-        self.updateConfigLayout('layout-repo-deleted')
-        self.sched.reconfigure(self.config)
-
         self.init_repo("org/delete-project")
         A = self.fake_gerrit.addFakeChange('org/delete-project', 'master', 'A')
 
@@ -2919,18 +2676,16 @@
         self.assertEqual(B.data['status'], 'MERGED')
         self.assertEqual(B.reported, 2)
 
+    @simple_layout('layouts/tags.yaml')
     def test_tags(self):
         "Test job tags"
-        self.updateConfigLayout('layout-tags')
-        self.sched.reconfigure(self.config)
-
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
 
-        self.assertEqual(len(self.history), 8)
+        self.assertEqual(len(self.history), 2)
 
         results = {self.getJobFromHistory('merge',
                    project='org/project1').uuid: 'extratag merge',
@@ -2943,8 +2698,12 @@
 
     def test_timer(self):
         "Test that a periodic job is triggered"
+        # This test can not use simple_layout because it must start
+        # with a configuration which does not include a
+        # timer-triggered job so that we have an opportunity to set
+        # the hold flag before the first job.
         self.executor_server.hold_jobs_in_build = True
-        self.updateConfigLayout('layout-timer')
+        self.commitConfigUpdate('common-config', 'layouts/timer.yaml')
         self.sched.reconfigure(self.config)
 
         # The pipeline triggers every second, so we should have seen
@@ -2957,14 +2716,14 @@
         port = self.webapp.server.socket.getsockname()[1]
 
         req = urllib.request.Request(
-            "http://localhost:%s/openstack/status" % port)
+            "http://localhost:%s/tenant-one/status" % port)
         f = urllib.request.urlopen(req)
         data = f.read()
 
         self.executor_server.hold_jobs_in_build = False
         # Stop queuing timer triggered jobs so that the assertions
         # below don't race against more jobs being queued.
-        self.commitLayoutUpdate('layout-timer', 'layout-no-timer')
+        self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
         self.executor_server.release()
         self.waitUntilSettled()
@@ -2987,13 +2746,18 @@
 
     def test_idle(self):
         "Test that frequent periodic jobs work"
+        # This test can not use simple_layout because it must start
+        # with a configuration which does not include a
+        # timer-triggered job so that we have an opportunity to set
+        # the hold flag before the first job.
         self.executor_server.hold_jobs_in_build = True
-        self.updateConfigLayout('layout-idle')
 
         for x in range(1, 3):
             # Test that timer triggers periodic jobs even across
             # layout config reloads.
             # Start timer trigger
+            self.commitConfigUpdate('common-config',
+                                    'layouts/idle.yaml')
             self.sched.reconfigure(self.config)
             self.waitUntilSettled()
 
@@ -3003,7 +2767,8 @@
 
             # Stop queuing timer triggered jobs so that the assertions
             # below don't race against more jobs being queued.
-            before = self.commitLayoutUpdate('layout-idle', 'layout-no-timer')
+            self.commitConfigUpdate('common-config',
+                                    'layouts/no-timer.yaml')
             self.sched.reconfigure(self.config)
             self.waitUntilSettled()
             self.assertEqual(len(self.builds), 2,
@@ -3012,16 +2777,9 @@
             self.waitUntilSettled()
             self.assertEqual(len(self.builds), 0)
             self.assertEqual(len(self.history), x * 2)
-            # Revert back to layout-idle
-            repo = git.Repo(os.path.join(self.test_root,
-                                         'upstream',
-                                         'layout-idle'))
-            repo.git.reset('--hard', before)
 
+    @simple_layout('layouts/smtp.yaml')
     def test_check_smtp_pool(self):
-        self.updateConfigLayout('layout-smtp')
-        self.sched.reconfigure(self.config)
-
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.waitUntilSettled()
 
@@ -3050,8 +2808,12 @@
 
     def test_timer_smtp(self):
         "Test that a periodic job is triggered"
+        # This test can not use simple_layout because it must start
+        # with a configuration which does not include a
+        # timer-triggered job so that we have an opportunity to set
+        # the hold flag before the first job.
         self.executor_server.hold_jobs_in_build = True
-        self.updateConfigLayout('layout-timer-smtp')
+        self.commitConfigUpdate('common-config', 'layouts/timer-smtp.yaml')
         self.sched.reconfigure(self.config)
 
         # The pipeline triggers every second, so we should have seen
@@ -3084,7 +2846,7 @@
 
         # Stop queuing timer triggered jobs and let any that may have
         # queued through so that end of test assertions pass.
-        self.commitLayoutUpdate('layout-timer-smtp', 'layout-no-timer')
+        self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
         self.waitUntilSettled()
         self.executor_server.release('.*')
@@ -3142,6 +2904,7 @@
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
         r = client.enqueue(tenant='tenant-one',
                            pipeline='gate',
                            project='org/project',
@@ -3163,6 +2926,7 @@
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
         r = client.enqueue_ref(
             tenant='tenant-one',
             pipeline='post',
@@ -3181,6 +2945,7 @@
         "Test that the RPC client returns errors"
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
                                          "Invalid tenant"):
             r = client.enqueue(tenant='tenant-foo',
@@ -3188,7 +2953,6 @@
                                project='org/project',
                                trigger='gerrit',
                                change='1,1')
-            client.shutdown()
             self.assertEqual(r, False)
 
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
@@ -3198,7 +2962,6 @@
                                project='project-does-not-exist',
                                trigger='gerrit',
                                change='1,1')
-            client.shutdown()
             self.assertEqual(r, False)
 
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
@@ -3208,7 +2971,6 @@
                                project='org/project',
                                trigger='gerrit',
                                change='1,1')
-            client.shutdown()
             self.assertEqual(r, False)
 
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
@@ -3218,7 +2980,6 @@
                                project='org/project',
                                trigger='trigger-does-not-exist',
                                change='1,1')
-            client.shutdown()
             self.assertEqual(r, False)
 
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure,
@@ -3228,7 +2989,6 @@
                                project='org/project',
                                trigger='gerrit',
                                change='1,1')
-            client.shutdown()
             self.assertEqual(r, False)
 
         self.waitUntilSettled()
@@ -3259,6 +3019,7 @@
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
         r = client.promote(tenant='tenant-one',
                            pipeline='gate',
                            change_ids=['2,1', '3,1'])
@@ -3307,7 +3068,6 @@
         self.assertEqual(C.data['status'], 'MERGED')
         self.assertEqual(C.reported, 2)
 
-        client.shutdown()
         self.assertEqual(r, True)
 
     def test_client_promote_dependent(self):
@@ -3333,6 +3093,7 @@
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
         r = client.promote(tenant='tenant-one',
                            pipeline='gate',
                            change_ids=['3,1'])
@@ -3375,7 +3136,6 @@
         self.assertEqual(C.data['status'], 'MERGED')
         self.assertEqual(C.reported, 2)
 
-        client.shutdown()
         self.assertEqual(r, True)
 
     def test_client_promote_negative(self):
@@ -3388,29 +3148,27 @@
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
 
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure):
             r = client.promote(tenant='tenant-one',
                                pipeline='nonexistent',
                                change_ids=['2,1', '3,1'])
-            client.shutdown()
             self.assertEqual(r, False)
 
         with testtools.ExpectedException(zuul.rpcclient.RPCFailure):
             r = client.promote(tenant='tenant-one',
                                pipeline='gate',
                                change_ids=['4,1'])
-            client.shutdown()
             self.assertEqual(r, False)
 
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
 
+    @simple_layout('layouts/rate-limit.yaml')
     def test_queue_rate_limiting(self):
         "Test that DependentPipelines are rate limited with dep across window"
-        self.updateConfigLayout('layout-rate-limit')
-        self.sched.reconfigure(self.config)
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
@@ -3434,9 +3192,10 @@
         self.assertEqual(self.builds[0].name, 'project-merge')
         self.assertEqual(self.builds[1].name, 'project-merge')
 
-        self.executor_server.release('.*-merge')
+        # Release the merge jobs one at a time.
+        self.builds[0].release()
         self.waitUntilSettled()
-        self.executor_server.release('.*-merge')
+        self.builds[0].release()
         self.waitUntilSettled()
 
         # Only A and B will have their test jobs queued because
@@ -3450,7 +3209,7 @@
         self.executor_server.release('project-.*')
         self.waitUntilSettled()
 
-        tenant = self.sched.abide.tenants.get('openstack')
+        tenant = self.sched.abide.tenants.get('tenant-one')
         queue = tenant.layout.pipelines['gate'].queues[0]
         # A failed so window is reduced by 1 to 1.
         self.assertEqual(queue.window, 1)
@@ -3498,10 +3257,9 @@
         self.assertEqual(queue.window_floor, 1)
         self.assertEqual(C.data['status'], 'MERGED')
 
+    @simple_layout('layouts/rate-limit.yaml')
     def test_queue_rate_limiting_dependent(self):
         "Test that DependentPipelines are rate limited with dep in window"
-        self.updateConfigLayout('layout-rate-limit')
-        self.sched.reconfigure(self.config)
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
@@ -3526,10 +3284,7 @@
         self.assertEqual(self.builds[0].name, 'project-merge')
         self.assertEqual(self.builds[1].name, 'project-merge')
 
-        self.executor_server.release('.*-merge')
-        self.waitUntilSettled()
-        self.executor_server.release('.*-merge')
-        self.waitUntilSettled()
+        self.orderedRelease(2)
 
         # Only A and B will have their test jobs queued because
         # window is 2.
@@ -3542,7 +3297,7 @@
         self.executor_server.release('project-.*')
         self.waitUntilSettled()
 
-        tenant = self.sched.abide.tenants.get('openstack')
+        tenant = self.sched.abide.tenants.get('tenant-one')
         queue = tenant.layout.pipelines['gate'].queues[0]
         # A failed so window is reduced by 1 to 1.
         self.assertEqual(queue.window, 1)
@@ -3555,8 +3310,7 @@
         self.assertEqual(len(self.builds), 1)
         self.assertEqual(self.builds[0].name, 'project-merge')
 
-        self.executor_server.release('.*-merge')
-        self.waitUntilSettled()
+        self.orderedRelease(1)
 
         # Only C's test jobs are queued because window is still 1.
         self.assertEqual(len(self.builds), 2)
@@ -3610,11 +3364,9 @@
         self.executor_server.release()
         self.waitUntilSettled()
 
+    @simple_layout('layouts/footer-message.yaml')
     def test_footer_message(self):
         "Test a pipeline's footer message is correctly added to the report."
-        self.updateConfigLayout('layout-footer-message')
-        self.sched.reconfigure(self.config)
-
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addApproval('code-review', 2)
         self.executor_server.failJob('project-test1', A)
@@ -3758,6 +3510,7 @@
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
+        self.addCleanup(client.shutdown)
 
         # Wait for gearman server to send the initial workData back to zuul
         start = time.time()
@@ -3806,6 +3559,7 @@
         running_items = client.get_running_jobs()
         self.assertEqual(0, len(running_items))
 
+    @simple_layout('layouts/nonvoting-pipeline.yaml')
     def test_nonvoting_pipeline(self):
         "Test that a nonvoting pipeline (experimental) can still report"
 
@@ -4072,7 +3826,7 @@
 
     def test_crd_gate_unknown(self):
         "Test unknown projects in dependent pipeline"
-        self.init_repo("org/unknown")
+        self.init_repo("org/unknown", tag='init')
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/unknown', 'master', 'B')
         A.addApproval('code-review', 2)
@@ -4286,13 +4040,12 @@
         independent pipelines"""
         # It's a hack for fake gerrit,
         # as it implies repo creation upon the creation of any change
-        self.init_repo("org/unknown")
+        self.init_repo("org/unknown", tag='init')
         self._test_crd_check_reconfiguration('org/project1', 'org/unknown')
 
+    @simple_layout('layouts/ignore-dependencies.yaml')
     def test_crd_check_ignore_dependencies(self):
         "Test cross-repo dependencies can be ignored"
-        self.updateConfigLayout('layout-ignore-dependencies')
-        self.sched.reconfigure(self.config)
 
         self.gearman_server.hold_jobs_in_queue = True
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
@@ -4311,7 +4064,7 @@
 
         # Make sure none of the items share a change queue, and all
         # are live.
-        tenant = self.sched.abide.tenants.get('openstack')
+        tenant = self.sched.abide.tenants.get('tenant-one')
         check_pipeline = tenant.layout.pipelines['check']
         self.assertEqual(len(check_pipeline.queues), 3)
         self.assertEqual(len(check_pipeline.getAllItems()), 3)
@@ -4333,6 +4086,7 @@
         for job in self.history:
             self.assertEqual(len(job.changes.split()), 1)
 
+    @simple_layout('layouts/three-projects.yaml')
     def test_crd_check_transitive(self):
         "Test transitive cross-repo dependencies"
         # Specifically, if A -> B -> C, and C gets a new patchset and
@@ -4375,7 +4129,7 @@
 
     def test_crd_check_unknown(self):
         "Test unknown projects in independent pipeline"
-        self.init_repo("org/unknown")
+        self.init_repo("org/unknown", tag='init')
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/unknown', 'master', 'D')
         # A Depends-On: B
@@ -4418,7 +4172,8 @@
         # processing.
 
         tenant = self.sched.abide.tenants.get('tenant-one')
-        source = tenant.layout.pipelines['gate'].source
+        (trusted, project) = tenant.getProject('org/project')
+        source = project.source
 
         # TODO(pabelanger): As we add more source / trigger APIs we should make
         # it easier for users to create events for testing.
@@ -4441,13 +4196,11 @@
         event.change_number = '2'
         source.getChange(event, True)
 
+    @simple_layout('layouts/disable_at.yaml')
     def test_disable_at(self):
         "Test a pipeline will only report to the disabled trigger when failing"
 
-        self.updateConfigLayout('layout-disabled-at')
-        self.sched.reconfigure(self.config)
-
-        tenant = self.sched.abide.tenants.get('openstack')
+        tenant = self.sched.abide.tenants.get('tenant-one')
         self.assertEqual(3, tenant.layout.pipelines['check'].disable_at)
         self.assertEqual(
             0, tenant.layout.pipelines['check']._consecutive_failures)
@@ -4545,7 +4298,7 @@
         # comes out of disabled
         self.sched.reconfigure(self.config)
 
-        tenant = self.sched.abide.tenants.get('openstack')
+        tenant = self.sched.abide.tenants.get('tenant-one')
 
         self.assertEqual(3, tenant.layout.pipelines['check'].disable_at)
         self.assertEqual(
@@ -4568,6 +4321,24 @@
         # No more messages reported via smtp
         self.assertEqual(3, len(self.smtp_messages))
 
+    @simple_layout('layouts/one-job-project.yaml')
+    def test_one_job_project(self):
+        "Test that queueing works with one job"
+        A = self.fake_gerrit.addFakeChange('org/one-job-project',
+                                           'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/one-job-project',
+                                           'master', 'B')
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'MERGED')
+        self.assertEqual(B.reported, 2)
+
     def test_rerun_on_abort(self):
         "Test that if a execute server fails to run a job, it is run again"
 
@@ -4638,6 +4409,39 @@
         self.assertIn('project-test2 : SKIPPED', A.messages[1])
 
 
+class TestExecutor(ZuulTestCase):
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def assertFinalState(self):
+        # In this test, we expect to shut down in a non-final state,
+        # so skip these checks.
+        pass
+
+    def assertCleanShutdown(self):
+        self.log.debug("Assert clean shutdown")
+
+        # After shutdown, make sure no jobs are running
+        self.assertEqual({}, self.executor_server.job_workers)
+
+        # Make sure that git.Repo objects have been garbage collected.
+        repos = []
+        gc.collect()
+        for obj in gc.get_objects():
+            if isinstance(obj, git.Repo):
+                self.log.debug("Leaked git repo object: %s" % repr(obj))
+                repos.append(obj)
+        self.assertEqual(len(repos), 0)
+
+    def test_executor_shutdown(self):
+        "Test that the executor can shut down with jobs running"
+
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+
 class TestDependencyGraph(ZuulTestCase):
     tenant_config_file = 'config/dependency-graph/main.yaml'
 
@@ -4784,27 +4588,6 @@
             self.assertIn('project-test1', A.messages[0])
 
 
-class TestSchedulerOneJobProject(ZuulTestCase):
-    tenant_config_file = 'config/one-job-project/main.yaml'
-
-    def test_one_job_project(self):
-        "Test that queueing works with one job"
-        A = self.fake_gerrit.addFakeChange('org/one-job-project',
-                                           'master', 'A')
-        B = self.fake_gerrit.addFakeChange('org/one-job-project',
-                                           'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.waitUntilSettled()
-
-        self.assertEqual(A.data['status'], 'MERGED')
-        self.assertEqual(A.reported, 2)
-        self.assertEqual(B.data['status'], 'MERGED')
-        self.assertEqual(B.reported, 2)
-
-
 class TestSchedulerTemplatedProject(ZuulTestCase):
     tenant_config_file = 'config/templated-project/main.yaml'
 
@@ -5077,6 +4860,231 @@
         self.waitUntilSettled()
 
 
+class TestSemaphore(ZuulTestCase):
+    tenant_config_file = 'config/semaphore/main.yaml'
+
+    def test_semaphore_one(self):
+        "Test semaphores with max=1 (mutex)"
+        tenant = self.sched.abide.tenants.get('tenant-one')
+
+        self.executor_server.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'semaphore-one-test1')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+
+        self.executor_server.release('semaphore-one-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-one-test2')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        self.executor_server.release('semaphore-one-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-one-test1')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        self.executor_server.release('semaphore-one-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-one-test2')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        self.executor_server.release('semaphore-one-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+    def test_semaphore_two(self):
+        "Test semaphores with max>1"
+        tenant = self.sched.abide.tenants.get('tenant-one')
+
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        self.assertFalse('test-semaphore-two' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'semaphore-two-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-two-test2')
+        self.assertEqual(self.builds[3].name, 'project-test1')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 2)
+
+        self.executor_server.release('semaphore-two-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'semaphore-two-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'semaphore-two-test1')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 2)
+
+        self.executor_server.release('semaphore-two-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-two-test1')
+        self.assertEqual(self.builds[3].name, 'semaphore-two-test2')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 2)
+
+        self.executor_server.release('semaphore-two-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-two-test2')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 1)
+
+        self.executor_server.release('semaphore-two-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertFalse('test-semaphore-two' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+
+    def test_semaphore_abandon(self):
+        "Test abandon with job semaphores"
+        self.executor_server.hold_jobs_in_build = True
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        check_pipeline = tenant.layout.pipelines['check']
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
+        self.waitUntilSettled()
+
+        # The check pipeline should be empty
+        items = check_pipeline.getAllItems()
+        self.assertEqual(len(items), 0)
+
+        # The semaphore should be released
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+    def test_semaphore_reconfigure(self):
+        "Test reconfigure with job semaphores"
+        self.executor_server.hold_jobs_in_build = True
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        # reconfigure without layout change
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('tenant-one')
+
+        # semaphore still must be held
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        self.commitConfigUpdate(
+            'common-config',
+            'config/semaphore/zuul-reconfiguration.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('tenant-one')
+
+        self.executor_server.release('project-test1')
+        self.waitUntilSettled()
+
+        # There should be no builds anymore
+        self.assertEqual(len(self.builds), 0)
+
+        # The semaphore should be released
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+
 class TestSemaphoreMultiTenant(ZuulTestCase):
     tenant_config_file = 'config/multi-tenant-semaphore/main.yaml'
 
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 678b957..3919418 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -264,7 +264,7 @@
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
         build = self.getJobFromHistory('timeout')
-        self.assertEqual(build.result, 'ABORTED')
+        self.assertEqual(build.result, 'TIMED_OUT')
         build = self.getJobFromHistory('faillocal')
         self.assertEqual(build.result, 'FAILURE')
         build = self.getJobFromHistory('check-vars')
diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py
index 8791a25..4511ec7 100644
--- a/tests/unit/test_webapp.py
+++ b/tests/unit/test_webapp.py
@@ -19,6 +19,7 @@
 import json
 
 from six.moves import urllib
+import webob
 
 from tests.base import ZuulTestCase, FIXTURE_DIR
 
@@ -96,3 +97,16 @@
             self.port)
         f = urllib.request.urlopen(req)
         self.assertEqual(f.read(), public_pem)
+
+    def test_webapp_custom_handler(self):
+        def custom_handler(path, tenant_name, request):
+            return webob.Response(body='ok')
+
+        self.webapp.register_path('/custom', custom_handler)
+        req = urllib.request.Request(
+            "http://localhost:%s/custom" % self.port)
+        f = urllib.request.urlopen(req)
+        self.assertEqual('ok', f.read())
+
+        self.webapp.unregister_path('/custom')
+        self.assertRaises(urllib.error.HTTPError, urllib.request.urlopen, req)
diff --git a/tox.ini b/tox.ini
index 9c0d949..8235483 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,14 +8,14 @@
 setenv = STATSD_HOST=127.0.0.1
          STATSD_PORT=8125
          VIRTUAL_ENV={envdir}
-         OS_TEST_TIMEOUT=90
+         OS_TEST_TIMEOUT=120
 passenv = ZUUL_TEST_ROOT OS_STDOUT_CAPTURE OS_STDERR_CAPTURE OS_LOG_CAPTURE OS_LOG_DEFAULTS
 usedevelop = True
 install_command = pip install {opts} {packages}
 deps = -r{toxinidir}/requirements.txt
        -r{toxinidir}/test-requirements.txt
 commands =
-  python setup.py testr --slowest --testr-args='{posargs}'
+  python setup.py test --slowest --testr-args='{posargs}'
 
 [testenv:bindep]
 # Do not install any requirements. We want this to be fast and work even if
@@ -32,7 +32,7 @@
 
 [testenv:cover]
 commands =
-  python setup.py testr --coverage
+  python setup.py test --coverage
 
 [testenv:docs]
 commands = python setup.py build_sphinx
@@ -46,7 +46,7 @@
 [testenv:nodepool]
 setenv =
    OS_TEST_PATH = ./tests/nodepool
-commands = python setup.py testr --slowest --testr-args='--concurrency=1 {posargs}'
+commands = python setup.py test --slowest --testr-args='--concurrency=1 {posargs}'
 
 [flake8]
 # These are ignored intentionally in openstack-infra projects;
diff --git a/zuul/cmd/scheduler.py b/zuul/cmd/scheduler.py
index ff4e1f4..f1d1015 100755
--- a/zuul/cmd/scheduler.py
+++ b/zuul/cmd/scheduler.py
@@ -182,7 +182,7 @@
         self.log.info('Starting scheduler')
         try:
             self.sched.start()
-            self.sched.registerConnections(self.connections)
+            self.sched.registerConnections(self.connections, webapp)
             self.sched.reconfigure(self.config)
             self.sched.resume()
         except Exception:
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 5e88ee7..9ef33ea 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -46,6 +46,17 @@
     pass
 
 
+class ProjectNotFoundError(Exception):
+    def __init__(self, project):
+        message = textwrap.dedent("""\
+        The project {project} was not found.  All projects
+        referenced within a Zuul configuration must first be
+        added to the main configuration file by the Zuul
+        administrator.""")
+        message = textwrap.fill(message.format(project=project))
+        super(ProjectNotFoundError, self).__init__(message)
+
+
 def indent(s):
     return '\n'.join(['  ' + x for x in s.split('\n')])
 
@@ -54,7 +65,7 @@
 def configuration_exceptions(stanza, conf):
     try:
         yield
-    except vs.Invalid as e:
+    except Exception as e:
         conf = copy.deepcopy(conf)
         context = conf.pop('_source_context')
         start_mark = conf.pop('_start_mark')
@@ -227,7 +238,7 @@
                'tags': to_list(str),
                'branches': to_list(str),
                'files': to_list(str),
-               'auth': to_list(auth),
+               'auth': auth,
                'irrelevant-files': to_list(str),
                'nodes': vs.Any([node], str),
                'timeout': int,
@@ -355,10 +366,7 @@
         if allowed_projects:
             allowed = []
             for p in as_list(allowed_projects):
-                # TODOv3(jeblair): this limits allowed_projects to the same
-                # source; we should remove that limitation.
-                source = job.source_context.project.connection_name
-                (trusted, project) = tenant.getRepo(source, p)
+                (trusted, project) = tenant.getProject(p)
                 if project is None:
                     raise Exception("Unknown project %s" % (p,))
                 allowed.append(project.name)
@@ -394,14 +402,12 @@
     def _makeZuulRole(tenant, job, role):
         name = role['zuul'].split('/')[-1]
 
-        # TODOv3(jeblair): this limits roles to the same
-        # source; we should remove that limitation.
-        source = job.source_context.project.connection_name
-        (trusted, project) = tenant.getRepo(source, role['zuul'])
+        (trusted, project) = tenant.getProject(role['zuul'])
         if project is None:
             return None
 
-        return model.ZuulRole(role.get('name', name), source,
+        return model.ZuulRole(role.get('name', name),
+                              project.connection_name,
                               project.name, trusted)
 
 
@@ -493,7 +499,13 @@
         for conf in conf_list:
             with configuration_exceptions('project', conf):
                 ProjectParser.getSchema(layout)(conf)
-        project = model.ProjectConfig(conf_list[0]['name'])
+
+        with configuration_exceptions('project', conf_list[0]):
+            project_name = conf_list[0]['name']
+            (trusted, project) = tenant.getProject(project_name)
+            if project is None:
+                raise ProjectNotFoundError(project_name)
+            project_config = model.ProjectConfig(project.canonical_name)
 
         configs = []
         for conf in conf_list:
@@ -509,14 +521,14 @@
                             for name in conf_templates])
             configs.append(project_template)
             mode = conf.get('merge-mode')
-            if mode and project.merge_mode is None:
+            if mode and project_config.merge_mode is None:
                 # Set the merge mode to the first one that we find and
                 # ignore subsequent settings.
-                project.merge_mode = model.MERGER_MAP[mode]
-        if project.merge_mode is None:
+                project_config.merge_mode = model.MERGER_MAP[mode]
+        if project_config.merge_mode is None:
             # If merge mode was not specified in any project stanza,
             # set it to the default.
-            project.merge_mode = model.MERGER_MAP['merge-resolve']
+            project_config.merge_mode = model.MERGER_MAP['merge-resolve']
         for pipeline in layout.pipelines.values():
             project_pipeline = model.ProjectPipelineConfig()
             queue_name = None
@@ -537,9 +549,8 @@
             if queue_name:
                 project_pipeline.queue_name = queue_name
             if pipeline_defined:
-                project.pipelines[pipeline.name] = project_pipeline
-
-        return project
+                project_config.pipelines[pipeline.name] = project_pipeline
+        return project_config
 
 
 class PipelineParser(object):
@@ -582,7 +593,7 @@
                               'email': str,
                               'older-than': str,
                               'newer-than': str,
-                              }, extra=True)
+                              }, extra=vs.ALLOW_EXTRA)
 
         require = {'approval': to_list(approval),
                    'open': bool,
@@ -598,7 +609,6 @@
 
         pipeline = {vs.Required('name'): str,
                     vs.Required('manager'): manager,
-                    'source': str,
                     'precedence': precedence,
                     'description': str,
                     'require': require,
@@ -636,8 +646,6 @@
         pipeline = model.Pipeline(conf['name'], layout)
         pipeline.description = conf.get('description')
 
-        pipeline.source = connections.getSource(conf['source'])
-
         precedence = model.PRECEDENCE_MAP[conf.get('precedence')]
         pipeline.precedence = precedence
         pipeline.failure_message = conf.get('failure-message',
@@ -743,8 +751,8 @@
 class TenantParser(object):
     log = logging.getLogger("zuul.TenantParser")
 
-    tenant_source = vs.Schema({'config-repos': [str],
-                               'project-repos': [str]})
+    tenant_source = vs.Schema({'config-projects': [str],
+                               'untrusted-projects': [str]})
 
     @staticmethod
     def validateTenantSources(connections):
@@ -774,25 +782,24 @@
         tenant = model.Tenant(conf['name'])
         tenant.unparsed_config = conf
         unparsed_config = model.UnparsedTenantConfig()
-        tenant.config_repos, tenant.project_repos = \
-            TenantParser._loadTenantConfigRepos(
+        config_projects, untrusted_projects = \
+            TenantParser._loadTenantProjects(
                 project_key_dir, connections, conf)
-        for source, repo in tenant.config_repos:
-            tenant.addConfigRepo(source, repo)
-        for source, repo in tenant.project_repos:
-            tenant.addProjectRepo(source, repo)
-        tenant.config_repos_config, tenant.project_repos_config = \
+        for project in config_projects:
+            tenant.addConfigProject(project)
+        for project in untrusted_projects:
+            tenant.addUntrustedProject(project)
+        tenant.config_projects_config, tenant.untrusted_projects_config = \
             TenantParser._loadTenantInRepoLayouts(merger, connections,
-                                                  tenant.config_repos,
-                                                  tenant.project_repos,
+                                                  tenant.config_projects,
+                                                  tenant.untrusted_projects,
                                                   cached)
-        unparsed_config.extend(tenant.config_repos_config)
-        unparsed_config.extend(tenant.project_repos_config)
+        unparsed_config.extend(tenant.config_projects_config)
+        unparsed_config.extend(tenant.untrusted_projects_config)
         tenant.layout = TenantParser._parseLayout(base, tenant,
                                                   unparsed_config,
                                                   scheduler,
                                                   connections)
-        tenant.layout.tenant = tenant
         return tenant
 
     @staticmethod
@@ -842,73 +849,73 @@
                 encryption.deserialize_rsa_keypair(f.read())
 
     @staticmethod
-    def _loadTenantConfigRepos(project_key_dir, connections, conf_tenant):
-        config_repos = []
-        project_repos = []
+    def _loadTenantProjects(project_key_dir, connections, conf_tenant):
+        config_projects = []
+        untrusted_projects = []
 
         for source_name, conf_source in conf_tenant.get('source', {}).items():
             source = connections.getSource(source_name)
 
-            for conf_repo in conf_source.get('config-repos', []):
+            for conf_repo in conf_source.get('config-projects', []):
                 project = source.getProject(conf_repo)
                 TenantParser._loadProjectKeys(
                     project_key_dir, source_name, project)
-                config_repos.append((source, project))
+                config_projects.append(project)
 
-            for conf_repo in conf_source.get('project-repos', []):
+            for conf_repo in conf_source.get('untrusted-projects', []):
                 project = source.getProject(conf_repo)
                 TenantParser._loadProjectKeys(
                     project_key_dir, source_name, project)
-                project_repos.append((source, project))
+                untrusted_projects.append(project)
 
-        return config_repos, project_repos
+        return config_projects, untrusted_projects
 
     @staticmethod
-    def _loadTenantInRepoLayouts(merger, connections, config_repos,
-                                 project_repos, cached):
-        config_repos_config = model.UnparsedTenantConfig()
-        project_repos_config = model.UnparsedTenantConfig()
+    def _loadTenantInRepoLayouts(merger, connections, config_projects,
+                                 untrusted_projects, cached):
+        config_projects_config = model.UnparsedTenantConfig()
+        untrusted_projects_config = model.UnparsedTenantConfig()
         jobs = []
 
-        for (source, project) in config_repos:
+        for project in config_projects:
             # If we have cached data (this is a reconfiguration) use it.
             if cached and project.unparsed_config:
                 TenantParser.log.info(
                     "Loading previously parsed configuration from %s" %
                     (project,))
-                config_repos_config.extend(project.unparsed_config)
+                config_projects_config.extend(project.unparsed_config)
                 continue
             # Otherwise, prepare an empty unparsed config object to
             # hold cached data later.
             project.unparsed_config = model.UnparsedTenantConfig()
             # Get main config files.  These files are permitted the
             # full range of configuration.
-            url = source.getGitUrl(project)
+            url = project.source.getGitUrl(project)
             job = merger.getFiles(project.name, url, 'master',
                                   files=['zuul.yaml', '.zuul.yaml'])
             job.source_context = model.SourceContext(project, 'master',
                                                      '', True)
             jobs.append(job)
 
-        for (source, project) in project_repos:
+        for project in untrusted_projects:
             # If we have cached data (this is a reconfiguration) use it.
             if cached and project.unparsed_config:
                 TenantParser.log.info(
                     "Loading previously parsed configuration from %s" %
                     (project,))
-                project_repos_config.extend(project.unparsed_config)
+                untrusted_projects_config.extend(project.unparsed_config)
                 continue
             # Otherwise, prepare an empty unparsed config object to
             # hold cached data later.
             project.unparsed_config = model.UnparsedTenantConfig()
             # Get in-project-repo config files which have a restricted
             # set of options.
-            url = source.getGitUrl(project)
+            url = project.source.getGitUrl(project)
             # For each branch in the repo, get the zuul.yaml for that
             # branch.  Remember the branch and then implicitly add a
             # branch selector to each job there.  This makes the
             # in-repo configuration apply only to that branch.
-            for branch in source.getProjectBranches(project):
+            for branch in project.source.getProjectBranches(project):
                 project.unparsed_branch_config[branch] = \
                     model.UnparsedTenantConfig()
                 job = merger.getFiles(project.name, url, branch,
@@ -941,27 +948,27 @@
                     project = job.source_context.project
                     branch = job.source_context.branch
                     if job.source_context.trusted:
-                        incdata = TenantParser._parseConfigRepoLayout(
+                        incdata = TenantParser._parseConfigProjectLayout(
                             job.files[fn], job.source_context)
-                        config_repos_config.extend(incdata)
+                        config_projects_config.extend(incdata)
                     else:
-                        incdata = TenantParser._parseProjectRepoLayout(
+                        incdata = TenantParser._parseUntrustedProjectLayout(
                             job.files[fn], job.source_context)
-                        project_repos_config.extend(incdata)
+                        untrusted_projects_config.extend(incdata)
                     project.unparsed_config.extend(incdata)
                     if branch in project.unparsed_branch_config:
                         project.unparsed_branch_config[branch].extend(incdata)
-        return config_repos_config, project_repos_config
+        return config_projects_config, untrusted_projects_config
 
     @staticmethod
-    def _parseConfigRepoLayout(data, source_context):
+    def _parseConfigProjectLayout(data, source_context):
         # This is the top-level configuration for a tenant.
         config = model.UnparsedTenantConfig()
         config.extend(safe_load_yaml(data, source_context))
         return config
 
     @staticmethod
-    def _parseProjectRepoLayout(data, source_context):
+    def _parseUntrustedProjectLayout(data, source_context):
         # TODOv3(jeblair): this should implement some rules to protect
         # aspects of the config that should not be changed in-repo
         config = model.UnparsedTenantConfig()
@@ -997,6 +1004,8 @@
             layout.addProjectConfig(ProjectParser.fromYaml(
                 tenant, layout, config_project))
 
+        layout.tenant = tenant
+
         for pipeline in layout.pipelines.values():
             pipeline.manager._postConfig(layout)
 
@@ -1049,42 +1058,44 @@
         new_abide.tenants[tenant.name] = new_tenant
         return new_abide
 
-    def _loadDynamicProjectData(self, config, source, project, files,
-                                config_repo):
-        for branch in source.getProjectBranches(project):
-            data = None
-            if config_repo:
-                fn = 'zuul.yaml'
-                data = files.getFile(project.name, branch, fn)
-            if not data:
-                fn = '.zuul.yaml'
-                data = files.getFile(project.name, branch, fn)
+    def _loadDynamicProjectData(self, config, project, files, trusted):
+        if trusted:
+            branches = ['master']
+            fn = 'zuul.yaml'
+        else:
+            branches = project.source.getProjectBranches(project)
+            fn = '.zuul.yaml'
+
+        for branch in branches:
+            incdata = None
+            data = files.getFile(project.name, branch, fn)
             if data:
                 source_context = model.SourceContext(project, branch,
-                                                     fn, config_repo)
-                if config_repo:
-                    incdata = TenantParser._parseConfigRepoLayout(
+                                                     fn, trusted)
+                if trusted:
+                    incdata = TenantParser._parseConfigProjectLayout(
                         data, source_context)
                 else:
-                    incdata = TenantParser._parseProjectRepoLayout(
+                    incdata = TenantParser._parseUntrustedProjectLayout(
                         data, source_context)
             else:
-                incdata = project.unparsed_branch_config.get(branch)
-            if not incdata:
-                continue
-            config.extend(incdata)
+                if trusted:
+                    incdata = project.unparsed_config
+                else:
+                    incdata = project.unparsed_branch_config.get(branch)
+            if incdata:
+                config.extend(incdata)
 
-    def createDynamicLayout(self, tenant, files, include_config_repos=False):
-        if include_config_repos:
+    def createDynamicLayout(self, tenant, files,
+                            include_config_projects=False):
+        if include_config_projects:
             config = model.UnparsedTenantConfig()
-            for source, project in tenant.config_repos:
-                self._loadDynamicProjectData(config, source, project,
-                                             files, True)
+            for project in tenant.config_projects:
+                self._loadDynamicProjectData(config, project, files, True)
         else:
-            config = tenant.config_repos_config.copy()
-        for source, project in tenant.project_repos:
-            self._loadDynamicProjectData(config, source, project,
-                                         files, False)
+            config = tenant.config_projects_config.copy()
+        for project in tenant.untrusted_projects:
+            self._loadDynamicProjectData(config, project, files, False)
 
         layout = model.Layout()
         # NOTE: the actual pipeline objects (complete with queues and
@@ -1101,6 +1112,12 @@
         # configuration changes.
         layout.semaphores = tenant.layout.semaphores
 
+        for config_nodeset in config.nodesets:
+            layout.addNodeSet(NodeSetParser.fromYaml(layout, config_nodeset))
+
+        for config_secret in config.secrets:
+            layout.addSecret(SecretParser.fromYaml(layout, config_secret))
+
         for config_job in config.jobs:
             layout.addJob(JobParser.fromYaml(tenant, layout, config_job))
 
diff --git a/zuul/connection/__init__.py b/zuul/connection/__init__.py
index 6913294..49624d7 100644
--- a/zuul/connection/__init__.py
+++ b/zuul/connection/__init__.py
@@ -59,3 +59,21 @@
         This lets the user supply a list of change objects that are
         still in use.  Anything in our cache that isn't in the supplied
         list should be safe to remove from the cache."""
+
+    def registerWebapp(self, webapp):
+        self.webapp = webapp
+
+    def registerHttpHandler(self, path, handler):
+        """Add connection handler for HTTP URI.
+
+        Connection can use builtin HTTP server for listening on incoming event
+        requests. The resulting path will be /connection/connection_name/path.
+        """
+        self.webapp.register_path(self._connectionPath(path), handler)
+
+    def unregisterHttpHandler(self, path):
+        """Remove the connection handler for HTTP URI."""
+        self.webapp.unregister_path(self._connectionPath(path))
+
+    def _connectionPath(self, path):
+        return '/connection/%s/%s' % (self.connection_name, path)
diff --git a/zuul/driver/__init__.py b/zuul/driver/__init__.py
index 1cc5235..57b5cf9 100644
--- a/zuul/driver/__init__.py
+++ b/zuul/driver/__init__.py
@@ -68,6 +68,17 @@
         """
         pass
 
+    def stop(self):
+        """Stop the driver from running.
+
+        This method is optional; the base implementation does nothing.
+
+        This method is called when the connection registry is stopped
+        allowing you additionally stop any running Driver computation
+        not specific to a connection.
+        """
+        pass
+
 
 @six.add_metaclass(abc.ABCMeta)
 class ConnectionInterface(object):
diff --git a/zuul/driver/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py
index e18daa9..73979be 100644
--- a/zuul/driver/gerrit/gerritconnection.py
+++ b/zuul/driver/gerrit/gerritconnection.py
@@ -26,7 +26,7 @@
 import voluptuous as v
 
 from zuul.connection import BaseConnection
-from zuul.model import TriggerEvent, Project, Change, Ref
+from zuul.model import TriggerEvent, Change, Ref
 from zuul import exceptions
 
 
@@ -76,6 +76,7 @@
         event.type = data.get('type')
         event.trigger_name = 'gerrit'
         change = data.get('change')
+        event.project_hostname = self.connection.canonical_hostname
         if change:
             event.project_name = change.get('project')
             event.branch = change.get('branch')
@@ -268,11 +269,13 @@
         self._change_cache = {}
         self.projects = {}
         self.gerrit_event_connector = None
+        self.source = driver.getSource(self)
 
     def getProject(self, name):
-        if name not in self.projects:
-            self.projects[name] = Project(name, self.connection_name)
-        return self.projects[name]
+        return self.projects.get(name)
+
+    def addProject(self, project):
+        self.projects[project.name] = project
 
     def maintainCache(self, relevant):
         # This lets the user supply a list of change objects that are
@@ -290,14 +293,14 @@
             change = self._getChange(event.change_number, event.patch_number,
                                      refresh=refresh)
         elif event.ref:
-            project = self.getProject(event.project_name)
+            project = self.source.getProject(event.project_name)
             change = Ref(project)
             change.ref = event.ref
             change.oldrev = event.oldrev
             change.newrev = event.newrev
             change.url = self._getGitwebUrl(project, sha=event.newrev)
         else:
-            project = self.getProject(event.project_name)
+            project = self.source.getProject(event.project_name)
             change = Ref(project)
             branch = event.branch or 'master'
             change.ref = 'refs/heads/%s' % branch
@@ -375,7 +378,7 @@
 
         if 'project' not in data:
             raise exceptions.ChangeNotFound(change.number, change.patchset)
-        change.project = self.getProject(data['project'])
+        change.project = self.source.getProject(data['project'])
         change.branch = data['branch']
         change.url = data['url']
         max_ps = 0
@@ -523,16 +526,16 @@
         # Wait for the ref to show up in the repo
         start = time.time()
         while time.time() - start < self.replication_timeout:
-            sha = self.getRefSha(project.name, ref)
+            sha = self.getRefSha(project, ref)
             if old_sha != sha:
                 return True
             time.sleep(self.replication_retry_interval)
         return False
 
-    def getRefSha(self, project_name, ref):
+    def getRefSha(self, project, ref):
         refs = {}
         try:
-            refs = self.getInfoRefs(project_name)
+            refs = self.getInfoRefs(project)
         except:
             self.log.exception("Exception looking for ref %s" %
                                ref)
@@ -594,7 +597,7 @@
         return changes
 
     def getProjectBranches(self, project):
-        refs = self.getInfoRefs(project.name)
+        refs = self.getInfoRefs(project)
         heads = [str(k[len('refs/heads/'):]) for k in refs.keys()
                  if k.startswith('refs/heads/')]
         return heads
@@ -728,9 +731,9 @@
             raise Exception("Gerrit error executing %s" % command)
         return (out, err)
 
-    def getInfoRefs(self, project_name):
+    def getInfoRefs(self, project):
         url = "%s/p/%s/info/refs?service=git-upload-pack" % (
-            self.baseurl, project_name)
+            self.baseurl, project.name)
         try:
             data = urllib.request.urlopen(url).read()
         except:
@@ -819,5 +822,5 @@
 
 
 def getSchema():
-    gerrit_connection = v.Any(str, v.Schema({}, extra=True))
+    gerrit_connection = v.Any(str, v.Schema(dict))
     return gerrit_connection
diff --git a/zuul/driver/gerrit/gerritreporter.py b/zuul/driver/gerrit/gerritreporter.py
index d132d65..a855db3 100644
--- a/zuul/driver/gerrit/gerritreporter.py
+++ b/zuul/driver/gerrit/gerritreporter.py
@@ -33,7 +33,7 @@
                        (item.change, self.config, message))
         changeid = '%s,%s' % (item.change.number, item.change.patchset)
         item.change._ref_sha = source.getRefSha(
-            item.change.project.name, 'refs/heads/' + item.change.branch)
+            item.change.project, 'refs/heads/' + item.change.branch)
 
         return self.connection.review(item.change.project.name, changeid,
                                       message, self.config)
@@ -48,5 +48,5 @@
 
 
 def getSchema():
-    gerrit_reporter = v.Any(str, v.Schema({}, extra=True))
+    gerrit_reporter = v.Any(str, v.Schema(dict))
     return gerrit_reporter
diff --git a/zuul/driver/gerrit/gerritsource.py b/zuul/driver/gerrit/gerritsource.py
index 2271cde..e6230df 100644
--- a/zuul/driver/gerrit/gerritsource.py
+++ b/zuul/driver/gerrit/gerritsource.py
@@ -14,6 +14,7 @@
 
 import logging
 from zuul.source import BaseSource
+from zuul.model import Project
 
 
 class GerritSource(BaseSource):
@@ -41,7 +42,11 @@
         return self.connection.getChange(event, refresh)
 
     def getProject(self, name):
-        return self.connection.getProject(name)
+        p = self.connection.getProject(name)
+        if not p:
+            p = Project(name, self)
+            self.connection.addProject(p)
+        return p
 
     def getProjectOpenChanges(self, project):
         return self.connection.getProjectOpenChanges(project)
diff --git a/zuul/driver/gerrit/gerrittrigger.py b/zuul/driver/gerrit/gerrittrigger.py
index c678bce..70c65fd 100644
--- a/zuul/driver/gerrit/gerrittrigger.py
+++ b/zuul/driver/gerrit/gerrittrigger.py
@@ -82,14 +82,14 @@
 def getSchema():
     def toList(x):
         return v.Any([x], x)
-    variable_dict = v.Schema({}, extra=True)
+    variable_dict = v.Schema(dict)
 
     approval = v.Schema({'username': str,
                          'email-filter': str,
                          'email': str,
                          'older-than': str,
                          'newer-than': str,
-                         }, extra=True)
+                         }, extra=v.ALLOW_EXTRA)
 
     gerrit_trigger = {
         v.Required('event'):
diff --git a/zuul/driver/git/gitconnection.py b/zuul/driver/git/gitconnection.py
index 9c8d658..ca88d3f 100644
--- a/zuul/driver/git/gitconnection.py
+++ b/zuul/driver/git/gitconnection.py
@@ -19,7 +19,6 @@
 import voluptuous as v
 
 from zuul.connection import BaseConnection
-from zuul.model import Project
 
 
 class GitConnection(BaseConnection):
@@ -44,9 +43,10 @@
         self.projects = {}
 
     def getProject(self, name):
-        if name not in self.projects:
-            self.projects[name] = Project(name, self.connection_name)
-        return self.projects[name]
+        return self.projects.get(name)
+
+    def addProject(self, project):
+        self.projects[project.name] = project
 
     def getProjectBranches(self, project):
         # TODO(jeblair): implement; this will need to handle local or
@@ -59,5 +59,5 @@
 
 
 def getSchema():
-    git_connection = v.Any(str, v.Schema({}, extra=True))
+    git_connection = v.Any(str, v.Schema(dict))
     return git_connection
diff --git a/zuul/driver/git/gitsource.py b/zuul/driver/git/gitsource.py
index 076e8b7..485a6e4 100644
--- a/zuul/driver/git/gitsource.py
+++ b/zuul/driver/git/gitsource.py
@@ -14,6 +14,7 @@
 
 import logging
 from zuul.source import BaseSource
+from zuul.model import Project
 
 
 class GitSource(BaseSource):
@@ -38,7 +39,11 @@
         raise NotImplemented()
 
     def getProject(self, name):
-        return self.connection.getProject(name)
+        p = self.connection.getProject(name)
+        if not p:
+            p = Project(name, self)
+            self.connection.addProject(p)
+        return p
 
     def getProjectBranches(self, project):
         return self.connection.getProjectBranches(project)
diff --git a/zuul/driver/smtp/smtpconnection.py b/zuul/driver/smtp/smtpconnection.py
index 6338cd5..56ca240 100644
--- a/zuul/driver/smtp/smtpconnection.py
+++ b/zuul/driver/smtp/smtpconnection.py
@@ -58,5 +58,5 @@
 
 
 def getSchema():
-    smtp_connection = v.Any(str, v.Schema({}, extra=True))
+    smtp_connection = v.Any(str, v.Schema(dict))
     return smtp_connection
diff --git a/zuul/driver/sql/sqlconnection.py b/zuul/driver/sql/sqlconnection.py
index 31bc13a..4b1b1a2 100644
--- a/zuul/driver/sql/sqlconnection.py
+++ b/zuul/driver/sql/sqlconnection.py
@@ -101,5 +101,5 @@
 
 
 def getSchema():
-    sql_connection = v.Any(str, v.Schema({}, extra=True))
+    sql_connection = v.Any(str, v.Schema(dict))
     return sql_connection
diff --git a/zuul/driver/timer/__init__.py b/zuul/driver/timer/__init__.py
index 3ce0b8d..115e6af 100644
--- a/zuul/driver/timer/__init__.py
+++ b/zuul/driver/timer/__init__.py
@@ -38,6 +38,10 @@
 
     def reconfigure(self, tenant):
         self._removeJobs(tenant)
+        if not self.apsched:
+            # Handle possible reuse of the driver without connection objects.
+            self.apsched = BackgroundScheduler()
+            self.apsched.start()
         self._addJobs(tenant)
 
     def _removeJobs(self, tenant):
@@ -76,16 +80,20 @@
 
     def _onTrigger(self, tenant, pipeline_name, timespec):
         for project_name in tenant.layout.project_configs.keys():
+            project_hostname, project_name = project_name.split('/', 1)
             event = TriggerEvent()
             event.type = 'timer'
             event.timespec = timespec
             event.forced_pipeline = pipeline_name
+            event.project_hostname = project_hostname
             event.project_name = project_name
             self.log.debug("Adding event %s" % event)
             self.sched.addEvent(event)
 
     def stop(self):
-        self.apsched.shutdown()
+        if self.apsched:
+            self.apsched.shutdown()
+            self.apsched = None
 
     def getTrigger(self, connection_name, config=None):
         return timertrigger.TimerTrigger(self, config)
diff --git a/zuul/driver/zuul/__init__.py b/zuul/driver/zuul/__init__.py
index 47ccec0..8c9d795 100644
--- a/zuul/driver/zuul/__init__.py
+++ b/zuul/driver/zuul/__init__.py
@@ -76,6 +76,7 @@
         event = TriggerEvent()
         event.type = PROJECT_CHANGE_MERGED
         event.trigger_name = self.name
+        event.project_hostname = change.project.canonical_hostname
         event.project_name = change.project.name
         event.change_number = change.number
         event.branch = change.branch
@@ -97,6 +98,7 @@
         event.type = PARENT_CHANGE_ENQUEUED
         event.trigger_name = self.name
         event.pipeline_name = pipeline.name
+        event.project_hostname = change.project.canonical_hostname
         event.project_name = change.project.name
         event.change_number = change.number
         event.branch = change.branch
diff --git a/zuul/driver/zuul/zuultrigger.py b/zuul/driver/zuul/zuultrigger.py
index bb7c04e..c0c2fb3 100644
--- a/zuul/driver/zuul/zuultrigger.py
+++ b/zuul/driver/zuul/zuultrigger.py
@@ -63,7 +63,7 @@
                          'email': str,
                          'older-than': str,
                          'newer-than': str,
-                         }, extra=True)
+                         }, extra=v.ALLOW_EXTRA)
 
     zuul_trigger = {
         v.Required('event'):
diff --git a/zuul/executor/client.py b/zuul/executor/client.py
index 90cfa9b..55f6874 100644
--- a/zuul/executor/client.py
+++ b/zuul/executor/client.py
@@ -45,14 +45,14 @@
         oldrev = None
         newrev = None
         branch = None
-    connection_name = item.pipeline.source.connection.connection_name
+    source = item.change.project.source
+    connection_name = source.connection.connection_name
     project = item.change.project.name
 
     return dict(project=project,
-                url=item.pipeline.source.getGitUrl(
-                    item.change.project),
+                url=source.getGitUrl(item.change.project),
                 connection_name=connection_name,
-                merge_mode=item.current_build_set.getMergeMode(project),
+                merge_mode=item.current_build_set.getMergeMode(),
                 refspec=refspec,
                 branch=branch,
                 ref=item.current_build_set.ref,
@@ -209,6 +209,7 @@
         return False
 
     def execute(self, job, item, pipeline, dependent_items=[]):
+        tenant = pipeline.layout.tenant
         uuid = str(uuid4().hex)
         self.log.info(
             "Execute job %s (uuid: %s) on nodes %s for change %s "
@@ -307,6 +308,7 @@
                               host_keys=node.host_keys,
                               provider=node.provider,
                               region=node.region,
+                              interface_ip=node.interface_ip,
                               public_ipv6=node.public_ipv6,
                               public_ipv4=node.public_ipv4))
         params['nodes'] = nodes
@@ -318,18 +320,22 @@
         projects = set()
         if job.repos:
             for repo in job.repos:
-                project = item.pipeline.source.getProject(repo)
+                (trusted, project) = tenant.getProject(repo)
+                connection = project.source.connection
                 params['projects'].append(
-                    dict(name=repo,
-                         url=item.pipeline.source.getGitUrl(project)))
+                    dict(name=project.name,
+                         connection_name=connection.connection_name,
+                         url=project.source.getGitUrl(project)))
                 projects.add(project)
         for item in all_items:
             if item.change.project not in projects:
+                project = item.change.project
+                connection = item.change.project.source.connection
                 params['projects'].append(
-                    dict(name=item.change.project.name,
-                         url=item.pipeline.source.getGitUrl(
-                             item.change.project)))
-                projects.add(item.change.project)
+                    dict(name=project.name,
+                         connection_name=connection.connection_name,
+                         url=project.source.getGitUrl(project)))
+                projects.add(project)
 
         build = Build(job, uuid)
         build.parameters = params
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index 0adb6de..c1e2d48 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -46,7 +46,8 @@
         self.timeout = timeout
         self.function = function
         self.args = args
-        self.thread = threading.Thread(target=self._run)
+        self.thread = threading.Thread(target=self._run,
+                                       name='executor-watchdog')
         self.thread.daemon = True
         self.timed_out = None
 
@@ -56,7 +57,11 @@
         if self._running:
             self.timed_out = True
             self.function(*self.args)
-        self.timed_out = False
+        else:
+            # Only set timed_out to false if we aren't _running
+            # anymore. This means that we stopped running not because
+            # of a timeout but because normal execution ended.
+            self.timed_out = False
 
     def start(self):
         self._running = True
@@ -344,10 +349,17 @@
     def stop(self):
         self.log.debug("Stopping")
         self._running = False
-        self.worker.shutdown()
         self._command_running = False
         self.command_socket.stop()
         self.update_queue.put(None)
+
+        for job_worker in self.job_workers.values():
+            try:
+                job_worker.stop()
+            except Exception:
+                self.log.exception("Exception sending stop command "
+                                   "to worker:")
+        self.worker.shutdown()
         self.log.debug("Stopped")
 
     def pause(self):
diff --git a/zuul/lib/connections.py b/zuul/lib/connections.py
index 9964ba9..403aca6 100644
--- a/zuul/lib/connections.py
+++ b/zuul/lib/connections.py
@@ -58,6 +58,13 @@
             if load:
                 connection.onLoad()
 
+    def registerWebapp(self, webapp):
+        for driver_name, driver in self.drivers.items():
+            if hasattr(driver, 'registerWebapp'):
+                driver.registerWebapp(webapp)
+        for connection_name, connection in self.connections.items():
+            connection.registerWebapp(webapp)
+
     def reconfigureDrivers(self, tenant):
         for driver in self.drivers.values():
             if hasattr(driver, 'reconfigure'):
@@ -66,10 +73,11 @@
     def stop(self):
         for connection_name, connection in self.connections.items():
             connection.onStop()
+        for driver in self.drivers.values():
+            driver.stop()
 
     def configure(self, config):
         # Register connections from the config
-        # TODO(jhesketh): import connection modules dynamically
         connections = {}
 
         for section_name in config.sections():
diff --git a/zuul/manager/__init__.py b/zuul/manager/__init__.py
index 9507d15..3a4ea6a 100644
--- a/zuul/manager/__init__.py
+++ b/zuul/manager/__init__.py
@@ -54,7 +54,6 @@
 
     def _postConfig(self, layout):
         self.log.info("Configured Pipeline Manager %s" % self.pipeline.name)
-        self.log.info("  Source: %s" % self.pipeline.source)
         self.log.info("  Requirements:")
         for f in self.changeish_filters:
             self.log.info("    %s" % f)
@@ -147,11 +146,12 @@
 
     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,
-                                      self.pipeline.source, item)
+                                      source, item)
                 if ret:
                     self.log.error("Reporting item start %s received: %s" %
                                    (item, ret))
@@ -454,14 +454,14 @@
         elif hasattr(item.change, 'newrev'):
             oldrev = item.change.oldrev
             newrev = item.change.newrev
-        connection_name = self.pipeline.source.connection.connection_name
+        source = item.change.project.source
+        connection_name = source.connection.connection_name
 
         project = item.change.project.name
         return dict(project=project,
-                    url=self.pipeline.source.getGitUrl(
-                        item.change.project),
+                    url=source.getGitUrl(item.change.project),
                     connection_name=connection_name,
-                    merge_mode=item.current_build_set.getMergeMode(project),
+                    merge_mode=item.current_build_set.getMergeMode(),
                     refspec=refspec,
                     branch=branch,
                     ref=item.current_build_set.ref,
@@ -487,14 +487,14 @@
             loader.createDynamicLayout(
                 item.pipeline.layout.tenant,
                 build_set.files,
-                include_config_repos=True)
+                include_config_projects=True)
 
             # Then create the config a second time but without changes
             # to config repos so that we actually use this config.
             layout = loader.createDynamicLayout(
                 item.pipeline.layout.tenant,
                 build_set.files,
-                include_config_repos=False)
+                include_config_projects=False)
         except zuul.configloader.ConfigurationSyntaxError as e:
             self.log.info("Configuration syntax error "
                           "in dynamic layout %s" %
@@ -742,9 +742,9 @@
         if self.changes_merge:
             succeeded = item.didAllJobsSucceed()
             merged = item.reported
+            source = item.change.project.source
             if merged:
-                merged = self.pipeline.source.isMerged(item.change,
-                                                       item.change.branch)
+                merged = source.isMerged(item.change, item.change.branch)
             self.log.info("Reported change %s status: all-succeeded: %s, "
                           "merged: %s" % (item.change, succeeded, merged))
             change_queue = item.queue
@@ -763,11 +763,11 @@
 
                 zuul_driver = self.sched.connections.drivers['zuul']
                 tenant = self.pipeline.layout.tenant
-                zuul_driver.onChangeMerged(tenant, item.change,
-                                           self.pipeline.source)
+                zuul_driver.onChangeMerged(tenant, item.change, source)
 
     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)
@@ -802,7 +802,7 @@
             try:
                 self.log.info("Reporting item %s, actions: %s" %
                               (item, actions))
-                ret = self.sendReport(actions, self.pipeline.source, item)
+                ret = self.sendReport(actions, source, item)
                 if ret:
                     self.log.error("Reporting item %s received: %s" %
                                    (item, ret))
diff --git a/zuul/manager/dependent.py b/zuul/manager/dependent.py
index 4c48568..6c56a30 100644
--- a/zuul/manager/dependent.py
+++ b/zuul/manager/dependent.py
@@ -38,13 +38,14 @@
         self.log.debug("Building shared change queues")
         change_queues = {}
         project_configs = self.pipeline.layout.project_configs
+        tenant = self.pipeline.layout.tenant
 
         for project_config in project_configs.values():
             project_pipeline_config = project_config.pipelines.get(
                 self.pipeline.name)
             if project_pipeline_config is None:
                 continue
-            project = self.pipeline.source.getProject(project_config.name)
+            (trusted, project) = tenant.getProject(project_config.name)
             queue_name = project_pipeline_config.queue_name
             if queue_name and queue_name in change_queues:
                 change_queue = change_queues[queue_name]
@@ -78,16 +79,17 @@
             self.pipeline.getQueue(change.project))
 
     def isChangeReadyToBeEnqueued(self, change):
-        if not self.pipeline.source.canMerge(change,
-                                             self.getSubmitAllowNeeds()):
+        source = change.project.source
+        if not source.canMerge(change, self.getSubmitAllowNeeds()):
             self.log.debug("Change %s can not merge, ignoring" % change)
             return False
         return True
 
     def enqueueChangesBehind(self, change, quiet, ignore_requirements,
                              change_queue):
-        to_enqueue = []
         self.log.debug("Checking for changes needing %s:" % change)
+        to_enqueue = []
+        source = change.project.source
         if not hasattr(change, 'needed_by_changes'):
             self.log.debug("  %s does not support dependencies" % type(change))
             return
@@ -99,8 +101,7 @@
                                    (other_change, other_change.project,
                                     change_queue))
                     continue
-            if self.pipeline.source.canMerge(other_change,
-                                             self.getSubmitAllowNeeds()):
+            if source.canMerge(other_change, self.getSubmitAllowNeeds()):
                 self.log.debug("  Change %s needs %s and is ready to merge" %
                                (other_change, change))
                 to_enqueue.append(other_change)
@@ -130,6 +131,7 @@
 
     def checkForChangesNeededBy(self, change, change_queue):
         self.log.debug("Checking for changes needed by %s:" % change)
+        source = change.project.source
         # Return true if okay to proceed enqueing this change,
         # false if the change should not be enqueued.
         if not hasattr(change, 'needs_changes'):
@@ -163,8 +165,7 @@
                     self.log.debug("  Needed change is already ahead "
                                    "in the queue")
                     continue
-                if self.pipeline.source.canMerge(needed_change,
-                                                 self.getSubmitAllowNeeds()):
+                if source.canMerge(needed_change, self.getSubmitAllowNeeds()):
                     self.log.debug("  Change %s is needed" % needed_change)
                     if needed_change not in changes_needed:
                         changes_needed.append(needed_change)
diff --git a/zuul/model.py b/zuul/model.py
index 0128610..4880be1 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -105,11 +105,7 @@
 
 
 class Pipeline(object):
-    """A configuration that ties triggers, reporters, managers and sources.
-
-    Source
-        Where changes should come from. It is a named connection to
-        an external service defined in zuul.conf
+    """A configuration that ties together triggers, reporters and managers
 
     Trigger
         A description of which events should be processed
@@ -135,7 +131,6 @@
         self.manager = None
         self.queues = []
         self.precedence = PRECEDENCE_NORMAL
-        self.source = None
         self.triggers = []
         self.start_actions = []
         self.success_actions = []
@@ -350,9 +345,12 @@
     # This makes a Project instance a unique identifier for a given
     # project from a given source.
 
-    def __init__(self, name, connection_name, foreign=False):
+    def __init__(self, name, source, foreign=False):
         self.name = name
-        self.connection_name = connection_name
+        self.source = source
+        self.connection_name = source.connection.connection_name
+        self.canonical_hostname = source.canonical_hostname
+        self.canonical_name = source.canonical_hostname + '/' + name
         # foreign projects are those referenced in dependencies
         # of layout projects, this should matter
         # when deciding whether to enqueue their changes
@@ -383,6 +381,7 @@
         # Attributes from Nodepool
         self._state = 'unknown'
         self.state_time = time.time()
+        self.interface_ip = None
         self.public_ipv4 = None
         self.private_ipv4 = None
         self.public_ipv6 = None
@@ -1234,10 +1233,14 @@
     def getTries(self, job_name):
         return self.tries.get(job_name)
 
-    def getMergeMode(self, job_name):
-        if not self.layout or job_name not in self.layout.project_configs:
-            return MERGER_MERGE_RESOLVE
-        return self.layout.project_configs[job_name].merge_mode
+    def getMergeMode(self):
+        if self.layout:
+            project = self.item.change.project
+            project_config = self.layout.project_configs.get(
+                project.canonical_name)
+            if project_config:
+                return project_config.merge_mode
+        return MERGER_MERGE_RESOLVE
 
 
 class QueueItem(object):
@@ -1824,6 +1827,7 @@
         self.type = None
         # For management events (eg: enqueue / promote)
         self.tenant_name = None
+        self.project_hostname = None
         self.project_name = None
         self.trigger_name = None
         # Representation of the user account that performed the event.
@@ -1848,8 +1852,12 @@
         # an admin command, etc):
         self.forced_pipeline = None
 
+    @property
+    def canonical_project_name(self):
+        return self.project_hostname + '/' + self.project_name
+
     def __repr__(self):
-        ret = '<TriggerEvent %s %s' % (self.type, self.project_name)
+        ret = '<TriggerEvent %s %s' % (self.type, self.canonical_project_name)
 
         if self.branch:
             ret += " %s" % self.branch
@@ -2407,7 +2415,7 @@
 
     def createJobGraph(self, item):
         project_config = self.project_configs.get(
-            item.change.project.name, None)
+            item.change.project.canonical_name, None)
         ret = JobGraph()
         # NOTE(pabelanger): It is possible for a foreign project not to have a
         # configured pipeline, if so return an empty JobGraph.
@@ -2514,51 +2522,88 @@
         # The unparsed configuration from the main zuul config for
         # this tenant.
         self.unparsed_config = None
-        # The list of repos from which we will read main
-        # configuration.  (source, project)
-        self.config_repos = []
-        # The unparsed config from those repos.
-        self.config_repos_config = None
-        # The list of projects from which we will read in-repo
-        # configuration.  (source, project)
-        self.project_repos = []
-        # The unparsed config from those repos.
-        self.project_repos_config = None
-        # A mapping of source -> {config_repos: {}, project_repos: {}}
-        self.sources = {}
-
+        # The list of projects from which we will read full
+        # configuration.
+        self.config_projects = []
+        # The unparsed config from those projects.
+        self.config_projects_config = None
+        # The list of projects from which we will read untrusted
+        # in-repo configuration.
+        self.untrusted_projects = []
+        # The unparsed config from those projects.
+        self.untrusted_projects_config = None
         self.semaphore_handler = SemaphoreHandler()
 
-    def addConfigRepo(self, source, project):
-        sd = self.sources.setdefault(source.name,
-                                     {'config_repos': {},
-                                      'project_repos': {}})
-        sd['config_repos'][project.name] = project
+        # A mapping of project names to projects.  project_name ->
+        # VALUE where VALUE is a further dictionary of
+        # canonical_hostname -> Project.
+        self.projects = {}
+        self.canonical_hostnames = set()
 
-    def addProjectRepo(self, source, project):
-        sd = self.sources.setdefault(source.name,
-                                     {'config_repos': {},
-                                      'project_repos': {}})
-        sd['project_repos'][project.name] = project
+    def _addProject(self, project):
+        """Add a project to the project index
 
-    def getRepo(self, source, project_name):
-        """Get a project given a source and project name
+        :arg Project project: The project to add.
+        """
+        self.canonical_hostnames.add(project.canonical_hostname)
+        hostname_dict = self.projects.setdefault(project.name, {})
+        if project.canonical_hostname in hostname_dict:
+            raise Exception("Project %s is already in project index" %
+                            (project,))
+        hostname_dict[project.canonical_hostname] = project
 
-        Returns a tuple (trusted, project) or (None, None) if the
-        project is not found.
+    def getProject(self, name):
+        """Return a project given its name.
 
-        Trusted indicates the project is a config repo.
+        :arg str name: The name of the project.  It may be fully
+            qualified (E.g., "git.example.com/subpath/project") or may
+            contain only the project name name may be supplied (E.g.,
+            "subpath/project").
+
+        :returns: A tuple (trusted, project) or (None, None) if the
+            project is not found or ambiguous.  The "trusted" boolean
+            indicates whether or not the project is trusted by this
+            tenant.
+        :rtype: (bool, Project)
 
         """
-
-        sd = self.sources.get(source)
-        if not sd:
+        path = name.split('/', 1)
+        if path[0] in self.canonical_hostnames:
+            hostname = path[0]
+            project_name = path[1]
+        else:
+            hostname = None
+            project_name = name
+        hostname_dict = self.projects.get(project_name)
+        project = None
+        if hostname_dict:
+            if hostname:
+                project = hostname_dict.get(hostname)
+            else:
+                values = hostname_dict.values()
+                if len(values) == 1:
+                    project = values[0]
+                else:
+                    raise Exception("Project name '%s' is ambiguous, "
+                                    "please fully qualify the project "
+                                    "with a hostname" % (name,))
+        if project is None:
             return (None, None)
-        if project_name in sd['config_repos']:
-            return (True, sd['config_repos'][project_name])
-        if project_name in sd['project_repos']:
-            return (False, sd['project_repos'][project_name])
-        return (None, None)
+        if project in self.config_projects:
+            return (True, project)
+        if project in self.untrusted_projects:
+            return (False, project)
+        # This should never happen:
+        raise Exception("Project %s is neither trusted nor untrusted" %
+                        (project,))
+
+    def addConfigProject(self, project):
+        self.config_projects.append(project)
+        self._addProject(project)
+
+    def addUntrustedProject(self, project):
+        self.untrusted_projects.append(project)
+        self._addProject(project)
 
 
 class Abide(object):
diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py
index 0fb557c..105c34b 100644
--- a/zuul/rpclistener.py
+++ b/zuul/rpclistener.py
@@ -98,9 +98,10 @@
         if tenant:
             event.tenant_name = args['tenant']
 
-            project = tenant.layout.project_configs.get(args['project'])
+            (trusted, project) = tenant.getProject(args['project'])
             if project:
-                event.project_name = args['project']
+                event.project_hostname = project.canonical_hostname
+                event.project_name = project.name
             else:
                 errors += 'Invalid project: %s\n' % (args['project'],)
 
@@ -119,15 +120,15 @@
         else:
             errors += 'Invalid tenant: %s\n' % (args['tenant'],)
 
-        return (args, event, errors, pipeline, project)
+        return (args, event, errors, project)
 
     def handle_enqueue(self, job):
-        (args, event, errors, pipeline, project) = self._common_enqueue(job)
+        (args, event, errors, project) = self._common_enqueue(job)
 
         if not errors:
             event.change_number, event.patch_number = args['change'].split(',')
             try:
-                pipeline.source.getChange(event, project)
+                project.source.getChange(event, project)
             except Exception:
                 errors += 'Invalid change: %s\n' % (args['change'],)
 
@@ -138,7 +139,7 @@
             job.sendWorkComplete()
 
     def handle_enqueue_ref(self, job):
-        (args, event, errors, pipeline, project) = self._common_enqueue(job)
+        (args, event, errors, project) = self._common_enqueue(job)
 
         if not errors:
             event.ref = args['ref']
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index 0fa1763..53ca4c1 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -232,10 +232,11 @@
         self.stopConnections()
         self.wake_event.set()
 
-    def registerConnections(self, connections, load=True):
+    def registerConnections(self, connections, webapp, load=True):
         # load: whether or not to trigger the onLoad for the connection. This
         # is useful for not doing a full load during layout validation.
         self.connections = connections
+        self.connections.registerWebapp(webapp)
         self.connections.registerScheduler(self, load)
 
     def stopConnections(self):
@@ -486,6 +487,41 @@
         finally:
             self.layout_lock.release()
 
+    def _reenqueueGetProject(self, tenant, item):
+        project = item.change.project
+        # Attempt to get the same project as the one passed in.  If
+        # the project is now found on a different connection, return
+        # the new version of the project.  If it is no longer
+        # available (due to a connection being removed), return None.
+        (trusted, new_project) = tenant.getProject(project.canonical_name)
+        if new_project:
+            return new_project
+        # If this is a non-live item we may be looking at a
+        # "foreign" project, ie, one which is not defined in the
+        # config but is constructed ad-hoc to satisfy a
+        # cross-repo-dependency.  Find the corresponding live item
+        # and use its source.
+        child = item
+        while child and not child.live:
+            # This assumes that the queue does not branch behind this
+            # item, which is currently true for non-live items; if
+            # that changes, this traversal will need to be more
+            # complex.
+            if child.items_behind:
+                child = child.items_behind[0]
+            else:
+                child = None
+        if child is item:
+            return None
+        if child and child.live:
+            (child_trusted, child_project) = tenant.getProject(
+                child.change.project.canonical_name)
+            if child_project:
+                source = child_project.source
+                new_project = source.getProject(project.name)
+                return new_project
+        return None
+
     def _reenqueueTenant(self, old_tenant, tenant):
         for name, new_pipeline in tenant.layout.pipelines.items():
             old_pipeline = old_tenant.layout.pipelines.get(name)
@@ -501,15 +537,15 @@
                 for item in shared_queue.queue:
                     if not item.item_ahead:
                         last_head = item
-                    item.item_ahead = None
-                    item.items_behind = []
                     item.pipeline = None
                     item.queue = None
-                    project_name = item.change.project.name
-                    item.change.project = new_pipeline.source.getProject(
-                        project_name)
-                    if new_pipeline.manager.reEnqueueItem(item,
-                                                          last_head):
+                    item.change.project = self._reenqueueGetProject(
+                        tenant, item)
+                    item.item_ahead = None
+                    item.items_behind = []
+                    if (item.change.project and
+                        new_pipeline.manager.reEnqueueItem(item,
+                                                           last_head)):
                         for build in item.current_build_set.getBuilds():
                             new_job = item.getJob(build.job.name)
                             if new_job:
@@ -553,7 +589,6 @@
 
         # TODOv3(jeblair): remove postconfig calls?
         for pipeline in tenant.layout.pipelines.values():
-            pipeline.source.postConfig()
             for trigger in pipeline.triggers:
                 trigger.postConfig(pipeline)
             for reporter in pipeline.actions:
@@ -611,9 +646,9 @@
 
     def _doEnqueueEvent(self, event):
         tenant = self.abide.tenants.get(event.tenant_name)
-        project = tenant.layout.project_configs.get(event.project_name)
+        (trusted, project) = tenant.getProject(event.project_name)
         pipeline = tenant.layout.pipelines[event.forced_pipeline]
-        change = pipeline.source.getChange(event, project)
+        change = project.source.getChange(event, project)
         self.log.debug("Event %s for change %s was directly assigned "
                        "to pipeline %s" % (event, change, self))
         pipeline.manager.addChange(change, ignore_requirements=True)
@@ -702,31 +737,28 @@
         event = self.trigger_event_queue.get()
         self.log.debug("Processing trigger event %s" % event)
         try:
+            full_project_name = ('/'.join([event.project_hostname,
+                                           event.project_name]))
             for tenant in self.abide.tenants.values():
-                reconfigured_tenant = False
+                (trusted, project) = tenant.getProject(full_project_name)
+                if project is None:
+                    continue
+                try:
+                    change = project.source.getChange(event)
+                except exceptions.ChangeNotFound as e:
+                    self.log.debug("Unable to get change %s from "
+                                   "source %s",
+                                   e.change, project.source)
+                    continue
+                if (event.type == 'change-merged' and
+                    hasattr(change, 'files') and
+                    change.updatesConfig()):
+                    # The change that just landed updates the config.
+                    # Clear out cached data for this project and
+                    # perform a reconfiguration.
+                    change.project.unparsed_config = None
+                    self.reconfigureTenant(tenant)
                 for pipeline in tenant.layout.pipelines.values():
-                    # Get the change even if the project is unknown to
-                    # us for the use of updating the cache if there is
-                    # another change depending on this foreign one.
-                    try:
-                        change = pipeline.source.getChange(event)
-                    except exceptions.ChangeNotFound as e:
-                        self.log.debug("Unable to get change %s from "
-                                       "source %s (most likely looking "
-                                       "for a change from another "
-                                       "connection trigger)",
-                                       e.change, pipeline.source)
-                        continue
-                    if (event.type == 'change-merged' and
-                        hasattr(change, 'files') and
-                        not reconfigured_tenant and
-                        change.updatesConfig()):
-                        # The change that just landed updates the config.
-                        # Clear out cached data for this project and
-                        # perform a reconfiguration.
-                        change.project.unparsed_config = None
-                        self.reconfigureTenant(tenant)
-                        reconfigured_tenant = True
                     if event.type == 'patchset-created':
                         pipeline.manager.removeOldVersionsOfChange(change)
                     elif event.type == 'change-abandoned':
diff --git a/zuul/webapp.py b/zuul/webapp.py
index 4f040fa..f5a7373 100644
--- a/zuul/webapp.py
+++ b/zuul/webapp.py
@@ -45,6 +45,7 @@
 
 class WebApp(threading.Thread):
     log = logging.getLogger("zuul.WebApp")
+    change_path_regexp = '/status/change/(\d+,\d+)$'
 
     def __init__(self, scheduler, port=8001, cache_expiry=1,
                  listen_address='0.0.0.0'):
@@ -56,10 +57,16 @@
         self.cache_time = 0
         self.cache = {}
         self.daemon = True
+        self.routes = {}
+        self._init_default_routes()
         self.server = httpserver.serve(
             dec.wsgify(self.app), host=self.listen_address, port=self.port,
             start_loop=False)
 
+    def _init_default_routes(self):
+        self.register_path('/(status\.json|status)$', self.status)
+        self.register_path(self.change_path_regexp, self.change)
+
     def run(self):
         self.server.serve_forever()
 
@@ -90,14 +97,13 @@
             return change['id'] == rev
         return self._changes_by_func(func, tenant_name)
 
-    def _normalize_path(self, path):
-        # support legacy status.json as well as new /status
-        if path == '/status.json' or path == '/status':
-            return "status"
-        m = re.match('/status/change/(\d+,\d+)$', path)
-        if m:
-            return m.group(1)
-        return None
+    def register_path(self, path, handler):
+        path_re = re.compile(path)
+        self.routes[path] = (path_re, handler)
+
+    def unregister_path(self, path):
+        if self.routes.get(path):
+            del self.routes[path]
 
     def _handle_keys(self, request, path):
         m = re.match('/keys/(.*?)/(.*?).pub', path)
@@ -120,14 +126,43 @@
         return response.conditional_response_app
 
     def app(self, request):
+        # Try registered paths without a tenant_name first
+        path = request.path
+        for path_re, handler in self.routes.itervalues():
+            if path_re.match(path):
+                return handler(path, '', request)
+
+        # Now try with a tenant_name stripped
         tenant_name = request.path.split('/')[1]
         path = request.path.replace('/' + tenant_name, '')
+        # Handle keys
         if path.startswith('/keys'):
             return self._handle_keys(request, path)
-        path = self._normalize_path(path)
-        if path is None:
+        for path_re, handler in self.routes.itervalues():
+            if path_re.match(path):
+                return handler(path, tenant_name, request)
+        else:
             raise webob.exc.HTTPNotFound()
 
+    def status(self, path, tenant_name, request):
+        def func():
+            return webob.Response(body=self.cache[tenant_name],
+                                  content_type='application/json')
+        return self._response_with_status_cache(func, tenant_name)
+
+    def change(self, path, tenant_name, request):
+        def func():
+            m = re.match(self.change_path_regexp, path)
+            change_id = m.group(1)
+            status = self._status_for_change(change_id, tenant_name)
+            if status:
+                return webob.Response(body=status,
+                                      content_type='application/json')
+            else:
+                raise webob.exc.HTTPNotFound()
+        return self._response_with_status_cache(func, tenant_name)
+
+    def _refresh_status_cache(self, tenant_name):
         if (tenant_name not in self.cache or
             (time.time() - self.cache_time) > self.cache_expiry):
             try:
@@ -140,16 +175,10 @@
                 self.log.exception("Exception formatting status:")
                 raise
 
-        if path == 'status':
-            response = webob.Response(body=self.cache[tenant_name],
-                                      content_type='application/json')
-        else:
-            status = self._status_for_change(path, tenant_name)
-            if status:
-                response = webob.Response(body=status,
-                                          content_type='application/json')
-            else:
-                raise webob.exc.HTTPNotFound()
+    def _response_with_status_cache(self, func, tenant_name):
+        self._refresh_status_cache(tenant_name)
+
+        response = func()
 
         response.headers['Access-Control-Allow-Origin'] = '*'