Merge lp:~doanac/qa-dashboard/live-status into lp:qa-dashboard

Proposed by Andy Doan
Status: Merged
Approved by: Andy Doan
Approved revision: 694
Merged at revision: 703
Proposed branch: lp:~doanac/qa-dashboard/live-status
Merge into: lp:qa-dashboard
Diff against target: 392 lines (+328/-1)
5 files modified
qa_dashboard/settings.py (+13/-0)
requirements.txt (+3/-0)
smokeng/api.py (+119/-0)
smokeng/tests.py (+188/-0)
smokeng/urls.py (+5/-1)
To merge this branch: bzr merge lp:~doanac/qa-dashboard/live-status
Reviewer Review Type Date Requested Status
Joe Talbott Approve
PS Jenkins bot continuous-integration Approve
Chris Johnston Approve
Andy Doan Pending
Review via email: mp+198602@code.launchpad.net

This proposal supersedes a proposal from 2013-11-21.

Commit message

Add a REST API to Smoke objects

Description of the change

Add a REST API to Smoke objects

This gives us the ability to have our jenkins jobs notify the
dashboard in realtime about smoke status and not have to wait
until the job has been pushed to the public server and then
polled by the pull-script.

In addition to a fairly thorough set of unit-tests, I've also
created a pretty easy CLI to do some exploratory testing with:

 http://paste.ubuntu.com/6454113/

That script will probably land in the lp:ubuntu-test-cases/touch repo
in some form or another.

Some notes to keep in mind:

* The API does not allow for delete operations (just to help decrease
  the amount of damage someone might accidentally do).

* It doesn't require auth[entication|orization] for GETS, but
  use's Django users + Tastypie ApiKey for PUT and PATCH operations.

* I had to use two hacks with Tastypie:
  1) 0.9.15 because 0.10 migrations don't work with python 2.7 and
     django 1.5

  2) settings.US_TZ had to be enabled as noted in the settings.py
     due to a tastypie bug.

I'm not really excited about #2, but given the limited use of "writes"
we have in the dashboard, I think this may be fairly safe/isolated.

To post a comment you must log in.
Revision history for this message
Andy Doan (doanac) wrote : Posted in a previous version of this proposal

wanted to share the branch, but I'd like to try and get one more unit test written to help make sure it plays nicely with the pull-script

Revision history for this message
Andy Doan (doanac) wrote : Posted in a previous version of this proposal
Revision history for this message
Andy Doan (doanac) wrote : Posted in a previous version of this proposal

tastypie 0.9.15 should be ready, so this can be reviewed now.

Revision history for this message
Andy Doan (doanac) wrote : Posted in a previous version of this proposal

here's a branch to give you guys some insight into how i use this API:

 http://bazaar.launchpad.net/~doanac/ubuntu-test-cases/live-status/revision/128

this is all working at home now. The remaining work I need to look at is getting our smoke pull script to handle multiple results from a single job.

Revision history for this message
Paul Larson (pwlars) wrote : Posted in a previous version of this proposal

> here's a branch to give you guys some insight into how i use this API:
>
> http://bazaar.launchpad.net/~doanac/ubuntu-test-cases/live-
> status/revision/128
>
> this is all working at home now. The remaining work I need to look at is
> getting our smoke pull script to handle multiple results from a single job.
Really cool! Should we have to care about the pull script though? It should just replace the current result with what it pulls. So if it was in progress before, or even complete before, it's only going to get the "DONE" status for a fully complete job on a pull. At worst, it should be the same as what it has.

Or do you mean that pulling a job that was in a still running state before would be marked as still running?

Revision history for this message
Andy Doan (doanac) wrote : Posted in a previous version of this proposal

On 12/06/2013 11:51 AM, Paul Larson wrote:
> Really cool! Should we have to care about the pull script though? It should just replace the current result with what it pulls. So if it was in progress before, or even complete before, it's only going to get the "DONE" status for a fully complete job on a pull. At worst, it should be the same as what it has.
>
> Or do you mean that pulling a job that was in a still running state before would be marked as still running?

The pulls script also learns about all the job artifacts. This is the
key thing we need before marking a result as "COMPLETE".

Revision history for this message
Joe Talbott (joetalbott) wrote : Posted in a previous version of this proposal

Only thing I see is the 2012 in the copyright.

Otherwise +1.

review: Approve
Revision history for this message
Joe Talbott (joetalbott) :
review: Approve
Revision history for this message
Chris Johnston (cjohnston) :
review: Approve
Revision history for this message
Chris Johnston (cjohnston) wrote :

The attempt to merge lp:~doanac/qa-dashboard/live-status into lp:qa-dashboard failed. Below is the output from the failed tests.

Traceback (most recent call last):
  File "./manage.py", line 11, in <module>
    execute_from_command_line(sys.argv)
  File "/usr/lib/python2.7/dist-packages/django/core/management/__init__.py", line 453, in execute_from_command_line
    utility.execute()
  File "/usr/lib/python2.7/dist-packages/django/core/management/__init__.py", line 392, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/usr/lib/python2.7/dist-packages/django/core/management/__init__.py", line 272, in fetch_command
    klass = load_command_class(app_name, subcommand)
  File "/usr/lib/python2.7/dist-packages/django/core/management/__init__.py", line 77, in load_command_class
    module = import_module('%s.management.commands.%s' % (app_name, name))
  File "/usr/lib/python2.7/dist-packages/django/utils/importlib.py", line 35, in import_module
    __import__(name)
  File "/usr/lib/python2.7/dist-packages/south/management/commands/__init__.py", line 10, in <module>
    import django.template.loaders.app_directories
  File "/usr/lib/python2.7/dist-packages/django/template/loaders/app_directories.py", line 25, in <module>
    raise ImproperlyConfigured('ImportError %s: %s' % (app, e.args[0]))
django.core.exceptions.ImproperlyConfigured: ImportError tastypie: No module named tastypie

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :

FAILED: Continuous integration, rev:692
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/274/
Executed test runs:

Click here to trigger a rebuild:
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/274/rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :

FAILED: Continuous integration, rev:692
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/275/
Executed test runs:

Click here to trigger a rebuild:
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/275/rebuild

review: Needs Fixing (continuous-integration)
lp:~doanac/qa-dashboard/live-status updated
693. By Andy Doan

a hack to deal with tastypie bug

setting USE_TZ=True is pain. It causes lots of django warnings and
broke some of our exsiting test case logic. This backs out the
settings change for USE_TZ and monkey-patches the actual buggy spot
in code so that we can use the admin panel properly

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :

FAILED: Continuous integration, rev:693
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/276/
Executed test runs:

Click here to trigger a rebuild:
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/276/rebuild

review: Needs Fixing (continuous-integration)
lp:~doanac/qa-dashboard/live-status updated
694. By Andy Doan

merge with trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :

PASSED: Continuous integration, rev:694
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/277/
Executed test runs:

Click here to trigger a rebuild:
http://s-jenkins.ubuntu-ci:8080/job/dashboard-ci/277/rebuild

review: Approve (continuous-integration)
Revision history for this message
Joe Talbott (joetalbott) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'qa_dashboard/settings.py'
2--- qa_dashboard/settings.py 2013-12-12 04:39:02 +0000
3+++ qa_dashboard/settings.py 2013-12-12 05:37:33 +0000
4@@ -192,6 +192,7 @@
5 'django.contrib.staticfiles',
6 'django_tables2',
7 'south',
8+ 'tastypie', # needed for ApiKeyAuthentication in the admin panel
9 )
10
11 TEST_RUNNER = "qa_dashboard.local_tests.LocalAppsTestSuiteRunner"
12@@ -323,3 +324,15 @@
13 pass
14
15 INSTALLED_APPS = PLUGIN_APPS + LOCAL_APPS + INSTALLED_APPS
16+
17+
18+# for tastypie, a hack to allow us to not require using TZ:
19+# http://github.com/toastdriven/django-tastypie/pull/561#issuecomment-26204496
20+def _now():
21+ from django.utils import timezone
22+ d = timezone.now()
23+ if d.tzinfo:
24+ d = timezone.localtime(timezone.now())
25+ return d
26+import tastypie.models
27+tastypie.models.ApiKey._meta.get_field_by_name('created')[0].default = _now
28
29=== modified file 'requirements.txt'
30--- requirements.txt 2013-05-20 16:36:00 +0000
31+++ requirements.txt 2013-12-12 05:37:33 +0000
32@@ -11,3 +11,6 @@
33 django-openid-auth==0.5
34 model-mommy==1.0
35 mock==1.0.1
36+# NOTE: django-tastypie 0.10 has a bug that prevents south migrations
37+# from working for us.
38+django-tastypie==0.9.15
39
40=== added file 'smokeng/api.py'
41--- smokeng/api.py 1970-01-01 00:00:00 +0000
42+++ smokeng/api.py 2013-12-12 05:37:33 +0000
43@@ -0,0 +1,119 @@
44+# QA Dashboard
45+# Copyright 2012-2013 Canonical Ltd.
46+
47+# This program is free software: you can redistribute it and/or modify it
48+# under the terms of the GNU Affero General Public License version 3, as
49+# published by the Free Software Foundation.
50+
51+# This program is distributed in the hope that it will be useful, but
52+# WITHOUT ANY WARRANTY; without even the implied warranties of
53+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
54+# PURPOSE. See the GNU Affero General Public License for more details.
55+
56+# You should have received a copy of the GNU Affero General Public License
57+# along with this program. If not, see <http://www.gnu.org/licenses/>.
58+
59+from tastypie import fields
60+from tastypie.api import Api
61+from tastypie.authentication import ApiKeyAuthentication
62+from tastypie.authorization import DjangoAuthorization
63+from tastypie.resources import ModelResource, ALL, ALL_WITH_RELATIONS
64+
65+from common.models import JenkinsBuild, JenkinsJob
66+from smokeng.models import SmokeImage, SmokeResult
67+
68+
69+# don't allow deletion of objects with this API
70+ALLOWED_METHODS = ['get', 'post', 'patch']
71+
72+
73+class WriteAuthentication(ApiKeyAuthentication):
74+ '''only require authentication for non-GET operations.'''
75+ def is_authenticated(self, req, **kwargs):
76+ if req.method == 'GET':
77+ return True
78+ return super(WriteAuthentication, self).is_authenticated(req, **kwargs)
79+
80+
81+class WriteAuthorization(DjangoAuthorization):
82+ '''only require authorization for non-GET operations.'''
83+
84+ def is_authorized(self, req, object=None):
85+ if req.method == 'GET':
86+ return True
87+ return super(WriteAuthorization, self).is_authorized(req, object)
88+
89+
90+class JenkinsJobResource(ModelResource):
91+ class Meta:
92+ queryset = JenkinsJob.objects.filter(publish=True)
93+ resource_name = 'job'
94+
95+ filtering = {
96+ 'name': ALL,
97+ }
98+
99+ authentication = WriteAuthentication()
100+ authorization = WriteAuthorization()
101+ allowed_methods = ALLOWED_METHODS
102+
103+
104+class JenkinsBuildResource(ModelResource):
105+ job = fields.ForeignKey(JenkinsJobResource, 'job')
106+
107+ class Meta:
108+ queryset = JenkinsBuild.objects.filter(publish=True)
109+ resource_name = 'build'
110+
111+ filtering = {
112+ 'job': ALL_WITH_RELATIONS,
113+ 'build_number': ALL,
114+ }
115+
116+ authentication = WriteAuthentication()
117+ authorization = WriteAuthorization()
118+ allowed_methods = ALLOWED_METHODS
119+
120+
121+class SmokeImageResource(ModelResource):
122+ class Meta:
123+ queryset = SmokeImage.objects.filter(publish=True)
124+ resource_name = 'image'
125+
126+ filtering = {
127+ 'build_number': ALL,
128+ 'release': ALL,
129+ 'flavor': ALL,
130+ 'variant': ALL,
131+ 'arch': ALL,
132+ }
133+
134+ authentication = WriteAuthentication()
135+ authorization = WriteAuthorization()
136+ allowed_methods = ALLOWED_METHODS
137+
138+
139+class SmokeResultResource(ModelResource):
140+ image = fields.ForeignKey(SmokeImageResource, 'image')
141+ jenkins_build = fields.ForeignKey(JenkinsBuildResource, 'jenkins_build')
142+
143+ class Meta:
144+ queryset = SmokeResult.objects.filter(publish=True)
145+ resource_name = 'result'
146+
147+ filtering = {
148+ 'jenkins_build': ALL_WITH_RELATIONS,
149+ 'image': ALL_WITH_RELATIONS,
150+ 'name': ALL,
151+ }
152+
153+ authentication = WriteAuthentication()
154+ authorization = WriteAuthorization()
155+ allowed_methods = ALLOWED_METHODS
156+
157+
158+v1_api = Api(api_name='v1')
159+v1_api.register(JenkinsJobResource())
160+v1_api.register(JenkinsBuildResource())
161+v1_api.register(SmokeImageResource())
162+v1_api.register(SmokeResultResource())
163
164=== modified file 'smokeng/tests.py'
165--- smokeng/tests.py 2013-12-05 19:22:35 +0000
166+++ smokeng/tests.py 2013-12-12 05:37:33 +0000
167@@ -14,6 +14,7 @@
168 # along with this program. If not, see <http://www.gnu.org/licenses/>.
169
170 import datetime
171+import json
172 import mock
173 import urllib2
174
175@@ -22,6 +23,9 @@
176 from django.test.client import Client
177 from django.contrib.auth.models import User
178
179+from tastypie.models import ApiKey
180+from tastypie.test import TestApiClient
181+
182 from smokeng.models import (
183 SmokeImage,
184 SmokeResult,
185@@ -835,3 +839,187 @@
186 resp = self.client.get('/smokeng/')
187 pass_rate = resp.context[-1]['table'].rows[0]['pass_rate']
188 self.assertEqual(u'Running', pass_rate.strip())
189+
190+
191+class TestSmokeApi(TestCase):
192+
193+ def setUp(self):
194+ _setUp(self)
195+
196+ # create an api key for "post" operations
197+ u = User()
198+ u.username = 'admin'
199+ u.is_superuser = True
200+ u.save()
201+
202+ k = ApiKey()
203+ k.user = u
204+ k.key = 'key'
205+ k.save()
206+ self.user = u.username
207+ self.key = k.key
208+
209+ # make a basic assumption on what's already in the DB for
210+ # tests below
211+ self.assertEqual(1, SmokeResult.objects.all().count())
212+ self.client = TestApiClient()
213+
214+ def _get(self, resource, params=None):
215+ return json.loads(self.client.get(resource, data=params).content)
216+
217+ def _post(self, resource, params):
218+ auth = 'ApiKey %s:%s' % (self.user, self.key)
219+ resp = self.client.post(resource, data=params, authentication=auth)
220+ self.assertEqual(201, resp.status_code)
221+ return resp['location']
222+
223+ def _patch(self, resource, params):
224+ auth = 'ApiKey %s:%s' % (self.user, self.key)
225+ resp = self.client.patch(resource, data=params, authentication=auth)
226+ self.assertEqual(202, resp.status_code)
227+
228+ def testBuildGet(self):
229+ params = {
230+ 'build_number': self.jenkins_build.build_number,
231+ 'job': self.jenkins_job.id,
232+ }
233+ obj = self._get('/smokeng/api/v1/build/', params)
234+ self.assertEqual(1, len(obj['objects']))
235+ obj = obj['objects'][0]
236+ self.assertEqual(self.jenkins_build.ran_at.isoformat(), obj['ran_at'])
237+
238+ def testImageGet(self):
239+ params = {
240+ 'build_number': self.image.build_number,
241+ 'release': self.image.release,
242+ 'variant': self.image.variant,
243+ 'arch': self.image.arch,
244+ }
245+ obj = self._get('/smokeng/api/v1/image/', params)
246+ self.assertEqual(1, len(obj['objects']))
247+ obj = obj['objects'][0]
248+ self.assertEqual(self.image.build_number, obj['build_number'])
249+ self.assertEqual(self.image.release, obj['release'])
250+
251+ def testResultGet(self):
252+ params = {
253+ 'jenkins_build': self.jenkins_build.id,
254+ 'image': self.image.id,
255+ 'name': self.result.name,
256+ }
257+ obj = self._get('/smokeng/api/v1/result/', params)
258+ self.assertEqual(1, len(obj['objects']))
259+ obj = obj['objects'][0]
260+ self.assertEqual(self.result.name, obj['name'])
261+ self.assertEqual(self.result.total_count, obj['total_count'])
262+ self.assertEqual(self.result.pass_count, obj['pass_count'])
263+ self.assertEqual(self.result.status, obj['status'])
264+
265+ def testBuildAdd(self):
266+ params = {
267+ 'build_number': 'build_number',
268+ 'job': '/smokeng/api/v1/job/%d/' % self.jenkins_job.id,
269+ 'ran_at': datetime.datetime.now().isoformat(),
270+ 'build_description': 'inprogress',
271+ }
272+ loc = self._post('/smokeng/api/v1/build/', params)
273+ obj = self._get(loc)
274+ self.assertEqual(obj['build_number'], params['build_number'])
275+ self.assertEqual(obj['build_description'], params['build_description'])
276+ self.assertEqual(obj['job'], params['job'])
277+ self.assertEqual(obj['ran_at'], params['ran_at'])
278+
279+ def testImageAdd(self):
280+ params = {
281+ 'build_number': 'build_number',
282+ 'release': 'release',
283+ 'flavor': 'flavor',
284+ 'variant': 'variant',
285+ 'arch': 'arch',
286+ }
287+ loc = self._post('/smokeng/api/v1/image/', params)
288+ obj = self._get(loc)
289+ self.assertEqual(params['arch'], obj['arch'])
290+ self.assertEqual(params['release'], obj['release'])
291+ self.assertEqual(params['build_number'], obj['rootfs_id'])
292+
293+ def testResultAdd(self):
294+ params = {
295+ 'ran_at': datetime.datetime.now().isoformat(),
296+ 'status': 0,
297+ 'total_count': 6,
298+ 'pass_count': 3,
299+ 'error_count': 2,
300+ 'fail_count': 1,
301+ 'jenkins_build': ('/smokeng/api/v1/build/%d/' %
302+ self.jenkins_build.id),
303+ 'image': '/smokeng/api/v1/image/%d/' % self.image.id,
304+ 'name': 'the-test-result-name',
305+ }
306+ loc = self._post('/smokeng/api/v1/result/', params)
307+ obj = self._get(loc)
308+ self.assertEqual(params['name'], obj['name'])
309+ self.assertEqual(params['ran_at'], obj['ran_at'])
310+ self.assertEqual(params['status'], obj['status'])
311+ self.assertEqual(params['total_count'], obj['total_count'])
312+ self.assertEqual(params['pass_count'], obj['pass_count'])
313+ self.assertEqual(params['error_count'], obj['error_count'])
314+ self.assertEqual(params['fail_count'], obj['fail_count'])
315+ self.assertEqual(params['jenkins_build'], obj['jenkins_build'])
316+ self.assertEqual(params['image'], obj['image'])
317+
318+ def _testResultState(self, state):
319+ resource = '/smokeng/api/v1/result/%d/' % self.result.id
320+ params = {
321+ 'ran_at': datetime.datetime.now().isoformat(),
322+ 'status': state,
323+ 'total_count': 6,
324+ 'pass_count': 3,
325+ 'error_count': 2,
326+ 'fail_count': 1,
327+ }
328+ self._patch(resource, params)
329+ res = SmokeResult.objects.get(pk=self.result.id)
330+ self.assertEqual(self.result.name, res.name)
331+ self.assertEqual(state, res.status)
332+
333+ def testResultStates(self):
334+ self._testResultState(SmokeResult.QUEUED)
335+ self._testResultState(SmokeResult.RUNNING)
336+ self._testResultState(SmokeResult.SYNCING)
337+ self._testResultState(SmokeResult.COMPLETE)
338+
339+ def _testPullInteraction(self, as_string):
340+ '''The ran_at field can be both strings and datetime objects'''
341+ ran_at = datetime.datetime.now()
342+ if as_string:
343+ ran_at = ran_at.strftime('%Y-%m-%d %H:%M:%S')
344+
345+ data = {
346+ # total/passes/errors/failures come from _testResultState
347+ 'test_name': self.result.name,
348+ 'total': 6,
349+ 'passes': 3,
350+ 'errors': 2,
351+ 'failures': 1,
352+ 'ran_at': ran_at,
353+ }
354+ self._testResultState(SmokeResult.SYNCING)
355+ c = Command()
356+ c.image = self.image
357+ c.bugs = []
358+ c.install_data = {
359+ 'test_name': self.result.name,
360+ 'jenkins_build': self.jenkins_build
361+ }
362+ c.add_result(data)
363+
364+ res = SmokeResult.objects.get(pk=self.result.id)
365+ self.assertEqual(self.result.name, res.name)
366+ self.assertEqual(SmokeResult.COMPLETE, res.status)
367+
368+ def testPullAsString(self):
369+ self._testPullInteraction(True)
370+
371+ def testPullAsDatetime(self):
372+ self._testPullInteraction(False)
373
374=== modified file 'smokeng/urls.py'
375--- smokeng/urls.py 2013-09-27 18:27:12 +0000
376+++ smokeng/urls.py 2013-12-12 05:37:33 +0000
377@@ -13,10 +13,14 @@
378 # You should have received a copy of the GNU Affero General Public License
379 # along with this program. If not, see <http://www.gnu.org/licenses/>.
380
381-from django.conf.urls import patterns, url
382+from django.conf.urls import include, patterns, url
383+
384+from smokeng.api import v1_api
385+
386
387 urlpatterns = patterns(
388 'smokeng.views',
389+ url(r'^api/', include(v1_api.urls)),
390 url(
391 r'^(?:(?P<release>\w+)/)?(?:(?P<variant>\w+)/)?$',
392 'overview',

Subscribers

People subscribed via source and target branches