Merge lp:~coreygoldberg/uci-engine/subunit-results into lp:uci-engine
- subunit-results
- Merge into trunk
Status: | Work in progress |
---|---|
Proposed branch: | lp:~coreygoldberg/uci-engine/subunit-results |
Merge into: | lp:uci-engine |
Diff against target: |
2797 lines (+2636/-0) 30 files modified
.bzrignore (+4/-0) docs/subunit-results.rst (+31/-0) juju-deployer/subunit-results.yaml.tmpl (+1/-0) subunit-results/README (+52/-0) subunit-results/setup.py (+38/-0) subunit-results/subunitresults/__init__.py (+41/-0) subunit-results/subunitresults/__main__.py (+26/-0) subunit-results/subunitresults/ci_utils/__init__.py (+51/-0) subunit-results/subunitresults/ci_utils/amqp_utils.py (+203/-0) subunit-results/subunitresults/ci_utils/amqp_worker.py (+257/-0) subunit-results/subunitresults/ci_utils/data_store.py (+168/-0) subunit-results/subunitresults/ci_utils/dump_stack.py (+41/-0) subunit-results/subunitresults/ci_utils/json_status.py (+162/-0) subunit-results/subunitresults/ci_utils/testing/__init__.py (+15/-0) subunit-results/subunitresults/ci_utils/testing/fixtures.py (+120/-0) subunit-results/subunitresults/ci_utils/tests/test_amqp.py (+101/-0) subunit-results/subunitresults/ci_utils/tests/test_amqp_worker.py (+267/-0) subunit-results/subunitresults/ci_utils/tests/test_data_store.py (+60/-0) subunit-results/subunitresults/ci_utils/tests/test_fixtures.py (+87/-0) subunit-results/subunitresults/ci_utils/tests/test_json_status.py (+144/-0) subunit-results/subunitresults/ci_utils/tests/test_tmpdir.py (+27/-0) subunit-results/subunitresults/ci_utils/unit_config.py (+60/-0) subunit-results/subunitresults/queue_worker.py (+91/-0) subunit-results/subunitresults/subunit_parser.py (+151/-0) subunit-results/subunitresults/tests/__init__.py (+14/-0) subunit-results/subunitresults/tests/test_queue_worker.py (+62/-0) subunit-results/subunitresults/tests/test_subunit_parser.py (+161/-0) subunit-results/subunitresults/tests/test_utils.py (+121/-0) subunit-results/subunitresults/utils.py (+59/-0) subunit-results/tox.ini (+21/-0) |
To merge this branch: | bzr merge lp:~coreygoldberg/uci-engine/subunit-results |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Canonical CI Engineering | Pending | ||
Review via email: mp+227524@code.launchpad.net |
Commit message
Description of the change
WIP
- 668. By Corey Goldberg
-
reshuffled files layout and updated readme and docs
- 669. By Corey Goldberg
-
added tox and fixed setup
- 670. By Corey Goldberg
-
more updates
- 671. By Corey Goldberg
-
more unit tests
- 672. By Corey Goldberg
-
more unit tests
- 673. By Corey Goldberg
-
unit tests for fetch_subunit
- 674. By Corey Goldberg
-
removed comment
- 675. By Corey Goldberg
-
removed test dep
- 676. By Corey Goldberg
-
removed dep from uci_tests
- 677. By Corey Goldberg
-
subunit parser initial checkin with test file
- 678. By Corey Goldberg
-
subunit parser initial checkin with test file
- 679. By Corey Goldberg
-
unit tests passing
- 680. By Corey Goldberg
-
first py3 port of ci_utils subset
- 681. By Corey Goldberg
-
merged trunk
- 682. By Corey Goldberg
-
removed unused modules from ci_utils after fork
- 683. By Corey Goldberg
-
fixed failiong json unit tests
- 684. By Corey Goldberg
-
removed unused module
- 685. By Corey Goldberg
-
test fixes
- 686. By Corey Goldberg
-
updated tox
- 687. By Corey Goldberg
-
updated tox using coverage
- 688. By Corey Goldberg
-
renaming and more unit tests
- 689. By Corey Goldberg
-
updated FakeDataStore and tests to only accept bytes
- 690. By Corey Goldberg
-
results summary implementation
- 691. By Corey Goldberg
-
updated test
- 692. By Corey Goldberg
-
updated README
- 693. By Corey Goldberg
-
changed file permissions
- 694. By Corey Goldberg
-
create_unique_name added
- 695. By Corey Goldberg
-
create_unique_name fixed
- 696. By Corey Goldberg
-
fixed test_details and summary
- 697. By Corey Goldberg
-
many fixes to implementation
- 698. By Corey Goldberg
-
pep8 fixes
- 699. By Corey Goldberg
-
re-merged trunk
- 700. By Corey Goldberg
-
queue qorker
- 701. By Corey Goldberg
-
updates to swift storage tests and functions
Unmerged revisions
- 701. By Corey Goldberg
-
updates to swift storage tests and functions
- 700. By Corey Goldberg
-
queue qorker
- 699. By Corey Goldberg
-
re-merged trunk
- 698. By Corey Goldberg
-
pep8 fixes
- 697. By Corey Goldberg
-
many fixes to implementation
- 696. By Corey Goldberg
-
fixed test_details and summary
- 695. By Corey Goldberg
-
create_unique_name fixed
- 694. By Corey Goldberg
-
create_unique_name added
- 693. By Corey Goldberg
-
changed file permissions
- 692. By Corey Goldberg
-
updated README
Preview Diff
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2014-06-04 09:28:06 +0000 |
3 | +++ .bzrignore 2014-08-21 14:14:59 +0000 |
4 | @@ -19,3 +19,7 @@ |
5 | charms/precise/*/hooks/charmhelpers |
6 | # The webui copies what it needs here to be deployed with the charm. |
7 | charms/precise/webui/files/webroot |
8 | +# ignore virtualenvs created by tox |
9 | +.tox/ |
10 | +# ignore coverage |
11 | +.coverage |
12 | |
13 | === added file 'docs/subunit-results.rst' |
14 | --- docs/subunit-results.rst 1970-01-01 00:00:00 +0000 |
15 | +++ docs/subunit-results.rst 2014-08-21 14:14:59 +0000 |
16 | @@ -0,0 +1,31 @@ |
17 | +++++++++++++++++++++++ |
18 | +SubUnit Results System |
19 | +++++++++++++++++++++++ |
20 | + |
21 | +`subunitresults` is a Python3 application that extracts SubUnit test results |
22 | +and attachments, and makes them available via RESTful interface. |
23 | + |
24 | +SubUnit is a streaming binary protocol for test results (testcase status events |
25 | +and file attachments). Individual status events are aggregated and used to |
26 | +create a test suite summary. File attachments are included to provide rich |
27 | +detail about the nature of a failure. File attachments can also be used to |
28 | +encapsulate stdout and stderr both during and outside tests. |
29 | + |
30 | +---- |
31 | + |
32 | +The Subunit Results System has 2 basic parts: |
33 | + |
34 | +1. **Subunit Extractor Service** : A Python3/RabbitMQ Worker that listens for |
35 | + test result messages on a queue. The message payload contains a URL to the |
36 | + Swift location containing a SubUnit result file. The SubUnit file is |
37 | + fetched, and the service decodes the binary stream into: |
38 | + |
39 | + * a JSON test suite summary |
40 | + * arbitrary test attachments (files of supplied mime-type) |
41 | + summary + attachments are sent to Swift data store. |
42 | + |
43 | + The files it generates are stored in Swift as they are extracted. |
44 | + |
45 | + |
46 | +2. **Test Result Details API** : A Python3/Pyramid app that exposes a RESTful |
47 | + interface to the test summary and test attachments stored in Swift. |
48 | |
49 | === added file 'juju-deployer/subunit-results.yaml.tmpl' |
50 | --- juju-deployer/subunit-results.yaml.tmpl 1970-01-01 00:00:00 +0000 |
51 | +++ juju-deployer/subunit-results.yaml.tmpl 2014-08-21 14:14:59 +0000 |
52 | @@ -0,0 +1,1 @@ |
53 | + |
54 | |
55 | === added directory 'subunit-results' |
56 | === added file 'subunit-results/README' |
57 | --- subunit-results/README 1970-01-01 00:00:00 +0000 |
58 | +++ subunit-results/README 2014-08-21 14:14:59 +0000 |
59 | @@ -0,0 +1,52 @@ |
60 | +====================== |
61 | +SubUnit Results System |
62 | +====================== |
63 | + |
64 | +`subunitresults` is a Python3 package, and is a component of the Ubuntu CI |
65 | +Engine (lp:uci-engine). |
66 | + |
67 | +---------- |
68 | +More Info: |
69 | +---------- |
70 | + |
71 | +documentation in reStructuredText: |
72 | + |
73 | + * /docs/subunit-results.rst |
74 | + |
75 | +published uci-engine documentation: |
76 | + |
77 | + * http://uci.readthedocs.org/en/latest/subunit-results.html |
78 | + |
79 | +--------------- |
80 | +Python Version: |
81 | +--------------- |
82 | + |
83 | + * Python 3.4 |
84 | + |
85 | +-------------------- |
86 | +Use the source Luke: |
87 | +-------------------- |
88 | + |
89 | + $ bzr branch lp:uci-engine |
90 | + $ cd uci-engine/subunit-results |
91 | + |
92 | +-------------- |
93 | +Run the Tests: |
94 | +-------------- |
95 | + |
96 | +To run the tests directly from a development branch, you can use Tox. Tox is |
97 | +a generic virtualenv management and test command line tool. See `tox.ini` for |
98 | +current configuration. Tox info: https://testrun.org/tox |
99 | + |
100 | + * install Tox: |
101 | + |
102 | + $ sudo apt-get install python-tox |
103 | + |
104 | + * grab a branch of `uci-engine` from Launchpad: |
105 | + |
106 | + $ bzr branch lp:uci-engine mybranch |
107 | + $ cd mybranch/subunit-results |
108 | + |
109 | + * run the tests: |
110 | + |
111 | + $ tox |
112 | |
113 | === added file 'subunit-results/setup.py' |
114 | --- subunit-results/setup.py 1970-01-01 00:00:00 +0000 |
115 | +++ subunit-results/setup.py 2014-08-21 14:14:59 +0000 |
116 | @@ -0,0 +1,38 @@ |
117 | +#!/usr/bin/env python |
118 | +# Ubuntu CI Engine |
119 | +# Copyright 2014 Canonical Ltd. |
120 | + |
121 | +# This program is free software: you can redistribute it and/or modify it |
122 | +# under the terms of the GNU Affero General Public License version 3, as |
123 | +# published by the Free Software Foundation. |
124 | + |
125 | +# This program is distributed in the hope that it will be useful, but |
126 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
127 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
128 | +# PURPOSE. See the GNU Affero General Public License for more details. |
129 | + |
130 | +# You should have received a copy of the GNU Affero General Public License |
131 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
132 | + |
133 | + |
134 | +from setuptools import setup, find_packages |
135 | + |
136 | + |
137 | +requires = [ |
138 | + 'amqplib==1.0.2', |
139 | + 'pyramid==1.5.1', |
140 | + 'python-swiftclient==2.2.0', |
141 | + 'PyYAML==3.10', |
142 | + 'python-subunit==0.0.18', |
143 | + 'testtools==0.9.35', |
144 | + "ucitests==0.1.4", |
145 | +] |
146 | + |
147 | + |
148 | +setup( |
149 | + name='subunitresults', |
150 | + version='0.1', |
151 | + packages=find_packages(), |
152 | + description='SubUnit Results System', |
153 | + install_requires=requires, |
154 | +) |
155 | |
156 | === added directory 'subunit-results/subunitresults' |
157 | === added file 'subunit-results/subunitresults/__init__.py' |
158 | --- subunit-results/subunitresults/__init__.py 1970-01-01 00:00:00 +0000 |
159 | +++ subunit-results/subunitresults/__init__.py 2014-08-21 14:14:59 +0000 |
160 | @@ -0,0 +1,41 @@ |
161 | +#!/usr/bin/python3 |
162 | +# Ubuntu CI Engine |
163 | +# Copyright 2014 Canonical Ltd. |
164 | + |
165 | +# This program is free software: you can redistribute it and/or modify it |
166 | +# under the terms of the GNU Affero General Public License version 3, as |
167 | +# published by the Free Software Foundation. |
168 | + |
169 | +# This program is distributed in the hope that it will be useful, but |
170 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
171 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
172 | +# PURPOSE. See the GNU Affero General Public License for more details. |
173 | + |
174 | +# You should have received a copy of the GNU Affero General Public License |
175 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
176 | + |
177 | + |
178 | +from wsgiref.simple_server import make_server |
179 | +from pyramid.config import Configurator |
180 | + |
181 | +from subunitresults.api import v1 as api_v1 |
182 | + |
183 | + |
184 | +def make_wsgi_app(): |
185 | + config = Configurator() |
186 | + # Instead of configuring all the routes here, we defer to the individual |
187 | + # API version. This allows us to easily add 'v2' in the future. The actual |
188 | + # routes are added in the 'configure_routes' callable. |
189 | + # |
190 | + # The root app is served at '/api/v1/' - note the trailing '/'. |
191 | + config.include(api_v1.configure_routes, route_prefix='/api/v1') |
192 | + config.scan() |
193 | + return config.make_wsgi_app() |
194 | + |
195 | + |
196 | +app = make_wsgi_app() |
197 | + |
198 | + |
199 | +if __name__ == '__main__': |
200 | + server = make_server('0.0.0.0', 8000, app) |
201 | + server.serve_forever() |
202 | |
203 | === added file 'subunit-results/subunitresults/__main__.py' |
204 | --- subunit-results/subunitresults/__main__.py 1970-01-01 00:00:00 +0000 |
205 | +++ subunit-results/subunitresults/__main__.py 2014-08-21 14:14:59 +0000 |
206 | @@ -0,0 +1,26 @@ |
207 | +#!/usr/bin/env python3 |
208 | +# Ubuntu CI Engine |
209 | +# Copyright 2014 Canonical Ltd. |
210 | +# |
211 | +# This program is free software: you can redistribute it and/or modify it |
212 | +# under the terms of the GNU Affero General Public License version 3, as |
213 | +# published by the Free Software Foundation. |
214 | +# |
215 | +# This program is distributed in the hope that it will be useful, but |
216 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
217 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
218 | +# PURPOSE. See the GNU Affero General Public License for more details. |
219 | +# |
220 | +# You should have received a copy of the GNU Affero General Public License |
221 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
222 | + |
223 | + |
224 | +from subunitresults.queue_worker import launch_amqp_listener |
225 | + |
226 | + |
227 | +def main(): |
228 | + launch_amqp_listener() |
229 | + |
230 | + |
231 | +if __name__ == '__main__': |
232 | + main() |
233 | |
234 | === added directory 'subunit-results/subunitresults/ci_utils' |
235 | === added file 'subunit-results/subunitresults/ci_utils/__init__.py' |
236 | --- subunit-results/subunitresults/ci_utils/__init__.py 1970-01-01 00:00:00 +0000 |
237 | +++ subunit-results/subunitresults/ci_utils/__init__.py 2014-08-21 14:14:59 +0000 |
238 | @@ -0,0 +1,51 @@ |
239 | +# Ubuntu CI Engine |
240 | +# Copyright 2013, 2014 Canonical Ltd. |
241 | + |
242 | +# This program is free software: you can redistribute it and/or modify it |
243 | +# under the terms of the GNU Affero General Public License version 3, as |
244 | +# published by the Free Software Foundation. |
245 | + |
246 | +# This program is distributed in the hope that it will be useful, but |
247 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
248 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
249 | +# PURPOSE. See the GNU Affero General Public License for more details. |
250 | + |
251 | +# You should have received a copy of the GNU Affero General Public License |
252 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
253 | + |
254 | +import shutil |
255 | +import tempfile |
256 | + |
257 | + |
258 | +SUPPORTED_SERIES = [ |
259 | + 'precise', |
260 | + 'trusty', |
261 | + 'utopic', |
262 | +] |
263 | + |
264 | + |
265 | +SUPPORTED_POCKETS = { |
266 | + '': 'Release', |
267 | + # '-updates': 'Updates', |
268 | +} |
269 | + |
270 | + |
271 | +def valid_suites(): |
272 | + suites = [] |
273 | + for pocket in SUPPORTED_POCKETS.keys(): |
274 | + for series in SUPPORTED_SERIES: |
275 | + suites.append('{}{}'.format(series, pocket)) |
276 | + return suites |
277 | + |
278 | + |
279 | +class TmpDir(object): |
280 | + '''Create a temporary directory with a context manager''' |
281 | + |
282 | + def __init__(self, suffix='', prefix='tmp', dir=None): |
283 | + self.path = tempfile.mkdtemp(suffix, prefix, dir) |
284 | + |
285 | + def __enter__(self): |
286 | + return self.path |
287 | + |
288 | + def __exit__(self, exc, value, tb): |
289 | + shutil.rmtree(self.path) |
290 | |
291 | === added file 'subunit-results/subunitresults/ci_utils/amqp_utils.py' |
292 | --- subunit-results/subunitresults/ci_utils/amqp_utils.py 1970-01-01 00:00:00 +0000 |
293 | +++ subunit-results/subunitresults/ci_utils/amqp_utils.py 2014-08-21 14:14:59 +0000 |
294 | @@ -0,0 +1,203 @@ |
295 | +# Ubuntu CI Engine |
296 | +# Copyright 2013, 2014 Canonical Ltd. |
297 | + |
298 | +# This program is free software: you can redistribute it and/or modify it |
299 | +# under the terms of the GNU Affero General Public License version 3, as |
300 | +# published by the Free Software Foundation. |
301 | + |
302 | +# This program is distributed in the hope that it will be useful, but |
303 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
304 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
305 | +# PURPOSE. See the GNU Affero General Public License for more details. |
306 | + |
307 | +# You should have received a copy of the GNU Affero General Public License |
308 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
309 | + |
310 | +import json |
311 | +import logging |
312 | +import os |
313 | +import socket |
314 | +import time |
315 | + |
316 | +from amqplib import client_0_8 as amqp |
317 | + |
318 | +BSBUILDER_QUEUE = 'bsbuilder' |
319 | +IMAGE_BUILDER_QUEUE = 'imagebuilder' |
320 | +TEST_RUNNER_QUEUE = 'test_runner' |
321 | +PUBLISHER_QUEUE = 'publisher' |
322 | +LANDER_QUEUE = 'lander' |
323 | + |
324 | +MONITORED_QUEUES = [ |
325 | + BSBUILDER_QUEUE, |
326 | + IMAGE_BUILDER_QUEUE, |
327 | + TEST_RUNNER_QUEUE, |
328 | + PUBLISHER_QUEUE, |
329 | + LANDER_QUEUE, |
330 | +] |
331 | + |
332 | +log = logging.getLogger(__name__) |
333 | + |
334 | + |
335 | +HERE = os.path.abspath(os.path.dirname(__file__)) |
336 | + |
337 | + |
338 | +def get_config(): |
339 | + '''Load the rabbit config created by the charm''' |
340 | + config = None |
341 | + try: |
342 | + f = os.path.join(HERE, '../../../../amqp_config.py') |
343 | + # TODO remove once all charms have converted to new code layout |
344 | + if not os.path.exists(f): |
345 | + f = os.path.join(HERE, '../../amqp_config.py') |
346 | + if os.path.exists(f): |
347 | + import imp |
348 | + config = imp.load_source('amqp_config', f) |
349 | + else: |
350 | + log.warn('No amqp_config found at: %s' % os.path.abspath(f)) |
351 | + except: |
352 | + log.exception('ERROR detecting rabbit args') |
353 | + return config |
354 | + |
355 | + |
356 | +def connection(rabbit_config): |
357 | + return amqp.Connection( |
358 | + userid=rabbit_config.AMQP_USER, |
359 | + virtual_host=rabbit_config.AMQP_VHOST, |
360 | + host=rabbit_config.AMQP_HOST, |
361 | + password=rabbit_config.AMQP_PASSWORD |
362 | + ) |
363 | + |
364 | + |
365 | +def declare_queue(queue_name, config=None): |
366 | + if config is None: |
367 | + config = get_config() |
368 | + conn = channel = None |
369 | + try: |
370 | + conn = connection(config) |
371 | + channel = conn.channel() |
372 | + channel.queue_declare(queue=queue_name, durable=True, |
373 | + auto_delete=False) |
374 | + except Exception as e: |
375 | + if channel: |
376 | + channel.close() |
377 | + if conn: |
378 | + conn.close() |
379 | + raise e |
380 | + return conn, channel |
381 | + |
382 | + |
383 | +def send(queue_name, msg, raise_errors=False): |
384 | + '''send a message to the given queue. |
385 | + |
386 | + :param raise_errors: There are two users of this function. Some users are |
387 | + sending non-critical messages in which errors can safely be ignored. Other |
388 | + users need to catch the exception when this function fails. |
389 | + ''' |
390 | + config = get_config() |
391 | + if not config: |
392 | + return 'rabbitmq settings not available.' |
393 | + conn = channel = None |
394 | + try: |
395 | + conn, channel = declare_queue(queue_name, config) |
396 | + body = amqp.Message(msg) |
397 | + body.properties['delivery_mode'] = 2 # Persistent |
398 | + channel.basic_publish(body, exchange='', routing_key=queue_name) |
399 | + except Exception as e: |
400 | + if raise_errors: |
401 | + raise |
402 | + logging.exception('unable to queue up message: %s', e) |
403 | + return str(e) |
404 | + finally: |
405 | + if channel: |
406 | + channel.close() |
407 | + if conn: |
408 | + conn.close() |
409 | + return None |
410 | + |
411 | + |
412 | +def _run_forever(channel, queue, callback, retry_period=120): |
413 | + logging.info('Waiting for messages. ^C to exit.') |
414 | + tag = channel.basic_consume(callback=callback, queue=queue) |
415 | + try: |
416 | + timeout = time.time() |
417 | + while channel.callbacks and time.time() < timeout + retry_period: |
418 | + try: |
419 | + channel.wait() |
420 | + timeout = time.time() |
421 | + except (amqp.AMQPConnectionException, socket.error): |
422 | + logging.error('lost connection to Rabbit') |
423 | + # TODO metrics.meter('lost_rabbit_connection') |
424 | + # Don't probe immediately, give the network/process |
425 | + # time to come back. |
426 | + time.sleep(0.1) |
427 | + if channel.callbacks: |
428 | + logging.error('Rabbit did not reappear quickly enough.') |
429 | + except KeyboardInterrupt: |
430 | + pass |
431 | + finally: |
432 | + if channel and channel.callbacks and channel.is_open: |
433 | + channel.basic_cancel(tag) |
434 | + |
435 | + |
436 | +def process_queue(config, queue_name, callback, delete=False): |
437 | + conn = channel = None |
438 | + try: |
439 | + conn, channel = declare_queue(queue_name, config) |
440 | + channel.basic_qos(0, 1, False) |
441 | + _run_forever(channel, queue_name, callback) |
442 | + finally: |
443 | + if channel: |
444 | + if delete: |
445 | + channel.queue_delete(queue_name) |
446 | + channel.close() |
447 | + if conn: |
448 | + conn.close() |
449 | + |
450 | + |
451 | +def progress_waiting(progress_trigger, data=None): |
452 | + if data is None: |
453 | + data = {} |
454 | + data['state'] = 'WAITING' |
455 | + return send(progress_trigger, json.dumps(data)) |
456 | + |
457 | + |
458 | +_ratelimit = {'trigger': None, 'timestamp': 0, 'delay': 5} |
459 | + |
460 | + |
461 | +def progress_update(progress_trigger, data=None, ratelimit=False): |
462 | + if ratelimit: |
463 | + now = time.time() |
464 | + if _ratelimit['trigger'] == progress_trigger: |
465 | + if now < _ratelimit['timestamp'] + _ratelimit['delay']: |
466 | + # too soon |
467 | + return |
468 | + else: |
469 | + # we'll allow the message to be logged, but we should back |
470 | + # off a little more before showing the same thing: |
471 | + if _ratelimit['delay'] < 20 * 60: # max throttle = 20 min |
472 | + _ratelimit['delay'] *= 2 |
473 | + _ratelimit['trigger'] = progress_trigger |
474 | + _ratelimit['timestamp'] = now |
475 | + |
476 | + if data is None: |
477 | + data = {} |
478 | + data['state'] = 'STATUS' |
479 | + return send(progress_trigger, json.dumps(data)) |
480 | + |
481 | + |
482 | +def progress_completed(progress_trigger, data=None): |
483 | + if data is None: |
484 | + data = {} |
485 | + data['state'] = 'COMPLETED' |
486 | + data['result'] = 'PASSED' |
487 | + data['exit'] = True |
488 | + return send(progress_trigger, json.dumps(data)) |
489 | + |
490 | + |
491 | +def progress_failed(progress_trigger, data=None): |
492 | + if data is None: |
493 | + data = {} |
494 | + data['state'] = 'COMPLETED' |
495 | + data['result'] = 'FAILED' |
496 | + data['exit'] = True |
497 | + return send(progress_trigger, json.dumps(data)) |
498 | |
499 | === added file 'subunit-results/subunitresults/ci_utils/amqp_worker.py' |
500 | --- subunit-results/subunitresults/ci_utils/amqp_worker.py 1970-01-01 00:00:00 +0000 |
501 | +++ subunit-results/subunitresults/ci_utils/amqp_worker.py 2014-08-21 14:14:59 +0000 |
502 | @@ -0,0 +1,257 @@ |
503 | +# Ubuntu CI Engine |
504 | +# Copyright 2014 Canonical Ltd. |
505 | + |
506 | +# This program is free software: you can redistribute it and/or modify it |
507 | +# under the terms of the GNU Affero General Public License version 3, as |
508 | +# published by the Free Software Foundation. |
509 | + |
510 | +# This program is distributed in the hope that it will be useful, but |
511 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
512 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
513 | +# PURPOSE. See the GNU Affero General Public License for more details. |
514 | + |
515 | +# You should have received a copy of the GNU Affero General Public License |
516 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
517 | + |
518 | +import fcntl |
519 | +import json |
520 | +import logging |
521 | +import os |
522 | +import io |
523 | +import subprocess |
524 | +import sys |
525 | +import threading |
526 | +import time |
527 | +import traceback |
528 | +import urllib.error |
529 | +import urllib.parse |
530 | +import urllib.request |
531 | + |
532 | +from ci_utils import ( |
533 | + amqp_utils, |
534 | + data_store, |
535 | + dump_stack, |
536 | + json_status, |
537 | + unit_config, |
538 | +) |
539 | + |
540 | + |
541 | +HERE = os.path.abspath(os.path.dirname(__file__)) |
542 | +logging.basicConfig(level=logging.INFO) |
543 | +log = logging.getLogger(__name__) |
544 | + |
545 | + |
546 | +def _get(url): |
547 | + try: |
548 | + resp = urllib.request.urlopen(url) |
549 | + resp = resp.read() |
550 | + return json.loads(resp) |
551 | + except: |
552 | + logging.exception('error checking url') |
553 | + return {} |
554 | + |
555 | + |
556 | +class _timer(threading.Thread): |
557 | + '''similar to threading.Timer, but repeats until cancel is called''' |
558 | + def __init__(self, interval, cb): |
559 | + super(_timer, self).__init__() |
560 | + self.interval = interval |
561 | + self.cb = cb |
562 | + self.finished = threading.Event() |
563 | + self.exit = False |
564 | + |
565 | + def cancel(self): |
566 | + """Stop the timer if it hasn't finished yet""" |
567 | + self.finished.set() |
568 | + self.exit = True |
569 | + |
570 | + def run(self): |
571 | + while not self.exit: |
572 | + self.finished.wait(self.interval) |
573 | + if not self.finished.is_set(): |
574 | + self.cb() |
575 | + self.finished.set() |
576 | + |
577 | + |
578 | +class AMQPWorker(object): |
579 | + '''Base class that handles the more complex issues of a rabbit worker. |
580 | + |
581 | + This allows a user to subclass this and provide a custom "handle_request" |
582 | + method. |
583 | + ''' |
584 | + |
585 | + def __init__(self, logger_name): |
586 | + self.logger_name = logger_name |
587 | + self.last_check_file = '/tmp/worker-data-' + logger_name + '.json' |
588 | + |
589 | + # how often to check if the current task should be cancelled |
590 | + cancel_interval = 120 |
591 | + |
592 | + def handle_request(self, params, logger): |
593 | + '''To be implemented by the subclass to do their work |
594 | + |
595 | + amqp_cb should either be one of: |
596 | + * amqp_utils.progress_failed |
597 | + * amqp_utils.progress_completed |
598 | + :return: tuple(ampq_cb, return_value_dictionary) |
599 | + ''' |
600 | + raise NotImplementedError |
601 | + |
602 | + def main(self, queue): |
603 | + dump_stack.install_stack_dump_signal() |
604 | + config = amqp_utils.get_config() |
605 | + if not config: |
606 | + sys.exit(1) # the get_config code prints an error |
607 | + |
608 | + json_status.run_server(self.engine_health) |
609 | + amqp_utils.process_queue(config, queue, self._on_message) |
610 | + |
611 | + def engine_health(self): |
612 | + status = json_status.JSONStatus() |
613 | + status.add_deployed_revision() |
614 | + status.add_data_store_health() |
615 | + return status |
616 | + |
617 | + def get_last_check(self): |
618 | + check = None |
619 | + # use a flock to ensure concurrent reader/writers are safe |
620 | + with open(self.last_check_file, 'a+') as f: |
621 | + try: |
622 | + fcntl.lockf(f, fcntl.LOCK_EX) |
623 | + f.seek(0, os.SEEK_END) |
624 | + # If the file exists but has zero length, |
625 | + # the check hasn't been run. |
626 | + if f.tell() > 0: |
627 | + f.seek(0) |
628 | + check = json.load(f) |
629 | + finally: |
630 | + fcntl.lockf(f, fcntl.LOCK_UN) |
631 | + return check |
632 | + |
633 | + def save_last_check(self, results): |
634 | + '''used to store results from our "check" scripts |
635 | + |
636 | + This file can also be analyzed by our `engine_health` api to show |
637 | + if the last check on this component worked. |
638 | + ''' |
639 | + # use a flock to ensure concurrent reader/writers are safe |
640 | + with open(self.last_check_file, 'a+') as f: |
641 | + try: |
642 | + fcntl.lockf(f, fcntl.LOCK_EX) |
643 | + f.seek(0) |
644 | + f.truncate() |
645 | + # we can't depend on the "mtime" attribute of the file since |
646 | + # we have to open in it read-write mode for flocking, so we |
647 | + # store a timestamp in the file itself |
648 | + if 'last_run' not in results: |
649 | + results['last_run'] = time.time() |
650 | + json.dump(results, f) |
651 | + finally: |
652 | + fcntl.lockf(f, fcntl.LOCK_UN) |
653 | + |
654 | + def _create_worker_logger(self): |
655 | + '''Create a logger that captures output into StringIO. |
656 | + |
657 | + This provides an easy way to let workers get free logging that will |
658 | + get added to the ticket. |
659 | + ''' |
660 | + log = logging.getLogger(self.logger_name) |
661 | + log.buffer = io.StringIO() |
662 | + logstream = logging.StreamHandler(log.buffer) |
663 | + formatter = logging.Formatter( |
664 | + '[%(asctime)s] %(name)s:%(levelname)s:%(message)s') |
665 | + logstream.setFormatter(formatter) |
666 | + logstream.setLevel(logging.INFO) |
667 | + log.addHandler(logstream) |
668 | + return log |
669 | + |
670 | + def _create_data_store(self, ticket): |
671 | + # Note that this may be called multiple times |
672 | + return data_store.create_for_ticket( |
673 | + ticket, unit_config.get_auth_config()) |
674 | + |
675 | + def _store_worker_log(self, store, logger, retval): |
676 | + '''An exception safe mechanism to upload log files. |
677 | + |
678 | + This needs to catch any exception so that progress complete/fail |
679 | + call can be made on the queue. |
680 | + ''' |
681 | + if not store or not logger: |
682 | + return |
683 | + content = logger.buffer.getvalue() |
684 | + if not content: |
685 | + log.info('no logging content from action, skipping upload') |
686 | + try: |
687 | + name = '{}.output.log'.format(logger.name) |
688 | + url = store.put_file(name, content, 'text/plain') |
689 | + self._create_artifact(name, url, retval) |
690 | + except: |
691 | + log.exception('unable to store worker log') |
692 | + |
693 | + def _create_artifact(self, name, url, retval, type='LOGS'): |
694 | + retval.setdefault('artifacts', []).append({ |
695 | + 'name': name, |
696 | + 'reference': url, |
697 | + 'type': type, |
698 | + }) |
699 | + |
700 | + def _handle_cancel(self, params): |
701 | + '''provide an advisory mechansim to inform the worker to stop |
702 | + |
703 | + This checks a URL to determine if the job has been cancelled. If so, |
704 | + it updates the "params" object with a params['cancelled'] = True. Thus |
705 | + this is an advisory mechansim that the worker can choose how/if to |
706 | + deal with. |
707 | + ''' |
708 | + url = params.get('cancel_url') |
709 | + if not url: |
710 | + return None |
711 | + |
712 | + def check_url(): |
713 | + data = _get(url) |
714 | + if not data.get('building', True): |
715 | + params['cancelled'] = True |
716 | + t = _timer(self.cancel_interval, check_url) |
717 | + t.start() |
718 | + return t |
719 | + |
720 | + def _handle_exception(self, e, store, trigger, logger, ret): |
721 | + try: |
722 | + err = 'Unexpected exception occurred' |
723 | + if isinstance(e, KeyboardInterrupt): |
724 | + err = 'Worker terminated' |
725 | + elif isinstance(e, subprocess.CalledProcessError) and logger: |
726 | + err = 'error running {}: {}'.format(e.cmd, e.output) |
727 | + ret['error_message'] = err |
728 | + ret['traceback'] = traceback.format_exc() |
729 | + if logger: |
730 | + logger.exception(err) |
731 | + self._store_worker_log(store, logger, ret) |
732 | + finally: |
733 | + if trigger: |
734 | + amqp_utils.progress_failed(trigger, ret) |
735 | + |
736 | + def _on_message(self, msg): |
737 | + log.info('on_message: %s', msg.body) |
738 | + ret = {} |
739 | + store = logger = trigger = cancel_thread = None |
740 | + try: |
741 | + logger = self._create_worker_logger() |
742 | + params = json.loads(msg.body) |
743 | + trigger = params['progress_trigger'] |
744 | + amqp_utils.progress_update(trigger, params) |
745 | + # Create the ticket specific data store |
746 | + store = self._create_data_store(params['ticket_id']) |
747 | + cancel_thread = self._handle_cancel(params) |
748 | + |
749 | + amqp_cb, ret = self.handle_request(params, logger) |
750 | + self._store_worker_log(store, logger, ret) |
751 | + amqp_cb(trigger, ret) |
752 | + except (KeyboardInterrupt, Exception) as e: |
753 | + self._handle_exception(e, store, trigger, logger, ret) |
754 | + if isinstance(e, KeyboardInterrupt): |
755 | + raise # re-raise so amqp_utils.process_queue can exit |
756 | + finally: |
757 | + msg.channel.basic_ack(msg.delivery_tag) |
758 | + if cancel_thread: |
759 | + cancel_thread.cancel() |
760 | |
761 | === added file 'subunit-results/subunitresults/ci_utils/data_store.py' |
762 | --- subunit-results/subunitresults/ci_utils/data_store.py 1970-01-01 00:00:00 +0000 |
763 | +++ subunit-results/subunitresults/ci_utils/data_store.py 2014-08-21 14:14:59 +0000 |
764 | @@ -0,0 +1,168 @@ |
765 | +# Ubuntu CI Engine |
766 | +# Copyright 2013, 2014 Canonical Ltd. |
767 | + |
768 | +# This program is free software: you can redistribute it and/or modify it |
769 | +# under the terms of the GNU Affero General Public License version 3, as |
770 | +# published by the Free Software Foundation. |
771 | + |
772 | +# This program is distributed in the hope that it will be useful, but |
773 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
774 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
775 | +# PURPOSE. See the GNU Affero General Public License for more details. |
776 | + |
777 | +# You should have received a copy of the GNU Affero General Public License |
778 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
779 | + |
780 | +import logging |
781 | +import os |
782 | + |
783 | +from swiftclient import client |
784 | +from swiftclient.client import ClientException |
785 | + |
786 | + |
787 | +class DataStoreException(Exception): |
788 | + pass |
789 | + |
790 | + |
791 | +def create_for_ticket(ticket_id, auth_config, public=True): |
792 | + return DataStore('ticket-{}'.format(ticket_id), auth_config, public=public) |
793 | + |
794 | + |
795 | +def _get_file_name(filename): |
796 | + return os.path.basename(filename) |
797 | + |
798 | + |
799 | +class DataStore(object): |
800 | + |
801 | + def __init__(self, component, auth_config, public=False): |
802 | + self.container_id = component |
803 | + self.auth_config = auth_config |
804 | + self.public = public |
805 | + self.client = None |
806 | + |
807 | + @staticmethod |
808 | + def validate_auth_config(auth_config): |
809 | + required_fields = ['auth_user', 'auth_password', 'auth_tenant_name', |
810 | + 'auth_url', 'auth_region'] |
811 | + |
812 | + missing_fields = [] |
813 | + |
814 | + for field in required_fields: |
815 | + if field not in list(auth_config.keys()): |
816 | + missing_fields.append(field) |
817 | + |
818 | + if len(missing_fields) > 0: |
819 | + raise DataStoreException( |
820 | + "missing fields: {}".format(', '.join(missing_fields)) |
821 | + ) |
822 | + |
823 | + def ensure_swift_client(self): |
824 | + if self.client is not None: |
825 | + return self.client |
826 | + |
827 | + self.validate_auth_config(self.auth_config) |
828 | + config = self.auth_config |
829 | + self.client = client.Connection( |
830 | + authurl=config.get('auth_url'), |
831 | + user=config.get('auth_user'), |
832 | + key=config.get('auth_password'), |
833 | + os_options={'tenant_name': config.get('auth_tenant_name'), |
834 | + 'region_name': config.get('auth_region')}, |
835 | + auth_version='2.0') |
836 | + try: |
837 | + self._create_container(self.container_id) |
838 | + except client.ClientException: |
839 | + logging.exception('Unable to connect to swift') |
840 | + raise DataStoreException("Missing or invalid authentication info.") |
841 | + self.change_visibility(public=self.public) |
842 | + self.container_url = "{}/{}".format(self.client.url, self.container_id) |
843 | + |
844 | + def list_files(self): |
845 | + self.ensure_swift_client() |
846 | + _, objects = self.client.get_container(self.container_id) |
847 | + return [x['name'] for x in objects] |
848 | + |
849 | + def put_file(self, filename, contents, content_type=None): |
850 | + self.ensure_swift_client() |
851 | + name = _get_file_name(filename) |
852 | + try: |
853 | + self.client.put_object(self.container_id, obj=name, |
854 | + contents=contents, |
855 | + content_type=content_type) |
856 | + except ClientException as e: |
857 | + raise DataStoreException( |
858 | + "Failed to upload file: {}, Error: {}".format(filename, e) |
859 | + ) |
860 | + |
861 | + return self.file_path(filename) |
862 | + |
863 | + def get_file(self, filename): |
864 | + self.ensure_swift_client() |
865 | + name = _get_file_name(filename) |
866 | + |
867 | + contents = None |
868 | + try: |
869 | + hdr, contents = self.client.get_object(self.container_id, obj=name) |
870 | + except ClientException as e: |
871 | + raise DataStoreException( |
872 | + "Failed to get file: {}, Error: {}".format(filename, e) |
873 | + ) |
874 | + return contents |
875 | + |
876 | + def change_visibility(self, public=False): |
877 | + self.ensure_swift_client() |
878 | + if public: |
879 | + read_acl = '.r:*' |
880 | + else: |
881 | + read_acl = '' |
882 | + self.client.post_container(self.container_id, |
883 | + {'X-Container-Read': read_acl}) |
884 | + self.public = public |
885 | + |
886 | + def delete_file(self, filename): |
887 | + self.ensure_swift_client() |
888 | + name = _get_file_name(filename) |
889 | + try: |
890 | + self.client.delete_object(self.container_id, obj=name) |
891 | + except ClientException as e: |
892 | + raise DataStoreException( |
893 | + "Failed to delete file: {}, Error: {}".format(filename, e) |
894 | + ) |
895 | + |
896 | + def clear(self): |
897 | + self.ensure_swift_client() |
898 | + try: |
899 | + files = self.client.get_container(self.container_id)[1] |
900 | + |
901 | + for f in files: |
902 | + try: |
903 | + self.client.delete_object(self.container_id, |
904 | + obj=f['name']) |
905 | + except ClientException as e: |
906 | + raise DataStoreException( |
907 | + "Failed to delete file: {}".format(e) |
908 | + ) |
909 | + except ClientException as e: |
910 | + raise DataStoreException( |
911 | + "Failed to get container: {}".format(e) |
912 | + ) |
913 | + |
914 | + def delete(self, recursive=False): |
915 | + self.ensure_swift_client() |
916 | + try: |
917 | + if recursive: |
918 | + self.clear() |
919 | + |
920 | + self.client.delete_container(self.container_id) |
921 | + except ClientException as e: |
922 | + raise DataStoreException( |
923 | + "Failed to delete container: {}".format(e) |
924 | + ) |
925 | + |
926 | + def file_path(self, filename): |
927 | + name = _get_file_name(filename) |
928 | + |
929 | + return "{}/{}".format(self.container_url, name) |
930 | + |
931 | + def _create_container(self, container): |
932 | + self.client.put_container(self.container_id) |
933 | |
934 | === added file 'subunit-results/subunitresults/ci_utils/dump_stack.py' |
935 | --- subunit-results/subunitresults/ci_utils/dump_stack.py 1970-01-01 00:00:00 +0000 |
936 | +++ subunit-results/subunitresults/ci_utils/dump_stack.py 2014-08-21 14:14:59 +0000 |
937 | @@ -0,0 +1,41 @@ |
938 | +# Ubuntu CI Engine |
939 | +# Copyright 2014 Canonical Ltd. |
940 | + |
941 | +# This program is free software: you can redistribute it and/or modify it |
942 | +# under the terms of the GNU Affero General Public License version 3, as |
943 | +# published by the Free Software Foundation. |
944 | + |
945 | +# This program is distributed in the hope that it will be useful, but |
946 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
947 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
948 | +# PURPOSE. See the GNU Affero General Public License for more details. |
949 | + |
950 | +# You should have received a copy of the GNU Affero General Public License |
951 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
952 | + |
953 | +import threading |
954 | +import sys |
955 | +import traceback |
956 | +import signal |
957 | + |
958 | + |
959 | +def dumpstacks(signal, frame): |
960 | + sys.stderr.writeline("") |
961 | + sys.stderr.writeline("User-requested stack trace:") |
962 | + sys.stderr.writeline("---------------------------") |
963 | + id2name = dict([(th.ident, th.name) for th in threading.enumerate()]) |
964 | + code = [] |
965 | + for threadId, stack in list(sys._current_frames().items()): |
966 | + tloc = "\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId) |
967 | + code.append(tloc) |
968 | + for filename, lineno, name, line in traceback.extract_stack(stack): |
969 | + if not filename.endswith('dump_stack.py'): |
970 | + loc = 'File: "%s", line %d, in %s' % (filename, lineno, name) |
971 | + code.append(loc) |
972 | + if line: |
973 | + code.append(" %s" % (line.strip())) |
974 | + sys.stderr.write("\n".join(code)) |
975 | + |
976 | + |
977 | +def install_stack_dump_signal(): |
978 | + signal.signal(signal.SIGQUIT, dumpstacks) |
979 | |
980 | === added file 'subunit-results/subunitresults/ci_utils/json_status.py' |
981 | --- subunit-results/subunitresults/ci_utils/json_status.py 1970-01-01 00:00:00 +0000 |
982 | +++ subunit-results/subunitresults/ci_utils/json_status.py 2014-08-21 14:14:59 +0000 |
983 | @@ -0,0 +1,162 @@ |
984 | +# Ubuntu Continuous Integration Engine |
985 | +# Copyright 2014 Canonical Ltd. |
986 | + |
987 | +# This program is free software: you can redistribute it and/or modify it under |
988 | +# the terms of the GNU General Public License version 3, as published by the |
989 | +# Free Software Foundation. |
990 | + |
991 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
992 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
993 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
994 | +# General Public License for more details. |
995 | + |
996 | +# You should have received a copy of the GNU General Public License along with |
997 | +# this program. If not, see <http://www.gnu.org/licenses/>. |
998 | + |
999 | +import http.server |
1000 | +import json |
1001 | +import os |
1002 | +import threading |
1003 | +import time |
1004 | +import urllib.error |
1005 | +import urllib.parse |
1006 | +import urllib.request |
1007 | + |
1008 | + |
1009 | +HERE = os.path.abspath(os.path.dirname(__file__)) |
1010 | + |
1011 | +# We have a few checks that take a while. The implementations for them use |
1012 | +# caching logic. This gives us a consistent place to manage this value. |
1013 | +CACHE_TIMEOUT = 60 |
1014 | + |
1015 | + |
1016 | +class JSONStatus(object): |
1017 | + def __init__(self): |
1018 | + self.results = [] |
1019 | + |
1020 | + def _add_result(self, name, value, status): |
1021 | + self.results.append({ |
1022 | + 'label': name, |
1023 | + 'value': value, |
1024 | + 'status': status, |
1025 | + }) |
1026 | + |
1027 | + def add_okay(self, name, value): |
1028 | + self._add_result(name, value, 'okay') |
1029 | + |
1030 | + def add_fail(self, name, value): |
1031 | + self._add_result(name, value, 'fail') |
1032 | + |
1033 | + def add_true_false(self, name, value, condition): |
1034 | + '''adds an 'okay' result if condition is true, 'fail' otherwise''' |
1035 | + if condition: |
1036 | + self.add_okay(name, value) |
1037 | + else: |
1038 | + self.add_fail(name, value) |
1039 | + |
1040 | + def add_url_test(self, name, url): |
1041 | + try: |
1042 | + urllib.request.urlopen(url).read() |
1043 | + self.add_okay(name, 'connected') |
1044 | + except Exception as e: |
1045 | + self.add_fail(name, str(e)) |
1046 | + |
1047 | + def add_rabbit_configured(self): |
1048 | + '''adds a result status for status of rabbit-configuration''' |
1049 | + # we don't want to import this for all users of the API. Some |
1050 | + # may not install rabbit (eg the ppa-assigner) |
1051 | + from ci_utils import amqp_utils |
1052 | + if amqp_utils.get_config() is not None: |
1053 | + self.add_okay('rabbit configured', True) |
1054 | + else: |
1055 | + self.add_fail('rabbit configured', False) |
1056 | + |
1057 | + def add_rabbit_worker_health(self, queue): |
1058 | + '''checks if there are any workers attached to the queue.''' |
1059 | + from ci_utils import amqp_utils |
1060 | + config = amqp_utils.get_config() |
1061 | + if config is None: |
1062 | + # we already report rabbit isn't configured, so no sense adding |
1063 | + # another failure for this |
1064 | + return |
1065 | + |
1066 | + mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() |
1067 | + base = 'http://{}:55672'.format(config.AMQP_HOST) |
1068 | + # NOTE: juju leaves this guest/guest and doesn't seem to expose |
1069 | + # anything in the relation to override it |
1070 | + mgr.add_password(None, base, 'guest', 'guest') |
1071 | + handler = urllib.request.HTTPBasicAuthHandler(mgr) |
1072 | + |
1073 | + try: |
1074 | + resp = urllib.request.build_opener(handler).open( |
1075 | + base + '/api/queues' |
1076 | + ) |
1077 | + content = resp.read() |
1078 | + data = json.loads(content) |
1079 | + consumers = None |
1080 | + for q in data: |
1081 | + if q['name'] == queue: |
1082 | + consumers = q['consumers'] |
1083 | + break |
1084 | + if consumers is None: |
1085 | + self.add_fail('workers-online', 'no queue defined') |
1086 | + else: |
1087 | + self.add_true_false('workers-online', consumers, consumers > 0) |
1088 | + except: |
1089 | + self.add_fail('workers-online', 'unable to check') |
1090 | + |
1091 | + def add_data_store_health(self, ticket=1): |
1092 | + from ci_utils import unit_config, data_store |
1093 | + try: |
1094 | + ds = data_store.create_for_ticket( |
1095 | + ticket, unit_config.get_auth_config()) |
1096 | + ds.ensure_swift_client() |
1097 | + self.add_okay('data-store', 'connected') |
1098 | + return ds |
1099 | + except Exception as e: |
1100 | + self.add_fail('data-store', str(e)) |
1101 | + |
1102 | + |
1103 | +class StatusHandler(http.server.BaseHTTPRequestHandler): |
1104 | + # must be static because this object is create for each request |
1105 | + _cache = (None, 0) |
1106 | + |
1107 | + def __init__(self, *args, **kwargs): |
1108 | + http.server.BaseHTTPRequestHandler.__init__(self, *args, **kwargs) |
1109 | + |
1110 | + def _get_cached_response(self): |
1111 | + query = urllib.parse.urlparse(self.path).query |
1112 | + query = urllib.parse.parse_qs(query) |
1113 | + |
1114 | + resp, timeout = StatusHandler._cache |
1115 | + if query.get('refresh', ['0'])[0] == '1' or time.time() > timeout: |
1116 | + resp = json.dumps(self.health_cb().results) |
1117 | + StatusHandler._cache = (resp, time.time() + CACHE_TIMEOUT) |
1118 | + |
1119 | + if 'jsonp' in query.get('format', []): |
1120 | + cb = query.get('callback', ['cb'])[0] |
1121 | + resp = cb + '(' + resp + ')' |
1122 | + |
1123 | + return resp.encode('utf-8') |
1124 | + |
1125 | + def do_GET(self): |
1126 | + self.send_response(200) |
1127 | + self.send_header('Content-type', 'text/json') |
1128 | + self.end_headers() |
1129 | + resp = self._get_cached_response() |
1130 | + self.wfile.write(resp) |
1131 | + |
1132 | + def log_message(self, format, *args): |
1133 | + return |
1134 | + |
1135 | + |
1136 | +def run_server(health_cb, port=8080, threaded=True): |
1137 | + httpd = http.server.HTTPServer(('0.0.0.0', port), StatusHandler) |
1138 | + httpd.RequestHandlerClass.health_cb = health_cb |
1139 | + if threaded: |
1140 | + t = threading.Thread(target=httpd.serve_forever) |
1141 | + t.daemon = True |
1142 | + t.start() |
1143 | + return httpd |
1144 | + else: |
1145 | + httpd.serve_forever() |
1146 | |
1147 | === added directory 'subunit-results/subunitresults/ci_utils/testing' |
1148 | === added file 'subunit-results/subunitresults/ci_utils/testing/__init__.py' |
1149 | --- subunit-results/subunitresults/ci_utils/testing/__init__.py 1970-01-01 00:00:00 +0000 |
1150 | +++ subunit-results/subunitresults/ci_utils/testing/__init__.py 2014-08-21 14:14:59 +0000 |
1151 | @@ -0,0 +1,15 @@ |
1152 | +# Ubuntu Continuous Integration Engine |
1153 | +# Copyright 2014 Canonical Ltd. |
1154 | + |
1155 | +# This program is free software: you can redistribute it and/or modify it |
1156 | +# under |
1157 | +# the terms of the GNU General Public License version 3, as published by the |
1158 | +# Free Software Foundation. |
1159 | + |
1160 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
1161 | +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, |
1162 | +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
1163 | +# General Public License for more details. |
1164 | + |
1165 | +# You should have received a copy of the GNU General Public License along with |
1166 | +# this program. If not, see <http://www.gnu.org/licenses/>. |
1167 | |
1168 | === added file 'subunit-results/subunitresults/ci_utils/testing/fixtures.py' |
1169 | --- subunit-results/subunitresults/ci_utils/testing/fixtures.py 1970-01-01 00:00:00 +0000 |
1170 | +++ subunit-results/subunitresults/ci_utils/testing/fixtures.py 2014-08-21 14:14:59 +0000 |
1171 | @@ -0,0 +1,120 @@ |
1172 | +# Ubuntu CI Engine |
1173 | +# Copyright 2014 Canonical Ltd. |
1174 | + |
1175 | +# This program is free software: you can redistribute it and/or modify it |
1176 | +# under the terms of the GNU Affero General Public License version 3, as |
1177 | +# published by the Free Software Foundation. |
1178 | + |
1179 | +# This program is distributed in the hope that it will be useful, but |
1180 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1181 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1182 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1183 | + |
1184 | +# You should have received a copy of the GNU Affero General Public License |
1185 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1186 | + |
1187 | + |
1188 | +import os |
1189 | + |
1190 | +from ucitests import fixtures |
1191 | + |
1192 | +from ci_utils import data_store |
1193 | + |
1194 | + |
1195 | +# A data store defines a namespace for objects. The namespace is rooted at the |
1196 | +# container id and objects are identified by a path inside the container. |
1197 | +# It defines an API to store and retrieve objects |
1198 | + |
1199 | + |
1200 | +class FakeDataStore(data_store.DataStore): |
1201 | + """A fake data store to avoid requiring a swift server. |
1202 | + |
1203 | + This stores data on disk and as such should be |
1204 | + isolated. ucitests.fixtures.set_uniq_cwd() provides such isolation and |
1205 | + cleanup. It's the test responsibility to properly isolate itself. |
1206 | + """ |
1207 | + |
1208 | + def __init__(self, component, auth_config=None, public=False): |
1209 | + super(FakeDataStore, self).__init__( |
1210 | + # We use the container id as our backend directory and just store |
1211 | + # files there. |
1212 | + os.path.abspath(component), |
1213 | + # We don't use the config so we inject a fake but valid one |
1214 | + { |
1215 | + 'auth_url': 'http://example.com', |
1216 | + 'auth_user': 'user', |
1217 | + 'auth_password': 'pass', |
1218 | + 'auth_tenant_name': 'tenant', |
1219 | + 'auth_region': 'region', |
1220 | + }, |
1221 | + public=public |
1222 | + ) |
1223 | + # The following call is unguarded to fail if one attempts to create the |
1224 | + # same data store twice. |
1225 | + os.mkdir(self.container_id) |
1226 | + |
1227 | + def list_files(self): |
1228 | + return sorted(os.listdir(self.container_id)) |
1229 | + |
1230 | + def put_file(self, filename, contents, content_type=None): |
1231 | + full_path = os.path.join(self.container_id, filename) |
1232 | + with open(full_path, 'wb') as f: |
1233 | + f.write(contents) |
1234 | + return self.file_path(filename) |
1235 | + |
1236 | + def get_file(self, filename): |
1237 | + full_path = os.path.join(self.container_id, filename) |
1238 | + with open(full_path, 'rb') as f: |
1239 | + return f.read() |
1240 | + |
1241 | + def delete_file(self, filename): |
1242 | + full_path = os.path.join(self.container_id, filename) |
1243 | + os.remove(full_path) |
1244 | + |
1245 | + def clear(self): |
1246 | + for f in self.list_files(): |
1247 | + self.delete_file(f) |
1248 | + |
1249 | + def delete(self, recursive=False): |
1250 | + self.clear() |
1251 | + os.rmdir(self.container_id) |
1252 | + |
1253 | + def file_path(self, filename): |
1254 | + return '{}{}/{}'.format('file://', self.container_id, filename) |
1255 | + |
1256 | + |
1257 | +class FakeTaskQueue(object): |
1258 | + |
1259 | + def __init__(self, queue_name, config=None): |
1260 | + self.msgs = [] |
1261 | + self.queue_name = queue_name |
1262 | + # We don't use the config which contains the credentials to access a |
1263 | + # real rabbit server. |
1264 | + |
1265 | + def publish(self, msg): |
1266 | + self.msgs.append(msg) |
1267 | + |
1268 | + def subscribe(self, func, *args, **kwargs): |
1269 | + returns = [] |
1270 | + while not self.is_empty(): |
1271 | + ret = func(self.msgs.pop(0), *args, **kwargs) |
1272 | + returns.append(ret) |
1273 | + return returns |
1274 | + |
1275 | + def is_empty(self): |
1276 | + return not self.msgs |
1277 | + |
1278 | + |
1279 | +def isolate_from_juju_env(test): |
1280 | + """Isolate a test from juju environment definitions. |
1281 | + |
1282 | + This is usually called in setUp for tests that want to modify the juju |
1283 | + environement definitions and restore them after the test is run. |
1284 | + |
1285 | + :param test: A test instance. |
1286 | + """ |
1287 | + # Juju environments are defined in ${JUJU_HOME}/environments.yaml and can |
1288 | + # be specified via ${JUJU_ENV} |
1289 | + fixtures.set_uniq_cwd(test) |
1290 | + fixtures.isolate_from_env( |
1291 | + test, dict(HOME=test.uniq_dir, JUJU_HOME='.', JUJU_ENV=None)) |
1292 | |
1293 | === added directory 'subunit-results/subunitresults/ci_utils/tests' |
1294 | === added file 'subunit-results/subunitresults/ci_utils/tests/__init__.py' |
1295 | === added file 'subunit-results/subunitresults/ci_utils/tests/test_amqp.py' |
1296 | --- subunit-results/subunitresults/ci_utils/tests/test_amqp.py 1970-01-01 00:00:00 +0000 |
1297 | +++ subunit-results/subunitresults/ci_utils/tests/test_amqp.py 2014-08-21 14:14:59 +0000 |
1298 | @@ -0,0 +1,101 @@ |
1299 | +# Ubuntu CI Engine |
1300 | +# Copyright 2013, 2014 Canonical Ltd. |
1301 | + |
1302 | +# This program is free software: you can redistribute it and/or modify it |
1303 | +# under the terms of the GNU Affero General Public License version 3, as |
1304 | +# published by the Free Software Foundation. |
1305 | + |
1306 | +# This program is distributed in the hope that it will be useful, but |
1307 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1308 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1309 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1310 | + |
1311 | +# You should have received a copy of the GNU Affero General Public License |
1312 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1313 | + |
1314 | +import json |
1315 | +import socket |
1316 | +import time |
1317 | +import unittest |
1318 | +from unittest import mock |
1319 | + |
1320 | +from ci_utils import amqp_utils |
1321 | + |
1322 | + |
1323 | +class TestAMQP(unittest.TestCase): |
1324 | + @mock.patch('ci_utils.amqp_utils.connection') |
1325 | + @mock.patch('ci_utils.amqp_utils.get_config') |
1326 | + def testConnectFailed(self, get_config, connect): |
1327 | + '''Ensure a failed queue connection returns an HTTP 503 error''' |
1328 | + get_config.return_value = mock.Mock() |
1329 | + error = 'mocked test exception' |
1330 | + connect.side_effect = RuntimeError(error) |
1331 | + r = amqp_utils.send('fake_queue', 'fake_message') |
1332 | + self.assertIsNotNone(r) |
1333 | + self.assertEqual(error, r) |
1334 | + |
1335 | + @mock.patch('ci_utils.amqp_utils.connection') |
1336 | + @mock.patch('ci_utils.amqp_utils.get_config') |
1337 | + def testSent(self, get_config, connect): |
1338 | + '''Test a successful send returns nothing |
1339 | + |
1340 | + There's not much you can test in isolation here, but this gives |
1341 | + a bit of a sanity check. |
1342 | + ''' |
1343 | + get_config.return_value = mock.Mock() |
1344 | + connect.return_value = mock.Mock() |
1345 | + r = amqp_utils.send('fake_queue', 'fake_message') |
1346 | + self.assertIsNone(r) |
1347 | + |
1348 | + connect.side_effect = RuntimeError('testSent') |
1349 | + r = amqp_utils.send('fake_queue', 'fake message') |
1350 | + self.assertIsNotNone(r) |
1351 | + |
1352 | + with self.assertRaises(RuntimeError): |
1353 | + amqp_utils.send('fake_queue', 'fake message', True) |
1354 | + |
1355 | + @mock.patch('ci_utils.amqp_utils._run_forever') |
1356 | + @mock.patch('ci_utils.amqp_utils.connection') |
1357 | + def testProcessQueue(self, connection, run_forever): |
1358 | + '''Ensure we close the connection if something fails''' |
1359 | + conn = mock.Mock() |
1360 | + connection.return_value = conn |
1361 | + run_forever.side_effect = RuntimeError |
1362 | + with self.assertRaises(RuntimeError): |
1363 | + amqp_utils.process_queue(None, None, None) |
1364 | + self.assertTrue(conn.close.called) |
1365 | + |
1366 | + def testRunForever(self): |
1367 | + '''Ensure this times out after the right amount of time''' |
1368 | + channel = mock.Mock() |
1369 | + callback = mock.Mock() |
1370 | + channel.wait.side_effect = socket.error() |
1371 | + start = time.time() |
1372 | + retry_period = 0.1 |
1373 | + amqp_utils._run_forever(channel, 'foo', callback, retry_period) |
1374 | + # see if it was within 1/100 of a second of the retry_period |
1375 | + self.assertAlmostEqual(start + retry_period, time.time(), places=2) |
1376 | + |
1377 | + |
1378 | +class TestProgressTrigger(unittest.TestCase): |
1379 | + @mock.patch('ci_utils.amqp_utils.send') |
1380 | + def testProgress(self, send): |
1381 | + trigger = 'foo_trigger' |
1382 | + amqp_utils.progress_waiting(trigger) |
1383 | + send.assert_called_with(trigger, json.dumps({'state': 'WAITING'})) |
1384 | + |
1385 | + send.reset_mock() |
1386 | + params = {'foo': 'bar'} |
1387 | + amqp_utils.progress_completed(trigger, params) |
1388 | + params['state'] = 'COMPLETED' |
1389 | + params['result'] = 'PASSED' |
1390 | + params['exit'] = True |
1391 | + send.assert_called_with(trigger, json.dumps(params)) |
1392 | + |
1393 | + send.reset_mock() |
1394 | + params = {'foo': 'bar'} |
1395 | + amqp_utils.progress_failed(trigger, params) |
1396 | + params['state'] = 'COMPLETED' |
1397 | + params['result'] = 'FAILED' |
1398 | + params['exit'] = True |
1399 | + send.assert_called_with(trigger, json.dumps(params)) |
1400 | |
1401 | === added file 'subunit-results/subunitresults/ci_utils/tests/test_amqp_worker.py' |
1402 | --- subunit-results/subunitresults/ci_utils/tests/test_amqp_worker.py 1970-01-01 00:00:00 +0000 |
1403 | +++ subunit-results/subunitresults/ci_utils/tests/test_amqp_worker.py 2014-08-21 14:14:59 +0000 |
1404 | @@ -0,0 +1,267 @@ |
1405 | +# Ubuntu CI Engine |
1406 | +# Copyright 2014 Canonical Ltd. |
1407 | + |
1408 | +# This program is free software: you can redistribute it and/or modify it |
1409 | +# under the terms of the GNU Affero General Public License version 3, as |
1410 | +# published by the Free Software Foundation. |
1411 | + |
1412 | +# This program is distributed in the hope that it will be useful, but |
1413 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1414 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1415 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1416 | + |
1417 | +# You should have received a copy of the GNU Affero General Public License |
1418 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1419 | + |
1420 | +import json |
1421 | +import os |
1422 | +import subprocess |
1423 | +import time |
1424 | +import unittest |
1425 | +from unittest import mock |
1426 | + |
1427 | +from ci_utils import amqp_worker |
1428 | + |
1429 | + |
1430 | +class _worker(amqp_worker.AMQPWorker): |
1431 | + '''Simple class to help mock the datastore api used in the worker.''' |
1432 | + |
1433 | + def __init__(self, amqp_utils, retval, exception=None, error_msg=None): |
1434 | + super(_worker, self).__init__('queue-name') |
1435 | + self.store = mock.Mock() |
1436 | + if amqp_utils: |
1437 | + self.amqp_utils = amqp_utils |
1438 | + self.retval = retval |
1439 | + self.exception = exception |
1440 | + self.error_msg = error_msg |
1441 | + |
1442 | + def _create_data_store(self, ticket): |
1443 | + # Always use our own store so the tests can assert on it |
1444 | + return self.store |
1445 | + |
1446 | + def handle_request(self, params, log): |
1447 | + log.info('called') |
1448 | + self.params = params |
1449 | + if self.exception: |
1450 | + raise self.exception(self.error_msg) |
1451 | + if self.error_msg: |
1452 | + return self.amqp_utils.progress_failed, self.retval |
1453 | + if params.get('cancel_url'): |
1454 | + time.sleep(0.1) |
1455 | + self.retval['cancelled'] = params['cancelled'] |
1456 | + return self.amqp_utils.progress_completed, self.retval |
1457 | + |
1458 | + |
1459 | +class TestAMQPWorker(unittest.TestCase): |
1460 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1461 | + def testOnMessageSimple(self, amqp_utils): |
1462 | + '''Test simple on message logic''' |
1463 | + msg = mock.Mock() |
1464 | + msg.delivery_tag = 'foo' |
1465 | + msg.body = json.dumps({ |
1466 | + 'ticket_id': 1, |
1467 | + 'progress_trigger': 'queue-name', |
1468 | + 'param1': 'foo', |
1469 | + 'param2': 42, |
1470 | + }) |
1471 | + retval = {'msg': 'this is the worker return value'} |
1472 | + worker = _worker(amqp_utils, retval) |
1473 | + worker._on_message(msg) |
1474 | + self.assertEqual(worker.params['param1'], 'foo') |
1475 | + self.assertEqual(worker.params['param2'], 42) |
1476 | + self.assertEqual(1, amqp_utils.progress_update.call_count) |
1477 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1478 | + self.assertEqual(0, amqp_utils.progress_failed.call_count) |
1479 | + self.assertEqual(1, worker.store.put_file.call_count) |
1480 | + amqp_utils.progress_completed.assert_called_once_with( |
1481 | + 'queue-name', retval) |
1482 | + # assert_called_once_with works, but doesn't check the values |
1483 | + # added to retval |
1484 | + retval = amqp_utils.progress_completed.call_args[0][1] |
1485 | + self.assertEqual(1, len(retval['artifacts'])) |
1486 | + self.assertEqual('queue-name.output.log', |
1487 | + retval['artifacts'][0]['name']) |
1488 | + |
1489 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1490 | + def testOnMessageFail(self, amqp_utils): |
1491 | + '''Test on message logic for a failure handled by the worker''' |
1492 | + msg = mock.Mock() |
1493 | + msg.delivery_tag = 'foo' |
1494 | + msg.body = json.dumps({ |
1495 | + 'ticket_id': 1, |
1496 | + 'progress_trigger': 'queue-name', |
1497 | + 'param1': 'foo', |
1498 | + 'param2': 42, |
1499 | + }) |
1500 | + retval = {'msg': 'this is the worker return value'} |
1501 | + worker = _worker(amqp_utils, retval, None, True) |
1502 | + worker._on_message(msg) |
1503 | + self.assertEqual(worker.params['param1'], 'foo') |
1504 | + self.assertEqual(worker.params['param2'], 42) |
1505 | + self.assertEqual(1, amqp_utils.progress_update.call_count) |
1506 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1507 | + amqp_utils.progress_failed.assert_called_once_with( |
1508 | + 'queue-name', retval) |
1509 | + self.assertEqual(0, amqp_utils.progress_completed.call_count) |
1510 | + # assert_called_once_with works, but doesn't check the values |
1511 | + # added to retval |
1512 | + retval = amqp_utils.progress_failed.call_args[0][1] |
1513 | + self.assertEqual(1, len(retval['artifacts'])) |
1514 | + self.assertEqual('queue-name.output.log', |
1515 | + retval['artifacts'][0]['name']) |
1516 | + |
1517 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1518 | + def testOnMessageUnexpected(self, amqp_utils): |
1519 | + '''Test on message logic for an unexpected failure''' |
1520 | + msg = mock.Mock() |
1521 | + msg.delivery_tag = 'foo' |
1522 | + msg.body = json.dumps({ |
1523 | + 'ticket_id': 1, |
1524 | + 'progress_trigger': 'queue-name', |
1525 | + }) |
1526 | + err = 'this is the worker error' |
1527 | + worker = _worker(None, None, RuntimeError, err) |
1528 | + worker._on_message(msg) |
1529 | + self.assertEqual(1, amqp_utils.progress_update.call_count) |
1530 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1531 | + self.assertEqual(1, amqp_utils.progress_failed.call_count) |
1532 | + self.assertEqual( |
1533 | + 'queue-name', amqp_utils.progress_failed.call_args[0][0]) |
1534 | + self.assertIn( |
1535 | + err, amqp_utils.progress_failed.call_args[0][1]['traceback']) |
1536 | + self.assertEqual(1, worker.store.put_file.call_count) |
1537 | + |
1538 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1539 | + def testOnMessageCalledProcessError(self, amqp_utils): |
1540 | + '''Test on message logic nicely describes failed subprocess calls''' |
1541 | + msg = mock.Mock() |
1542 | + msg.delivery_tag = 'foo' |
1543 | + msg.body = json.dumps({ |
1544 | + 'ticket_id': 1, |
1545 | + 'progress_trigger': 'queue-name', |
1546 | + }) |
1547 | + err = 'this is the worker error' |
1548 | + |
1549 | + class _calledprocess_worker(_worker): |
1550 | + rc = 1234 |
1551 | + cmd = ['/bin/foo', 'bar'] |
1552 | + output = 'i can\'t believe its not butter' |
1553 | + |
1554 | + def handle_request(self, params, log): |
1555 | + raise subprocess.CalledProcessError( |
1556 | + self.rc, self.cmd, self.output) |
1557 | + |
1558 | + worker = _calledprocess_worker(None, None, None, None) |
1559 | + worker._on_message(msg) |
1560 | + |
1561 | + self.assertEqual(1, amqp_utils.progress_update.call_count) |
1562 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1563 | + self.assertEqual(1, amqp_utils.progress_failed.call_count) |
1564 | + self.assertEqual( |
1565 | + 'queue-name', amqp_utils.progress_failed.call_args[0][0]) |
1566 | + self.assertIn('CalledProcessError', |
1567 | + amqp_utils.progress_failed.call_args[0][1]['traceback']) |
1568 | + err = amqp_utils.progress_failed.call_args[0][1]['error_message'] |
1569 | + self.assertIn(str(worker.cmd), err) |
1570 | + self.assertIn(str(worker.output), err) |
1571 | + self.assertEqual(1, worker.store.put_file.call_count) |
1572 | + |
1573 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1574 | + def testOnMessageKilled(self, amqp_utils): |
1575 | + '''Test on message logic for handling ctrl-c (upstart stopping)''' |
1576 | + msg = mock.Mock() |
1577 | + msg.delivery_tag = 'foo' |
1578 | + msg.body = json.dumps({ |
1579 | + 'ticket_id': 1, |
1580 | + 'progress_trigger': 'queue-name', |
1581 | + }) |
1582 | + worker = _worker(None, None, KeyboardInterrupt) |
1583 | + with self.assertRaises(KeyboardInterrupt): |
1584 | + worker._on_message(msg) |
1585 | + self.assertEqual(1, amqp_utils.progress_update.call_count) |
1586 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1587 | + self.assertEqual(1, amqp_utils.progress_failed.call_count) |
1588 | + self.assertEqual( |
1589 | + 'queue-name', amqp_utils.progress_failed.call_args[0][0]) |
1590 | + self.assertIn('KeyboardInterrupt', |
1591 | + amqp_utils.progress_failed.call_args[0][1]['traceback']) |
1592 | + self.assertEqual(1, worker.store.put_file.call_count) |
1593 | + |
1594 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1595 | + def testNoTicket(self, amqp_utils): |
1596 | + '''Ensure we can gracefully deal with a bad message in the queue''' |
1597 | + msg = mock.Mock() |
1598 | + msg.delivery_tag = 'foo' |
1599 | + msg.body = json.dumps({ |
1600 | + 'progress_trigger': 'queue-name', |
1601 | + }) |
1602 | + worker = _worker(amqp_utils, 1) |
1603 | + worker._on_message(msg) |
1604 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1605 | + self.assertEqual(1, amqp_utils.progress_failed.call_count) |
1606 | + self.assertEqual( |
1607 | + 'queue-name', amqp_utils.progress_failed.call_args[0][0]) |
1608 | + |
1609 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1610 | + def testNoQueue(self, amqp_utils): |
1611 | + '''Ensure we can gracefully deal with a bad message in the queue''' |
1612 | + msg = mock.Mock() |
1613 | + msg.delivery_tag = 'foo' |
1614 | + msg.body = json.dumps({ |
1615 | + }) |
1616 | + worker = _worker(amqp_utils, 1) |
1617 | + worker._on_message(msg) |
1618 | + # all we can check is that the message got acked |
1619 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1620 | + |
1621 | + @mock.patch('ci_utils.amqp_worker.amqp_utils') |
1622 | + @mock.patch('ci_utils.amqp_worker._get') |
1623 | + def testCancel(self, get, amqp_utils): |
1624 | + '''Ensure workers get the "cancel" message''' |
1625 | + msg = mock.Mock() |
1626 | + msg.delivery_tag = 'foo' |
1627 | + msg.body = json.dumps({ |
1628 | + 'ticket_id': 1, |
1629 | + 'progress_trigger': 'queue-name', |
1630 | + 'param1': 'foo', |
1631 | + 'param2': 42, |
1632 | + 'cancel_url': 'foo' |
1633 | + }) |
1634 | + get.return_value = {'building': False} |
1635 | + retval = {'msg': 'this is the worker return value'} |
1636 | + worker = _worker(amqp_utils, retval) |
1637 | + worker.cancel_interval = 0.01 |
1638 | + worker._on_message(msg) |
1639 | + msg.channel.basic_ack.assert_called_once_with('foo') |
1640 | + amqp_utils.progress_completed.assert_called_once_with( |
1641 | + 'queue-name', retval) |
1642 | + retval = amqp_utils.progress_completed.call_args[0][1] |
1643 | + self.assertTrue(retval['cancelled']) |
1644 | + |
1645 | + def testSaveLastRun(self): |
1646 | + results = {'foo': 'bar'} |
1647 | + worker = _worker(None, None) |
1648 | + worker.save_last_check(results) |
1649 | + self.addCleanup(os.unlink, worker.last_check_file) |
1650 | + check = worker.get_last_check() |
1651 | + self.assertIn('last_run', check) |
1652 | + self.assertEqual(results['foo'], check['foo']) |
1653 | + |
1654 | + |
1655 | +class TestTimer(unittest.TestCase): |
1656 | + def testCanCancel(self): |
1657 | + def cb(): |
1658 | + raise RuntimeError('cancel failed') |
1659 | + t = amqp_worker._timer(1, cb) |
1660 | + t.start() |
1661 | + t.cancel() |
1662 | + |
1663 | + def testCBRuns(self): |
1664 | + def cb(): |
1665 | + self.run = True |
1666 | + self.run = False |
1667 | + t = amqp_worker._timer(0.01, cb) |
1668 | + t.start() |
1669 | + time.sleep(.02) |
1670 | + t.cancel() |
1671 | + self.assertTrue(self.run) |
1672 | |
1673 | === added file 'subunit-results/subunitresults/ci_utils/tests/test_data_store.py' |
1674 | --- subunit-results/subunitresults/ci_utils/tests/test_data_store.py 1970-01-01 00:00:00 +0000 |
1675 | +++ subunit-results/subunitresults/ci_utils/tests/test_data_store.py 2014-08-21 14:14:59 +0000 |
1676 | @@ -0,0 +1,60 @@ |
1677 | +# Ubuntu CI Engine |
1678 | +# Copyright 2014 Canonical Ltd. |
1679 | + |
1680 | +# This program is free software: you can redistribute it and/or modify it |
1681 | +# under the terms of the GNU Affero General Public License version 3, as |
1682 | +# published by the Free Software Foundation. |
1683 | + |
1684 | +# This program is distributed in the hope that it will be useful, but |
1685 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1686 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1687 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1688 | + |
1689 | +# You should have received a copy of the GNU Affero General Public License |
1690 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1691 | + |
1692 | +import unittest |
1693 | + |
1694 | + |
1695 | +from ci_utils import data_store |
1696 | + |
1697 | + |
1698 | +filename = 'myproject_1.0-1_source.changes' |
1699 | + |
1700 | + |
1701 | +class TestDataStoreConfig(unittest.TestCase): |
1702 | + |
1703 | + def test_valid_auth_config(self): |
1704 | + data_store.DataStore.validate_auth_config({ |
1705 | + 'auth_url': 'http://example.com', |
1706 | + 'auth_user': 'user', |
1707 | + 'auth_password': 'pass', |
1708 | + 'auth_tenant_name': 'tenant', |
1709 | + 'auth_region': 'region', |
1710 | + }) |
1711 | + |
1712 | + def test_invalid_auth_config(self): |
1713 | + with self.assertRaises(data_store.DataStoreException): |
1714 | + data_store.DataStore.validate_auth_config({}) |
1715 | + |
1716 | + |
1717 | +class TestDataStoreFileName(unittest.TestCase): |
1718 | + |
1719 | + def test_get_file_name(self): |
1720 | + path = '/home/ubuntu/myproject/{}'.format(filename) |
1721 | + resp = data_store._get_file_name(path) |
1722 | + self.assertEqual(resp, filename) |
1723 | + |
1724 | + def test_get_relativepath_file_name(self): |
1725 | + path = '../../myproject/{}'.format(filename) |
1726 | + resp = data_store._get_file_name(path) |
1727 | + self.assertEqual(resp, filename) |
1728 | + |
1729 | + def test_get_file_name_same_directory(self): |
1730 | + path = '{}'.format(filename) |
1731 | + resp = data_store._get_file_name(path) |
1732 | + self.assertEqual(resp, filename) |
1733 | + |
1734 | + |
1735 | +if __name__ == "__main__": |
1736 | + unittest.main() |
1737 | |
1738 | === added file 'subunit-results/subunitresults/ci_utils/tests/test_fixtures.py' |
1739 | --- subunit-results/subunitresults/ci_utils/tests/test_fixtures.py 1970-01-01 00:00:00 +0000 |
1740 | +++ subunit-results/subunitresults/ci_utils/tests/test_fixtures.py 2014-08-21 14:14:59 +0000 |
1741 | @@ -0,0 +1,87 @@ |
1742 | +# Ubuntu CI Engine |
1743 | +# Copyright 2014 Canonical Ltd. |
1744 | + |
1745 | +# This program is free software: you can redistribute it and/or modify it |
1746 | +# under the terms of the GNU Affero General Public License version 3, as |
1747 | +# published by the Free Software Foundation. |
1748 | + |
1749 | +# This program is distributed in the hope that it will be useful, but |
1750 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1751 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1752 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1753 | + |
1754 | +# You should have received a copy of the GNU Affero General Public License |
1755 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1756 | + |
1757 | +import errno |
1758 | +import os |
1759 | +import unittest |
1760 | + |
1761 | +from ucitests import fixtures as uci_fixtures |
1762 | + |
1763 | +from ci_utils.testing import fixtures |
1764 | + |
1765 | + |
1766 | +class TestFakeDataStore(unittest.TestCase): |
1767 | + """Test the FakeDataStore implementation. |
1768 | + |
1769 | + Since the target is tests themselves focusing on happy paths, no failures |
1770 | + are tested here. |
1771 | + """ |
1772 | + |
1773 | + def setUp(self): |
1774 | + super().setUp() |
1775 | + uci_fixtures.set_uniq_cwd(self) |
1776 | + self.ds_path = os.path.join(self.uniq_dir, 'data_store') |
1777 | + self.ds = fixtures.FakeDataStore(self.ds_path, None) |
1778 | + |
1779 | + def test_put_get_file(self): |
1780 | + self.assertEqual([], self.ds.list_files()) |
1781 | + url = self.ds.put_file('foo', b'foo content\n') |
1782 | + self.assertTrue(url.startswith('file:///')) |
1783 | + self.assertTrue(url.endswith(os.path.join(self.ds_path, 'foo'))) |
1784 | + self.assertEqual(['foo'], self.ds.list_files()) |
1785 | + self.assertEqual(b'foo content\n', self.ds.get_file('foo')) |
1786 | + |
1787 | + def test_list_files_empty(self): |
1788 | + self.assertEqual([], self.ds.list_files()) |
1789 | + |
1790 | + def test_list_files_some_files(self): |
1791 | + self.ds.put_file('foo', b'foo content\n') |
1792 | + self.ds.put_file('bar', b'bar content\n') |
1793 | + self.assertEqual(sorted(['foo', 'bar']), self.ds.list_files()) |
1794 | + |
1795 | + def test_delete_file(self): |
1796 | + self.ds.put_file('foo', b'foo content\n') |
1797 | + self.assertEqual(['foo'], self.ds.list_files()) |
1798 | + self.ds.delete_file('foo') |
1799 | + self.assertEqual([], self.ds.list_files()) |
1800 | + |
1801 | + def test_clear(self): |
1802 | + self.ds.put_file('foo', b'foo content\n') |
1803 | + self.ds.put_file('bar', b'bar content\n') |
1804 | + self.assertEqual(sorted(['foo', 'bar']), self.ds.list_files()) |
1805 | + self.ds.clear() |
1806 | + self.assertEqual([], self.ds.list_files()) |
1807 | + |
1808 | + def test_delete(self): |
1809 | + self.assertTrue(os.path.exists(self.ds_path)) |
1810 | + self.ds.put_file('foo', b'foo content\n') |
1811 | + self.ds.delete() |
1812 | + self.assertFalse(os.path.exists(self.ds_path)) |
1813 | + |
1814 | + |
1815 | +class TestUsingFakeDataStore(unittest.TestCase): |
1816 | + |
1817 | + def setUp(self): |
1818 | + super().setUp() |
1819 | + uci_fixtures.set_uniq_cwd(self) |
1820 | + self.ds_path = os.path.join(self.uniq_dir, 'data_store') |
1821 | + self.ds = fixtures.FakeDataStore(self.ds_path, None) |
1822 | + |
1823 | + def test_create_same_data_store(self): |
1824 | + # Trying to create the same data store twice fails |
1825 | + with self.assertRaises(OSError) as cm: |
1826 | + fixtures.FakeDataStore(self.ds_path, None) |
1827 | + self.assertEqual(errno.EEXIST, cm.exception.errno) |
1828 | + self.assertEqual(self.ds_path, cm.exception.filename) |
1829 | |
1830 | === added file 'subunit-results/subunitresults/ci_utils/tests/test_json_status.py' |
1831 | --- subunit-results/subunitresults/ci_utils/tests/test_json_status.py 1970-01-01 00:00:00 +0000 |
1832 | +++ subunit-results/subunitresults/ci_utils/tests/test_json_status.py 2014-08-21 14:14:59 +0000 |
1833 | @@ -0,0 +1,144 @@ |
1834 | +# Ubuntu CI Engine |
1835 | +# Copyright 2014 Canonical Ltd. |
1836 | + |
1837 | +# This program is free software: you can redistribute it and/or modify it |
1838 | +# under the terms of the GNU Affero General Public License version 3, as |
1839 | +# published by the Free Software Foundation. |
1840 | + |
1841 | +# This program is distributed in the hope that it will be useful, but |
1842 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1843 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1844 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1845 | + |
1846 | +# You should have received a copy of the GNU Affero General Public License |
1847 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1848 | + |
1849 | +import json |
1850 | +import unittest |
1851 | +from unittest import mock |
1852 | +import urllib.error |
1853 | +import urllib.parse |
1854 | +import urllib.request |
1855 | + |
1856 | + |
1857 | +from ci_utils.json_status import JSONStatus, run_server |
1858 | + |
1859 | + |
1860 | +class TestRabbitStatus(unittest.TestCase): |
1861 | + @mock.patch('ci_utils.amqp_utils.get_config') |
1862 | + def testNoConfig(self, get_config): |
1863 | + get_config.return_value = None |
1864 | + status = JSONStatus() |
1865 | + status.add_rabbit_worker_health('foo') |
1866 | + self.assertEqual([], status.results) |
1867 | + |
1868 | + @mock.patch('ci_utils.amqp_utils.get_config') |
1869 | + @mock.patch('urllib.request.build_opener') |
1870 | + def testCantReach(self, opener, get_config): |
1871 | + opener.side_effect = RuntimeError('foo') |
1872 | + status = JSONStatus() |
1873 | + status.add_rabbit_worker_health('foo') |
1874 | + expected = { |
1875 | + 'status': 'fail', |
1876 | + 'value': 'unable to check', |
1877 | + 'label': 'workers-online' |
1878 | + } |
1879 | + self.assertEqual([expected], status.results) |
1880 | + |
1881 | + @mock.patch('ci_utils.amqp_utils.get_config') |
1882 | + @mock.patch('urllib.request.build_opener') |
1883 | + def _testRabbitHealth(self, queue, data, opener, get_config): |
1884 | + fdfake = mock.Mock() |
1885 | + fdfake.read.return_value = json.dumps(data) |
1886 | + con = mock.Mock() |
1887 | + con.open.return_value = fdfake |
1888 | + opener.return_value = con |
1889 | + status = JSONStatus() |
1890 | + status.add_rabbit_worker_health(queue) |
1891 | + return status.results |
1892 | + |
1893 | + def testWorkers(self): |
1894 | + data = [ |
1895 | + {'name': 'bla', 'consumers': 2}, |
1896 | + {'name': 'foo', 'consumers': 1}, |
1897 | + {'name': 'bar', 'consumers': 1}, |
1898 | + ] |
1899 | + expected = { |
1900 | + 'status': 'okay', |
1901 | + 'value': 1, |
1902 | + 'label': 'workers-online' |
1903 | + } |
1904 | + results = self._testRabbitHealth('foo', data) |
1905 | + self.assertEqual([expected], results) |
1906 | + |
1907 | + def testNoWorkers(self): |
1908 | + data = [ |
1909 | + {'name': 'bla', 'consumers': 2}, |
1910 | + {'name': 'foo', 'consumers': 0}, |
1911 | + {'name': 'bar', 'consumers': 1}, |
1912 | + ] |
1913 | + expected = { |
1914 | + 'status': 'fail', |
1915 | + 'value': 0, |
1916 | + 'label': 'workers-online' |
1917 | + } |
1918 | + results = self._testRabbitHealth('foo', data) |
1919 | + self.assertEqual([expected], results) |
1920 | + |
1921 | + def testNoQueue(self): |
1922 | + data = [ |
1923 | + {'name': 'bla', 'consumers': 2}, |
1924 | + {'name': 'bar', 'consumers': 1}, |
1925 | + ] |
1926 | + expected = { |
1927 | + 'status': 'fail', |
1928 | + 'value': 'no queue defined', |
1929 | + 'label': 'workers-online' |
1930 | + } |
1931 | + results = self._testRabbitHealth('foo', data) |
1932 | + self.assertEqual([expected], results) |
1933 | + |
1934 | + |
1935 | +class TestHealthServer(unittest.TestCase): |
1936 | + def get_resp(self, path='/'): |
1937 | + server = run_server(self.cb, 0) |
1938 | + try: |
1939 | + url = 'http://localhost:%d%s' % (server.server_port, path) |
1940 | + return urllib.request.urlopen(url) |
1941 | + finally: |
1942 | + server.shutdown() |
1943 | + |
1944 | + def cb(self): |
1945 | + self.cb_called = True |
1946 | + status = JSONStatus() |
1947 | + status.add_okay('name', 'value') |
1948 | + return status |
1949 | + |
1950 | + def test_plain(self): |
1951 | + resp = self.get_resp() |
1952 | + self.assertEqual(200, resp.code) |
1953 | + self.assertEqual( |
1954 | + self.cb().results, |
1955 | + json.loads(resp.read().decode('utf-8')) |
1956 | + ) |
1957 | + |
1958 | + def test_jsonp(self): |
1959 | + resp = self.get_resp('/?format=jsonp&callback=foo') |
1960 | + self.assertEqual(200, resp.code) |
1961 | + expected = 'foo(' + json.dumps(self.cb().results) + ')' |
1962 | + self.assertEqual(expected, resp.read().decode('utf-8')) |
1963 | + |
1964 | + def test_cache(self): |
1965 | + self.cb_called = False |
1966 | + resp = self.get_resp() |
1967 | + self.assertEqual(200, resp.code) |
1968 | + self.assertTrue(self.cb_called) |
1969 | + |
1970 | + self.cb_called = False |
1971 | + resp = self.get_resp() |
1972 | + self.assertEqual(200, resp.code) |
1973 | + self.assertFalse(self.cb_called) |
1974 | + |
1975 | + resp = self.get_resp('/?refresh=1') |
1976 | + self.assertEqual(200, resp.code) |
1977 | + self.assertTrue(self.cb_called) |
1978 | |
1979 | === added file 'subunit-results/subunitresults/ci_utils/tests/test_tmpdir.py' |
1980 | --- subunit-results/subunitresults/ci_utils/tests/test_tmpdir.py 1970-01-01 00:00:00 +0000 |
1981 | +++ subunit-results/subunitresults/ci_utils/tests/test_tmpdir.py 2014-08-21 14:14:59 +0000 |
1982 | @@ -0,0 +1,27 @@ |
1983 | +# Ubuntu CI Engine |
1984 | +# Copyright 2014 Canonical Ltd. |
1985 | + |
1986 | +# This program is free software: you can redistribute it and/or modify it |
1987 | +# under the terms of the GNU Affero General Public License version 3, as |
1988 | +# published by the Free Software Foundation. |
1989 | + |
1990 | +# This program is distributed in the hope that it will be useful, but |
1991 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1992 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1993 | +# PURPOSE. See the GNU Affero General Public License for more details. |
1994 | + |
1995 | +# You should have received a copy of the GNU Affero General Public License |
1996 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1997 | + |
1998 | +import os |
1999 | +import unittest |
2000 | + |
2001 | +from ci_utils import TmpDir |
2002 | + |
2003 | + |
2004 | +class TestTmpDir(unittest.TestCase): |
2005 | + def test_tmpdir(self): |
2006 | + with TmpDir() as tmpdir: |
2007 | + dir_created = tmpdir |
2008 | + self.assertTrue(os.path.exists(dir_created)) |
2009 | + self.assertFalse(os.path.exists(dir_created)) |
2010 | |
2011 | === added file 'subunit-results/subunitresults/ci_utils/unit_config.py' |
2012 | --- subunit-results/subunitresults/ci_utils/unit_config.py 1970-01-01 00:00:00 +0000 |
2013 | +++ subunit-results/subunitresults/ci_utils/unit_config.py 2014-08-21 14:14:59 +0000 |
2014 | @@ -0,0 +1,60 @@ |
2015 | +# Ubuntu CI Engine |
2016 | +# Copyright 2014 Canonical Ltd. |
2017 | + |
2018 | +# This program is free software: you can redistribute it and/or modify it |
2019 | +# under the terms of the GNU Affero General Public License version 3, as |
2020 | +# published by the Free Software Foundation. |
2021 | + |
2022 | +# This program is distributed in the hope that it will be useful, but |
2023 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2024 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2025 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2026 | + |
2027 | +# You should have received a copy of the GNU Affero General Public License |
2028 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2029 | + |
2030 | +import os |
2031 | + |
2032 | +import yaml |
2033 | + |
2034 | + |
2035 | +HERE = os.path.abspath(os.path.dirname(__file__)) |
2036 | + |
2037 | + |
2038 | +_unit_config = None |
2039 | + |
2040 | + |
2041 | +def _load(): |
2042 | + path = os.path.join(HERE, '../../../../unit_config') |
2043 | + # TODO remove once all charms have converted to new code layout |
2044 | + # Fallback to old location configs (inside code tree) for local branches |
2045 | + # testing and charms transition. |
2046 | + if not os.path.exists(path): |
2047 | + path = os.path.abspath(os.path.join(HERE, '../../unit_config')) |
2048 | + with open(path) as f: |
2049 | + global _unit_config |
2050 | + _unit_config = yaml.safe_load(f) |
2051 | + |
2052 | + |
2053 | +def get(key): |
2054 | + if not _unit_config: |
2055 | + _load() |
2056 | + return _unit_config[key] |
2057 | + |
2058 | + |
2059 | +def get_auth_config(): |
2060 | + if not _unit_config: |
2061 | + _load() |
2062 | + return _unit_config |
2063 | + |
2064 | + |
2065 | +def is_hpcloud(string=None): |
2066 | + """Are we running against/on HP cloud. |
2067 | + |
2068 | + :param string: The authentication URL to check (defaults to OS_AUTH_URL). |
2069 | + """ |
2070 | + if string is None: |
2071 | + string = os.environ.get('OS_AUTH_URL', None) |
2072 | + if string and 'hpcloud' in string: |
2073 | + return True |
2074 | + return False |
2075 | |
2076 | === added file 'subunit-results/subunitresults/queue_worker.py' |
2077 | --- subunit-results/subunitresults/queue_worker.py 1970-01-01 00:00:00 +0000 |
2078 | +++ subunit-results/subunitresults/queue_worker.py 2014-08-21 14:14:59 +0000 |
2079 | @@ -0,0 +1,91 @@ |
2080 | +#!/usr/bin/env python3 |
2081 | +# Ubuntu CI Engine |
2082 | +# Copyright 2014 Canonical Ltd. |
2083 | +# |
2084 | +# This program is free software: you can redistribute it and/or modify it |
2085 | +# under the terms of the GNU Affero General Public License version 3, as |
2086 | +# published by the Free Software Foundation. |
2087 | +# |
2088 | +# This program is distributed in the hope that it will be useful, but |
2089 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2090 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2091 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2092 | +# |
2093 | +# You should have received a copy of the GNU Affero General Public License |
2094 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2095 | + |
2096 | + |
2097 | +import logging |
2098 | +import json |
2099 | +from queue import Queue |
2100 | + |
2101 | +# TODO: |
2102 | +# use real data store and remove this import |
2103 | +from subunitresults.ci_utils.testing import fixtures |
2104 | +from subunitresults.ci_utils import amqp_utils, amqp_worker |
2105 | + |
2106 | +from subunitresults.utils import retry |
2107 | +from subunitresults.subunit_parser import run_result |
2108 | + |
2109 | + |
2110 | +logging.basicConfig(level='INFO') |
2111 | +logger = logging.getLogger(__name__) |
2112 | + |
2113 | + |
2114 | +def get_item(queue): |
2115 | + return queue.get(block=True, timeout=None) |
2116 | + |
2117 | + |
2118 | +def get_subunit_path(json_blob): |
2119 | + d = json.loads(json_blob) |
2120 | + try: |
2121 | + subunit_path = d['subunit_path'] |
2122 | + except KeyError: |
2123 | + raise Exception("subunit_path not found in json message") |
2124 | + return subunit_path |
2125 | + |
2126 | + |
2127 | +@retry(Exception, tries=5, delay=1, backoff=2, logger=None) |
2128 | +def fetch_subunit(data_store, url): |
2129 | + contents = data_store.get_file(url) |
2130 | + return contents |
2131 | + |
2132 | + |
2133 | +class Worker: |
2134 | + def __init__(self, data_store, queue): |
2135 | + self.queue = queue |
2136 | + self.data_store = data_store |
2137 | + |
2138 | + def do_one_job(self): |
2139 | + json_blob = get_item(self.queue) |
2140 | + path = get_subunit_path(json_blob) |
2141 | + subunit_file = fetch_subunit(self.data_store, path) |
2142 | + run_result(self.data_store, subunit_file) |
2143 | + |
2144 | + |
2145 | +class RabbitWorker(amqp_worker.AMQPWorker): |
2146 | + def __init__(self, logger_name, data_store, queue): |
2147 | + super().__init__(logger_name) |
2148 | + self.logger_name = logger_name |
2149 | + self.data_store = data_store |
2150 | + self.queue = queue |
2151 | + self.worker = Worker(self.data_store, self.queue) |
2152 | + |
2153 | + def handle_request(self, params, logger): |
2154 | + try: |
2155 | + # TODO: |
2156 | + # need to get message from rabbit queue, put on python queue |
2157 | + self.worker.do_one_job() |
2158 | + return (amqp_utils.progress_completed, {}) |
2159 | + except Exception: |
2160 | + return (amqp_utils.progress_failed, {}) |
2161 | + |
2162 | + |
2163 | +def launch_amqp_listener(): |
2164 | + # TODO: |
2165 | + # need to create RabbitWorker instance with |
2166 | + # real data store |
2167 | + logger_name = 'subunit-results' |
2168 | + data_store = fixtures.FakeDataStore('./data_store', None) |
2169 | + queue = Queue() |
2170 | + RabbitWorker(logger_name, data_store, queue) |
2171 | |
2172 | === added file 'subunit-results/subunitresults/subunit_parser.py' |
2173 | --- subunit-results/subunitresults/subunit_parser.py 1970-01-01 00:00:00 +0000 |
2174 | +++ subunit-results/subunitresults/subunit_parser.py 2014-08-21 14:14:59 +0000 |
2175 | @@ -0,0 +1,151 @@ |
2176 | +#!/usr/bin/env python3 |
2177 | +# Ubuntu CI Engine |
2178 | +# Copyright 2014 Canonical Ltd. |
2179 | +# |
2180 | +# This program is free software: you can redistribute it and/or modify it |
2181 | +# under the terms of the GNU Affero General Public License version 3, as |
2182 | +# published by the Free Software Foundation. |
2183 | +# |
2184 | +# This program is distributed in the hope that it will be useful, but |
2185 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2186 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2187 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2188 | +# |
2189 | +# You should have received a copy of the GNU Affero General Public License |
2190 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2191 | + |
2192 | +from datetime import datetime |
2193 | +import logging |
2194 | +import re |
2195 | + |
2196 | +from subunit import ByteStreamToStreamResult |
2197 | +from testtools import StreamToExtendedDecorator, TestResult |
2198 | + |
2199 | + |
2200 | +logging.basicConfig(level='INFO') |
2201 | +logger = logging.getLogger(__name__) |
2202 | + |
2203 | + |
2204 | +class FileSaveResult(TestResult): |
2205 | + |
2206 | + """A TestResult that saves a test summary, details, and attachments.""" |
2207 | + |
2208 | + def __init__(self, data_store): |
2209 | + super().__init__() |
2210 | + self.data_store = data_store |
2211 | + |
2212 | + self.testcase_summaries = {} |
2213 | + self.summary = dict( |
2214 | + tests_run=0, |
2215 | + errors=0, |
2216 | + expected_failures=0, |
2217 | + failures=0, |
2218 | + num_attachments=0, |
2219 | + run_time=0, |
2220 | + skipped=0, |
2221 | + unexpected_successes=0, |
2222 | + tests=self.testcase_summaries |
2223 | + ) |
2224 | + |
2225 | + self.content_attachments = [] |
2226 | + self.testcase_details = dict( |
2227 | + run_time=2.0, |
2228 | + content_attachments=self.content_attachments |
2229 | + ) |
2230 | + |
2231 | + def addError(self, test, err=None, details=None): |
2232 | + super().addError(test, err=err, details=details) |
2233 | + self.summary['num_attachments'] += len(details) |
2234 | + test.status = 'error' |
2235 | + self.save_file_attachments(test, details) |
2236 | + |
2237 | + def addExpectedFailure(self, test, err=None, details=None): |
2238 | + super().addExpectedFailure(test, err=err, details=details) |
2239 | + self.summary['num_attachments'] += len(details) |
2240 | + test.status = 'xfail' |
2241 | + self.save_file_attachments(test, details) |
2242 | + |
2243 | + def addFailure(self, test, err=None, details=None): |
2244 | + super().addFailure(test, err=err, details=details) |
2245 | + self.summary['num_attachments'] += len(details) |
2246 | + test.status = 'fail' |
2247 | + self.save_file_attachments(test, details) |
2248 | + |
2249 | + def addSkip(self, test, reason=None, details=None): |
2250 | + super().addSkip(test, reason=reason, details=details) |
2251 | + self.summary['num_attachments'] += len(details) |
2252 | + test.status = 'skip' |
2253 | + self.save_file_attachments(test, details) |
2254 | + |
2255 | + def addSuccess(self, test, details=None): |
2256 | + super().addSuccess(test, details=details) |
2257 | + self.summary['num_attachments'] += len(details) |
2258 | + test.status = 'pass' |
2259 | + self.save_file_attachments(test, details) |
2260 | + |
2261 | + def addUnexpectedSuccess(self, test, details=None): |
2262 | + super().addUnexpectedSuccess(test, details=details) |
2263 | + self.summary['num_attachments'] += len(details) |
2264 | + test.status = 'uxsuccess' |
2265 | + self.save_file_attachments(test, details) |
2266 | + |
2267 | + def stopTest(self, test): |
2268 | + super().stopTest(test) |
2269 | + elapsed = test._timestamps[1] - test._timestamps[0] |
2270 | + test_time = elapsed.total_seconds() |
2271 | + self.summary['run_time'] += test_time |
2272 | + self.testcase_summaries[test.id()] = [test.status, test_time] |
2273 | + self.testcase_details['run_time'] = test_time |
2274 | + self.testcase_details['status'] = test.status |
2275 | + |
2276 | + def stopTestRun(self): |
2277 | + super().stopTestRun() |
2278 | + self.summary['errors'] = len(self.errors) |
2279 | + self.summary['expected_failures'] = len(self.expectedFailures) |
2280 | + self.summary['failures'] = len(self.failures) |
2281 | + self.summary['skipped'] = len(self.skip_reasons) |
2282 | + self.summary['tests_run'] = self.testsRun |
2283 | + self.summary['unexpected_successes'] = len(self.unexpectedSuccesses) |
2284 | + |
2285 | + def save_file_attachments(self, test, details): |
2286 | + testcase_details = dict( |
2287 | + content_attachments=[] |
2288 | + ) |
2289 | + |
2290 | + if details is not None: |
2291 | + for name, detail in details.items(): |
2292 | + if detail.iter_bytes() != [b'']: |
2293 | + saved_name = create_unique_name(test.id(), name) |
2294 | + content = b''.join(chunk for chunk in detail.iter_bytes()) |
2295 | + mime_type = str(detail.content_type) |
2296 | + self.data_store.put_file(saved_name, content, mime_type) |
2297 | + content_attachment = dict( |
2298 | + name=saved_name, |
2299 | + mime_type=mime_type, |
2300 | + size=len(content), |
2301 | + location=self.data_store.file_path(saved_name) |
2302 | + ) |
2303 | + testcase_details['content_attachments'].append( |
2304 | + content_attachment |
2305 | + ) |
2306 | + self.testcase_details = testcase_details |
2307 | + return testcase_details |
2308 | + |
2309 | + |
2310 | +def create_unique_name(test_id, file_name, timestamp=None): |
2311 | + if timestamp is None: |
2312 | + timestamp = datetime.timestamp(datetime.utcnow()) |
2313 | + slug = re.sub(r'(?u)[^-\w.]', '', file_name) |
2314 | + return '%s+%s+%s' % (timestamp, test_id, slug) |
2315 | + |
2316 | + |
2317 | +def run_result(data_store, subunit_stream): |
2318 | + suite = ByteStreamToStreamResult(subunit_stream) |
2319 | + fs_result = FileSaveResult(data_store) |
2320 | + result = StreamToExtendedDecorator(fs_result) |
2321 | + result.startTestRun() |
2322 | + try: |
2323 | + suite.run(result) |
2324 | + finally: |
2325 | + result.stopTestRun() |
2326 | + return True |
2327 | |
2328 | === added directory 'subunit-results/subunitresults/tests' |
2329 | === added file 'subunit-results/subunitresults/tests/__init__.py' |
2330 | --- subunit-results/subunitresults/tests/__init__.py 1970-01-01 00:00:00 +0000 |
2331 | +++ subunit-results/subunitresults/tests/__init__.py 2014-08-21 14:14:59 +0000 |
2332 | @@ -0,0 +1,14 @@ |
2333 | +# Ubuntu CI Engine |
2334 | +# Copyright 2014 Canonical Ltd. |
2335 | +# |
2336 | +# This program is free software: you can redistribute it and/or modify it |
2337 | +# under the terms of the GNU Affero General Public License version 3, as |
2338 | +# published by the Free Software Foundation. |
2339 | +# |
2340 | +# This program is distributed in the hope that it will be useful, but |
2341 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2342 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2343 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2344 | +# |
2345 | +# You should have received a copy of the GNU Affero General Public License |
2346 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2347 | |
2348 | === added file 'subunit-results/subunitresults/tests/results_for_tests.subunit' |
2349 | Binary files subunit-results/subunitresults/tests/results_for_tests.subunit 1970-01-01 00:00:00 +0000 and subunit-results/subunitresults/tests/results_for_tests.subunit 2014-08-21 14:14:59 +0000 differ |
2350 | === added file 'subunit-results/subunitresults/tests/test_queue_worker.py' |
2351 | --- subunit-results/subunitresults/tests/test_queue_worker.py 1970-01-01 00:00:00 +0000 |
2352 | +++ subunit-results/subunitresults/tests/test_queue_worker.py 2014-08-21 14:14:59 +0000 |
2353 | @@ -0,0 +1,62 @@ |
2354 | +#!/usr/bin/env python3 |
2355 | +# Ubuntu CI Engine |
2356 | +# Copyright 2014 Canonical Ltd. |
2357 | +# |
2358 | +# This program is free software: you can redistribute it and/or modify it |
2359 | +# under the terms of the GNU Affero General Public License version 3, as |
2360 | +# published by the Free Software Foundation. |
2361 | +# |
2362 | +# This program is distributed in the hope that it will be useful, but |
2363 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2364 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2365 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2366 | +# |
2367 | +# You should have received a copy of the GNU Affero General Public License |
2368 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2369 | + |
2370 | +import json |
2371 | +import os |
2372 | +from queue import Queue |
2373 | +import unittest |
2374 | + |
2375 | +from testtools.content_type import ContentType |
2376 | +from ucitests import fixtures as uci_fixtures |
2377 | + |
2378 | +from ci_utils.testing import fixtures |
2379 | +from subunitresults import queue_worker |
2380 | + |
2381 | + |
2382 | +class TestQueueWorker(unittest.TestCase): |
2383 | + |
2384 | + def test_get_item(self): |
2385 | + test_msg = 'hello' |
2386 | + q = Queue() |
2387 | + q.put(test_msg) |
2388 | + msg = queue_worker.get_item(q) |
2389 | + self.assertEqual(msg, test_msg) |
2390 | + |
2391 | + def test_get_subunit_path(self): |
2392 | + test_path = 'http://foo/12345/results.sub' |
2393 | + json_blob = json.dumps({ |
2394 | + 'subunit_path': '%s' % test_path, |
2395 | + 'ticket_id': 12345 |
2396 | + }) |
2397 | + path = queue_worker.get_subunit_path(json_blob) |
2398 | + self.assertEqual(path, test_path) |
2399 | + |
2400 | + def test_fetch_subunit(self): |
2401 | + uci_fixtures.set_uniq_cwd(self) |
2402 | + ds_path = os.path.join(self.uniq_dir, 'data_store') |
2403 | + ds = fixtures.FakeDataStore(ds_path, None) |
2404 | + self.addCleanup(ds.delete, recursive=True) |
2405 | + filename = 'test_file' |
2406 | + test_path = 'http://foo/12345/results.sub' |
2407 | + json_blob = json.dumps({ |
2408 | + 'subunit_path': '%s' % test_path, |
2409 | + 'ticket_id': 12345 |
2410 | + }).encode('utf-8') |
2411 | + content_type = ContentType('application', 'json') |
2412 | + ds.put_file(filename, json_blob, content_type) |
2413 | + self.addCleanup(ds.delete_file, filename) |
2414 | + fetched = queue_worker.fetch_subunit(ds, filename) |
2415 | + self.assertEqual(json_blob, fetched) |
2416 | |
2417 | === added file 'subunit-results/subunitresults/tests/test_subunit_parser.py' |
2418 | --- subunit-results/subunitresults/tests/test_subunit_parser.py 1970-01-01 00:00:00 +0000 |
2419 | +++ subunit-results/subunitresults/tests/test_subunit_parser.py 2014-08-21 14:14:59 +0000 |
2420 | @@ -0,0 +1,161 @@ |
2421 | +#!/usr/bin/env python3 |
2422 | +# Ubuntu CI Engine |
2423 | +# Copyright 2014 Canonical Ltd. |
2424 | +# |
2425 | +# This program is free software: you can redistribute it and/or modify it |
2426 | +# under the terms of the GNU Affero General Public License version 3, as |
2427 | +# published by the Free Software Foundation. |
2428 | +# |
2429 | +# This program is distributed in the hope that it will be useful, but |
2430 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2431 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2432 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2433 | +# |
2434 | +# You should have received a copy of the GNU Affero General Public License |
2435 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2436 | + |
2437 | + |
2438 | +from datetime import datetime |
2439 | +import unittest |
2440 | +import os |
2441 | + |
2442 | +from testtools import TestCase, TestResult |
2443 | +from testtools.content import Content, ContentType |
2444 | +from testtools.content_type import UTF8_TEXT |
2445 | +from ucitests import fixtures as uci_fixtures |
2446 | + |
2447 | +from subunitresults import subunit_parser |
2448 | +from subunitresults.ci_utils.testing import fixtures |
2449 | + |
2450 | + |
2451 | +def get_case(attachment_name, data): |
2452 | + # Define the class in a funCtion so test loading won't try to load it as a |
2453 | + # regular test class. |
2454 | + class TestyTest(TestCase): |
2455 | + def test_add_utf8_detail(self): |
2456 | + self._timestamps = ( |
2457 | + datetime(2007, 12, 6, 15, 29, 43, 79060), |
2458 | + datetime(2007, 12, 6, 15, 29, 43, 79060) |
2459 | + ) |
2460 | + self.addDetail( |
2461 | + attachment_name, |
2462 | + Content(UTF8_TEXT, lambda: [data]) |
2463 | + ) |
2464 | + return TestyTest('test_add_utf8_detail') |
2465 | + |
2466 | + |
2467 | +class TestSubUnit(unittest.TestCase): |
2468 | + |
2469 | + def setUp(self): |
2470 | + uci_fixtures.set_uniq_cwd(self) |
2471 | + ds_path = os.path.join(self.uniq_dir, 'data_store') |
2472 | + self.ds = fixtures.FakeDataStore(ds_path, None) |
2473 | + self.addCleanup(self.ds.delete, recursive=True) |
2474 | + |
2475 | + def run_test_with_utf8_attachment(self, attachment_name, data): |
2476 | + test = get_case(attachment_name, data) |
2477 | + fs_result = subunit_parser.FileSaveResult(self.ds) |
2478 | + test.run(fs_result) |
2479 | + return fs_result |
2480 | + |
2481 | + def test_file_save_result(self): |
2482 | + fs_result = subunit_parser.FileSaveResult(self.ds) |
2483 | + self.assertIsInstance(fs_result, TestResult) |
2484 | + |
2485 | + def test_default_summary(self): |
2486 | + fs_result = subunit_parser.FileSaveResult(self.ds) |
2487 | + default_summary = dict( |
2488 | + tests_run=0, |
2489 | + errors=0, |
2490 | + expected_failures=0, |
2491 | + failures=0, |
2492 | + num_attachments=0, |
2493 | + run_time=0, |
2494 | + skipped=0, |
2495 | + unexpected_successes=0, |
2496 | + tests=dict() |
2497 | + ) |
2498 | + self.assertDictEqual(fs_result.summary, default_summary) |
2499 | + |
2500 | + def test_testsuite_summary(self): |
2501 | + attachment_name = 'foo' |
2502 | + attachment_data = b'bar' |
2503 | + fs_result = self.run_test_with_utf8_attachment( |
2504 | + attachment_name, |
2505 | + attachment_data |
2506 | + ) |
2507 | + expected_summary = { |
2508 | + 'failures': 0, |
2509 | + 'skipped': 0, |
2510 | + 'num_attachments': 1, |
2511 | + 'run_time': 0.0, |
2512 | + 'tests_run': 0, |
2513 | + 'errors': 0, |
2514 | + 'unexpected_successes': 0, |
2515 | + 'expected_failures': 0, |
2516 | + 'tests': { |
2517 | + 'tests.test_subunit_parser.TestyTest.test_add_utf8_detail': |
2518 | + ['pass', 0.0] |
2519 | + } |
2520 | + } |
2521 | + self.assertDictEqual(fs_result.summary, expected_summary) |
2522 | + |
2523 | + def test_testcase_details(self): |
2524 | + attachment_name = 'foo' |
2525 | + attachment_data = b'bar' |
2526 | + fs_result = self.run_test_with_utf8_attachment( |
2527 | + attachment_name, |
2528 | + attachment_data |
2529 | + ) |
2530 | + self.assertEqual(fs_result.testcase_details['status'], 'pass') |
2531 | + self.assertEqual(fs_result.testcase_details['run_time'], 0.0) |
2532 | + attachment = fs_result.testcase_details['content_attachments'][0] |
2533 | + self.assertEqual(attachment['size'], 3) |
2534 | + |
2535 | + def test_save_attachment(self): |
2536 | + attachment_name = 'foo' |
2537 | + attachment_data = b'bar' |
2538 | + fs_result = self.run_test_with_utf8_attachment( |
2539 | + attachment_name, |
2540 | + attachment_data |
2541 | + ) |
2542 | + attachments = fs_result.testcase_details['content_attachments'] |
2543 | + saved_name = attachments[0]['name'] |
2544 | + self.addCleanup(self.ds.delete_file, saved_name) |
2545 | + self.assertEqual([saved_name], self.ds.list_files()) |
2546 | + self.assertEqual(attachment_data, self.ds.get_file(saved_name)) |
2547 | + |
2548 | + def test_put_attachment(self): |
2549 | + # uci_fixtures.set_uniq_cwd(self) |
2550 | + # ds_path = os.path.join(self.uniq_dir, 'data_store') |
2551 | + # ds = fixtures.FakeDataStore(ds_path, None) |
2552 | + # self.addCleanup(ds.delete, recursive=True) |
2553 | + filename = 'test_file' |
2554 | + content = b'{"subunit_path": "http://example/foo"}' |
2555 | + mime_type = ContentType('application', 'json') |
2556 | + # queue_worker.put_attachment(self.ds, filename, contents, content_type) |
2557 | + self.ds.put_file(filename, content, mime_type) |
2558 | + self.addCleanup(self.ds.delete_file, filename) |
2559 | + self.assertEqual([filename], self.ds.list_files()) |
2560 | + |
2561 | + def test_run_result(self): |
2562 | + test_dir = os.path.dirname(os.path.realpath(__file__)) |
2563 | + subunit_file = os.path.join(test_dir, 'results_for_tests.subunit') |
2564 | + with open(subunit_file, 'rb') as f: |
2565 | + success = subunit_parser.run_result(self.ds, f) |
2566 | + self.assertTrue(success) |
2567 | + |
2568 | + |
2569 | +class TestSubUnitName(unittest.TestCase): |
2570 | + |
2571 | + def test_create_unique_name(self): |
2572 | + id = 'a.b.c' |
2573 | + name = 'name' |
2574 | + fake_epoch = '1407454141.539095' |
2575 | + uniq_name = subunit_parser.create_unique_name( |
2576 | + id, |
2577 | + name, |
2578 | + timestamp=fake_epoch |
2579 | + ) |
2580 | + expected_name = '%s+%s+%s' % (fake_epoch, id, name) |
2581 | + self.assertEqual(uniq_name, expected_name) |
2582 | |
2583 | === added file 'subunit-results/subunitresults/tests/test_utils.py' |
2584 | --- subunit-results/subunitresults/tests/test_utils.py 1970-01-01 00:00:00 +0000 |
2585 | +++ subunit-results/subunitresults/tests/test_utils.py 2014-08-21 14:14:59 +0000 |
2586 | @@ -0,0 +1,121 @@ |
2587 | +#!/usr/bin/python3 |
2588 | +# Ubuntu CI Engine |
2589 | +# Copyright 2014 Canonical Ltd. |
2590 | + |
2591 | +# This program is free software: you can redistribute it and/or modify it |
2592 | +# under the terms of the GNU Affero General Public License version 3, as |
2593 | +# published by the Free Software Foundation. |
2594 | + |
2595 | +# This program is distributed in the hope that it will be useful, but |
2596 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2597 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2598 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2599 | + |
2600 | +# You should have received a copy of the GNU Affero General Public License |
2601 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2602 | + |
2603 | + |
2604 | +import logging |
2605 | +import unittest |
2606 | + |
2607 | +from subunitresults.utils import retry |
2608 | + |
2609 | + |
2610 | +class RetryableError(Exception): |
2611 | + pass |
2612 | + |
2613 | + |
2614 | +class AnotherRetryableError(Exception): |
2615 | + pass |
2616 | + |
2617 | + |
2618 | +class UnexpectedError(Exception): |
2619 | + pass |
2620 | + |
2621 | + |
2622 | +class RetryTestCase(unittest.TestCase): |
2623 | + |
2624 | + def test_no_retry_required(self): |
2625 | + self.counter = 0 |
2626 | + |
2627 | + @retry(RetryableError, tries=4, delay=0.1) |
2628 | + def succeeds(): |
2629 | + self.counter += 1 |
2630 | + return 'success' |
2631 | + |
2632 | + r = succeeds() |
2633 | + |
2634 | + self.assertEqual(r, 'success') |
2635 | + self.assertEqual(self.counter, 1) |
2636 | + |
2637 | + def test_retries_once(self): |
2638 | + self.counter = 0 |
2639 | + |
2640 | + @retry(RetryableError, tries=4, delay=0.1) |
2641 | + def fails_once(): |
2642 | + self.counter += 1 |
2643 | + if self.counter < 2: |
2644 | + raise RetryableError('failed') |
2645 | + else: |
2646 | + return 'success' |
2647 | + |
2648 | + r = fails_once() |
2649 | + self.assertEqual(r, 'success') |
2650 | + self.assertEqual(self.counter, 2) |
2651 | + |
2652 | + def test_limit_is_reached(self): |
2653 | + self.counter = 0 |
2654 | + |
2655 | + @retry(RetryableError, tries=4, delay=0.1) |
2656 | + def always_fails(): |
2657 | + self.counter += 1 |
2658 | + raise RetryableError('failed') |
2659 | + |
2660 | + with self.assertRaises(RetryableError): |
2661 | + always_fails() |
2662 | + self.assertEqual(self.counter, 4) |
2663 | + |
2664 | + def test_multiple_exception_types(self): |
2665 | + self.counter = 0 |
2666 | + |
2667 | + @retry((RetryableError, AnotherRetryableError), tries=4, delay=0.1) |
2668 | + def raise_multiple_exceptions(): |
2669 | + self.counter += 1 |
2670 | + if self.counter == 1: |
2671 | + raise RetryableError('a retryable error') |
2672 | + elif self.counter == 2: |
2673 | + raise AnotherRetryableError('another retryable error') |
2674 | + else: |
2675 | + return 'success' |
2676 | + |
2677 | + r = raise_multiple_exceptions() |
2678 | + self.assertEqual(r, 'success') |
2679 | + self.assertEqual(self.counter, 3) |
2680 | + |
2681 | + def test_unexpected_exception_does_not_retry(self): |
2682 | + |
2683 | + @retry(RetryableError, tries=4, delay=0.1) |
2684 | + def raise_unexpected_error(): |
2685 | + raise UnexpectedError('unexpected error') |
2686 | + |
2687 | + with self.assertRaises(UnexpectedError): |
2688 | + raise_unexpected_error() |
2689 | + |
2690 | + def test_using_a_logger(self): |
2691 | + self.counter = 0 |
2692 | + |
2693 | + sh = logging.StreamHandler() |
2694 | + logger = logging.getLogger(__name__) |
2695 | + logger.addHandler(sh) |
2696 | + |
2697 | + @retry(RetryableError, tries=4, delay=0.1, logger=logger) |
2698 | + def fails_once(): |
2699 | + self.counter += 1 |
2700 | + if self.counter < 2: |
2701 | + raise RetryableError('failed') |
2702 | + else: |
2703 | + return 'success' |
2704 | + |
2705 | + |
2706 | +if __name__ == '__main__': |
2707 | + unittest.main() |
2708 | |
2709 | === added file 'subunit-results/subunitresults/utils.py' |
2710 | --- subunit-results/subunitresults/utils.py 1970-01-01 00:00:00 +0000 |
2711 | +++ subunit-results/subunitresults/utils.py 2014-08-21 14:14:59 +0000 |
2712 | @@ -0,0 +1,59 @@ |
2713 | +# Ubuntu CI Engine |
2714 | +# Copyright 2014 Canonical Ltd. |
2715 | + |
2716 | +# This program is free software: you can redistribute it and/or modify it |
2717 | +# under the terms of the GNU Affero General Public License version 3, as |
2718 | +# published by the Free Software Foundation. |
2719 | + |
2720 | +# This program is distributed in the hope that it will be useful, but |
2721 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2722 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2723 | +# PURPOSE. See the GNU Affero General Public License for more details. |
2724 | + |
2725 | +# You should have received a copy of the GNU Affero General Public License |
2726 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2727 | + |
2728 | + |
2729 | +import time |
2730 | +from functools import wraps |
2731 | + |
2732 | + |
2733 | +def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None): |
2734 | + """Retry calling the decorated function using an exponential backoff. |
2735 | + |
2736 | + http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/ |
2737 | + original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry |
2738 | + |
2739 | + :param ExceptionToCheck: the exception to check. may be a tuple of |
2740 | + exceptions to check |
2741 | + :type ExceptionToCheck: Exception or tuple |
2742 | + :param tries: number of times to try (not retry) before giving up |
2743 | + :type tries: int |
2744 | + :param delay: initial delay between retries in seconds |
2745 | + :type delay: int |
2746 | + :param backoff: backoff multiplier e.g. value of 2 will double the delay |
2747 | + each retry |
2748 | + :type backoff: int |
2749 | + :param logger: logger to use. |
2750 | + :type logger: logging.Logger instance |
2751 | + """ |
2752 | + def deco_retry(f): |
2753 | + |
2754 | + @wraps(f) |
2755 | + def f_retry(*args, **kwargs): |
2756 | + mtries, mdelay = tries, delay |
2757 | + while mtries > 1: |
2758 | + try: |
2759 | + return f(*args, **kwargs) |
2760 | + except ExceptionToCheck as e: |
2761 | + msg = "%s, Retrying in %d seconds..." % (str(e), mdelay) |
2762 | + if logger: |
2763 | + logger.warning(msg) |
2764 | + time.sleep(mdelay) |
2765 | + mtries -= 1 |
2766 | + mdelay *= backoff |
2767 | + return f(*args, **kwargs) |
2768 | + |
2769 | + return f_retry # true decorator |
2770 | + |
2771 | + return deco_retry |
2772 | |
2773 | === added file 'subunit-results/tox.ini' |
2774 | --- subunit-results/tox.ini 1970-01-01 00:00:00 +0000 |
2775 | +++ subunit-results/tox.ini 2014-08-21 14:14:59 +0000 |
2776 | @@ -0,0 +1,21 @@ |
2777 | +[tox] |
2778 | +envlist = py34, flake8 |
2779 | + |
2780 | +[testenv] |
2781 | +deps = |
2782 | + ucitests |
2783 | + coverage |
2784 | +commands = |
2785 | + {envpython} setup.py develop |
2786 | + {envpython} -m unittest discover -s subunitresults -v |
2787 | + # coverage erase |
2788 | + # coverage run --omit=.tox/* -m unittest discover -s subunitresults |
2789 | + # coverage html |
2790 | + # coverage report -m |
2791 | + # coverage html |
2792 | + |
2793 | +[testenv:flake8] |
2794 | +deps = |
2795 | + flake8 |
2796 | +commands = |
2797 | + flake8 subunitresults |
Hi Corey,
I'll preface this by saying that I'm sick, and my brain isn't working as well as it usually does, so if any of this doesn't make sense, there's a very real chance it's my fault - please feel free to ask any clarifying questions if that's the case.
The code is mostly good, but:
There are a few places that are missing implementation.
I've made a few suggestions throughout the code.
I haven't yet run the coverage metrics - at this stage you should be hitting 100% coverage, or very close to it.
I suggest you implement the missing pieces, so this can be deployed with the rest of the CI engine, and get reviews from the rest of the CI team.
Cheers,