Merge lp:~coreygoldberg/uci-engine/subunit-results into lp:uci-engine

Proposed by Corey Goldberg
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
Reviewer Review Type Date Requested Status
Canonical CI Engineering Pending
Review via email: mp+227524@code.launchpad.net

Description of the change

WIP

To post a comment you must log in.
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

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

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,

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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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'
2349Binary 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

Subscribers

People subscribed via source and target branches