Merge lp:~canonical-ci-engineering/adt-cloud-worker/enable-testing into lp:adt-cloud-worker

Proposed by Thomi Richards
Status: Merged
Approved by: Thomi Richards
Approved revision: 13
Merged at revision: 10
Proposed branch: lp:~canonical-ci-engineering/adt-cloud-worker/enable-testing
Merge into: lp:adt-cloud-worker
Diff against target: 557 lines (+344/-163)
6 files modified
.bzrignore (+1/-0)
README.rst (+12/-0)
adt-cloud-worker.py (+1/-162)
adt_cloud_worker/__init__.py (+207/-0)
adt_cloud_worker/tests/test_cloud_worker.py (+122/-0)
setup.py (+1/-1)
To merge this branch: bzr merge lp:~canonical-ci-engineering/adt-cloud-worker/enable-testing
Reviewer Review Type Date Requested Status
Celso Providelo (community) Approve
Review via email: mp+251686@code.launchpad.net

This proposal supersedes a proposal from 2015-03-03.

Commit message

Add tests, refactor code.

Description of the change

This branch makes several changes, all with an eye to adding some tests to this service:

* Move the bulk of the code into an adt_cloud_worker package, so it can be imported.
* Create the 'tests' sub-package, and create a file with a few simple tests.
* Refactor code which can easily be written in a functional style out of the main body of the code so it can be tested.
* Update README with instructions on how to run the tests.

While this can land right now, I still want to add some system-level tests to ensure that the service actually does something useful!

To post a comment you must log in.
Revision history for this message
Celso Providelo (cprov) wrote :

Thomi,

Thanks for working on tests for the project. I have no objections to the code refactoring, it is still small e reads nicely.

There is the py3 deps discussion on going, but this change does really affects it, so my vote is for landing once you consider my inline comments (not blocking, though).

review: Approve
11. By Thomi Richards

Merge code that uses pip to get dependencies.

12. By Thomi Richards

Merged trunk.

13. By Thomi Richards

Fix up code from review.

Revision history for this message
Celso Providelo (cprov) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '.bzrignore'
2--- .bzrignore 2015-03-04 19:28:28 +0000
3+++ .bzrignore 2015-03-04 20:24:52 +0000
4@@ -1,1 +1,2 @@
5+adt_cloud_worker.egg-info
6 ve
7
8=== modified file 'README.rst'
9--- README.rst 2015-03-04 19:38:23 +0000
10+++ README.rst 2015-03-04 20:24:52 +0000
11@@ -42,6 +42,18 @@
12 $ python setup.py develop
13
14
15+
16+Run the tests!
17+==============
18+
19+Install dependencies::
20+
21+ $ pip install -r test_requirements.txt
22+
23+Run those tests - with vigour!::
24+
25+ $ python setup.py test
26+
27 Run the Service
28 ===============
29
30
31=== modified file 'adt-cloud-worker.py'
32--- adt-cloud-worker.py 2015-03-03 19:03:34 +0000
33+++ adt-cloud-worker.py 2015-03-04 20:24:52 +0000
34@@ -19,168 +19,7 @@
35
36 """Main entry point for adt-cloud-worker."""
37
38-
39-from __future__ import print_function
40-
41-import argparse
42-import configparser
43-import kombu
44-from kombu.log import get_logger
45-from kombu.mixins import ConsumerMixin
46-from kombu.utils.debug import setup_logging
47-import os
48-import shutil
49-import subprocess
50-from swiftclient import client
51-import tarfile
52-import tempfile
53-
54-logger = get_logger(__name__)
55-
56-
57-class AdtNovaWorker(ConsumerMixin):
58-
59- def __init__(self, connection, config):
60- self.connection = connection
61- self.config = config
62- self.name = self.config.get('adt', 'name')
63-
64- def get_consumers(self, Consumer, channel):
65- """Return consumers instances for all configured queues."""
66- exchange = kombu.Exchange("adt.exchange", type="topic")
67- queues = [
68- kombu.Queue(
69- 'adt.requests.{}'.format(tag), exchange, routing_key=tag)
70- for tag in self.config.get('adt', 'tags').split()
71- ]
72- return [Consumer(queues=queues, callbacks=[self.process])]
73-
74- def process(self, body, message):
75- """Process incomming test request.
76-
77- Run requested tests and posts results to the 'adt_results' queue
78- for later checking.
79- """
80- logger.info('Got: {}'.format(body))
81- # TODO: body validation
82- body['worker'] = self.name
83- # Run requested tests safely.
84- try:
85- # Create and temporary directory for storing adt results.
86- result_dir = tempfile.mkdtemp(
87- prefix='adt-{}-{}'.format(self.name, body['request_id']))
88- # Run `adt-run`.
89- adt_kwargs = body.copy()
90- adt_kwargs['result_dir'] = result_dir
91- body['exit_code'] = self.run_adt(**adt_kwargs)
92- # Compress the results directory in a temporary file.
93- _unused, tarball_path = tempfile.mkstemp()
94- with tarfile.open(tarball_path, "w:gz") as tar:
95- for entry in os.listdir(result_dir):
96- tar.add(os.path.join(result_dir, entry), arcname=entry)
97- # Upload the results tarball to swift with a temporary name
98- # 'adt-<req_id>/results.tgz.tmp', result checker will rename
99- # if after verification.
100- swift_client = client.Connection(
101- authurl=self.config.get('nova', 'os_auth_url'),
102- user=self.config.get('nova', 'os_username'),
103- key=self.config.get('nova', 'os_password'),
104- os_options={
105- 'tenant_name': self.config.get('nova', 'os_tenant_name'),
106- 'region_name': self.config.get('nova', 'os_region_name'),
107- }, auth_version=(
108- '/v1.' in self.config.get('nova','os_auth_url') and '1.0'
109- or '2.0'))
110- container_name = 'adt-{}'.format(body['request_id'])
111- swift_client.put_container(container_name)
112- with open(tarball_path) as fd:
113- swift_client.put_object(
114- container_name, obj='results.tgz.tmp', contents=fd,
115- content_type='application/x-compressed')
116- except Exception as e:
117- # Unexpected failures ...
118- logger.error(e, exc_info=True)
119- body['exit_code'] = '100'
120- finally:
121- # The post results
122- # TODO: send error logging to result-checker in the message
123- queue = self.connection.SimpleQueue('adt.results')
124- queue.put(body)
125- queue.close()
126- # Drop the result directory and result tarball.
127- shutil.rmtree(result_dir)
128- os.remove(tarball_path)
129- message.ack()
130- logger.info('Done!')
131-
132- def run_adt(self, **kwargs):
133- """Run adt-run according to the given request parameters.
134-
135- Always resets the environment with configured nova variables.
136-
137- Returns the exit code from adt-run.
138- """
139- # TODO: We probably want something more clever here:
140-
141- # what information do we need from the message?
142- adt_run_args = [
143- '--apt-source', kwargs['package_name'],
144- '--output-dir', kwargs['result_dir'],
145- ]
146- if 'apt_pocket' in kwargs:
147- adt_run_args += [
148- '--apt-pocket', kwargs['apt_pocket'],
149- '--apt-upgrade',
150- ]
151- adt_ssh_nova_args = [
152- '--',
153- '--flavor', kwargs['nova_flavor'],
154- '--image', kwargs['nova_image'],
155- ]
156-
157- arguments = (
158- adt_run_args +
159- ['---', 'ssh', '-s', 'nova'] +
160- adt_ssh_nova_args +
161- self.config.get('nova', 'extra_args').split()
162- )
163-
164- # Setup nova environment variables based on the configuration file.
165- for k, v in self.config.items('nova'):
166- if not k.startswith('os_'):
167- continue
168- os.environ[k.upper()] = str(v)
169-
170- try:
171- subprocess.check_call(['adt-run'] + arguments)
172- except subprocess.CalledProcessError as e:
173- # log?
174- # TODO: filter log content to avoid leaking cloud credentials.
175- return e.returncode
176- return 0
177-
178-
179-def main():
180- setup_logging(loglevel='DEBUG', loggers=[''])
181-
182- parser = argparse.ArgumentParser(
183- description='ADT cloud worker ...')
184- parser.add_argument('-c', '--conf', default='.adt-service.conf',
185- help='Configuration file path')
186- args = parser.parse_args()
187-
188- # Load configuration options.
189- config = configparser.ConfigParser()
190- config.read(args.conf)
191- amqp_uris = config.get('amqp', 'uris').split()
192-
193- with kombu.Connection(amqp_uris) as conn:
194- try:
195- worker = AdtNovaWorker(conn, config)
196- worker.run()
197- except KeyboardInterrupt:
198- print('Bye!')
199-
200+from adt_cloud_worker import main
201
202 if __name__ == '__main__':
203 main()
204
205=== added directory 'adt_cloud_worker'
206=== added file 'adt_cloud_worker/__init__.py'
207--- adt_cloud_worker/__init__.py 1970-01-01 00:00:00 +0000
208+++ adt_cloud_worker/__init__.py 2015-03-04 20:24:52 +0000
209@@ -0,0 +1,207 @@
210+# adt-cloud-worker
211+# Copyright (C) 2015 Canonical
212+#
213+# This program is free software: you can redistribute it and/or modify
214+# it under the terms of the GNU General Public License as published by
215+# the Free Software Foundation, either version 3 of the License, or
216+# (at your option) any later version.
217+#
218+# This program is distributed in the hope that it will be useful,
219+# but WITHOUT ANY WARRANTY; without even the implied warranty of
220+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
221+# GNU General Public License for more details.
222+#
223+# You should have received a copy of the GNU General Public License
224+# along with this program. If not, see <http://www.gnu.org/licenses/>.
225+#
226+
227+"""ADT Cloud worker functional code."""
228+
229+
230+from __future__ import print_function
231+
232+import argparse
233+import configparser
234+import kombu
235+from kombu.log import get_logger
236+from kombu.mixins import ConsumerMixin
237+from kombu.utils.debug import setup_logging
238+import os
239+import shutil
240+import subprocess
241+from swiftclient import client
242+import tarfile
243+import tempfile
244+
245+logger = get_logger(__name__)
246+
247+
248+class AdtNovaWorker(ConsumerMixin):
249+
250+ def __init__(self, connection, config):
251+ self.connection = connection
252+ self.config = config
253+ # Set the nova configuration env vars:
254+ _set_nova_environment_variables(self.config.items('nova'))
255+ self.name = self.config.get('adt', 'name')
256+
257+ def get_consumers(self, Consumer, channel):
258+ """Return consumers instances for all configured queues."""
259+ exchange = kombu.Exchange("adt.exchange", type="topic")
260+ queues = [
261+ kombu.Queue(
262+ 'adt.requests.{}'.format(tag), exchange, routing_key=tag)
263+ for tag in self.config.get('adt', 'tags').split()
264+ ]
265+ return [Consumer(queues=queues, callbacks=[self.process])]
266+
267+ def process(self, body, message):
268+ """Process incomming test request.
269+
270+ Run requested tests and posts results to the 'adt_results' queue
271+ for later checking.
272+ """
273+ logger.info('Got: {}'.format(body))
274+ # TODO: body validation
275+ body['worker'] = self.name
276+ # Run requested tests safely.
277+ try:
278+ # Create and temporary directory for storing adt results.
279+ result_dir = tempfile.mkdtemp(
280+ prefix='adt-{}-{}'.format(self.name, body['request_id']))
281+ # Run `adt-run`.
282+ adt_kwargs = body.copy()
283+ adt_kwargs['result_dir'] = result_dir
284+ adt_kwargs['nova_extra_args'] = self.config.get('nova', 'extra_args').split()
285+ arguments = _make_adt_argument_list(adt_kwargs)
286+
287+ body['exit_code'] = run_adt(arguments)
288+ # Compress the results directory in a temporary file.
289+ _unused, tarball_path = tempfile.mkstemp()
290+ with tarfile.open(tarball_path, "w:gz") as tar:
291+ for entry in os.listdir(result_dir):
292+ tar.add(os.path.join(result_dir, entry), arcname=entry)
293+ # Upload the results tarball to swift with a temporary name
294+ # 'adt-<req_id>/results.tgz.tmp', result checker will rename
295+ # if after verification.
296+ swift_client = client.Connection(
297+ authurl=self.config.get('nova', 'os_auth_url'),
298+ user=self.config.get('nova', 'os_username'),
299+ key=self.config.get('nova', 'os_password'),
300+ os_options={
301+ 'tenant_name': self.config.get('nova', 'os_tenant_name'),
302+ 'region_name': self.config.get('nova', 'os_region_name'),
303+ }, auth_version=(
304+ '/v1.' in self.config.get('nova','os_auth_url') and '1.0'
305+ or '2.0'))
306+ container_name = 'adt-{}'.format(body['request_id'])
307+ swift_client.put_container(container_name)
308+ with open(tarball_path) as fd:
309+ swift_client.put_object(
310+ container_name, obj='results.tgz.tmp', contents=fd,
311+ content_type='application/x-compressed')
312+ except Exception as e:
313+ # Unexpected failures ...
314+ logger.error(e, exc_info=True)
315+ body['exit_code'] = '100'
316+ finally:
317+ # The post results
318+ # TODO: send error logging to result-checker in the message
319+ queue = self.connection.SimpleQueue('adt.results')
320+ queue.put(body)
321+ queue.close()
322+ # Drop the result directory and result tarball.
323+ shutil.rmtree(result_dir)
324+ os.remove(tarball_path)
325+ message.ack()
326+ logger.info('Done!')
327+
328+
329+def run_adt(arguments):
330+ """Run adt-run according to the given request parameters.
331+
332+ Always resets the environment with configured nova variables.
333+
334+ Returns the exit code from adt-run.
335+ """
336+ # TODO: We probably want something more clever here:
337+ try:
338+ subprocess.check_call(['adt-run'] + arguments)
339+ except subprocess.CalledProcessError as e:
340+ # log?
341+ # TODO: filter log content to avoid leaking cloud credentials.
342+ return e.returncode
343+ return 0
344+
345+
346+def _make_adt_argument_list(request_configuration):
347+ """Generate the argument list to adt-run, given the
348+ requestion_configuration dictionary.
349+
350+ :returns: A tuple of arguments.
351+ """
352+ adt_run_args = [
353+ '--apt-source', request_configuration['package_name'],
354+ '--output-dir', request_configuration['result_dir'],
355+ ]
356+ if 'apt_pocket' in request_configuration:
357+ adt_run_args += [
358+ '--apt-pocket', request_configuration['apt_pocket'],
359+ '--apt-upgrade',
360+ ]
361+ adt_ssh_nova_args = [
362+ '--',
363+ '--flavor', request_configuration['nova_flavor'],
364+ '--image', request_configuration['nova_image'],
365+ ]
366+ if 'nova_extra_args' in request_configuration:
367+ adt_ssh_nova_args += request_configuration['nova_extra_args']
368+
369+
370+ return (
371+ adt_run_args +
372+ ['---', 'ssh', '-s', 'nova'] +
373+ adt_ssh_nova_args
374+ )
375+
376+
377+def _set_nova_environment_variables(nova_config):
378+ """Set environment variables for nova configuration.
379+
380+ 'nova_config' will be read, and any keys that start with 'os_' will be
381+ set as environment variables.
382+
383+ 'nova_config' must be an iterable of (key, value) pairs.
384+
385+ """
386+ # Setup nova environment variables based on the configuration file.
387+ for k, v in nova_config:
388+ if not k.startswith('os_'):
389+ continue
390+ os.environ[k.upper()] = str(v)
391+
392+
393+def main():
394+ setup_logging(loglevel='DEBUG', loggers=[''])
395+
396+ parser = argparse.ArgumentParser(
397+ description='ADT cloud worker ...')
398+ parser.add_argument('-c', '--conf', default='.adt-service.conf',
399+ help='Configuration file path')
400+ args = parser.parse_args()
401+
402+ # Load configuration options.
403+ config = configparser.ConfigParser()
404+ config.read(args.conf)
405+ amqp_uris = config.get('amqp', 'uris').split()
406+
407+ with kombu.Connection(amqp_uris) as conn:
408+ try:
409+ worker = AdtNovaWorker(conn, config)
410+ worker.run()
411+ except KeyboardInterrupt:
412+ print('Bye!')
413+
414+
415+if __name__ == '__main__':
416+ main()
417
418=== added directory 'adt_cloud_worker/tests'
419=== added file 'adt_cloud_worker/tests/__init__.py'
420=== added file 'adt_cloud_worker/tests/test_cloud_worker.py'
421--- adt_cloud_worker/tests/test_cloud_worker.py 1970-01-01 00:00:00 +0000
422+++ adt_cloud_worker/tests/test_cloud_worker.py 2015-03-04 20:24:52 +0000
423@@ -0,0 +1,122 @@
424+# adt-cloud-worker
425+# Copyright (C) 2015 Canonical
426+#
427+# This program is free software: you can redistribute it and/or modify
428+# it under the terms of the GNU General Public License as published by
429+# the Free Software Foundation, either version 3 of the License, or
430+# (at your option) any later version.
431+#
432+# This program is distributed in the hope that it will be useful,
433+# but WITHOUT ANY WARRANTY; without even the implied warranty of
434+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
435+# GNU General Public License for more details.
436+#
437+# You should have received a copy of the GNU General Public License
438+# along with this program. If not, see <http://www.gnu.org/licenses/>.
439+#
440+
441+import os
442+from fixtures import Fixture
443+import testtools
444+
445+from adt_cloud_worker import (
446+ _make_adt_argument_list,
447+ _set_nova_environment_variables,
448+)
449+
450+class TestADTArgumentGeneration(testtools.TestCase):
451+
452+ def createRequiredAdtRequestArgumets(self, **kwargs):
453+ """Generate a dict that contains all the required parts of the
454+ adt-cloud-worker request.
455+
456+ You may pass in additional arguments to customise the contents of the
457+ dictionary.
458+ """
459+ defaults = {
460+ 'package_name': "some_package_name",
461+ 'result_dir': '/tmp/some/dir',
462+ 'nova_flavor': 'some-nova-flavour',
463+ 'nova_image': 'some-nova-image',
464+ }
465+ defaults.update(kwargs)
466+ return defaults
467+
468+ def test_required_arguments(self):
469+ observed = _make_adt_argument_list(
470+ self.createRequiredAdtRequestArgumets()
471+ )
472+ self.assertEqual(
473+ [
474+ '--apt-source', 'some_package_name',
475+ '--output-dir', '/tmp/some/dir',
476+ '---',
477+ 'ssh',
478+ '-s', 'nova',
479+ '--',
480+ '--flavor', 'some-nova-flavour',
481+ '--image', 'some-nova-image',
482+ ],
483+ observed)
484+
485+ def test_includes_pocket_args(self):
486+ observed = _make_adt_argument_list(
487+ self.createRequiredAdtRequestArgumets(apt_pocket='foo')
488+ )
489+ self.assertEqual(
490+ [
491+ '--apt-source', 'some_package_name',
492+ '--output-dir', '/tmp/some/dir',
493+ '--apt-pocket', 'foo', '--apt-upgrade',
494+ '---',
495+ 'ssh',
496+ '-s', 'nova',
497+ '--',
498+ '--flavor', 'some-nova-flavour',
499+ '--image', 'some-nova-image',
500+ ],
501+ observed)
502+
503+
504+class RestoreEnvironmentVariableFixture(Fixture):
505+
506+ def __init__(self, env_var_name):
507+ self.name = env_var_name
508+
509+ def setUp(self):
510+ # TODO: Make this python3-ised
511+ super(RestoreEnvironmentVariableFixture, self).setUp()
512+ old_value = os.environ.get(self.name)
513+ self.addCleanup(self._restore_value, old_value)
514+
515+ def _restore_value(self, value):
516+ if value is None:
517+ try:
518+ del os.environ[self.name]
519+ except KeyError:
520+ # key was never set, nbothing to do:
521+ pass
522+ else:
523+ os.environ[self.name] = value
524+
525+
526+class NovaEnvironmentVariableTests(testtools.TestCase):
527+
528+ def test_does_nothing_with_empty_dict(self):
529+ _set_nova_environment_variables({})
530+
531+ def test_sets__os_keys(self):
532+ with RestoreEnvironmentVariableFixture('OS_FOO'):
533+ _set_nova_environment_variables([('os_foo', 'bar')])
534+ self.assertEqual('bar', os.getenv('OS_FOO'))
535+
536+ def test_ignores_non_os_keys(self):
537+ with RestoreEnvironmentVariableFixture('FOO'):
538+ os.environ['FOO'] = 'bar'
539+ _set_nova_environment_variables([('foo', 'baz')])
540+ self.assertEqual('bar', os.getenv('FOO'))
541+
542+ def test_stringifies_values(self):
543+ with RestoreEnvironmentVariableFixture('OS_FOO'):
544+ _set_nova_environment_variables([('os_foo', 123)])
545+ self.assertEqual('123', os.getenv('OS_FOO'))
546
547=== modified file 'setup.py'
548--- setup.py 2015-03-03 00:30:40 +0000
549+++ setup.py 2015-03-04 20:24:52 +0000
550@@ -37,6 +37,6 @@
551 url='https://launchpad.net/adt-cloud-worker',
552 license='GPLv3',
553 packages=find_packages(),
554- # test_suite='autopilot.tests',
555+ test_suite='adt_cloud_worker.tests',
556 scripts=['adt-cloud-worker.py']
557 )

Subscribers

People subscribed via source and target branches

to all changes: