Merge "Remove file extension when building SimpleLayout" into feature/zuulv3
diff --git a/doc/source/admin/drivers/github.rst b/doc/source/admin/drivers/github.rst
index 7eebbdc..8dd7764 100644
--- a/doc/source/admin/drivers/github.rst
+++ b/doc/source/admin/drivers/github.rst
@@ -7,18 +7,95 @@
 interact with the public GitHub service as well as site-local
 installations of GitHub enterprise.
 
-.. TODO: make this section more user friendly
+Configure GitHub
+----------------
 
-Configure GitHub `webhook events
-<https://developer.github.com/webhooks/creating/>`_.
+There are two options currently available. GitHub's project owner can either
+manually setup web-hook or install a GitHub Application. In the first case,
+the project's owner needs to know the zuul endpoint and the webhook secrets.
 
-Set *Payload URL* to
-``http://<zuul-hostname>/connection/<connection-name>/payload``.
 
-Set *Content Type* to ``application/json``.
+Web-Hook
+........
+
+To configure a project's `webhook events <https://developer.github.com/webhooks/creating/>`_:
+
+* Set *Payload URL* to ``http://<zuul-hostname>/connection/<connection-name>/payload``.
+
+* Set *Content Type* to ``application/json``.
 
 Select *Events* you are interested in. See below for the supported events.
 
+You will also need to have a GitHub user created for your zuul:
+
+* Zuul public key needs to be added to the GitHub account
+
+* A api_token needs to be created too, see this `article <See https://help.github.com/articles/creating-an-access-token-for-command-line-use/>`_
+
+Then in the zuul.conf, set webhook_token and api_token.
+
+Application
+...........
+
+To create a `GitHub application <https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/registering-github-apps/>`_:
+
+* Go to your organization settings page to create the application, e.g.: https://github.com/organizations/my-org/settings/apps/new
+
+* Set GitHub App name to "my-org-zuul"
+
+* Set Setup URL to your setup documentation, when user install the application they are redirected to this url
+
+* Set Webhook URL to ``http://<zuul-hostname>/connection/<connection-name>/payload``.
+
+* Create a Webhook secret
+
+* Set permissions:
+
+  * Commit statuses: Read & Write
+
+  * Issues: Read & Write
+
+  * Pull requests: Read & Write
+
+  * Repository contents: Read & Write (write to let zuul merge change)
+
+* Set events subscription:
+
+  * Label
+
+  * Status
+
+  * Issue comment
+
+  * Issues
+
+  * Pull request
+
+  * Pull request review
+
+  * Pull request review comment
+
+  * Commit comment
+
+  * Create
+
+  * Push
+
+  * Release
+
+* Set Where can this GitHub App be installed to "Any account"
+
+* Create the App
+
+* Generate a Private key in the app settings page
+
+Then in the zuul.conf, set webhook_token, app_id and app_key.
+After restarting zuul-scheduler, verify in the 'Advanced' tab that the
+Ping payload works (green tick and 200 response)
+
+Users can now install the application using its public page, e.g.: https://github.com/apps/my-org-zuul
+
+
 Connection Configuration
 ------------------------
 
diff --git a/doc/source/user/config.rst b/doc/source/user/config.rst
index 3ea20ab..96e55a8 100644
--- a/doc/source/user/config.rst
+++ b/doc/source/user/config.rst
@@ -1210,7 +1210,9 @@
            label: controller-label
          - name: compute1
            label: compute-label
-         - name: compute2
+         - name:
+             - compute2
+             - web
            label: compute-label
        groups:
          - name: ceph-osd
@@ -1221,6 +1223,9 @@
              - controller
              - compute1
              - compute2
+          - name: ceph-web
+            nodes:
+              - web
 
 .. attr:: nodeset
 
@@ -1242,6 +1247,9 @@
          The name of the node.  This will appear in the Ansible inventory
          for the job.
 
+         This can also be as a list of strings. If so, then the list of hosts in
+         the Ansible inventory will share a common ansible_host address.
+
       .. attr:: label
          :required:
 
diff --git a/doc/source/user/jobs.rst b/doc/source/user/jobs.rst
index 989338a..278c4f4 100644
--- a/doc/source/user/jobs.rst
+++ b/doc/source/user/jobs.rst
@@ -220,14 +220,15 @@
          `src/git.example.com/org/project`.
 
    .. var:: projects
-      :type: list
+      :type: dict
 
-      A list of all projects prepared by Zuul for the item.  It
+      A dictionary of all projects prepared by Zuul for the item.  It
       includes, at least, the item's own project.  It also includes
       the projects of any items this item depends on, as well as the
       projects that appear in :attr:`job.required-projects`.
 
-      This is a list of dictionaries, with each element consisting of:
+      This is a dictionary of dictionaries.  Each value has a key of
+      the `canonical_name`, then each entry consists of:
 
       .. var:: name
 
@@ -264,6 +265,20 @@
          This may be influenced by the branch or tag associated with
          the item as well as the job configuration.
 
+      For example, to access the source directory of a single known
+      project, you might use::
+
+        {{ zuul.projects['git.example.com/org/project'].src_dir }}
+
+      To iterate over the project list, you might write a task
+      something like::
+
+        - name: Sample project iteration
+          debug:
+            msg: "Project {{ item.name }} is at {{ item.src_dir }}
+          with_items: {{ zuul.projects.values() | list }}
+
+
    .. var:: _projects
       :type: dict
 
diff --git a/etc/zuul.conf-sample b/etc/zuul.conf-sample
index f0e1765..17092af 100644
--- a/etc/zuul.conf-sample
+++ b/etc/zuul.conf-sample
@@ -38,6 +38,7 @@
 listen_address=127.0.0.1
 port=9000
 static_cache_expiry=0
+;sql_connection_name=mydatabase
 
 [webapp]
 listen_address=0.0.0.0
diff --git a/playbooks/zuul-stream/templates/ansible.cfg.j2 b/playbooks/zuul-stream/templates/ansible.cfg.j2
index 24f459e..41ffc0c 100644
--- a/playbooks/zuul-stream/templates/ansible.cfg.j2
+++ b/playbooks/zuul-stream/templates/ansible.cfg.j2
@@ -1,5 +1,5 @@
 [defaults]
-hostfile = {{ ansible_user_dir }}/inventory.yaml
+inventory = {{ ansible_user_dir }}/inventory.yaml
 gathering = smart
 gather_subset = !all
 lookup_plugins = {{ ansible_user_dir }}/src/git.openstack.org/openstack-infra/zuul/zuul/ansible/lookup
diff --git a/tests/base.py b/tests/base.py
index 241ef89..4599942 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -1435,7 +1435,7 @@
             host['host_vars']['ansible_connection'] = 'local'
 
         hosts.append(dict(
-            name='localhost',
+            name=['localhost'],
             host_vars=dict(ansible_connection='local'),
             host_keys=[]))
         return hosts
diff --git a/tests/fixtures/config/inventory/git/common-config/zuul.yaml b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
index 74ddf2d..ad530a7 100644
--- a/tests/fixtures/config/inventory/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
@@ -52,6 +52,16 @@
     run: playbooks/single-inventory.yaml
 
 - job:
+    name: single-inventory-list
+    nodeset:
+      nodes:
+        - name:
+            - compute
+            - controller
+          label: ubuntu-xenial
+    run: playbooks/single-inventory.yaml
+
+- job:
     name: group-inventory
     nodeset: nodeset1
     run: playbooks/group-inventory.yaml
diff --git a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml
index 1a8bf5d..6a29049 100644
--- a/tests/fixtures/config/inventory/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/inventory/git/org_project/.zuul.yaml
@@ -3,5 +3,6 @@
     check:
       jobs:
         - single-inventory
+        - single-inventory-list
         - group-inventory
         - hostvars-inventory
diff --git a/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml b/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml
index e21f967..a28ef54 100644
--- a/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml
@@ -18,8 +18,10 @@
 - job:
     name: common-config-job
 
+# Use the canonical name here. This should be merged with the org/project1 in
+# the other repo.
 - project:
-    name: org/project1
+    name: review.example.com/org/project1
     check:
       jobs:
         - common-config-job
diff --git a/tests/unit/test_inventory.py b/tests/unit/test_inventory.py
index 04dcb05..1c41f5f 100644
--- a/tests/unit/test_inventory.py
+++ b/tests/unit/test_inventory.py
@@ -57,6 +57,26 @@
         self.executor_server.release()
         self.waitUntilSettled()
 
+    def test_single_inventory_list(self):
+
+        inventory = self._get_build_inventory('single-inventory-list')
+
+        all_nodes = ('compute', 'controller')
+        self.assertIn('all', inventory)
+        self.assertIn('hosts', inventory['all'])
+        self.assertIn('vars', inventory['all'])
+        for node_name in all_nodes:
+            self.assertIn(node_name, inventory['all']['hosts'])
+        self.assertIn('zuul', inventory['all']['vars'])
+        z_vars = inventory['all']['vars']['zuul']
+        self.assertIn('executor', z_vars)
+        self.assertIn('src_root', z_vars['executor'])
+        self.assertIn('job', z_vars)
+        self.assertEqual(z_vars['job'], 'single-inventory-list')
+
+        self.executor_server.release()
+        self.waitUntilSettled()
+
     def test_group_inventory(self):
 
         inventory = self._get_build_inventory('group-inventory')
diff --git a/tests/unit/test_log_streamer.py b/tests/unit/test_log_streamer.py
index c808540..27368e3 100644
--- a/tests/unit/test_log_streamer.py
+++ b/tests/unit/test_log_streamer.py
@@ -158,7 +158,7 @@
 
     def runWSClient(self, build_uuid, event):
         async def client(loop, build_uuid, event):
-            uri = 'http://[::1]:9000/console-stream'
+            uri = 'http://[::1]:9000/tenant-one/console-stream'
             try:
                 session = aiohttp.ClientSession(loop=loop)
                 async with session.ws_connect(uri) as ws:
diff --git a/tests/unit/test_nodepool.py b/tests/unit/test_nodepool.py
index d3f9ddb..aa0f082 100644
--- a/tests/unit/test_nodepool.py
+++ b/tests/unit/test_nodepool.py
@@ -67,8 +67,8 @@
         # Test a simple node request
 
         nodeset = model.NodeSet()
-        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
-        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['controller', 'foo'], 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['compute'], 'ubuntu-xenial'))
         job = model.Job('testjob')
         job.nodeset = nodeset
         request = self.nodepool.requestNodes(None, job)
@@ -99,8 +99,8 @@
         # Test that node requests are re-submitted after disconnect
 
         nodeset = model.NodeSet()
-        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
-        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['controller'], 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['compute'], 'ubuntu-xenial'))
         job = model.Job('testjob')
         job.nodeset = nodeset
         self.fake_nodepool.paused = True
@@ -116,8 +116,8 @@
         # Test that node requests can be canceled
 
         nodeset = model.NodeSet()
-        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
-        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['controller'], 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['compute'], 'ubuntu-xenial'))
         job = model.Job('testjob')
         job.nodeset = nodeset
         self.fake_nodepool.paused = True
@@ -131,8 +131,8 @@
         # Test that a resubmitted request would not lock nodes
 
         nodeset = model.NodeSet()
-        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
-        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['controller'], 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['compute'], 'ubuntu-xenial'))
         job = model.Job('testjob')
         job.nodeset = nodeset
         request = self.nodepool.requestNodes(None, job)
@@ -152,8 +152,8 @@
         # Test that a lost request would not lock nodes
 
         nodeset = model.NodeSet()
-        nodeset.addNode(model.Node('controller', 'ubuntu-xenial'))
-        nodeset.addNode(model.Node('compute', 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['controller'], 'ubuntu-xenial'))
+        nodeset.addNode(model.Node(['compute'], 'ubuntu-xenial'))
         job = model.Job('testjob')
         job.nodeset = nodeset
         request = self.nodepool.requestNodes(None, job)
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index cad557e..aacc81e 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -2581,7 +2581,7 @@
         self.assertEqual('project-merge', status_jobs[0]['name'])
         # TODO(mordred) pull uuids from self.builds
         self.assertEqual(
-            'static/stream.html?uuid={uuid}&logfile=console.log'.format(
+            'stream.html?uuid={uuid}&logfile=console.log'.format(
                 uuid=status_jobs[0]['uuid']),
             status_jobs[0]['url'])
         self.assertEqual(
@@ -2597,7 +2597,7 @@
             status_jobs[0]['report_url'])
         self.assertEqual('project-test1', status_jobs[1]['name'])
         self.assertEqual(
-            'static/stream.html?uuid={uuid}&logfile=console.log'.format(
+            'stream.html?uuid={uuid}&logfile=console.log'.format(
                 uuid=status_jobs[1]['uuid']),
             status_jobs[1]['url'])
         self.assertEqual(
@@ -2613,7 +2613,7 @@
 
         self.assertEqual('project-test2', status_jobs[2]['name'])
         self.assertEqual(
-            'static/stream.html?uuid={uuid}&logfile=console.log'.format(
+            'stream.html?uuid={uuid}&logfile=console.log'.format(
                 uuid=status_jobs[2]['uuid']),
             status_jobs[2]['url'])
         self.assertEqual(
@@ -4210,7 +4210,7 @@
                 self.assertEqual('gate', job['pipeline'])
                 self.assertEqual(False, job['retry'])
                 self.assertEqual(
-                    'static/stream.html?uuid={uuid}&logfile=console.log'
+                    'stream.html?uuid={uuid}&logfile=console.log'
                     .format(uuid=job['uuid']), job['url'])
                 self.assertEqual(
                     'finger://{hostname}/{uuid}'.format(
diff --git a/tools/test-logs.sh b/tools/test-logs.sh
index bf2147d..a514dd8 100644
--- a/tools/test-logs.sh
+++ b/tools/test-logs.sh
@@ -42,7 +42,7 @@
 
 cat >$WORK_DIR/ansible.cfg <<EOF
 [defaults]
-hostfile = $INVENTORY
+inventory = $INVENTORY
 gathering = smart
 gather_subset = !all
 fact_caching = jsonfile
diff --git a/zuul/ansible/library/zuul_return.py b/zuul/ansible/library/zuul_return.py
index 9f3332b..4935226 100644
--- a/zuul/ansible/library/zuul_return.py
+++ b/zuul/ansible/library/zuul_return.py
@@ -63,7 +63,7 @@
         path = os.path.join(os.environ['ZUUL_JOBDIR'], 'work',
                             'results.json')
     set_value(path, p['data'], p['file'])
-    module.exit_json(changed=True, e=os.environ)
+    module.exit_json(changed=True, e=os.environ.copy())
 
 from ansible.module_utils.basic import *  # noqa
 from ansible.module_utils.basic import AnsibleModule
diff --git a/zuul/cmd/web.py b/zuul/cmd/web.py
index 6e5489f..ad3062f 100755
--- a/zuul/cmd/web.py
+++ b/zuul/cmd/web.py
@@ -22,6 +22,7 @@
 import zuul.cmd
 import zuul.web
 
+from zuul.driver.sql import sqlconnection
 from zuul.lib.config import get_default
 
 
@@ -48,6 +49,30 @@
         params['ssl_cert'] = get_default(self.config, 'gearman', 'ssl_cert')
         params['ssl_ca'] = get_default(self.config, 'gearman', 'ssl_ca')
 
+        sql_conn_name = get_default(self.config, 'web',
+                                    'sql_connection_name')
+        sql_conn = None
+        if sql_conn_name:
+            # we want a specific sql connection
+            sql_conn = self.connections.connections.get(sql_conn_name)
+            if not sql_conn:
+                self.log.error("Couldn't find sql connection '%s'" %
+                               sql_conn_name)
+                sys.exit(1)
+        else:
+            # look for any sql connection
+            connections = [c for c in self.connections.connections.values()
+                           if isinstance(c, sqlconnection.SQLConnection)]
+            if len(connections) > 1:
+                self.log.error("Multiple sql connection found, "
+                               "set the sql_connection_name option "
+                               "in zuul.conf [web] section")
+                sys.exit(1)
+            if connections:
+                # use this sql connection by default
+                sql_conn = connections[0]
+        params['sql_connection'] = sql_conn
+
         try:
             self.web = zuul.web.ZuulWeb(**params)
         except Exception as e:
@@ -79,6 +104,8 @@
         self.setup_logging('web', 'log_config')
         self.log = logging.getLogger("zuul.WebServer")
 
+        self.configure_connections()
+
         try:
             self._run()
         except Exception:
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 99f10f6..fb1695c 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -340,7 +340,7 @@
 class NodeSetParser(object):
     @staticmethod
     def getSchema(anonymous=False):
-        node = {vs.Required('name'): str,
+        node = {vs.Required('name'): to_list(str),
                 vs.Required('label'): str,
                 }
 
@@ -365,11 +365,13 @@
         node_names = set()
         group_names = set()
         for conf_node in as_list(conf['nodes']):
-            if conf_node['name'] in node_names:
-                raise DuplicateNodeError(conf['name'], conf_node['name'])
-            node = model.Node(conf_node['name'], conf_node['label'])
+            for name in as_list(conf_node['name']):
+                if name in node_names:
+                    raise DuplicateNodeError(name, conf_node['name'])
+            node = model.Node(as_list(conf_node['name']), conf_node['label'])
             ns.addNode(node)
-            node_names.add(conf_node['name'])
+            for name in as_list(conf_node['name']):
+                node_names.add(name)
         for conf_group in as_list(conf.get('groups', [])):
             for node_name in as_list(conf_group['nodes']):
                 if node_name not in node_names:
@@ -517,6 +519,7 @@
         # "job.run.append(...)").
 
         job = model.Job(name)
+        job.description = conf.get('description')
         job.source_context = conf.get('_source_context')
         job.source_line = conf.get('_start_mark').line + 1
 
@@ -1161,8 +1164,8 @@
                                                   tenant.config_projects,
                                                   tenant.untrusted_projects,
                                                   cached, tenant)
-        unparsed_config.extend(tenant.config_projects_config)
-        unparsed_config.extend(tenant.untrusted_projects_config)
+        unparsed_config.extend(tenant.config_projects_config, tenant=tenant)
+        unparsed_config.extend(tenant.untrusted_projects_config, tenant=tenant)
         tenant.layout = TenantParser._parseLayout(base, tenant,
                                                   unparsed_config,
                                                   scheduler,
diff --git a/zuul/executor/client.py b/zuul/executor/client.py
index a8b94f0..06c2087 100644
--- a/zuul/executor/client.py
+++ b/zuul/executor/client.py
@@ -180,8 +180,7 @@
         if (hasattr(item.change, 'newrev') and item.change.newrev
             and item.change.newrev != '0' * 40):
             zuul_params['newrev'] = item.change.newrev
-        zuul_params['projects'] = []  # Set below
-        zuul_params['_projects'] = {}  # transitional to convert to dict
+        zuul_params['projects'] = {}  # Set below
         zuul_params['items'] = dependent_changes
 
         params = dict()
@@ -253,7 +252,7 @@
                 params['projects'].append(make_project_dict(project))
                 projects.add(project)
         for p in projects:
-            zuul_params['_projects'][p.canonical_name] = (dict(
+            zuul_params['projects'][p.canonical_name] = (dict(
                 name=p.name,
                 short_name=p.name.split('/')[-1],
                 # Duplicate this into the dict too, so that iterating
@@ -265,12 +264,10 @@
             ))
         # We are transitioning "projects" from a list to a dict
         # indexed by canonical name, as it is much easier to access
-        # values in ansible.  Existing callers are converted to
-        # "_projects", then once "projects" is unused we switch it,
-        # then convert callers back.  Finally when "_projects" is
-        # unused it will be removed.
-        for cn, p in zuul_params['_projects'].items():
-            zuul_params['projects'].append(p)
+        # values in ansible.  Existing callers have been converted to
+        # "_projects" and "projects" is swapped; we will convert users
+        # back to "projects" and remove this soon.
+        zuul_params['_projects'] = zuul_params['projects']
 
         build = Build(job, uuid)
         build.parameters = params
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index 016d0e6..83fdc3c 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -497,7 +497,8 @@
 
     hosts = {}
     for node in nodes:
-        hosts[node['name']] = node['host_vars']
+        for name in node['name']:
+            hosts[name] = node['host_vars']
 
     inventory = {
         'all': {
@@ -1187,7 +1188,7 @@
             callback_path = self.executor_server.callback_dir
         with open(jobdir_playbook.ansible_config, 'w') as config:
             config.write('[defaults]\n')
-            config.write('hostfile = %s\n' % self.jobdir.inventory)
+            config.write('inventory = %s\n' % self.jobdir.inventory)
             config.write('local_tmp = %s/local_tmp\n' %
                          self.jobdir.ansible_cache_root)
             config.write('retry_files_enabled = False\n')
diff --git a/zuul/model.py b/zuul/model.py
index c1e1914..3b49591 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -498,9 +498,10 @@
         return n
 
     def addNode(self, node):
-        if node.name in self.nodes:
-            raise Exception("Duplicate node in %s" % (self,))
-        self.nodes[node.name] = node
+        for name in node.name:
+            if name in self.nodes:
+                raise Exception("Duplicate node in %s" % (self,))
+        self.nodes[tuple(node.name)] = node
 
     def getNodes(self):
         return list(self.nodes.values())
@@ -858,6 +859,7 @@
             source_line=None,
             inheritance_path=(),
             parent_data=None,
+            description=None,
         )
 
         self.inheritable_attributes = {}
@@ -1195,8 +1197,8 @@
                 if soft:
                     current_parent_jobs = set()
                 else:
-                    raise Exception("Dependent job %s not found: " %
-                                    (dependent_job,))
+                    raise Exception("Job %s depends on %s which was not run." %
+                                    (dependent_job, current_job))
             new_parent_jobs = current_parent_jobs - all_parent_jobs
             jobs_to_iterate |= new_parent_jobs
             all_parent_jobs |= new_parent_jobs
@@ -1862,7 +1864,7 @@
                 result = build.result
                 finger_url = build.url
                 # TODO(tobiash): add support for custom web root
-                urlformat = 'static/stream.html?' \
+                urlformat = 'stream.html?' \
                             'uuid={build.uuid}&' \
                             'logfile=console.log'
                 if websocket_url:
@@ -2384,14 +2386,25 @@
         r.semaphores = copy.deepcopy(self.semaphores)
         return r
 
-    def extend(self, conf):
+    def extend(self, conf, tenant=None):
         if isinstance(conf, UnparsedTenantConfig):
             self.pragmas.extend(conf.pragmas)
             self.pipelines.extend(conf.pipelines)
             self.jobs.extend(conf.jobs)
             self.project_templates.extend(conf.project_templates)
             for k, v in conf.projects.items():
-                self.projects.setdefault(k, []).extend(v)
+                name = k
+                # If we have the tenant add the projects to
+                # the according canonical name instead of the given project
+                # name. If it is not found, it's ok to add this to the given
+                # name. We also don't need to throw the
+                # ProjectNotFoundException here as semantic validation occurs
+                # later where it will fail then.
+                if tenant is not None:
+                    trusted, project = tenant.getProject(k)
+                    if project is not None:
+                        name = project.canonical_name
+                self.projects.setdefault(name, []).extend(v)
             self.nodesets.extend(conf.nodesets)
             self.secrets.extend(conf.secrets)
             self.semaphores.extend(conf.semaphores)
diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py
index 8c8c783..d40505e 100644
--- a/zuul/rpclistener.py
+++ b/zuul/rpclistener.py
@@ -21,6 +21,7 @@
 import gear
 
 from zuul import model
+from zuul.lib import encryption
 from zuul.lib.config import get_default
 
 
@@ -58,6 +59,8 @@
         self.worker.registerFunction("zuul:get_job_log_stream_address")
         self.worker.registerFunction("zuul:tenant_list")
         self.worker.registerFunction("zuul:status_get")
+        self.worker.registerFunction("zuul:job_list")
+        self.worker.registerFunction("zuul:key_get")
 
     def getFunctions(self):
         functions = {}
@@ -283,3 +286,25 @@
         args = json.loads(job.arguments)
         output = self.sched.formatStatusJSON(args.get("tenant"))
         job.sendWorkComplete(output)
+
+    def handle_job_list(self, job):
+        args = json.loads(job.arguments)
+        tenant = self.sched.abide.tenants.get(args.get("tenant"))
+        output = []
+        for job_name in sorted(tenant.layout.jobs):
+            desc = None
+            for tenant_job in tenant.layout.jobs[job_name]:
+                if tenant_job.description:
+                    desc = tenant_job.description.split('\n')[0]
+                    break
+            output.append({"name": job_name,
+                           "description": desc})
+        job.sendWorkComplete(json.dumps(output))
+
+    def handle_key_get(self, job):
+        args = json.loads(job.arguments)
+        source_name, project_name = args.get("source"), args.get("project")
+        source = self.sched.connections.getSource(source_name)
+        project = source.getProject(project_name)
+        job.sendWorkComplete(
+            encryption.serialize_rsa_public_key(project.public_key))
diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py
index 766a21d..e4a3612 100755
--- a/zuul/web/__init__.py
+++ b/zuul/web/__init__.py
@@ -20,11 +20,14 @@
 import logging
 import os
 import time
+import urllib.parse
 import uvloop
 
 import aiohttp
 from aiohttp import web
 
+from sqlalchemy.sql import select
+
 import zuul.rpcclient
 
 STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
@@ -162,6 +165,8 @@
         self.controllers = {
             'tenant_list': self.tenant_list,
             'status_get': self.status_get,
+            'job_list': self.job_list,
+            'key_get': self.key_get,
         }
 
     def tenant_list(self, request):
@@ -182,6 +187,18 @@
         resp.last_modified = self.cache_time[tenant]
         return resp
 
+    def job_list(self, request):
+        tenant = request.match_info["tenant"]
+        job = self.rpc.submitJob('zuul:job_list', {'tenant': tenant})
+        return web.json_response(json.loads(job.data[0]))
+
+    def key_get(self, request):
+        source = request.match_info["source"]
+        project = request.match_info["project"]
+        job = self.rpc.submitJob('zuul:key_get', {'source': source,
+                                                  'project': project})
+        return web.Response(body=job.data[0])
+
     async def processRequest(self, request, action):
         try:
             resp = self.controllers[action](request)
@@ -194,6 +211,92 @@
         return resp
 
 
+class SqlHandler(object):
+    log = logging.getLogger("zuul.web.SqlHandler")
+    filters = ("project", "pipeline", "change", "patchset", "ref",
+               "result", "uuid", "job_name", "voting", "node_name", "newrev")
+
+    def __init__(self, connection):
+        self.connection = connection
+
+    def query(self, args):
+        build = self.connection.zuul_build_table
+        buildset = self.connection.zuul_buildset_table
+        query = select([
+            buildset.c.project,
+            buildset.c.pipeline,
+            buildset.c.change,
+            buildset.c.patchset,
+            buildset.c.ref,
+            buildset.c.newrev,
+            buildset.c.ref_url,
+            build.c.result,
+            build.c.uuid,
+            build.c.job_name,
+            build.c.voting,
+            build.c.node_name,
+            build.c.start_time,
+            build.c.end_time,
+            build.c.log_url]).select_from(build.join(buildset))
+        for table in ('build', 'buildset'):
+            for k, v in args['%s_filters' % table].items():
+                if table == 'build':
+                    column = build.c
+                else:
+                    column = buildset.c
+                query = query.where(getattr(column, k).in_(v))
+        return query.limit(args['limit']).offset(args['skip']).order_by(
+            build.c.id.desc())
+
+    def get_builds(self, args):
+        """Return a list of build"""
+        builds = []
+        with self.connection.engine.begin() as conn:
+            query = self.query(args)
+            for row in conn.execute(query):
+                build = dict(row)
+                # Convert date to iso format
+                if row.start_time:
+                    build['start_time'] = row.start_time.strftime(
+                        '%Y-%m-%dT%H:%M:%S')
+                if row.end_time:
+                    build['end_time'] = row.end_time.strftime(
+                        '%Y-%m-%dT%H:%M:%S')
+                # Compute run duration
+                if row.start_time and row.end_time:
+                    build['duration'] = (row.end_time -
+                                         row.start_time).total_seconds()
+                builds.append(build)
+        return builds
+
+    async def processRequest(self, request):
+        try:
+            args = {
+                'buildset_filters': {},
+                'build_filters': {},
+                'limit': 50,
+                'skip': 0,
+            }
+            for k, v in urllib.parse.parse_qsl(request.rel_url.query_string):
+                if k in ("tenant", "project", "pipeline", "change",
+                         "patchset", "ref", "newrev"):
+                    args['buildset_filters'].setdefault(k, []).append(v)
+                elif k in ("uuid", "job_name", "voting", "node_name",
+                           "result"):
+                    args['build_filters'].setdefault(k, []).append(v)
+                elif k in ("limit", "skip"):
+                    args[k] = int(v)
+                else:
+                    raise ValueError("Unknown parameter %s" % k)
+            data = self.get_builds(args)
+            resp = web.json_response(data)
+        except Exception as e:
+            self.log.exception("Jobs exception:")
+            resp = web.json_response({'error_description': 'Internal error'},
+                                     status=500)
+        return resp
+
+
 class ZuulWeb(object):
 
     log = logging.getLogger("zuul.web.ZuulWeb")
@@ -201,7 +304,8 @@
     def __init__(self, listen_address, listen_port,
                  gear_server, gear_port,
                  ssl_key=None, ssl_cert=None, ssl_ca=None,
-                 static_cache_expiry=3600):
+                 static_cache_expiry=3600,
+                 sql_connection=None):
         self.listen_address = listen_address
         self.listen_port = listen_port
         self.event_loop = None
@@ -212,6 +316,10 @@
                                             ssl_key, ssl_cert, ssl_ca)
         self.log_streaming_handler = LogStreamingHandler(self.rpc)
         self.gearman_handler = GearmanHandler(self.rpc)
+        if sql_connection:
+            self.sql_handler = SqlHandler(sql_connection)
+        else:
+            self.sql_handler = None
 
     async def _handleWebsocket(self, request):
         return await self.log_streaming_handler.processRequest(
@@ -224,12 +332,27 @@
     async def _handleStatusRequest(self, request):
         return await self.gearman_handler.processRequest(request, 'status_get')
 
+    async def _handleJobsRequest(self, request):
+        return await self.gearman_handler.processRequest(request, 'job_list')
+
+    async def _handleSqlRequest(self, request):
+        return await self.sql_handler.processRequest(request)
+
+    async def _handleKeyRequest(self, request):
+        return await self.gearman_handler.processRequest(request, 'key_get')
+
     async def _handleStaticRequest(self, request):
         fp = None
         if request.path.endswith("tenants.html") or request.path.endswith("/"):
             fp = os.path.join(STATIC_DIR, "index.html")
         elif request.path.endswith("status.html"):
             fp = os.path.join(STATIC_DIR, "status.html")
+        elif request.path.endswith("jobs.html"):
+            fp = os.path.join(STATIC_DIR, "jobs.html")
+        elif request.path.endswith("builds.html"):
+            fp = os.path.join(STATIC_DIR, "builds.html")
+        elif request.path.endswith("stream.html"):
+            fp = os.path.join(STATIC_DIR, "stream.html")
         headers = {}
         if self.static_cache_expiry:
             headers['Cache-Control'] = "public, max-age=%d" % \
@@ -248,14 +371,24 @@
             is run within a separate (non-main) thread.
         """
         routes = [
-            ('GET', '/console-stream', self._handleWebsocket),
             ('GET', '/tenants.json', self._handleTenantsRequest),
             ('GET', '/{tenant}/status.json', self._handleStatusRequest),
+            ('GET', '/{tenant}/jobs.json', self._handleJobsRequest),
+            ('GET', '/{tenant}/console-stream', self._handleWebsocket),
+            ('GET', '/{source}/{project}.pub', self._handleKeyRequest),
             ('GET', '/{tenant}/status.html', self._handleStaticRequest),
+            ('GET', '/{tenant}/jobs.html', self._handleStaticRequest),
+            ('GET', '/{tenant}/stream.html', self._handleStaticRequest),
             ('GET', '/tenants.html', self._handleStaticRequest),
             ('GET', '/', self._handleStaticRequest),
         ]
 
+        if self.sql_handler:
+            routes.append(('GET', '/{tenant}/builds.json',
+                           self._handleSqlRequest))
+            routes.append(('GET', '/{tenant}/builds.html',
+                           self._handleStaticRequest))
+
         self.log.debug("ZuulWeb starting")
         asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
         user_supplied_loop = loop is not None
diff --git a/zuul/web/static/README b/zuul/web/static/README
index f17ea5f..e924dc7 100644
--- a/zuul/web/static/README
+++ b/zuul/web/static/README
@@ -50,8 +50,7 @@
   </Directory>
 
   # Console-stream needs a special proxy-pass for websocket
-  ProxyPass /console-stream ws://localhost:9000/console-stream nocanon retry=0
-  ProxyPassReverse /console-stream ws://localhost:9000/console-stream
+  ProxyPassMatch /(.*)/console-stream ws://localhost:9000/$1/console-stream nocanon retry=0
 
   # Then only the json calls are sent to the zuul-web endpoints
   ProxyPassMatch ^/(.*.json)$ http://localhost:9000/$1 nocanon retry=0
diff --git a/zuul/web/static/builds.html b/zuul/web/static/builds.html
new file mode 100644
index 0000000..921c9e2
--- /dev/null
+++ b/zuul/web/static/builds.html
@@ -0,0 +1,84 @@
+<!--
+Copyright 2017 Red Hat
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may
+not use this file except in compliance with the License. You may obtain
+a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+-->
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Zuul Builds</title>
+    <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
+    <link rel="stylesheet" href="../static/styles/zuul.css" />
+    <script src="/static/js/jquery.min.js"></script>
+    <script src="/static/js/angular.min.js"></script>
+    <script src="../static/javascripts/zuul.angular.js"></script>
+</head>
+<body ng-app="zuulBuilds" ng-controller="mainController"><div class="container-fluid">
+  <nav class="navbar navbar-default">
+  <div class="container-fluid">
+    <div class="navbar-header">
+      <a class="navbar-brand" href="../" target="_self">Zuul Dashboard</a>
+    </div>
+    <ul class="nav navbar-nav">
+      <li><a href="status.html" target="_self">Status</a></li>
+      <li><a href="jobs.html" target="_self">Jobs</a></li>
+      <li class="active"><a href="builds.html" target="_self">Builds</a></li>
+    </ul>
+  <span style="float: right; margin-top: 7px;">
+    <form ng-submit="builds_fetch()">
+      <label>Pipeline:</label>
+      <input name="pipeline" ng-model="pipeline" />
+      <label>Job:</label>
+      <input name="job_name" ng-model="job_name" />
+      <label>Project:</label>
+      <input name="project" ng-model="project" />
+      <input type="submit" value="Refresh" />
+    </form>
+  </span>
+  </div>
+  </nav>
+  <table class="table table-hover table-condensed">
+    <thead>
+      <tr>
+        <th width="20px">id</th>
+        <th>Job</th>
+        <th>Project</th>
+        <th>Pipeline</th>
+        <th>Change</th>
+        <th>Newrev</th>
+        <th>Duration</th>
+        <th>Log url</th>
+        <th>Node name</th>
+        <th>Start time</th>
+        <th>End time</th>
+        <th>Result</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr ng-repeat="build in builds" ng-class="rowClass(build)">
+        <td>{{ build.id }}</td>
+        <td>{{ build.job_name }}</td>
+        <td>{{ build.project }}</td>
+        <td>{{ build.pipeline }}</td>
+        <td><a href="{{ build.ref_url }}" target="_self">change</a></td>
+        <td>{{ build.newrev }}</td>
+        <td>{{ build.duration }} seconds</td>
+        <td><a ng-if="build.log_url" href="{{ build.log_url }}" target="_self">logs</a></td>
+        <td>{{ build.node_name }}</td>
+        <td>{{ build.start_time }}</td>
+        <td>{{ build.end_time }}</td>
+        <td>{{ build.result }}</td>
+      </tr>
+    </tbody>
+  </table>
+</div></body></html>
diff --git a/zuul/web/static/javascripts/zuul.angular.js b/zuul/web/static/javascripts/zuul.angular.js
index 3152fc0..87cbbdd 100644
--- a/zuul/web/static/javascripts/zuul.angular.js
+++ b/zuul/web/static/javascripts/zuul.angular.js
@@ -30,3 +30,70 @@
     }
     $scope.tenants_fetch();
 });
+
+angular.module('zuulJobs', []).controller(
+    'mainController', function($scope, $http)
+{
+    $scope.jobs = undefined;
+    $scope.jobs_fetch = function() {
+        $http.get("jobs.json")
+            .then(function success(result) {
+                $scope.jobs = result.data;
+            });
+    }
+    $scope.jobs_fetch();
+});
+
+angular.module('zuulBuilds', [], function($locationProvider) {
+    $locationProvider.html5Mode({
+        enabled: true,
+        requireBase: false
+    });
+}).controller('mainController', function($scope, $http, $location)
+{
+    $scope.rowClass = function(build) {
+        if (build.result == "SUCCESS") {
+            return "success";
+        } else {
+            return "warning";
+        }
+    };
+    var query_args = $location.search();
+    var url = $location.url();
+    var tenant_start = url.lastIndexOf(
+        '/', url.lastIndexOf('/builds.html') - 1) + 1;
+    var tenant_length = url.lastIndexOf('/builds.html') - tenant_start;
+    $scope.tenant = url.substr(tenant_start, tenant_length);
+    $scope.builds = undefined;
+    if (query_args["pipeline"]) {$scope.pipeline = query_args["pipeline"];
+    } else {$scope.pipeline = "";}
+    if (query_args["job_name"]) {$scope.job_name = query_args["job_name"];
+    } else {$scope.job_name = "";}
+    if (query_args["project"]) {$scope.project = query_args["project"];
+    } else {$scope.project = "";}
+    $scope.builds_fetch = function() {
+        query_string = "";
+        if ($scope.tenant) {query_string += "&tenant="+$scope.tenant;}
+        if ($scope.pipeline) {query_string += "&pipeline="+$scope.pipeline;}
+        if ($scope.job_name) {query_string += "&job_name="+$scope.job_name;}
+        if ($scope.project) {query_string += "&project="+$scope.project;}
+        if (query_string != "") {query_string = "?" + query_string.substr(1);}
+        $http.get("builds.json" + query_string)
+            .then(function success(result) {
+                for (build_pos = 0;
+                     build_pos < result.data.length;
+                     build_pos += 1) {
+                    build = result.data[build_pos]
+                    if (build.node_name == null) {
+                        build.node_name = 'master'
+                    }
+                    /* Fix incorect url for post_failure job */
+                    if (build.log_url == build.job_name) {
+                        build.log_url = undefined;
+                    }
+                }
+                $scope.builds = result.data;
+            });
+    }
+    $scope.builds_fetch()
+});
diff --git a/zuul/web/static/jobs.html b/zuul/web/static/jobs.html
new file mode 100644
index 0000000..6946723
--- /dev/null
+++ b/zuul/web/static/jobs.html
@@ -0,0 +1,55 @@
+<!--
+Copyright 2017 Red Hat
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may
+not use this file except in compliance with the License. You may obtain
+a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+-->
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Zuul Builds</title>
+    <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
+    <link rel="stylesheet" href="../static/styles/zuul.css" />
+    <script src="/static/js/jquery.min.js"></script>
+    <script src="/static/js/angular.min.js"></script>
+    <script src="../static/javascripts/zuul.angular.js"></script>
+</head>
+<body ng-app="zuulJobs" ng-controller="mainController"><div class="container-fluid">
+  <nav class="navbar navbar-default">
+  <div class="container-fluid">
+    <div class="navbar-header">
+      <a class="navbar-brand" href="../" target="_self">Zuul Dashboard</a>
+    </div>
+    <ul class="nav navbar-nav">
+      <li><a href="status.html" target="_self">Status</a></li>
+      <li class="active"><a href="jobs.html" target="_self">Jobs</a></li>
+      <li><a href="builds.html" target="_self">Builds</a></li>
+    </ul>
+  </div>
+  </nav>
+  <table class="table table-hover table-condensed">
+    <thead>
+      <tr>
+        <th>Name</th>
+        <th>Description</th>
+        <th>Last builds</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr ng-repeat="job in jobs">
+        <td>{{ job.name }}</td>
+        <td>{{ job.description }}</td>
+        <td><a href="builds.html?job_name={{ job.name }}">builds</a></td>
+      </tr>
+    </tbody>
+  </table>
+</div></body></html>
diff --git a/zuul/web/static/stream.html b/zuul/web/static/stream.html
index dbeb66b..f2e7081 100644
--- a/zuul/web/static/stream.html
+++ b/zuul/web/static/stream.html
@@ -73,7 +73,7 @@
               } else {
                   protocol = 'ws://';
               }
-              path = url['pathname'].replace(/static\/.*$/g, '') + 'console-stream';
+              path = url['pathname'].replace(/stream.html.*$/g, '') + 'console-stream';
               params['websocket_url'] = protocol + url['host'] + path;
           }
           var ws = new WebSocket(params['websocket_url']);