diff --git a/tests/test_webapp.py b/tests/test_webapp.py new file mode 100644 index 0000000000..b127c517e9 --- /dev/null +++ b/tests/test_webapp.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python + +# Copyright 2014 Hewlett-Packard Development Company, L.P. +# Copyright 2014 Rackspace Australia +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import urllib2 + +from tests.base import ZuulTestCase + + +class TestWebapp(ZuulTestCase): + + def _cleanup(self): + self.worker.hold_jobs_in_build = False + self.worker.release() + self.waitUntilSettled() + + def setUp(self): + super(TestWebapp, self).setUp() + self.addCleanup(self._cleanup) + self.worker.hold_jobs_in_build = True + A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A') + A.addApproval('CRVW', 2) + self.fake_gerrit.addEvent(A.addApproval('APRV', 1)) + B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B') + B.addApproval('CRVW', 2) + self.fake_gerrit.addEvent(B.addApproval('APRV', 1)) + self.waitUntilSettled() + self.port = self.webapp.server.socket.getsockname()[1] + + def test_webapp_status(self): + "Test that we can filter to only certain changes in the webapp." + + req = urllib2.Request( + "http://localhost:%s/status" % self.port) + f = urllib2.urlopen(req) + data = json.loads(f.read()) + + self.assertIn('pipelines', data) + + def test_webapp_status_compat(self): + # testing compat with status.json + req = urllib2.Request( + "http://localhost:%s/status.json" % self.port) + f = urllib2.urlopen(req) + data = json.loads(f.read()) + + self.assertIn('pipelines', data) + + def test_webapp_bad_url(self): + # do we 404 correctly + req = urllib2.Request( + "http://localhost:%s/status/foo" % self.port) + self.assertRaises(urllib2.HTTPError, urllib2.urlopen, req) + + def test_webapp_find_change(self): + # can we filter by change id + req = urllib2.Request( + "http://localhost:%s/status/change/1,1" % self.port) + f = urllib2.urlopen(req) + data = json.loads(f.read()) + + self.assertEqual(1, len(data), data) + self.assertEqual("org/project", data[0]['project']) + + req = urllib2.Request( + "http://localhost:%s/status/change/2,1" % self.port) + f = urllib2.urlopen(req) + data = json.loads(f.read()) + + self.assertEqual(1, len(data), data) + self.assertEqual("org/project1", data[0]['project'], data) diff --git a/zuul/webapp.py b/zuul/webapp.py index 4d6115f96d..e289398acc 100644 --- a/zuul/webapp.py +++ b/zuul/webapp.py @@ -13,13 +13,32 @@ # License for the specific language governing permissions and limitations # under the License. +import copy +import json import logging +import re import threading import time from paste import httpserver import webob from webob import dec +"""Zuul main web app. + +Zuul supports HTTP requests directly against it for determining the +change status. These responses are provided as json data structures. + +The supported urls are: + + - /status: return a complex data structure that represents the entire + queue / pipeline structure of the system + - /status.json (backwards compatibility): same as /status + - /status/change/X,Y: return status just for gerrit change X,Y + +When returning status for a single gerrit change you will get an +array of changes, they will not include the queue structure. +""" + class WebApp(threading.Thread): log = logging.getLogger("zuul.WebApp") @@ -41,9 +60,44 @@ class WebApp(threading.Thread): def stop(self): self.server.server_close() + def _changes_by_func(self, func): + """Filter changes by a user provided function. + + In order to support arbitrary collection of subsets of changes + we provide a low level filtering mechanism that takes a + function which applies to changes. The output of this function + is a flattened list of those collected changes. + """ + status = [] + jsonstruct = json.loads(self.cache) + for pipeline in jsonstruct['pipelines']: + for change_queue in pipeline['change_queues']: + for head in change_queue['heads']: + for change in head: + if func(change): + status.append(copy.deepcopy(change)) + return json.dumps(status) + + def _status_for_change(self, rev): + """Return the statuses for a particular change id X,Y.""" + def func(change): + return change['id'] == rev + return self._changes_by_func(func) + + 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 app(self, request): - if request.path != '/status.json': + path = self._normalize_path(request.path) + if path is None: raise webob.exc.HTTPNotFound() + if (not self.cache or (time.time() - self.cache_time) > self.cache_expiry): try: @@ -54,8 +108,18 @@ class WebApp(threading.Thread): except: self.log.exception("Exception formatting status:") raise - response = webob.Response(body=self.cache, - content_type='application/json') + + if path == 'status': + response = webob.Response(body=self.cache, + content_type='application/json') + else: + status = self._status_for_change(path) + if status: + response = webob.Response(body=status, + content_type='application/json') + else: + raise webob.exc.HTTPNotFound() + response.headers['Access-Control-Allow-Origin'] = '*' response.last_modified = self.cache_time return response