Merge lp:~doanac/qa-dashboard/live-status into lp:qa-dashboard
- live-status
- Merge into dev
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 |
Related bugs: |
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://
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
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.
Andy Doan (doanac) wrote : Posted in a previous version of this proposal | # |
Andy Doan (doanac) wrote : Posted in a previous version of this proposal | # |
we are blocked on:
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.
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://
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.
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://
> 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?
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".
Joe Talbott (joetalbott) wrote : Posted in a previous version of this proposal | # |
Only thing I see is the 2012 in the copyright.
Otherwise +1.
Joe Talbott (joetalbott) : | # |
Chris Johnston (cjohnston) : | # |
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_
File "/usr/lib/
utility.
File "/usr/lib/
self.
File "/usr/lib/
klass = load_command_
File "/usr/lib/
module = import_
File "/usr/lib/
__import_
File "/usr/lib/
import django.
File "/usr/lib/
raise ImproperlyConfi
django.
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:692
http://
Executed test runs:
Click here to trigger a rebuild:
http://
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:692
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 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
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:693
http://
Executed test runs:
Click here to trigger a rebuild:
http://
PS Jenkins bot (ps-jenkins) wrote : | # |
PASSED: Continuous integration, rev:694
http://
Executed test runs:
Click here to trigger a rebuild:
http://
Joe Talbott (joetalbott) : | # |
Preview Diff
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', |
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