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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Leo Arias | Pending | ||
Review via email:
|
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 |