Merge lp:~vila/uci-vms/nova into lp:uci-vms

Proposed by Vincent Ladeuil
Status: Merged
Merged at revision: 126
Proposed branch: lp:~vila/uci-vms/nova
Merge into: lp:uci-vms
Diff against target: 1840 lines (+1287/-94)
12 files modified
NEWS.rst (+2/-0)
TODO.rst (+13/-0)
ucivms/commands.py (+8/-4)
ucivms/config.py (+96/-0)
ucivms/tests/__init__.py (+151/-1)
ucivms/tests/features.py (+58/-2)
ucivms/tests/fixtures.py (+21/-1)
ucivms/tests/test_commands.py (+44/-19)
ucivms/tests/test_ssh.py (+4/-4)
ucivms/tests/test_vms.py (+368/-15)
ucivms/vms/__init__.py (+47/-48)
ucivms/vms/nova.py (+475/-0)
To merge this branch: bzr merge lp:~vila/uci-vms/nova
Reviewer Review Type Date Requested Status
Leo Arias Pending
Review via email: mp+269240@code.launchpad.net

Commit message

Add nova support.

Description of the change

Add support for OpenStack nova (vm.class = nova).

Most of the tests and code were written for the uci-engine.

A few tweaks were needed to re-sync the API that have diverged a bit but mostly this is copying known working test and code (hence the huge diff :-/).

There are still a few rough edges but the basics (setup, shell, stop, teardown) work.

To post a comment you must log in.
lp:~vila/uci-vms/nova updated
151. By Vincent Ladeuil

Merge trunk resolving conflicts

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'NEWS.rst'
2--- NEWS.rst 2015-08-21 15:42:06 +0000
3+++ NEWS.rst 2015-09-01 11:19:08 +0000
4@@ -7,6 +7,8 @@
5 dev
6 ===
7
8+* Add support for OpenStack nova (vm.class = nova).
9+
10 * Fix the script name in the help output.
11
12 * Restore python2 support.
13
14=== modified file 'TODO.rst'
15--- TODO.rst 2015-07-22 15:22:18 +0000
16+++ TODO.rst 2015-09-01 11:19:08 +0000
17@@ -1,3 +1,16 @@
18+* Instead of juggling in tests to acquire config options from the user, we'd
19+ better create a specific config stack for tests. Whether it's an
20+ independent one or one wrapping (patching) the existing one with an
21+ additional file.
22+
23+* Add a vm.os_credentials_file option, a file to be sourced to set the OS_
24+ env vars and populate the vm.os.* options without polluting the environ.
25+
26+* According to https://git.openstack.org/cgit/openstack/python-novaclient ,
27+ openstack is moving to python3. According to zul/jamespage ubuntu will see
28+ progress starting with wily. The short term for uci-vms seems to be to
29+ support python2 again...
30+
31 * The error when vm.release is not set is obscure.
32
33 * Ensure that vms can be created/used without any vm.ssh_keys at all. With
34
35=== modified file 'ucivms/commands.py'
36--- ucivms/commands.py 2015-08-21 15:42:06 +0000
37+++ ucivms/commands.py 2015-09-01 11:19:08 +0000
38@@ -38,6 +38,10 @@
39 'Linux container virtual machine')
40 config.vm_class_registry.register('ephemeral-lxc', vms.EphemeralLxc,
41 'Linux container ephemeral virtual machine')
42+if sys.version_info < (3,):
43+ from ucivms.vms import nova
44+ config.vm_class_registry.register(
45+ 'nova', nova.NovaServer, 'Openstack Nova instance')
46
47
48 class ArgParser(argparse.ArgumentParser):
49@@ -361,10 +365,10 @@
50 # FIXME: states need to be defined uniquely across the various vms
51 # implementations -- vila 2014-01-17
52 if state in('shut off', 'STOPPED'):
53- self.vm.undefine()
54+ self.vm.teardown()
55 elif state in ('running', 'RUNNING'):
56 raise errors.VmRunning(self.vm_name)
57- self.vm.install()
58+ self.vm.setup()
59 return 0
60
61
62@@ -453,7 +457,7 @@
63 # implementations -- vila 2014-01-17
64 if state not in ('running', 'RUNNING'):
65 raise errors.VmNotRunning(self.vm_name)
66- self.vm.poweroff()
67+ self.vm.stop()
68 return 0
69
70
71@@ -473,7 +477,7 @@
72 # implementations -- vila 2014-01-17
73 if state in ('running', 'RUNNING'):
74 raise errors.VmRunning(self.vm_name)
75- self.vm.undefine()
76+ self.vm.teardown()
77 return 0
78
79
80
81=== modified file 'ucivms/config.py'
82--- ucivms/config.py 2015-07-21 17:10:34 +0000
83+++ ucivms/config.py 2015-09-01 11:19:08 +0000
84@@ -112,6 +112,22 @@
85 super(VmStack, self).__init__(
86 section_getters, user_store, mutable_section_id=name)
87
88+ def get_nova_creds(self):
89+ """Get nova credentials from a config.
90+
91+ This defines the set of options needed to authenticate against nova in
92+ a single place.
93+
94+ :raises: uciconfig.errors.OptionMandatoryValueError if one of the
95+ options is not set.
96+ """
97+ creds = {}
98+ for k in ('username', 'password', 'tenant_name',
99+ 'auth_url', 'region_name'):
100+ opt_name = 'vm.os.{}'.format(k)
101+ creds[opt_name] = self.get(opt_name)
102+ return creds
103+
104
105 def path_from_unicode(path_string):
106 return os.path.expanduser(path_string)
107@@ -427,3 +443,83 @@
108 - up_to: seconds after which to give up
109 - retries: how many attempts after the first try
110 '''))
111+
112+# nova options
113+register(options.Option('vm.os.username', default_from_env=['OS_USERNAME'],
114+ default=options.MANDATORY,
115+ help_string='''The Open Stack user name.
116+
117+This is generally set via OS_USERNAME, sourced from a novarc file
118+(~/.novarc, ~/.canonistack/novarc).
119+'''))
120+register(options.Option('vm.os.password', default_from_env=['OS_PASSWORD'],
121+ default=options.MANDATORY,
122+ help_string='''The Open Stack password.
123+
124+This is generally set via OS_PASSWORD, sourced from a novarc file
125+(~/.novarc, ~/.canonistack/novarc).
126+'''))
127+register(options.Option('vm.os.region_name',
128+ default_from_env=['OS_REGION_NAME'],
129+ default=options.MANDATORY,
130+ help_string='''The Open Stack region name.
131+
132+This is generally set via OS_REGION_NAME, sourced from a novarc file
133+(~/.novarc, ~/.canonistack/novarc).
134+'''))
135+register(options.Option('vm.os.tenant_name',
136+ default_from_env=['OS_TENANT_NAME'],
137+ default=options.MANDATORY,
138+ help_string='''The Open Stack tenant name.
139+
140+This is generally set via OS_TENANT_NAME, sourced from a novarc file
141+(~/.novarc, ~/.canonistack/novarc).
142+'''))
143+register(options.Option('vm.os.auth_url', default_from_env=['OS_AUTH_URL'],
144+ default=options.MANDATORY,
145+ help_string='''The Open Stack keystone url.
146+
147+This is generally set via OS_AUTH_URL, sourced from a novarc file
148+(~/.novarc, ~/.canonistack/novarc).
149+'''))
150+register(options.ListOption('vm.os.flavors', default=None,
151+ help_string='''\
152+A list of flavors for all supported clouds.
153+
154+The first known one is used.
155+'''))
156+register(options.Option('vm.image', default=options.MANDATORY,
157+ help_string='''The glance image to boot from.'''))
158+register(options.Option('vm.net_id', default=None,
159+ help_string='''The network id for the vm.'''))
160+register(PathOption('vm.ssh_key_path', default='~/.ssh/id_rsa',
161+ help_string='''The ssh key for the vm.'''))
162+register(options.ListOption('vm.apt_get.update.timeouts',
163+ default='15.0, 90.0, 240.0',
164+ help_string='''apt-get update timeouts in seconds.
165+
166+When apt-get update fails on hash sum mismatches, retry after the specified
167+timeouts. More values mean more retries.
168+'''))
169+# FIXME: According to the help string this can (and should) be fixed
170+# -- vila 2015-07-20
171+register(options.ListOption('vm.ppas',
172+ default=None,
173+ help_string='''PPAs to be added to the testbed.
174+
175+This works around cloud-init not activating the deb-src line and not providing
176+a way to do so. This is intended to be fixed in uci-vms so vm.apt_sources can
177+be used again.
178+'''))
179+register(options.Option('vm.nova.boot_timeout', default='300',
180+ from_unicode=options.float_from_store,
181+ help_string='''\
182+Max time to boot a nova instance (in seconds).'''))
183+register(options.Option('vm.nova.set_ip_timeout', default='300',
184+ from_unicode=options.float_from_store,
185+ help_string='''\
186+Max time for a nova instance to get an IP (in seconds).'''))
187+register(options.Option('vm.nova.cloud_init_timeout', default='1200',
188+ from_unicode=options.float_from_store,
189+ help_string='''\
190+Max time for cloud-init to fisnish (in seconds).'''))
191
192=== modified file 'ucivms/tests/__init__.py'
193--- ucivms/tests/__init__.py 2014-01-15 13:28:13 +0000
194+++ ucivms/tests/__init__.py 2015-09-01 11:19:08 +0000
195@@ -1,6 +1,6 @@
196 # This file is part of Ubuntu Continuous Integration virtual machine tools.
197 #
198-# Copyright 2014 Canonical Ltd.
199+# Copyright 2014, 2015 Canonical Ltd.
200 #
201 # This program is free software: you can redistribute it and/or modify it under
202 # the terms of the GNU General Public License version 3, as published by the
203@@ -13,3 +13,153 @@
204 #
205 # You should have received a copy of the GNU General Public License along with
206 # this program. If not, see <http://www.gnu.org/licenses/>.
207+from __future__ import unicode_literals
208+import functools
209+import io
210+import logging
211+import unittest
212+
213+
214+from ucitests import (
215+ assertions,
216+ results,
217+)
218+
219+
220+class TestLogger(logging.Logger):
221+ """A logger dedicated to a given test.
222+
223+ Log messages are captured in string buffer.
224+ """
225+
226+ def __init__(self, test, level=logging.DEBUG,
227+ fmt='%(asctime)-15s %(message)s'):
228+ super(TestLogger, self).__init__(test.id(), level)
229+ self.stream = io.StringIO()
230+ handler = logging.StreamHandler(self.stream)
231+ handler.setFormatter(logging.Formatter(fmt))
232+ self.addHandler(handler)
233+
234+ def getvalue(self):
235+ return self.stream.getvalue()
236+
237+
238+class log_on_failure(object):
239+ """Decorates a test to display log on failure.
240+
241+ This adds a 'logger' attribute to the parameters of the decorated
242+ test. Using this logger the test can display its content when it fails or
243+ errors but stay silent otherwise.
244+ """
245+
246+ def __init__(self, level=logging.INFO, fmt='%(message)s'):
247+ self.level = level
248+ self.fmt = fmt
249+
250+ def __call__(self, func):
251+
252+ @functools.wraps(func)
253+ def decorator(*args):
254+ test = args[0]
255+ logger = TestLogger(test, level=self.level, fmt=self.fmt)
256+ display_log = True
257+
258+ # We need to delay the log acquisition until we attempt to display
259+ # it (or we get no content).
260+ def delayed_display_log():
261+ msg = 'Failed test log: >>>\n{}\n<<<'.format(logger.getvalue())
262+ if display_log:
263+ raise Exception(msg)
264+
265+ test.addCleanup(delayed_display_log)
266+
267+ # Run the test without the decoration
268+ func(*args + (logger,))
269+ # If it terminates properly, disable log display
270+ display_log = False
271+
272+ return decorator
273+
274+
275+class TestLogOnFailure(unittest.TestCase):
276+
277+ def setUp(self):
278+ self.result = results.TextResult(io.StringIO(), verbosity=2)
279+
280+ # We don't care about timing here so we always return 0 which
281+ # simplifies matching the expected result
282+ def zero(atime):
283+ return 0.0
284+
285+ self.result._delta_to_float = zero
286+ # Inner tests will set this from the logger they receive so outter
287+ # tests can assert the content.
288+ self.logger = None
289+
290+ def test_log_not_displayed(self):
291+
292+ class Test(unittest.TestCase):
293+
294+ @log_on_failure()
295+ def test_pass(inner, logger):
296+ self.logger = logger
297+ logger.info('pass')
298+
299+ t = Test('test_pass')
300+ t.run(self.result)
301+ self.assertEqual('pass\n', self.logger.getvalue())
302+ self.assertEqual('ucivms.tests.Test.test_pass ... OK (0.000 secs)\n',
303+ self.result.stream.getvalue())
304+ self.assertEqual([], self.result.errors)
305+
306+ def test_log_displayed(self):
307+
308+ class Test(unittest.TestCase):
309+
310+ @log_on_failure()
311+ def test_fail(inner, logger):
312+ self.logger = logger
313+ logger.info("I'm broken")
314+ inner.fail()
315+
316+ t = Test('test_fail')
317+ t.run(self.result)
318+ self.assertEqual("I'm broken\n", self.logger.getvalue())
319+ # FAILERROR: The test FAIL, the cleanup ERRORs out.
320+ self.assertEqual(
321+ 'ucivms.tests.Test.test_fail ... FAILERROR (0.000 secs)\n',
322+ self.result.stream.getvalue())
323+ assertions.assertLength(self, 1, self.result.errors)
324+ failing_test, traceback = self.result.errors[0]
325+ self.assertIs(t, failing_test)
326+ expected = traceback.endswith("Failed test log:"
327+ " >>>\nI'm broken\n\n<<<\n")
328+ self.assertTrue(expected, 'Actual traceback: {}'.format(traceback))
329+
330+ def test_log_debug_not_displayed(self):
331+
332+ class Test(unittest.TestCase):
333+
334+ @log_on_failure()
335+ def test_debug_silent(inner, logger):
336+ self.logger = logger
337+ logger.debug('more info')
338+ self.fail()
339+
340+ t = Test('test_debug_silent')
341+ t.run(self.result)
342+ self.assertEqual('', self.logger.getvalue())
343+
344+ def test_log_debug_displayed(self):
345+
346+ class Test(unittest.TestCase):
347+
348+ @log_on_failure(level=logging.DEBUG)
349+ def test_debug_verbose(inner, logger):
350+ self.logger = logger
351+ logger.debug('more info')
352+ self.fail()
353+
354+ t = Test('test_debug_verbose')
355+ t.run(self.result)
356+ self.assertEqual('more info\n', self.logger.getvalue())
357
358=== modified file 'ucivms/tests/features.py'
359--- ucivms/tests/features.py 2015-07-21 08:07:22 +0000
360+++ ucivms/tests/features.py 2015-09-01 11:19:08 +0000
361@@ -17,9 +17,16 @@
362 import errno
363 import os
364 import subprocess
365-
366+import sys
367+
368+
369+if sys.version_info < (3,):
370+ # novaclient doesn't support python3 (yet)
371+ from ucivms.vms import nova
372+
373+
374+from uciconfig import errors
375 from ucitests import features
376-
377 from ucivms import config
378
379
380@@ -103,3 +110,52 @@
381 user_conf = config.VmStack(vm_name)
382 if user_conf.get('vm.name') != vm_name:
383 test.skipTest('{} does not exist'.format(vm_name))
384+
385+
386+class NovaCompute(features.Feature):
387+
388+ def _probe(self):
389+ if sys.version_info >= (3,):
390+ # novaclient doesn't support python3 (yet)
391+ return False
392+ client = self.get_client()
393+ if client is None:
394+ return False
395+ try:
396+ # can transiently fail with requests.exceptions.ConnectionError
397+ # (converted from MaxRetryError).
398+ client.authenticate()
399+ except nova.exceptions.ClientException:
400+ return False
401+ return True
402+
403+ def get_client(self):
404+ test_vm_conf = config.VmStack('uci-vms-tests-nova')
405+ return nova.get_os_nova_client(test_vm_conf)
406+
407+
408+# The single instance shared by all tests
409+nova_compute = NovaCompute()
410+
411+
412+class NovaCredentials(features.Feature):
413+
414+ def __init__(self):
415+ super(NovaCredentials, self).__init__()
416+
417+ def _probe(self):
418+ if sys.version_info >= (3,):
419+ # novaclient doesn't support python3 (yet)
420+ return False
421+ try:
422+ config.VmStack('uci-vms-tests-nova').get_nova_creds()
423+ except errors.OptionMandatoryValueError:
424+ return False
425+ return True
426+
427+ def feature_name(self):
428+ return 'Valid nova credentials'
429+
430+
431+# The single instance shared by all tests
432+nova_creds = NovaCredentials()
433
434=== modified file 'ucivms/tests/fixtures.py'
435--- ucivms/tests/fixtures.py 2014-01-13 21:58:22 +0000
436+++ ucivms/tests/fixtures.py 2015-09-01 11:19:08 +0000
437@@ -1,6 +1,6 @@
438 # This file is part of Ubuntu Continuous Integration virtual machine tools.
439 #
440-# Copyright 2014 Canonical Ltd.
441+# Copyright 2014, 2015 Canonical Ltd.
442 #
443 # This program is free software: you can redistribute it and/or modify it under
444 # the terms of the GNU General Public License version 3, as published by the
445@@ -36,3 +36,23 @@
446 test.etc_dir = os.path.join(test.uniq_dir, 'etc')
447 os.mkdir(test.etc_dir)
448 fixtures.patch(test, config, 'system_config_dir', lambda: test.etc_dir)
449+
450+
451+def share_nova_test_creds(test):
452+ """Set default nova test creds in user config.
453+
454+ :note: This should be called early during setUp so the user configuration
455+ is still available (i.e. the test has not yet been isolated from disk).
456+
457+ """
458+ test_vm_conf = config.VmStack('uci-vms-tests-nova')
459+ credentials = test_vm_conf.get_nova_creds()
460+ isolate_from_disk(test)
461+ user_conf = config.VmStack(None)
462+ for k, v in credentials.items():
463+ user_conf.set(k, v)
464+ user_conf.set('vm.os.flavors',
465+ test_vm_conf.get('vm.os.flavors', convert=False))
466+ # Avoid triggering the 'atexit' hook as the config files are long gone
467+ # when atexit run.
468+ test.addCleanup(user_conf.store.save_changes)
469
470=== modified file 'ucivms/tests/test_commands.py'
471--- ucivms/tests/test_commands.py 2015-08-21 15:42:06 +0000
472+++ ucivms/tests/test_commands.py 2015-09-01 11:19:08 +0000
473@@ -25,6 +25,7 @@
474 from ucitests import (
475 assertions,
476 fixtures,
477+ scenarii,
478 )
479 from ucivms import (
480 commands,
481@@ -36,6 +37,8 @@
482 fixtures as vms_fixtures,
483 )
484
485+load_tests = scenarii.load_tests_with_scenarios
486+
487
488 class TestHelpOptions(unittest.TestCase):
489
490@@ -112,19 +115,19 @@
491 def __init__(self, conf, vm_states=None):
492 super(FakeVM, self).__init__(conf)
493 self.states = vm_states
494- self.install_called = False
495+ self.setup_called = False
496 self.start_called = False
497 self.shell_called = False
498 self.shell_command = None
499 self.shell_cmd_args = None
500- self.undefine_called = False
501- self.poweroff_called = False
502+ self.teardown_called = False
503+ self.stop_called = False
504
505 def state(self):
506 return self.states.get(self.conf.get('vm.name'), None)
507
508- def install(self):
509- self.install_called = True
510+ def setup(self):
511+ self.setup_called = True
512
513 def start(self):
514 self.start_called = True
515@@ -134,11 +137,11 @@
516 self.shell_command = command
517 self.shell_cmd_args = cmd_args
518
519- def poweroff(self):
520- self.poweroff_called = True
521+ def stop(self):
522+ self.stop_called = True
523
524- def undefine(self):
525- self.undefine_called = True
526+ def teardown(self):
527+ self.teardown_called = True
528
529
530 def setup_fake_vm(test):
531@@ -436,16 +439,16 @@
532 self.conf.set('vm.name', 'foo')
533 self.states = {'foo': 'shut off'}
534 self.run_setup(['foo'])
535- self.assertTrue(self.vm.install_called)
536- self.assertTrue(self.vm.undefine_called)
537+ self.assertTrue(self.vm.setup_called)
538+ self.assertTrue(self.vm.teardown_called)
539
540 def test_while_running(self):
541 self.conf.set('vm.name', 'foo')
542 self.states = {'foo': 'running'}
543 with self.assertRaises(errors.VmRunning):
544 self.run_setup(['foo'])
545- self.assertFalse(self.vm.install_called)
546- self.assertFalse(self.vm.undefine_called)
547+ self.assertFalse(self.vm.setup_called)
548+ self.assertFalse(self.vm.teardown_called)
549
550
551 class TestStatus(unittest.TestCase):
552@@ -478,6 +481,28 @@
553 self.assertEqual('RUNNING\n', self.out.getvalue())
554
555
556+class TestStatusPerVm(unittest.TestCase):
557+
558+ scenarios = [(k, dict(kls=k)) for k in config.vm_class_registry.keys()]
559+
560+ def setUp(self):
561+ super(TestStatusPerVm, self).setUp()
562+ # FIXME: If tests config was properly shared across all tests we could
563+ # get rid of the specific reference to nova below -- vila 2015-08-26
564+ vms_fixtures.share_nova_test_creds(self)
565+ self.conf = config.VmStack('foo')
566+ self.conf.set('vm.name', 'foo')
567+ self.conf.set('vm.class', self.kls)
568+ self.conf.set('vm.release', 'trusty')
569+ self.conf.set('vm.cpu_model', 'amd64')
570+ self.addCleanup(self.conf.store.save_changes)
571+
572+ def test_unknown_vm(self):
573+ vm_class = self.conf.get('vm.class')
574+ vm = vm_class(self.conf)
575+ self.assertEqual('UNKNOWN', vm.state())
576+
577+
578 class TestStart(unittest.TestCase):
579
580 def setUp(self):
581@@ -598,20 +623,20 @@
582 self.states = {'foo': 'shut off'}
583 with self.assertRaises(errors.VmNotRunning):
584 self.run_stop()
585- self.assertFalse(self.vm.poweroff_called)
586+ self.assertFalse(self.vm.stop_called)
587
588 def test_while_running(self):
589 self.conf.set('vm.name', 'foo')
590 self.states = {'foo': 'running'}
591 self.run_stop()
592- self.assertTrue(self.vm.poweroff_called)
593+ self.assertTrue(self.vm.stop_called)
594
595 def test_unknown(self):
596 self.conf.set('vm.name', 'I-dont-exist')
597 self.states = {}
598 with self.assertRaises(errors.VmUnknown):
599 self.run_stop()
600- self.assertFalse(self.vm.poweroff_called)
601+ self.assertFalse(self.vm.stop_called)
602
603
604 class TestTeardown(unittest.TestCase):
605@@ -630,18 +655,18 @@
606 self.conf.set('vm.name', 'foo')
607 self.states = {'foo': 'shut off'}
608 self.run_teardown(['foo'])
609- self.assertTrue(self.vm.undefine_called)
610+ self.assertTrue(self.vm.teardown_called)
611
612 def test_while_running(self):
613 self.conf.set('vm.name', 'foo')
614 self.states = {'foo': 'running'}
615 with self.assertRaises(errors.VmRunning):
616 self.run_teardown(['foo'])
617- self.assertFalse(self.vm.undefine_called)
618+ self.assertFalse(self.vm.teardown_called)
619
620 def test_unknown(self):
621 self.conf.set('vm.name', 'I-dont-exist')
622 self.states = {}
623 with self.assertRaises(errors.VmUnknown):
624 self.run_teardown(['I-dont-exist'])
625- self.assertFalse(self.vm.undefine_called)
626+ self.assertFalse(self.vm.teardown_called)
627
628=== modified file 'ucivms/tests/test_ssh.py'
629--- ucivms/tests/test_ssh.py 2015-07-22 13:02:27 +0000
630+++ ucivms/tests/test_ssh.py 2015-09-01 11:19:08 +0000
631@@ -99,7 +99,7 @@
632
633 def setUp(self):
634 super(TestSsh, self).setUp()
635- vms_features.requires_existing_vm(self, 'uci-vms-tests-trusty')
636+ vms_features.requires_existing_vm(self, 'uci-vms-tests-lxc')
637 vms_fixtures.isolate_from_disk(self)
638 # To isolate tests from each other, created vms needs a unique name. To
639 # keep those names legal and still user-readable we use the class name
640@@ -112,15 +112,15 @@
641 conf.store._load_from_string('''
642 vm.ssh_opts=-oUserKnownHostsFile=/dev/null,-oStrictHostKeyChecking=no
643 vm.config_dir={config_dir}
644-[uci-vms-tests-trusty]
645+[uci-vms-tests-lxc]
646 # /!\ Should match the one defined by the user
647-vm.name = uci-vms-tests-trusty
648+vm.name = uci-vms-tests-lxc
649 vm.class = lxc
650 vm.release = trusty
651 [{vm_name}]
652 vm.name = {vm_name}
653 vm.class = ephemeral-lxc
654-vm.backing = uci-vms-tests-trusty
655+vm.backing = uci-vms-tests-lxc
656 vm.release = trusty
657 '''.format(config_dir=config_dir, vm_name=self.vm_name))
658 conf.store.save()
659
660=== modified file 'ucivms/tests/test_vms.py'
661--- ucivms/tests/test_vms.py 2015-07-22 12:03:03 +0000
662+++ ucivms/tests/test_vms.py 2015-09-01 11:19:08 +0000
663@@ -16,15 +16,21 @@
664 from __future__ import unicode_literals
665 import io
666 import os
667+import subprocess
668 import sys
669 import unittest
670 import yaml
671
672
673-from uciconfig import errors as conf_errors
674+from uciconfig import (
675+ errors as conf_errors,
676+ options,
677+)
678 from ucitests import (
679+ assertions,
680 features,
681 fixtures,
682+ scenarii,
683 )
684 from ucivms import (
685 config,
686@@ -32,11 +38,17 @@
687 vms,
688 ssh,
689 subprocesses,
690+ tests,
691 )
692 from ucivms.tests import (
693 features as vms_features,
694 fixtures as vms_fixtures,
695 )
696+if sys.version_info < (3,):
697+ from ucivms.vms import nova
698+
699+
700+load_tests = scenarii.load_tests_with_scenarios
701
702
703 def requires_known_reference_image(test):
704@@ -701,13 +713,13 @@
705
706
707 @features.requires(vms_features.use_sudo_for_tests_feature)
708-class TestInstallWithSeed(unittest.TestCase):
709+class TestSetupWithSeed(unittest.TestCase):
710
711 def setUp(self):
712 (download_cache,
713 reference_cloud_image_name,
714 images_dir) = requires_known_reference_image(self)
715- super(TestInstallWithSeed, self).setUp()
716+ super(TestSetupWithSeed, self).setUp()
717 vms_fixtures.isolate_from_disk(self)
718 # We need to allow other users to read this dir
719 os.chmod(self.uniq_dir, 0o755)
720@@ -733,9 +745,9 @@
721 download_cache=download_cache,
722 cloud_image_name=reference_cloud_image_name))
723
724- def test_install_with_seed(self):
725- self.addCleanup(self.vm.undefine)
726- self.vm.install()
727+ def test_setup_with_seed(self):
728+ self.addCleanup(self.vm.teardown)
729+ self.vm.setup()
730 self.assertEqual('shut off', self.vm.state())
731 # As a side-effect, the console and the interface file are created
732 # MISSINGTEST: This applies to Kvm only, lxc needs the same,
733@@ -745,13 +757,13 @@
734
735
736 @features.requires(vms_features.use_sudo_for_tests_feature)
737-class TestInstallWithBacking(unittest.TestCase):
738+class TestSetupWithBacking(unittest.TestCase):
739
740 def setUp(self):
741 (download_cache_dir,
742 reference_cloud_image_name,
743 images_dir) = requires_known_reference_image(self)
744- super(TestInstallWithBacking, self).setUp()
745+ super(TestSetupWithBacking, self).setUp()
746 vms_fixtures.isolate_from_disk(self)
747 # We need to allow other users to read this dir
748 os.chmod(self.uniq_dir, 0o755)
749@@ -788,10 +800,10 @@
750 self.addCleanup(subprocesses.run,
751 ['sudo', 'rm', '-f', temp_vm._disk_image_path])
752
753- def test_install_with_backing(self):
754+ def test_setup_with_backing(self):
755 vm = vms.Kvm(config.VmStack('selftest-backing'))
756- self.addCleanup(vm.undefine)
757- vm.install()
758+ self.addCleanup(vm.teardown)
759+ vm.setup()
760 self.assertEqual('shut off', vm.state())
761 # As a side-effect, the console and the interface files are created
762 # MISSINGTEST: This applies to Kvm only, lxc needs the same,
763@@ -959,7 +971,7 @@
764
765 def setUp(self):
766 super(TestEphemeralLXC, self).setUp()
767- vms_features.requires_existing_vm(self, 'uci-vms-tests-trusty')
768+ vms_features.requires_existing_vm(self, 'uci-vms-tests-lxc')
769 vms_fixtures.isolate_from_disk(self)
770 # To isolate tests from each other, created vms needs a unique name. To
771 # keep those names legal and still user-readable we use the class name
772@@ -973,14 +985,14 @@
773 vm.ssh_opts=-oUserKnownHostsFile=/dev/null,-oStrictHostKeyChecking=no
774 vm.config_dir={config_dir}
775 # /!\ Should match the one defined by the user
776-[uci-vms-tests-trusty]
777-vm.name = uci-vms-tests-trusty
778+[uci-vms-tests-lxc]
779+vm.name = uci-vms-tests-lxc
780 vm.class = lxc
781 vm.release = trusty
782 [{vm_name}]
783 vm.name = {vm_name}
784 vm.class = ephemeral-lxc
785-vm.backing = uci-vms-tests-trusty
786+vm.backing = uci-vms-tests-lxc
787 vm.release = trusty
788 '''.format(config_dir=config_dir, vm_name=self.vm_name))
789 conf.store.save()
790@@ -1011,3 +1023,344 @@
791 #
792 # def test_fail_once_then_succeed(self):
793 # pass
794+
795+
796+@features.requires(vms_features.nova_creds)
797+class TestUciImageName(unittest.TestCase):
798+
799+ def test_valid_britney_image(self):
800+ self.assertEqual(
801+ 'uci/britney/precise-amd64.img',
802+ nova.uci_image_name('britney', 'precise', 'amd64'))
803+
804+ def test_valid_cloud_image(self):
805+ self.assertEqual(
806+ 'uci/cloudimg/precise-amd64.img',
807+ nova.uci_image_name('cloudimg', 'precise', 'amd64'))
808+
809+ def test_invalid_image(self):
810+ with self.assertRaises(ValueError) as cm:
811+ nova.uci_image_name('I-dont-exist', 'precise', 'amd64')
812+ self.assertEqual('Invalid image domain', '{}'.format(cm.exception))
813+
814+
815+@features.requires(vms_features.nova_creds)
816+@features.requires(vms_features.nova_compute)
817+class TestNovaClient(unittest.TestCase):
818+ """Check the nova client behavior when it encounters exceptions.
819+
820+ This is achieved by overriding specific methods from NovaClient and
821+ exercising it through the TestBed methods.
822+ """
823+
824+ def setUp(self):
825+ super(TestNovaClient, self).setUp()
826+ vms_fixtures.share_nova_test_creds(self)
827+ conf = config.VmStack('testing-nova-client')
828+ # Default to precise
829+ conf.set('vm.release', 'precise')
830+ # Avoid triggering the 'atexit' hook as the config files are long gone
831+ # when atexit run.
832+ self.addCleanup(conf.store.save_changes)
833+ self.conf = conf
834+ os.makedirs(self.conf.get('vm.vms_dir'))
835+
836+ def get_image_id(self, series, arch):
837+ return nova.uci_image_name('cloudimg', series, arch)
838+
839+ @tests.log_on_failure()
840+ def test_retry_is_called(self, logger):
841+ self.retry_calls = []
842+
843+ class RetryingNovaClient(nova.NovaClient):
844+
845+ def __init__(inner, conf, **kwargs):
846+ # We don't want to wait, it's enough to retry
847+ super(RetryingNovaClient, inner).__init__(
848+ conf, first_wait=0, wait_up_to=0, **kwargs)
849+
850+ def retry(inner, func, *args, **kwargs):
851+ self.retry_calls.append((func, args, kwargs))
852+ return super(RetryingNovaClient, inner).retry(
853+ func, *args, **kwargs)
854+
855+ image_id = self.get_image_id('trusty', 'amd64')
856+ self.conf.set('vm.image', image_id)
857+ fixtures.patch(self, nova.NovaServer,
858+ 'nova_client_class', RetryingNovaClient)
859+ tb = nova.NovaServer(self.conf, logger)
860+ self.assertEqual(image_id, tb.find_nova_image().name)
861+ assertions.assertLength(self, 1, self.retry_calls)
862+
863+ @tests.log_on_failure()
864+ def test_known_failure_is_retried(self, logger):
865+ self.nb_calls = 0
866+
867+ class FailingOnceNovaClient(nova.NovaClient):
868+
869+ def __init__(inner, conf, **kwargs):
870+ # We don't want to wait, it's enough to retry
871+ super(FailingOnceNovaClient, inner).__init__(
872+ conf, first_wait=0, wait_up_to=0, retries=1,
873+ **kwargs)
874+
875+ def fail_once(inner):
876+ self.nb_calls += 1
877+ if self.nb_calls == 1:
878+ raise nova.client.requests.ConnectionError()
879+ else:
880+ return inner.nova.flavors.list()
881+
882+ def flavors_list(inner):
883+ return inner.retry(inner.fail_once)
884+
885+ fixtures.patch(self, nova.NovaServer,
886+ 'nova_client_class', FailingOnceNovaClient)
887+ tb = nova.NovaServer(self.conf, logger)
888+ tb.find_flavor()
889+ self.assertEqual(2, self.nb_calls)
890+
891+ @tests.log_on_failure()
892+ def test_unknown_failure_is_raised(self, logger):
893+
894+ class FailingNovaClient(nova.NovaClient):
895+
896+ def __init__(inner, conf, **kwargs):
897+ # We don't want to wait, it's enough to retry
898+ super(FailingNovaClient, inner).__init__(
899+ conf, first_wait=0, wait_up_to=0,
900+ **kwargs)
901+
902+ def fail(inner):
903+ raise AssertionError('Boom!')
904+
905+ def flavors_list(inner):
906+ return inner.retry(inner.fail)
907+
908+ fixtures.patch(self, nova.NovaServer,
909+ 'nova_client_class', FailingNovaClient)
910+ tb = nova.NovaServer(self.conf, logger)
911+ # This mimics what will happen when we encounter unknown transient
912+ # failures we want to catch: an exception will bubble up and we'll have
913+ # to add it to NovaClient.retry().
914+ with self.assertRaises(nova.NovaServerException) as cm:
915+ tb.find_flavor()
916+ self.assertEqual('fail failed', '{}'.format(cm.exception))
917+
918+
919+@features.requires(vms_features.nova_creds)
920+@features.requires(vms_features.nova_compute)
921+class TestTestbed(unittest.TestCase):
922+
923+ def setUp(self):
924+ super(TestTestbed, self).setUp()
925+ vms_fixtures.share_nova_test_creds(self)
926+ conf = config.VmStack('testing-testbed')
927+ conf.set('vm.name', 'testing-testbed')
928+ # Default to precise
929+ conf.set('vm.release', 'precise')
930+ # Avoid triggering the 'atexit' hook as the config files are long gone
931+ # at that point.
932+ self.addCleanup(conf.store.save_changes)
933+ self.conf = conf
934+ os.makedirs(self.conf.get('vm.vms_dir'))
935+
936+ def get_image_id(self, series='precise', arch='amd64'):
937+ return nova.uci_image_name('cloudimg', series, arch)
938+
939+ @tests.log_on_failure()
940+ def test_create_no_image(self, logger):
941+ tb = nova.NovaServer(self.conf, logger)
942+ with self.assertRaises(options.errors.OptionMandatoryValueError) as cm:
943+ tb.setup()
944+ self.assertEqual('vm.image must be set.', '{}'.format(cm.exception))
945+
946+ @tests.log_on_failure()
947+ def test_create_unknown_image(self, logger):
948+ image_name = "I don't exist and eat kittens"
949+ self.conf.set('vm.image', image_name)
950+ tb = nova.NovaServer(self.conf, logger)
951+ with self.assertRaises(nova.NovaServerException) as cm:
952+ tb.setup()
953+ self.assertEqual('Image "{}" cannot be found'.format(image_name),
954+ '{}'.format(cm.exception))
955+
956+ @tests.log_on_failure()
957+ def test_create_unknown_flavor(self, logger):
958+ flavors = "I don't exist and eat kittens"
959+ self.conf.set('vm.os.flavors', flavors)
960+ tb = nova.NovaServer(self.conf, logger)
961+ with self.assertRaises(nova.NovaServerException) as cm:
962+ tb.setup()
963+ self.assertEqual('None of [{}] can be found'.format(flavors),
964+ '{}'.format(cm.exception))
965+
966+ @tests.log_on_failure()
967+ def test_existing_home_ssh(self, logger):
968+ # The first request for the worker requires creating ~/.ssh if it
969+ # doesn't exist, but it may happen that this directory already exists
970+ # (see http://pad.lv/1334146).
971+ ssh_home = os.path.expanduser('~/sshkeys')
972+ os.mkdir(ssh_home)
973+ self.conf.set('vm.ssh_key_path', os.path.join(ssh_home, 'id_rsa'))
974+ tb = nova.NovaServer(self.conf, logger)
975+ tb.ensure_ssh_key_is_available()
976+ self.assertTrue(os.path.exists(ssh_home))
977+ self.assertTrue(os.path.exists(os.path.join(ssh_home, 'id_rsa')))
978+ self.assertTrue(os.path.exists(os.path.join(ssh_home, 'id_rsa.pub')))
979+
980+ @tests.log_on_failure()
981+ def test_create_new_ssh_key(self, logger):
982+ self.conf.set('vm.image', self.get_image_id())
983+ # We use a '~' path to cover proper uci-vms user expansion
984+ self.conf.set('vm.ssh_key_path', '~/sshkeys/id_rsa')
985+ tb = nova.NovaServer(self.conf, logger)
986+ tb.ensure_ssh_key_is_available()
987+ self.assertTrue(os.path.exists(os.path.expanduser('~/sshkeys/id_rsa')))
988+ self.assertTrue(
989+ os.path.exists(os.path.expanduser('~/sshkeys/id_rsa.pub')))
990+
991+ @tests.log_on_failure()
992+ def test_ssh_failure(self, logger):
993+ self.conf.set('vm.release', 'trusty')
994+ self.conf.set('vm.image', self.get_image_id())
995+ tb = nova.NovaServer(self.conf, logger)
996+ self.addCleanup(tb.teardown)
997+ tb.setup()
998+ # Sabotage ssh access
999+ os.remove(self.conf.get('vm.ssh_key_path'))
1000+ # Oh, we can't ssh anymore !
1001+ with self.assertRaises(nova.NovaServerException) as cm:
1002+ tb.ensure_ssh_works()
1003+ msg = 'No ssh access to {}, IP: {}'
1004+ msg = msg.format(tb.instance.id, tb.ip)
1005+ self.assertEqual(msg, '{}'.format(cm.exception))
1006+
1007+ @tests.log_on_failure()
1008+ def test_apt_get_update_retries(self, logger):
1009+ self.conf.set('vm.image', self.get_image_id())
1010+ self.conf.set('vm.apt_get.update.timeouts', '0.1, 0.1')
1011+ tb = nova.NovaServer(self.conf, logger)
1012+ self.nb_calls = 0
1013+
1014+ class Proc(object):
1015+ returncode = 0
1016+
1017+ def failing_update():
1018+ self.nb_calls += 1
1019+ if self.nb_calls > 1:
1020+ return Proc(), 'stdout success', 'stderr success'
1021+ else:
1022+ # Fake a failed apt-get update
1023+ proc = Proc()
1024+ proc.returncode = 1
1025+ return proc, 'stdout error', 'stderr error'
1026+
1027+ tb.apt_get_update = failing_update
1028+ tb.safe_apt_get_update()
1029+ self.assertEqual(2, self.nb_calls)
1030+
1031+ @tests.log_on_failure()
1032+ def test_apt_get_update_fails(self, logger):
1033+ self.conf.set('vm.image', self.get_image_id())
1034+ self.conf.set('vm.apt_get.update.timeouts', '0.1, 0.1, 0.1')
1035+ tb = nova.NovaServer(self.conf, logger)
1036+
1037+ def failing_update():
1038+ class Proc(object):
1039+ pass
1040+
1041+ proc = Proc()
1042+ proc.returncode = 1
1043+ return proc, 'stdout', 'stderr'
1044+
1045+ tb.apt_get_update = failing_update
1046+ with self.assertRaises(nova.NovaServerException) as cm:
1047+ tb.safe_apt_get_update()
1048+ self.assertEqual('apt-get update never succeeded',
1049+ '{}'.format(cm.exception))
1050+
1051+ @tests.log_on_failure()
1052+ def test_wait_for_instance_fails(self, logger):
1053+ self.conf.set('vm.image', self.get_image_id())
1054+ # Force a 0 timeout so the instance can't finish booting
1055+ self.conf.set('vm.nova.boot_timeout', '0')
1056+ tb = nova.NovaServer(self.conf, logger)
1057+ self.addCleanup(tb.teardown)
1058+ with self.assertRaises(nova.NovaServerException) as cm:
1059+ tb.setup()
1060+ msg = 'Instance {} never came up (last status: BUILD)'
1061+ msg = msg.format(tb.instance.id)
1062+ self.assertEqual(msg, '{}'.format(cm.exception))
1063+
1064+ @tests.log_on_failure()
1065+ def test_wait_for_instance_errors(self, logger):
1066+ self.conf.set('vm.image', self.get_image_id())
1067+ tb = nova.NovaServer(self.conf, logger)
1068+ self.addCleanup(tb.teardown)
1069+
1070+ def update_instance_to_error():
1071+ # Fake an instance starting in error state
1072+ tb.instance.status = 'ERROR'
1073+ return True
1074+ tb.update_instance = update_instance_to_error
1075+ with self.assertRaises(nova.NovaServerException) as cm:
1076+ tb.setup()
1077+ msg = 'Instance {} never came up (last status: ERROR)'
1078+ msg = msg.format(tb.instance.id)
1079+ self.assertEqual(msg, '{}'.format(cm.exception))
1080+
1081+ @tests.log_on_failure()
1082+ def test_wait_for_ip_fails(self, logger):
1083+ self.conf.set('vm.image', self.get_image_id())
1084+ # Force a 0 timeout so the instance never get an IP
1085+ self.conf.set('vm.nova.set_ip_timeout', '0')
1086+ tb = nova.NovaServer(self.conf, logger)
1087+ self.addCleanup(tb.teardown)
1088+ with self.assertRaises(nova.NovaServerException) as cm:
1089+ tb.setup()
1090+ msg = 'Instance {} never provided an IP'.format(tb.instance.id)
1091+ self.assertEqual(msg, '{}'.format(cm.exception))
1092+
1093+
1094+@features.requires(vms_features.nova_creds)
1095+@features.requires(vms_features.nova_compute)
1096+class TestUsableTestbed(unittest.TestCase):
1097+
1098+ scenarios = scenarii.multiply_scenarios(
1099+ # series
1100+ ([('precise', dict(series='precise', result='skip')),
1101+ ('trusty', dict(series='trusty', result='pass')),
1102+ ('utopic', dict(series='utopic', result='pass')),
1103+ ('vivid', dict(series='vivid', result='pass')),
1104+ ('wily', dict(series='wily', result='pass'))]),
1105+ # architectures
1106+ ([('amd64', dict(arch='amd64')), ('i386', dict(arch='i386'))]))
1107+
1108+ def setUp(self):
1109+ super(TestUsableTestbed, self).setUp()
1110+ vms_fixtures.share_nova_test_creds(self)
1111+ tb_name = 'testing-testbed-{}-{}'.format(self.series, self.arch)
1112+ conf = config.VmStack(tb_name)
1113+ conf.set('vm.name', tb_name)
1114+ conf.set('vm.release', self.series)
1115+ # Avoid triggering the 'atexit' hook as the config files are long gone
1116+ # at that point.
1117+ self.addCleanup(conf.store.save_changes)
1118+ self.conf = conf
1119+ os.makedirs(self.conf.get('vm.vms_dir'))
1120+
1121+ def get_image_id(self):
1122+ return nova.uci_image_name('cloudimg', self.series, self.arch)
1123+
1124+ @tests.log_on_failure()
1125+ def test_create_usable_testbed(self, logger):
1126+ self.conf.set('vm.image', self.get_image_id())
1127+ tb = nova.NovaServer(self.conf, logger)
1128+ self.addCleanup(tb.teardown)
1129+ tb.setup()
1130+ # We should be able to ssh with the right user
1131+ proc, out, err = tb.ssh('whoami',
1132+ out=subprocess.PIPE, err=subprocess.PIPE)
1133+ self.assertEqual(0, proc.returncode)
1134+ self.assertEqual('ubuntu\n', out)
1135
1136=== added directory 'ucivms/vms'
1137=== renamed file 'ucivms/vms.py' => 'ucivms/vms/__init__.py'
1138--- ucivms/vms.py 2015-07-22 07:48:25 +0000
1139+++ ucivms/vms/__init__.py 2015-09-01 11:19:08 +0000
1140@@ -1,4 +1,3 @@
1141-#!/usr/bin/env python
1142 # This file is part of Ubuntu Continuous Integration virtual machine tools.
1143 #
1144 # Copyright 2014, 2015 Canonical Ltd.
1145@@ -384,6 +383,32 @@
1146 self._user_data_path = None
1147 self.ci_user_data = None
1148
1149+ def download(self, force=False):
1150+ raise NotImplementedError(self.download)
1151+
1152+ def setup(self):
1153+ raise NotImplementedError(self.setup)
1154+
1155+ def start(self):
1156+ raise NotImplementedError(self.start)
1157+
1158+ def stop(self):
1159+ raise NotImplementedError(self.stop)
1160+
1161+ def teardown(self):
1162+ raise NotImplementedError(self.teardown)
1163+
1164+ # MISSINGTEST
1165+ def shell(self, command, *args):
1166+ ssh_cmd = self.get_ssh_command(command, *args)
1167+ retcode = subprocesses.raw_run(ssh_cmd)
1168+ return retcode
1169+
1170+ def shell_captured(self, command, *args):
1171+ ssh_cmd = self.get_ssh_command(command, *args)
1172+ retcode, out, err = subprocesses.run(ssh_cmd)
1173+ return retcode, out, err
1174+
1175 def _download_in_cache(self, source_url, name, force=False):
1176 """Download ``name`` from ``source_url`` in ``vm.download_cache``.
1177
1178@@ -475,9 +500,6 @@
1179 f.write(ci_user_data.dump())
1180 self.ci_user_data = ci_user_data
1181
1182- def download(self, force=False):
1183- raise NotImplementedError(self.download)
1184-
1185 def iface_path(self, iface):
1186 return os.path.join(self.config_dir_path(),
1187 'interface.{}'.format(iface))
1188@@ -519,8 +541,8 @@
1189 else:
1190 raise
1191
1192- def scan_console_during_install(self, console_size, cmd):
1193- """Scan the console output until the end of the install.
1194+ def scan_console_during_setup(self, console_size, cmd):
1195+ """Scan the console output until the end of the setup.
1196
1197 We add a specific part for cloud-init to ensure we properly detect
1198 the end of the run.
1199@@ -528,7 +550,7 @@
1200 :param console_size: The size of the console file before 'install' is
1201 run.
1202
1203- :param cmd: The install command (used for error display).
1204+ :param cmd: The setup command (used for error display).
1205 """
1206 console = monitors.TailMonitor(self.console_path(), console_size)
1207 iface = 'eth0'
1208@@ -604,17 +626,6 @@
1209 cmd += args
1210 return cmd
1211
1212- # MISSINGTEST
1213- def shell(self, command, *args):
1214- ssh_cmd = self.get_ssh_command(command, *args)
1215- retcode = subprocesses.raw_run(ssh_cmd)
1216- return retcode
1217-
1218- def shell_captured(self, command, *args):
1219- ssh_cmd = self.get_ssh_command(command, *args)
1220- retcode, out, err = subprocesses.run(ssh_cmd)
1221- return retcode, out, err
1222-
1223
1224 def kvm_states(source=None):
1225 """A dict of states for kvms indexed by name.
1226@@ -646,7 +657,7 @@
1227 try:
1228 state = states[self.conf.get('vm.name')]
1229 except KeyError:
1230- state = None
1231+ state = 'UNKNOWN'
1232 return state
1233
1234 def download_iso(self, force=False):
1235@@ -732,17 +743,17 @@
1236 disk_image_path, self.conf.get('vm.disk_size')])
1237 self._disk_image_path = disk_image_path
1238
1239- def scan_console_during_install(self, console_size, cmd):
1240- """See Vm.scan_console_during_install."""
1241+ def scan_console_during_setup(self, console_size, cmd):
1242+ """See Vm.scan_console_during_setup."""
1243 # The console is re-created by virt-install (even if we created it
1244 # before) which requires sudo but creates the file 0600 for
1245 # libvirt-qemu. We give read access to all otherwise 'tail -f' requires
1246 # sudo and can't be killed anymore.
1247 subprocesses.run(['sudo', 'chmod', '0644', self.console_path()])
1248 # While `virt-install` is running, let's connect to the console
1249- super(Kvm, self).scan_console_during_install(console_size, cmd)
1250+ super(Kvm, self).scan_console_during_setup(console_size, cmd)
1251
1252- def install(self):
1253+ def setup(self):
1254 # Create a kvm, relying on cloud-init to customize the base image.
1255 #
1256 # There are two processes involvded here:
1257@@ -788,13 +799,13 @@
1258 ]
1259 console_size = monitors.actual_file_size(self.console_path())
1260 subprocesses.run(virt_install)
1261- self.scan_console_during_install(console_size, virt_install)
1262+ self.scan_console_during_setup(console_size, virt_install)
1263 # We've seen the console signaling halt, but the vm will need a bit
1264 # more time to get there so we help it a bit.
1265 if self.conf.get('vm.release') in ('precise', 'quantal'):
1266 # cloud-init doesn't implement power_state until raring and need a
1267 # gentle nudge.
1268- self.poweroff()
1269+ self.stop()
1270 while True:
1271 state = self.state()
1272 # We expect the vm's state to be 'in shutdown' but in some rare
1273@@ -808,8 +819,8 @@
1274 break
1275 # FIXME: No idea on how to test the following. Manually tested by
1276 # altering the expected state above and running 'selftest.py -v'
1277- # which errors out for test_install_with_seed and
1278- # test_install_backing. Also reproduced when 'running' wasn't
1279+ # which errors out for test_setup_with_seed and
1280+ # test_setup_backing. Also reproduced when 'running' wasn't
1281 # expected before 'in shutdown' -- vila 2013-02-19
1282 # Unexpected state reached, bad.
1283 raise errors.UciVmsError(
1284@@ -834,17 +845,11 @@
1285 # available -- vila 2015-06-18
1286 return proc
1287
1288- # FIXME: Something is wrong in the API, all call sites should use stop()
1289- # not poweroff() and stop() should be defined in the base class -- vila
1290- # 2015-06-18
1291 def stop(self):
1292- self.poweroff()
1293-
1294- def poweroff(self):
1295 return subprocesses.run(
1296 ['sudo', 'virsh', 'destroy', self.conf.get('vm.name')])
1297
1298- def undefine(self):
1299+ def teardown(self):
1300 return subprocesses.run(
1301 ['sudo', 'virsh', 'undefine', self.conf.get('vm.name'),
1302 '--remove-all-storage'])
1303@@ -928,7 +933,7 @@
1304 self._lxc_seed_path]
1305 _, out, err = subprocesses.run(cp_seeds)
1306
1307- def install(self):
1308+ def setup(self):
1309 '''Create an lxc, relying on cloud-init to customize the base image.
1310
1311 There are two processes involvded here:
1312@@ -980,7 +985,7 @@
1313 # print 'cmd: %s' % (' '.join(lxc_start),)
1314 console_size = monitors.actual_file_size(self.console_path())
1315 proc = subprocesses.run(lxc_start)
1316- self.scan_console_during_install(console_size, lxc_start)
1317+ self.scan_console_during_setup(console_size, lxc_start)
1318 return proc
1319
1320 # MISSINGTEST
1321@@ -1054,14 +1059,11 @@
1322 self.wait_for_ip()
1323 self.wait_for_ssh()
1324
1325- def poweroff(self):
1326+ def stop(self):
1327 return subprocesses.run(
1328 ['sudo', 'lxc-stop', '-n', self.conf.get('vm.name')])
1329
1330- def stop(self):
1331- self.poweroff()
1332-
1333- def undefine(self):
1334+ def teardown(self):
1335 try:
1336 return subprocesses.run(
1337 ['sudo', 'lxc-destroy', '-n', self.conf.get('vm.name')])
1338@@ -1082,11 +1084,11 @@
1339 def download(self, force=False):
1340 raise NotImplementedError(self.download)
1341
1342- def install(self):
1343- raise NotImplementedError(self.download)
1344+ def setup(self):
1345+ raise NotImplementedError(self.setup)
1346
1347- def undefine(self):
1348- raise NotImplementedError(self.download)
1349+ def teardown(self):
1350+ raise NotImplementedError(self.teardown)
1351
1352 def start(self):
1353 start_cmd = ['sudo', 'lxc-start-ephemeral',
1354@@ -1098,6 +1100,3 @@
1355 subprocesses.run(start_cmd)
1356 self.wait_for_ip()
1357 self.wait_for_ssh()
1358-
1359- def stop(self):
1360- self.poweroff()
1361
1362=== added file 'ucivms/vms/nova.py'
1363--- ucivms/vms/nova.py 1970-01-01 00:00:00 +0000
1364+++ ucivms/vms/nova.py 2015-09-01 11:19:08 +0000
1365@@ -0,0 +1,475 @@
1366+# This file is part of Ubuntu Continuous Integration virtual machine tools.
1367+#
1368+# Copyright 2015 Canonical Ltd.
1369+#
1370+# This program is free software: you can redistribute it and/or modify it under
1371+# the terms of the GNU General Public License version 3, as published by the
1372+# Free Software Foundation.
1373+#
1374+# This program is distributed in the hope that it will be useful, but WITHOUT
1375+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
1376+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1377+# General Public License for more details.
1378+#
1379+# You should have received a copy of the GNU General Public License along with
1380+# this program. If not, see <http://www.gnu.org/licenses/>.
1381+from __future__ import unicode_literals
1382+
1383+import errno
1384+import logging
1385+import os
1386+import subprocess
1387+import time
1388+
1389+
1390+from novaclient import exceptions
1391+from novaclient import client
1392+
1393+
1394+from ucivms import (
1395+ timeouts,
1396+ vms,
1397+)
1398+
1399+
1400+def uci_image_name(domain, series, architecture):
1401+ """Returns an image name.
1402+
1403+ The images are uploaded to glance for specific needs.
1404+
1405+ :param domain: 'cloudimg' or 'britney'.
1406+
1407+ :param series: The ubuntu series (precise, utopic, etc).
1408+
1409+ :param architecture: The processor architecture ('amd64', i386, etc).
1410+ """
1411+ if domain not in ('cloudimg', 'britney'):
1412+ raise ValueError('Invalid image domain')
1413+ return 'uci/{}/{}-{}.img'.format(domain, series, architecture)
1414+
1415+
1416+def get_os_nova_client(conf, debug=False):
1417+ os_nova_client = client.Client(
1418+ '1.1',
1419+ conf.get('vm.os.username'), conf.get('vm.os.password'),
1420+ conf.get('vm.os.tenant_name'),
1421+ conf.get('vm.os.auth_url'),
1422+ region_name=conf.get('vm.os.region_name'),
1423+ service_type='compute')
1424+ return os_nova_client
1425+
1426+
1427+# FIXME: This should inherit from ucivms.errors.UciVmsError (respecting the
1428+# API) so it can be caught by upper layers -- vila 2015-08-26
1429+class NovaServerException(Exception):
1430+ pass
1431+
1432+
1433+class NovaClient(object):
1434+ """A nova client re-trying requests on known transient failures."""
1435+
1436+ def __init__(self, conf, **kwargs):
1437+ self.logger = kwargs.pop('logger')
1438+ self.first_wait = kwargs.pop('first_wait', 30)
1439+ self.wait_up_to = kwargs.pop('wait_up_to', 600)
1440+ self.retries = kwargs.pop('retries', 8)
1441+ debug = kwargs.pop('debug', False)
1442+ # Activating debug will output the http requests issued by nova and the
1443+ # corresponding responses (/!\ including credentials).
1444+ if debug:
1445+ self.logger.setLevel(logging.DEBUG)
1446+ self.nova = get_os_nova_client(conf, debug)
1447+
1448+ def retry(self, func, *args, **kwargs):
1449+ no_404_retry = kwargs.pop('no_404_retry', False)
1450+ sleeps = timeouts.ExponentialBackoff(
1451+ self.first_wait, self.wait_up_to, self.retries)
1452+ for attempt, sleep in enumerate(sleeps, start=1):
1453+ try:
1454+ if attempt > 1:
1455+ self.logger.info('Re-trying {} {}/{}'.format(
1456+ func.__name__, attempt, self.retries))
1457+ return func(*args, **kwargs)
1458+ except client.requests.ConnectionError:
1459+ # Most common transient failure: the API server is unreachable
1460+ msg = 'Connection error for {}, will sleep for {} seconds'
1461+ self.logger.warn(msg.format(func.__name__, sleep))
1462+ except (exceptions.OverLimit, exceptions.RateLimit):
1463+ msg = ('Rate limit reached for {},'
1464+ ' will sleep for {} seconds')
1465+ # This happens rarely but breaks badly if not caught. elmo
1466+ # recommended a 30 seconds nap in that case.
1467+ sleep += 30
1468+ self.logger.exception(msg.format(func.__name__, sleep))
1469+ except exceptions.ClientException as e:
1470+ if no_404_retry and e.http_status == 404:
1471+ raise
1472+ msg = '{} failed will sleep for {} seconds'
1473+ self.logger.exception(msg.format(func.__name__, sleep))
1474+ except:
1475+ # All other exceptions are raised
1476+ self.logger.exception('{} failed'.format(func.__name__))
1477+ raise NovaServerException('{} failed'.format(func.__name__))
1478+ # Take a nap before retrying
1479+ self.logger.info('Sleeping {} seconds for {} {}/{}'.format(
1480+ sleep, func.__name__, attempt, self.retries))
1481+ time.sleep(sleep)
1482+ # Raise if we didn't succeed at all
1483+ raise NovaServerException("Failed to '{}' after {} retries".format(
1484+ func.__name__, attempt))
1485+
1486+ def flavors_list(self):
1487+ return self.retry(self.nova.flavors.list)
1488+
1489+ def images_list(self):
1490+ return self.retry(self.nova.images.list)
1491+
1492+ def create_server(self, name, flavor, image, user_data, nics,
1493+ availability_zone):
1494+ return self.retry(self.nova.servers.create, name=name,
1495+ flavor=flavor, image=image, userdata=user_data,
1496+ nics=nics, availability_zone=availability_zone)
1497+
1498+ def delete_server(self, server_id):
1499+ # FIXME: 404 shouldn't be retried, if it's not there anymore, there is
1500+ # nothing to delete. -- vila 2015-07-16
1501+ return self.retry(self.nova.servers.delete, server_id)
1502+
1503+ def start_server(self, instance):
1504+ return self.retry(instance.start)
1505+
1506+ def stop_server(self, instance):
1507+ return self.retry(instance.stop)
1508+
1509+ def create_floating_ip(self):
1510+ return self.retry(self.nova.floating_ips.create)
1511+
1512+ def delete_floating_ip(self, floating_ip):
1513+ return self.retry(self.nova.floating_ips.delete, floating_ip)
1514+
1515+ def add_floating_ip(self, instance, floating_ip):
1516+ return self.retry(instance.add_floating_ip, floating_ip)
1517+
1518+ def get_server_details(self, server_id):
1519+ return self.retry(self.nova.servers.get, server_id,
1520+ no_404_retry=True)
1521+
1522+ def get_server_console(self, server, length=None):
1523+ return self.retry(server.get_console_output, length)
1524+
1525+
1526+class NovaServer(vms.VM):
1527+
1528+ nova_client_class = NovaClient
1529+
1530+ def __init__(self, conf, logger=None):
1531+ super(NovaServer, self).__init__(conf)
1532+ if logger is None:
1533+ # FIXME: We probably want to generalize logging -- vila 2015-08-25
1534+ self.ensure_dir(self.config_dir_path())
1535+ logging.basicConfig(
1536+ level=logging.INFO,
1537+ format="%(asctime)s %(levelname)s %(message)s",
1538+ filename=os.path.join(self.config_dir_path(), 'uci-vms.log'))
1539+ logger = logging.getLogger()
1540+ self.logger = logger
1541+ self.instance = None
1542+ self.floating_ip = None
1543+ self.nova = self.build_nova_client()
1544+ self.test_bed_key_path = None
1545+ self.conf.set('vm.ssh_authorized_keys',
1546+ self.conf.get('vm.ssh_key_path') + '.pub')
1547+ # No need to reboot a nova instance
1548+ self.conf.set('vm.poweroff', 'False')
1549+ self.conf.set('vm.final_message', 'testbed setup completed.')
1550+
1551+ # MISSINGTEST
1552+ def state(self):
1553+ try:
1554+ with open(self.nova_id_path()) as f:
1555+ nova_id = f.read().strip()
1556+ except IOError as e:
1557+ # python2 does not provide FileNotFoundError
1558+ if e.errno == errno.ENOENT:
1559+ # Unknown interface
1560+ return 'UNKNOWN'
1561+ try:
1562+ self.instance = self.nova.get_server_details(nova_id)
1563+ except exceptions.NotFound:
1564+ return 'UNKNOWN'
1565+ # The instance may remain in the DELETED state for some time.
1566+ nova_states = dict(BUILD='STARTING',
1567+ ACTIVE='RUNNING',
1568+ SHUTOFF='STOPPED',
1569+ DELETED='UNKNOWN')
1570+ return nova_states[self.instance.status]
1571+
1572+ def build_nova_client(self):
1573+ nova_client = self.nova_client_class(self.conf, logger=self.logger)
1574+ return nova_client
1575+
1576+ def ensure_ssh_key_is_available(self):
1577+ self.test_bed_key_path = self.conf.get('vm.ssh_key_path')
1578+ # FIXME: Needs to be unified for all vm classes -- vila 2015-08-26
1579+ # From the test runner, we need an ssh key that can be used to connect
1580+ # to the testbed. For testing purposes, we rely on self.auth_conf to
1581+ # provide this key.
1582+ if not os.path.exists(self.test_bed_key_path):
1583+ base_dir = os.path.dirname(self.test_bed_key_path)
1584+ try:
1585+ # Try to create needed dirs
1586+ os.makedirs(base_dir)
1587+ except OSError as e:
1588+ # They are already there, no worries
1589+ if e.errno != errno.EEXIST:
1590+ raise
1591+ # First time the test runner instance needs to create a test bed
1592+ # instance, we need to create the ssh key pair.
1593+ subprocess.call(
1594+ ['ssh-keygen', '-t', 'rsa', '-q',
1595+ '-f', self.test_bed_key_path, '-N', ''])
1596+
1597+ def find_flavor(self):
1598+ flavors = self.conf.get('vm.os.flavors')
1599+ existing_flavors = self.nova.flavors_list()
1600+ for flavor in flavors:
1601+ for existing in existing_flavors:
1602+ if flavor == existing.name:
1603+ return existing
1604+ raise NovaServerException(
1605+ 'None of [{}] can be found'.format(','.join(flavors)))
1606+
1607+ def find_nova_image(self):
1608+ image_name = self.conf.get('vm.image')
1609+ existing_images = self.nova.images_list()
1610+ for existing in existing_images:
1611+ if image_name == existing.name:
1612+ return existing
1613+ raise NovaServerException(
1614+ 'Image "{}" cannot be found'.format(image_name))
1615+
1616+ def find_nics(self):
1617+ net_id = self.conf.get('vm.net_id')
1618+ if net_id:
1619+ return [{'net-id': self.conf.get('vm.net_id')}]
1620+ return None
1621+
1622+ # FIXME: This should save the console whether or not the setup fails
1623+ # -- vila 2015-08-26
1624+ def setup(self):
1625+ flavor = self.find_flavor()
1626+ image = self.find_nova_image()
1627+ nics = self.find_nics()
1628+ self.ensure_ssh_key_is_available()
1629+ self.create_user_data()
1630+ with open(self._user_data_path) as f:
1631+ user_data = f.read()
1632+ self.instance = self.nova.create_server(
1633+ name=self.conf.get('vm.name'), flavor=flavor, image=image,
1634+ user_data=user_data, nics=nics,
1635+ # FIXME: We probably want at least a vm.az_name option. And get
1636+ # that option from higher levels too -- vila 2014-10-13
1637+ availability_zone=None)
1638+ self.create_nova_id_file(self.instance.id)
1639+ self.wait_for_active_instance()
1640+# FIXME: We want a vm.create_floating_ip option ? -- vila 2015-08-24
1641+# if unit_config.is_hpcloud(self.conf.get('os.auth_url')):
1642+# self.floating_ip = self.nova.create_floating_ip()
1643+# self.nova.add_floating_ip(self.instance, self.floating_ip)
1644+ self.wait_for_ip()
1645+ self.wait_for_cloud_init()
1646+ self.ensure_ssh_works()
1647+ ppas = self.conf.get('vm.ppas')
1648+ if ppas:
1649+ cmd = ['sudo', 'add-apt-repository']
1650+ if self.conf.get('vm.release') > 'precise':
1651+ cmd.append('--enable-source')
1652+ for ppa in ppas:
1653+ self.ssh(*(cmd + [ppa]))
1654+ # Now we can apt-get update (doing it earlier would lead to the wrong
1655+ # source package to be installed).
1656+ self.safe_apt_get_update()
1657+
1658+ def apt_get_update(self):
1659+ return self.ssh('sudo', 'apt-get', 'update')
1660+
1661+ def safe_apt_get_update(self):
1662+ for timeout in self.conf.get('vm.apt_get.update.timeouts'):
1663+ proc, out, err = self.apt_get_update()
1664+ if proc.returncode == 0:
1665+ # We're done
1666+ return
1667+ else:
1668+ msg = ('apt-get update failed, wait {}s\n'
1669+ 'stdout:\n{}\n'
1670+ 'stderr:\n{}\n')
1671+ self.logger.info(msg.format(timeout, out, err))
1672+ time.sleep(float(timeout))
1673+ raise NovaServerException('apt-get update never succeeded')
1674+
1675+ def update_instance(self, nova_id=None):
1676+ if nova_id is None:
1677+ nova_id = self.instance.id
1678+ try:
1679+ # Always query nova to get updated data about the instance
1680+ self.instance = self.nova.get_server_details(nova_id)
1681+ return True
1682+ except:
1683+ # But catch exceptions if something goes wrong. Higher levels will
1684+ # deal with the instance not replying.
1685+ return False
1686+
1687+ def wait_for_active_instance(self):
1688+ timeout_limit = time.time() + self.conf.get('vm.nova.boot_timeout')
1689+ while (time.time() < timeout_limit
1690+ and self.instance.status not in ('ACTIVE', 'ERROR')):
1691+ time.sleep(5)
1692+ self.update_instance()
1693+ if self.instance.status != 'ACTIVE':
1694+ msg = 'Instance {} never came up (last status: {})'.format(
1695+ self.instance.id, self.instance.status)
1696+ raise NovaServerException(msg)
1697+
1698+ def nova_id_path(self):
1699+ return os.path.join(self.config_dir_path(), 'nova_id')
1700+
1701+ def create_nova_id_file(self, nova_id):
1702+ nova_id_path = self.nova_id_path()
1703+ self.ensure_dir(self.config_dir_path())
1704+ with open(nova_id_path, 'w') as f:
1705+ f.write(nova_id + '\n')
1706+
1707+ def wait_for_ip(self):
1708+ timeout_limit = time.time() + self.conf.get('vm.nova.set_ip_timeout')
1709+ while time.time() < timeout_limit:
1710+ if not self.update_instance():
1711+ time.sleep(5)
1712+ continue
1713+ networks = self.instance.networks.values()
1714+ if networks:
1715+ # The network name is arbitrary, can vary for different clouds
1716+ # but there should be only one network so we get the first one
1717+ # and avoid the need for a config option for the network name.
1718+ # We take the last IP address so it's either the only one or
1719+ # the floating one. In both cases that gives us a reachable IP.
1720+ self.ip = networks[0][-1]
1721+ self.logger.info('Got IP {} for {}'.format(
1722+ self.ip, self.instance.id))
1723+ # FIXME: Why not get it from the console ? -- vila 2015-08-26
1724+ # MISSINGTEST
1725+ self.create_iface_file('eth0', self.ip, 'unknown', 'unknown')
1726+ return
1727+ else:
1728+ self.logger.info(
1729+ 'IP not yet available for {}'.format(self.instance.id))
1730+ time.sleep(5)
1731+ msg = 'Instance {} never provided an IP'.format(self.instance.id)
1732+ raise NovaServerException(msg)
1733+
1734+ def get_cloud_init_console(self, length=None):
1735+ return self.nova.get_server_console(self.instance, length)
1736+
1737+ def wait_for_cloud_init(self):
1738+ timeout_limit = (time.time()
1739+ + self.conf.get('vm.nova.cloud_init_timeout'))
1740+ final_message = self.conf.get('vm.final_message')
1741+ while time.time() < timeout_limit:
1742+ # A relatively cheap way to catch cloud-init completion is to watch
1743+ # the console for the specific message we specified in user-data).
1744+ # FIXME: or at least check that we don't miss when we sleep a
1745+ # significant time between two calls (like on canonistack where we
1746+ # can sleep for minute(s) -- vila 2015-07-17
1747+ console = self.get_cloud_init_console(10)
1748+ if final_message in console:
1749+ # We're good to go
1750+ self.logger.info(
1751+ 'cloud-init completed for {}'.format(self.instance.id))
1752+ return
1753+ time.sleep(5)
1754+ raise NovaServerException('Instance never completed cloud-init')
1755+
1756+ def ensure_ssh_works(self):
1757+ proc, out, err = self.ssh('whoami')
1758+ if proc.returncode:
1759+ msg = ('testbed {} IP {} cannot be reached with ssh, retcode: {}\n'
1760+ 'stdout:\n{}\n'
1761+ 'stderr:\n{}\n')
1762+ self.logger.info(msg.format(self.instance.id, self.ip,
1763+ proc.returncode, out, err))
1764+ raise NovaServerException('No ssh access to {}, IP: {}'.format(
1765+ self.instance.id, self.ip))
1766+
1767+ # MISSINGTEST
1768+ def start(self):
1769+ self.nova.start_server(self.instance)
1770+
1771+ # MISSINGTEST
1772+ def stop(self):
1773+ self.nova.stop_server(self.instance)
1774+
1775+ def teardown(self):
1776+ if self.instance is not None:
1777+ self.logger.info('Deleting instance {}'.format(self.instance.id))
1778+ self.nova.delete_server(self.instance.id)
1779+ self.instance = None
1780+ os.remove(self.nova_id_path())
1781+ if self.floating_ip is not None:
1782+ self.nova.delete_floating_ip(self.floating_ip)
1783+ self.floating_ip = None
1784+ # FIXME: Now we can remove the testbed key from known_hosts (see
1785+ # ssh()). -- vila 2014-01-30
1786+
1787+ # FIXME: Should be unified with 'shell' and 'shell_captured'
1788+ # -- vila 2015-08-26
1789+ def ssh(self, command, *args, **kwargs):
1790+ """Run a command in the testbed via ssh.
1791+
1792+ :param args: The command and its positional arguments.
1793+
1794+ :param kwargs: The named arguments for the command.
1795+
1796+ The stdout and stderr outputs are captured and returned to the caller.
1797+ """
1798+ user = 'ubuntu'
1799+ host = self.ip
1800+ cmd = ['ssh',
1801+ # FIXME: It would be better to ssh-keygen -f
1802+ # "~/.ssh/known_hosts" -R self.ip once we're done with the test
1803+ # bed to avoid polluting ssh commands stdout, but that will do
1804+ # for now (that's what juju is doing after all ;)
1805+ # -- vila 2014-01-29
1806+ '-oStrictHostKeyChecking=no',
1807+ '-i', self.test_bed_key_path,
1808+ '{}@{}'.format(user, host)]
1809+ if command is not None:
1810+ cmd += [command]
1811+ if args:
1812+ cmd += args
1813+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
1814+ stderr=subprocess.PIPE)
1815+ out, err = proc.communicate()
1816+ return proc, out, err
1817+
1818+ # FIXME: Should be provided by the base class -- vila 2015-08-26
1819+ def scp(self, local_path, remote_path):
1820+ cmd = ['scp',
1821+ # FIXME: It would be better to ssh-keygen -f
1822+ # "~/.ssh/known_hosts" -R self.ip once we're done with the test
1823+ # bed to avoid polluting ssh commands stdout, but that will do
1824+ # for now (that's what juju is doing after all ;)
1825+ # -- vila 2014-01-29
1826+ '-oStrictHostKeyChecking=no',
1827+ '-i', self.test_bed_key_path,
1828+ local_path, remote_path]
1829+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
1830+ stderr=subprocess.PIPE)
1831+ out, err = proc.communicate()
1832+ return proc, out, err
1833+
1834+ def get_remote_content(self, path):
1835+ proc, content, err = self.ssh('cat', path)
1836+ if proc.returncode:
1837+ # We didn't get a proper content, report it instead
1838+ content = ("{} couldn't be copied from testbed {}:\n"
1839+ "error: {}\n".format(path, self.ip, err))
1840+ return content

Subscribers

People subscribed via source and target branches