Merge lp:~javier.collado/utah/trunk-0.8 into lp:~nuclearbob/utah/trunk
- trunk-0.8
- Merge into trunk
Proposed by
Javier Collado
Status: | Merged |
---|---|
Merged at revision: | 267 |
Proposed branch: | lp:~javier.collado/utah/trunk-0.8 |
Merge into: | lp:~nuclearbob/utah/trunk |
Diff against target: |
826 lines (+401/-52) 14 files modified
debian/changelog (+17/-0) examples/run_test_vm.py (+5/-1) examples/run_utah_tests.py (+27/-1) utah/client/battery.py (+83/-0) utah/client/common.py (+15/-1) utah/client/result.py (+9/-0) utah/client/runner.py (+25/-2) utah/provisioning/baremetal/cobbler.py (+12/-7) utah/provisioning/provisioning.py (+32/-10) utah/provisioning/ssh.py (+73/-12) utah/retry.py (+20/-3) utah/run.py (+24/-1) utah/timeout.py (+38/-13) utah/url.py (+21/-1) |
To merge this branch: | bzr merge lp:~javier.collado/utah/trunk-0.8 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Javier Collado (community) | Approve | ||
Max Brustkern | Pending | ||
Review via email: mp+148774@code.launchpad.net |
Commit message
Description of the change
This branch contains all the changes for version 0.8.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'debian/changelog' |
2 | --- debian/changelog 2013-01-29 19:18:51 +0000 |
3 | +++ debian/changelog 2013-02-15 17:42:27 +0000 |
4 | @@ -1,3 +1,20 @@ |
5 | +utah (0.8ubuntu1) quantal; urgency=low |
6 | + |
7 | + [Max Brustkern] |
8 | + * Raise exception on mount ISO error (LP: #1110550, LP: #1123899) |
9 | + |
10 | + [Javier Collado] |
11 | + * SIGTERM signal captured to ensure cleanup happens (LP: #1100550, LP: #1123899) |
12 | + * Warning messages contain command on failure return code (LP: #1123896) |
13 | + * Battery information added to test report if available |
14 | + * /etc/rc.local truncation problems fixed (LP: #1118581) |
15 | + * Ability to execute runlists in an already provisioned system |
16 | + |
17 | + [Joe Talbott] |
18 | + * Fixed client behavior on `bzr revno` failure (LP: #1110310, LP: #1123303) |
19 | + |
20 | + -- Javier Collado <javier.collado@canonical.com> Fri, 15 Feb 2013 18:32:06 +0100 |
21 | + |
22 | utah (0.7ubuntu1) precise; urgency=low |
23 | |
24 | * Updating to version 0.7 |
25 | |
26 | === modified file 'examples/run_test_vm.py' |
27 | --- examples/run_test_vm.py 2013-01-23 12:26:38 +0000 |
28 | +++ examples/run_test_vm.py 2013-02-15 17:42:27 +0000 |
29 | @@ -21,7 +21,11 @@ |
30 | from utah.exceptions import UTAHException |
31 | from utah.group import check_user_group, print_group_error_message |
32 | from utah.provisioning.vm.vm import TinySQLiteInventory |
33 | -from utah.run import common_arguments, run_tests, configure_logging |
34 | +from utah.run import ( |
35 | + common_arguments, |
36 | + run_tests, |
37 | + configure_logging, |
38 | +) |
39 | |
40 | |
41 | def get_parser(): |
42 | |
43 | === modified file 'examples/run_utah_tests.py' |
44 | --- examples/run_utah_tests.py 2013-01-23 12:26:38 +0000 |
45 | +++ examples/run_utah_tests.py 2013-02-15 17:42:27 +0000 |
46 | @@ -26,10 +26,13 @@ |
47 | file_arguments, |
48 | name_argument, |
49 | virtual_arguments, |
50 | - configure_logging |
51 | + configure_logging, |
52 | + run_tests, |
53 | ) |
54 | from utah.timeout import timeout, UTAHTimeout |
55 | from run_install_test import run_install_test |
56 | +from utah.provisioning.ssh import ProvisionedMachine |
57 | +from utah.exceptions import UTAHException |
58 | |
59 | |
60 | def get_parser(): |
61 | @@ -50,6 +53,9 @@ |
62 | help='Type of machine to provision (%(choices)s)') |
63 | parser.add_argument('-v', '--variant', |
64 | help='Variant of architecture, i.e., armel, armhf') |
65 | + parser.add_argument('--skip-provisioning', action='store_true', |
66 | + help=('Reuse a system that is already provisioned ' |
67 | + '(name argument must be passed)')) |
68 | parser = common_arguments(parser) |
69 | parser = custom_arguments(parser) |
70 | parser = file_arguments(parser) |
71 | @@ -76,6 +82,26 @@ |
72 | # Default is now CustomVM |
73 | function = run_install_test |
74 | |
75 | + if args.skip_provisioning: |
76 | + def run_provisioned_tests(args): |
77 | + """Run test cases in a provisioned machine.""" |
78 | + locallogs = [] |
79 | + exitstatus = 0 |
80 | + try: |
81 | + # TBD: Inventory should be used to verify machine |
82 | + # is not running other tests |
83 | + machine = ProvisionedMachine(name=args.name) |
84 | + exitstatus, locallogs = run_tests(args, machine) |
85 | + except UTAHException as error: |
86 | + sys.stderr.write('Exception: ' + str(error)) |
87 | + exitstatus = 2 |
88 | + finally: |
89 | + if len(locallogs) != 0: |
90 | + print('Test logs copied to the following files:') |
91 | + print("\t" + "\n\t".join(locallogs)) |
92 | + sys.exit(exitstatus) |
93 | + |
94 | + function = run_provisioned_tests |
95 | if args.arch is not None and 'arm' in args.arch: |
96 | # If arch is arm, use BambooFeederMachine |
97 | from run_test_bamboo_feeder import run_test_bamboo_feeder |
98 | |
99 | === added file 'utah/client/battery.py' |
100 | --- utah/client/battery.py 1970-01-01 00:00:00 +0000 |
101 | +++ utah/client/battery.py 2013-02-15 17:42:27 +0000 |
102 | @@ -0,0 +1,83 @@ |
103 | +"""Helpers for battery measurements.""" |
104 | +import os |
105 | +import re |
106 | +import logging |
107 | + |
108 | + |
109 | +class _Battery(object): |
110 | + """Battery information gathering.""" |
111 | + POWER_SUPPLY_DIR = '/sys/class/power_supply' |
112 | + BATTERY_DIR_REGEX = re.compile(r'bat.*', re.IGNORECASE) |
113 | + BATTERY_FILE_REGEX = re.compile(r'(.*_now|capacity|status)') |
114 | + |
115 | + def __init__(self): |
116 | + self.filenames = self._get_files() |
117 | + |
118 | + def get_data(self): |
119 | + """Get data from the battery information files |
120 | + |
121 | + Every file is read and the contents of the file is written to a |
122 | + dictionary using as key the basename of the file. |
123 | + |
124 | + :returns: Data as read from the battery information files |
125 | + :rtype: dict |
126 | + """ |
127 | + def read_file(filename): |
128 | + """Read contents of a file and try to convert data to integer |
129 | + |
130 | + :param filename: Path to file to be read |
131 | + :type filename: str |
132 | + :returns: File contents converted to integer if possible |
133 | + :rtype: str | int |
134 | + """ |
135 | + with open(filename, 'r') as f: |
136 | + data_str = f.read().strip() |
137 | + try: |
138 | + data_int = int(data_str) |
139 | + return data_int |
140 | + except ValueError: |
141 | + return data_str |
142 | + |
143 | + return {os.path.basename(filename): read_file(filename) |
144 | + for filename in self.filenames} |
145 | + |
146 | + def _get_files(self): |
147 | + """Figure out which files to look at. |
148 | + |
149 | + :returns: Paths to files with battery information. |
150 | + :rtype: list(str) |
151 | + |
152 | + """ |
153 | + if not os.path.isdir(self.POWER_SUPPLY_DIR): |
154 | + logging.warning('Power supply directory not found: {!r}' |
155 | + .format(self.POWER_SUPPLY_DIR)) |
156 | + return [] |
157 | + |
158 | + entries = [os.path.join(self.POWER_SUPPLY_DIR, entry) |
159 | + for entry in os.listdir(self.POWER_SUPPLY_DIR) |
160 | + if self.BATTERY_DIR_REGEX.match(entry)] |
161 | + subdirectories = [entry for entry in entries |
162 | + if os.path.isdir(entry)] |
163 | + |
164 | + if not subdirectories: |
165 | + logging.warning('Battery information directory not found') |
166 | + return [] |
167 | + |
168 | + if len(subdirectories) > 1: |
169 | + logging.error('Multiple battery information directories found: ' |
170 | + '{}'.format(', '.join(subdirectories))) |
171 | + return [] |
172 | + |
173 | + subdirectory = subdirectories[0] |
174 | + entries = [os.path.join(subdirectory, filename) |
175 | + for filename in os.listdir(subdirectory) |
176 | + if self.BATTERY_FILE_REGEX.match(filename)] |
177 | + filenames = [filename for filename in entries |
178 | + if os.path.isfile(filename)] |
179 | + |
180 | + logging.debug('Battery information files found: {}' |
181 | + .format(', '.join(filenames))) |
182 | + return filenames |
183 | + |
184 | +# Use singleton object to avoid multiple unneeded initializations |
185 | +battery = _Battery() |
186 | |
187 | === modified file 'utah/client/common.py' |
188 | --- utah/client/common.py 2012-12-10 14:07:05 +0000 |
189 | +++ utah/client/common.py 2013-02-15 17:42:27 +0000 |
190 | @@ -35,6 +35,8 @@ |
191 | YAMLEmptyFile, |
192 | ) |
193 | |
194 | +from utah.client.battery import battery |
195 | + |
196 | |
197 | PASS = 0 |
198 | FAIL = 1 |
199 | @@ -101,6 +103,7 @@ |
200 | def alarm_handler(_signum, _frame): |
201 | raise TimeoutAlarm |
202 | |
203 | + start_battery = battery.get_data() |
204 | start_time = datetime.datetime.now() |
205 | p = subprocess.Popen("{} {}".format(cmd_prefix, command), |
206 | shell=True, cwd=cwd, |
207 | @@ -141,6 +144,7 @@ |
208 | ) |
209 | |
210 | time_delta = datetime.datetime.now() - start_time |
211 | + end_battery = battery.get_data() |
212 | |
213 | return make_result(command=command, |
214 | retcode=p.returncode, |
215 | @@ -150,6 +154,8 @@ |
216 | time_delta=str(time_delta), |
217 | cmd_type=cmd_type, |
218 | user=run_as, |
219 | + start_battery=start_battery, |
220 | + end_battery=end_battery, |
221 | ) |
222 | |
223 | |
224 | @@ -172,7 +178,8 @@ |
225 | # TODO: it might make sense to have a result object that keeps track of |
226 | # test pass, fail, error and serializes it's data. |
227 | def make_result(command, retcode, stdout='', stderr='', start_time='', |
228 | - time_delta='', cmd_type=CMD_TC_TEST, user="unknown"): |
229 | + time_delta='', cmd_type=CMD_TC_TEST, user="unknown", |
230 | + start_battery=None, end_battery=None): |
231 | """ |
232 | Make a result dictionary. |
233 | """ |
234 | @@ -187,6 +194,13 @@ |
235 | 'user': user, |
236 | } |
237 | |
238 | + if start_battery or end_battery: |
239 | + res['battery'] = {} |
240 | + if start_battery: |
241 | + res['battery']['start'] = start_battery |
242 | + if end_battery: |
243 | + res['battery']['end'] = end_battery |
244 | + |
245 | return res |
246 | |
247 | |
248 | |
249 | === modified file 'utah/client/result.py' |
250 | --- utah/client/result.py 2012-12-08 02:10:12 +0000 |
251 | +++ utah/client/result.py 2013-02-15 17:42:27 +0000 |
252 | @@ -71,6 +71,8 @@ |
253 | self.publish_type = publish_type |
254 | self.publish = None |
255 | self.install_type = install_type |
256 | + self.start_battery = None |
257 | + self.end_battery = None |
258 | |
259 | self.errors = 0 |
260 | self.fetch_errors = 0 |
261 | @@ -207,6 +209,13 @@ |
262 | if self.publish is not None: |
263 | data['publish'] = self.publish |
264 | |
265 | + if self.start_battery or self.end_battery: |
266 | + data['battery'] = {} |
267 | + if self.start_battery: |
268 | + data['battery']['start'] = self.start_battery |
269 | + if self.end_battery: |
270 | + data['battery']['end'] = self.end_battery |
271 | + |
272 | return data |
273 | |
274 | |
275 | |
276 | === modified file 'utah/client/runner.py' |
277 | --- utah/client/runner.py 2012-12-12 11:21:10 +0000 |
278 | +++ utah/client/runner.py 2013-02-15 17:42:27 +0000 |
279 | @@ -18,12 +18,17 @@ |
280 | from utah.client.testsuite import TestSuite |
281 | from utah.client.state_agent import StateAgentYAML |
282 | from utah.client import exceptions |
283 | +from utah.exceptions import UTAHException |
284 | +from utah.timeout import timeout |
285 | +from utah.retry import retry |
286 | +from utah.client.battery import battery |
287 | |
288 | import os |
289 | import shutil |
290 | import stat |
291 | import urllib |
292 | import jsonschema |
293 | +import time |
294 | |
295 | from utah.client.common import ( |
296 | MASTER_RUNLIST, |
297 | @@ -251,6 +256,7 @@ |
298 | """ |
299 | Run the test suites we've parsed. |
300 | """ |
301 | + self.result.start_battery = battery.get_data() |
302 | |
303 | # Return value to indicate whether processing of a Runner should |
304 | # continue. This is to avoid a shutdown race on reboot cases. |
305 | @@ -273,6 +279,7 @@ |
306 | self.status = "DONE" |
307 | self.save_state() |
308 | |
309 | + self.result.end_battery = battery.get_data() |
310 | return self.process_results() |
311 | |
312 | def add_suite(self, suite): |
313 | @@ -443,9 +450,20 @@ |
314 | if not os.path.exists(name): |
315 | os.mkdir(name) |
316 | |
317 | + def vcs_get_retriable(): |
318 | + result = vcs_handler.get(directory=name) |
319 | + if (isinstance(vcs_handler, BzrHandler) and |
320 | + result['returncode'] == 3 and |
321 | + 'Unable to handle http code 502' in result['stderr']): |
322 | + # Separate every retry attempt with a timeout of 3 seconds |
323 | + time.sleep(3) |
324 | + raise UTAHException('Launchpad temporary error detected', |
325 | + retry=True) |
326 | + return result |
327 | + |
328 | if name not in self.fetched_suites: |
329 | - |
330 | - res = vcs_handler.get(directory=name) |
331 | + # Retry bzr export on http errors for 60 seconds |
332 | + res = timeout(60, retry, vcs_get_retriable) |
333 | self.result.add_result(res) |
334 | |
335 | # If fetch failed move on to the next testsuite. |
336 | @@ -457,6 +475,11 @@ |
337 | res = vcs_handler.revision(directory=rev_dir) |
338 | self.result.add_result(res) |
339 | |
340 | + # If we're unable to get the revision number indicate a |
341 | + # fetch failure but allow the test runs to proceed. |
342 | + if self.result.status != 'PASS': |
343 | + self.result.status = 'PASS' |
344 | + |
345 | self.fetched_suites.append(name) |
346 | |
347 | # Create a TestSuite |
348 | |
349 | === modified file 'utah/provisioning/baremetal/cobbler.py' |
350 | --- utah/provisioning/baremetal/cobbler.py 2013-01-29 16:43:59 +0000 |
351 | +++ utah/provisioning/baremetal/cobbler.py 2013-02-15 17:42:27 +0000 |
352 | @@ -20,7 +20,6 @@ |
353 | import netifaces |
354 | import os |
355 | import pipes |
356 | -import shutil |
357 | import subprocess |
358 | import tempfile |
359 | import time |
360 | @@ -107,8 +106,14 @@ |
361 | self.cleanfunction(self._removenfs) |
362 | os.makedirs(self.isodir, mode=0775) |
363 | self.logger.debug('Mounting ISO') |
364 | - self._runargs(['sudo', 'mount', '-o', 'loop', self.image.image, |
365 | - self.isodir]) |
366 | + result = self._runargs(['sudo', 'mount', '-o', 'loop', |
367 | + self.image.image, |
368 | + self.isodir]) |
369 | + returncode = result['returncode'] |
370 | + if returncode != 0: |
371 | + raise UTAHBMProvisioningException( |
372 | + 'Mount failed with return code: {} Output: {}' |
373 | + .format(returncode, result['output'])) |
374 | |
375 | kernel = self._preparekernel() |
376 | if self.cinitrd is None: |
377 | @@ -282,14 +287,14 @@ |
378 | |
379 | def _depcheck(self): |
380 | """ |
381 | - Check for NFS if installtype is desktop. |
382 | + Check for NFS if installtype requires it. |
383 | """ |
384 | super(CobblerMachine, self)._depcheck() |
385 | - if self.installtype == 'desktop': |
386 | + if self.installtype in ['alternate', 'desktop', 'server']: |
387 | cmd = config.nfscommand + ['status'] |
388 | if self._runargs(cmd)['returncode'] != 0: |
389 | - raise UTAHBMProvisioningException('NFS needed for desktop' |
390 | - ' install') |
391 | + raise UTAHBMProvisioningException( |
392 | + 'NFS needed for {} install'.format(self.installtype)) |
393 | if not os.path.isfile(config.nfsconfigfile): |
394 | raise UTAHBMProvisioningException( |
395 | 'NFS config file: {nfsconfigfile} not available' |
396 | |
397 | === modified file 'utah/provisioning/provisioning.py' |
398 | --- utah/provisioning/provisioning.py 2013-01-24 16:31:58 +0000 |
399 | +++ utah/provisioning/provisioning.py 2013-02-15 17:42:27 +0000 |
400 | @@ -302,15 +302,29 @@ |
401 | self._start() |
402 | |
403 | def pingcheck(self, timeout=config.checktimeout): |
404 | - """Check network connectivity using ping.""" |
405 | - self.logger.info('Sleeping {timeout} seconds' |
406 | - .format(timeout=timeout)) |
407 | - time.sleep(timeout) |
408 | + """Check network connectivity using ping. |
409 | + |
410 | + :param timeout: Amount of time in seconds to sleep after a failure |
411 | + :type timeout: int |
412 | + :raises: UTAHProvisioningException |
413 | + |
414 | + If there's a network connectivity failure, then sleep ``timeout`` |
415 | + seconds and raise a retriable exception. |
416 | + |
417 | + .. seealso:: :func:`utah.retry.retry`, :meth:`pingpoll` |
418 | + |
419 | + """ |
420 | self.logger.info('Checking network connectivity (ping)') |
421 | returncode = \ |
422 | self._runargs(['ping', '-c1', '-w5', self.name])['returncode'] |
423 | if returncode != 0: |
424 | err = 'Ping returned {0}'.format(returncode) |
425 | + |
426 | + if timeout > 0: |
427 | + self.logger.info('Sleeping {timeout} seconds' |
428 | + .format(timeout=timeout)) |
429 | + time.sleep(timeout) |
430 | + |
431 | raise UTAHProvisioningException(err, retry=True) |
432 | |
433 | def pingpoll(self, |
434 | @@ -551,9 +565,11 @@ |
435 | output = '\n'.join(output) |
436 | |
437 | returncode = p.returncode |
438 | - log_level = logging.DEBUG if returncode == 0 else logging.WARNING |
439 | - log_message = 'Return code: {}'.format(returncode) |
440 | - self.logger.log(log_level, log_message) |
441 | + if returncode == 0: |
442 | + self.logger.debug('Return code: {}'.format(returncode)) |
443 | + else: |
444 | + self.logger.warning('Command ({}) failed with return code: {}' |
445 | + .format(' '.join(arglist), returncode)) |
446 | |
447 | return {'returncode': returncode, |
448 | 'output': output} |
449 | @@ -854,12 +870,18 @@ |
450 | .format(self.CHECK_LOCK_COMMAND, pkg_name, log_file) |
451 | for pkg_name in config.installpackages])) |
452 | postinstall_commands = ( |
453 | - 'sed -i -e "/^exit 0$/i{}" /etc/rc.local;' |
454 | + 'sed -i -e \'/^exit 0$/i{}\' /etc/rc.local;' |
455 | .format(install_commands) |
456 | ) |
457 | chroot_command = ' '.join([install_commands, postinstall_commands]) |
458 | - question.value.prepend('in-target sh -c \'{}\'; ' |
459 | - .format(chroot_command)) |
460 | + # Note that since the chroot command is passed to sh within single |
461 | + # quotes, the single quotes inside the chroot command have to be |
462 | + # escaped using '"'"' |
463 | + # For more information, please refer to: |
464 | + # http://stackoverflow.com/q/1250079/183066 |
465 | + question.value.prepend( |
466 | + 'in-target sh -c \'{}\'; ' |
467 | + .format(chroot_command.replace('\'', '\'"\'"\''))) |
468 | question.prepend("ubiquity ubiquity/summary note") |
469 | question.prepend("ubiquity ubiquity/reboot boolean true") |
470 | else: |
471 | |
472 | === modified file 'utah/provisioning/ssh.py' |
473 | --- utah/provisioning/ssh.py 2013-01-23 09:10:16 +0000 |
474 | +++ utah/provisioning/ssh.py 2013-02-15 17:42:27 +0000 |
475 | @@ -14,7 +14,8 @@ |
476 | # with this program. If not, see <http://www.gnu.org/licenses/>. |
477 | |
478 | """ |
479 | -Provide a mixin class for machines with SSH support. |
480 | +SSH based machine class for a provisioned system |
481 | +and SSHMixin for every machine class that needs SSH support. |
482 | """ |
483 | |
484 | import logging |
485 | @@ -29,6 +30,7 @@ |
486 | |
487 | from utah import config |
488 | from utah.provisioning.exceptions import UTAHProvisioningException |
489 | +from utah.provisioning.provisioning import Machine |
490 | from utah.retry import retry |
491 | |
492 | |
493 | @@ -40,6 +42,15 @@ |
494 | # Note: Since this is a mixin it doesn't expect any argument |
495 | # However, it calls super to initialize any other mixins in the mro |
496 | super(SSHMixin, self).__init__(*args, **kwargs) |
497 | + self.initialize() |
498 | + |
499 | + def initialize(self): |
500 | + """SSH mixin initialization |
501 | + |
502 | + Use this method when it isn't appropriate to follow the MRO as in |
503 | + __init__ |
504 | + |
505 | + """ |
506 | ssh_client = paramiko.SSHClient() |
507 | ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) |
508 | self.ssh_client = ssh_client |
509 | @@ -85,9 +96,12 @@ |
510 | self.ssh_logger.debug('Closing SSH connection') |
511 | self.ssh_client.close() |
512 | |
513 | - log_level = logging.DEBUG if retval == 0 else logging.WARNING |
514 | - log_message = 'Return code: {}'.format(retval) |
515 | - self.ssh_logger.log(log_level, log_message) |
516 | + if retval == 0: |
517 | + self.ssh_logger.debug('Return code: {}'.format(retval)) |
518 | + else: |
519 | + self.ssh_logger.warning( |
520 | + 'SSH command ({}) failed with return code: {}' |
521 | + .format(commandstring, retval)) |
522 | |
523 | self.ssh_logger.debug('Standard output follows:') |
524 | stdout_lines = stdout.readlines() |
525 | @@ -223,20 +237,29 @@ |
526 | super(SSHMixin, self).destroy(*args, **kw) |
527 | |
528 | def sshcheck(self, timeout=config.checktimeout): |
529 | - """ |
530 | - Sleep for a while and check if the machine is available via ssh. |
531 | - Return a retryable exception if it is not. |
532 | - Intended for use with retry. |
533 | - """ |
534 | - self.ssh_logger.info('Sleeping {timeout} seconds' |
535 | - .format(timeout=timeout)) |
536 | - time.sleep(timeout) |
537 | + """Check if the machine is available via ssh. |
538 | + |
539 | + :param timeout: Amount of time in seconds to sleep after a failure |
540 | + :type timeout: int |
541 | + :raises: UTAHProvisioningException |
542 | + |
543 | + If there's a network connectivity failure, then sleep ``timeout`` |
544 | + seconds and raise a retriable exception. |
545 | + |
546 | + .. seealso:: :func:`utah.retry.retry`, :meth:`sshpoll` |
547 | + |
548 | + """ |
549 | self.ssh_logger.info('Checking for ssh availability') |
550 | try: |
551 | self.ssh_client.connect(self.name, |
552 | username=config.user, |
553 | key_filename=config.sshprivatekey) |
554 | except socket.error as err: |
555 | + if timeout > 0: |
556 | + self.ssh_logger.info('Sleeping {timeout} seconds' |
557 | + .format(timeout=timeout)) |
558 | + time.sleep(timeout) |
559 | + |
560 | raise UTAHProvisioningException(str(err), retry=True) |
561 | |
562 | def sshpoll(self, timeout=None, |
563 | @@ -260,3 +283,41 @@ |
564 | if not self.active: |
565 | self._start() |
566 | self.sshcheck() |
567 | + |
568 | + |
569 | +class ProvisionedMachine(SSHMixin, Machine): |
570 | + """A machine that is provisioned and can be accessed through ssh.""" |
571 | + def __init__(self, name, installtype=None): |
572 | + SSHMixin.initialize(self) |
573 | + self.name = name |
574 | + self._loggersetup() |
575 | + |
576 | + # No cleanup needed for systems that are already provisioned |
577 | + self.clean = False |
578 | + |
579 | + # System is expected to be available already, so there's no need to |
580 | + # wait before trying to connect through ssh |
581 | + self.check_timeout = 3 |
582 | + self.connectivity_timeout = 60 |
583 | + |
584 | + # TBD: Figure out install type by getting information through ssh |
585 | + if installtype is None: |
586 | + self.installtype = config.installtype |
587 | + |
588 | + def activecheck(self): |
589 | + """Check if machine is active. |
590 | + |
591 | + Given that the machine is already provisioned, it's considered to be |
592 | + active as long as it's reachable through ssh |
593 | + |
594 | + """ |
595 | + try: |
596 | + self.pingpoll(timeout=self.connectivity_timeout, |
597 | + checktimeout=self.check_timeout) |
598 | + except utah.timeout.UTAHTimeout: |
599 | + # Ignore timeout for ping, since depending on the network |
600 | + # configuration ssh might still work despite of the ping failure. |
601 | + self.logger.warning('Network connectivity (ping) failure') |
602 | + |
603 | + self.sshpoll(timeout=self.connectivity_timeout, |
604 | + checktimeout=self.check_timeout) |
605 | |
606 | === modified file 'utah/retry.py' |
607 | --- utah/retry.py 2012-12-03 14:02:18 +0000 |
608 | +++ utah/retry.py 2013-02-15 17:42:27 +0000 |
609 | @@ -23,9 +23,26 @@ |
610 | |
611 | |
612 | def retry(command, *args, **kw): |
613 | - """ |
614 | - Retry an operation until it completes or an exception is raised without |
615 | - retry set to True. |
616 | + """Retry a command as long as a retriable exception is captured. |
617 | + |
618 | + A retriable exception is considered to be a |
619 | + ``UTAHException`` that has the attribute ``retry`` set to |
620 | + ``True``. |
621 | + |
622 | + :param command: Command to be executed. |
623 | + :type command: callable |
624 | + :param args: Positional arguments to be passed to the callable. |
625 | + :param kwargs: |
626 | + Keyword arguments to be passed to the callable. |
627 | + |
628 | + .. note:: |
629 | + If a keyword argument named ``logmethod`` is found, it will be |
630 | + extracted (i.e. it won't be passed to the callable) and used to log |
631 | + every retry attempt (using ``sys.stderr`` by default). |
632 | + :returns: The value returned by the callable. |
633 | + |
634 | + .. seealso:: :func:`utah.timeout.timeout` |
635 | + |
636 | """ |
637 | try: |
638 | logmethod = kw.pop('logmethod') |
639 | |
640 | === modified file 'utah/run.py' |
641 | --- utah/run.py 2013-01-23 12:26:38 +0000 |
642 | +++ utah/run.py 2013-02-15 17:42:27 +0000 |
643 | @@ -22,10 +22,12 @@ |
644 | import urllib |
645 | import traceback |
646 | import shutil |
647 | +import signal |
648 | |
649 | from utah import config |
650 | from utah.exceptions import UTAHException |
651 | from utah.url import url_argument |
652 | +from utah.provisioning.ssh import ProvisionedMachine |
653 | |
654 | |
655 | def common_arguments(parser): |
656 | @@ -111,6 +113,8 @@ |
657 | """ |
658 | Run some tests and retrieve results. |
659 | """ |
660 | + install_sigterm_handler() |
661 | + |
662 | if args.json: |
663 | report_type = 'json' |
664 | else: |
665 | @@ -193,7 +197,10 @@ |
666 | except Exception as err: |
667 | logging.warning('Failed to download files: ' + str(err)) |
668 | |
669 | - if args.outputpreseed or config.outputpreseed: |
670 | + # Provisioned systems have an image already installed |
671 | + # and the preseed file is no longer available |
672 | + if (not isinstance(machine, ProvisionedMachine) and |
673 | + (args.outputpreseed or config.outputpreseed)): |
674 | if args.outputpreseed: |
675 | logging.debug('Capturing preseed due to command line option') |
676 | elif config.outputpreseed: |
677 | @@ -287,3 +294,19 @@ |
678 | logger = logging.getLogger('paramiko') |
679 | logger.propagate = False |
680 | logger.addHandler(ssh_file_handler) |
681 | + |
682 | + |
683 | +def install_sigterm_handler(): |
684 | + """Capture SIGTERM signal to avoid abrupt process termination. |
685 | + |
686 | + This function registers a handler that raises an exception when SIGTERM is |
687 | + received to ensure that machine object cleanup methods are called. |
688 | + Otherwise, cleanup methods aren't called when the process is abruptly |
689 | + terminated. |
690 | + |
691 | + """ |
692 | + def handler(signum, frame): |
693 | + """Raise exception when signal is received.""" |
694 | + raise UTAHException('Signal received: {}'.format(signum)) |
695 | + |
696 | + signal.signal(signal.SIGTERM, handler) |
697 | |
698 | === modified file 'utah/timeout.py' |
699 | --- utah/timeout.py 2012-12-08 02:10:12 +0000 |
700 | +++ utah/timeout.py 2013-02-15 17:42:27 +0000 |
701 | @@ -25,18 +25,29 @@ |
702 | |
703 | |
704 | class UTAHTimeout(UTAHException): |
705 | - """ |
706 | - Provide a special exception to indicate an operation timed out. |
707 | - """ |
708 | + """Provide a special exception to indicate an operation timed out.""" |
709 | |
710 | |
711 | # Mostly pulled from utah/client/common.py |
712 | # Inspired by: |
713 | # http://stackoverflow.com/a/3326559 |
714 | def timeout(timeout, command, *args, **kw): |
715 | - """ |
716 | - Run command with all other arguments for up to timeout seconds, after with |
717 | - a UTAHTimeout exception is returned. |
718 | + """Run a command for up to ``timeout`` seconds. |
719 | + |
720 | + :param timeout: |
721 | + Maximum amount of time to wait for the command to complete in seconds. |
722 | + :type timeout: int |
723 | + :param command: |
724 | + Command whose execution should complete before timeout expires. |
725 | + :type command: callable |
726 | + :param args: Positional arguments to be passed to the callable. |
727 | + :param kwargs: Keyword arguments to be passed to the callable. |
728 | + :returns: The value returned by the callable. |
729 | + :raises UTAHTimeout: |
730 | + If command execution hasn't finished before ``timeout`` seconds. |
731 | + |
732 | + .. seealso:: :func:`utah.retry.retry` |
733 | + |
734 | """ |
735 | #TODO: Better support for nested timeouts. |
736 | if command is None: |
737 | @@ -71,11 +82,20 @@ |
738 | |
739 | |
740 | def subprocesstimeout(timeout, *args, **kw): |
741 | - """ |
742 | - Pass all arguments except timeout to subprocess.Popen and run for timeout |
743 | - seconds. |
744 | - After the timeout is reached, kill all subprocesses and return a |
745 | - UTAHTimeout exception. |
746 | + """Run command through ``subprocess.Popen`` for up to ``timeout`` seconds. |
747 | + |
748 | + Command termination is checked using ``subprocess.Popen.poll``. |
749 | + |
750 | + :param timeout: |
751 | + Maximum amount of time to wait for the command to complete in seconds. |
752 | + :type timeout: int |
753 | + :param args: Positional arguments to be passed to ``subprocess.Popen``. |
754 | + :param kwargs: Keyword arguments to be passed to the ``subprocess.Popen``. |
755 | + :returns: The subprocess object |
756 | + :rtype: ``subprocess.Popen`` |
757 | + :raises UTAHTimeout: |
758 | + If command execution hasn't finished before ``timeout`` seconds. |
759 | + |
760 | """ |
761 | if args is None: |
762 | return |
763 | @@ -118,8 +138,13 @@ |
764 | |
765 | |
766 | def get_process_children(pid): |
767 | - """ |
768 | - Find process children so they can be killed when the timeout expires. |
769 | + """Find process children so they can be killed when the timeout expires. |
770 | + |
771 | + :param pid: Process ID for the parent process |
772 | + :type pid: int |
773 | + :returns: The pid for each children process |
774 | + :rtype: list(int) |
775 | + |
776 | """ |
777 | p = subprocess.Popen('ps --no-headers -o pid --ppid %d' % pid, shell=True, |
778 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
779 | |
780 | === modified file 'utah/url.py' |
781 | --- utah/url.py 2012-12-13 13:48:54 +0000 |
782 | +++ utah/url.py 2013-02-15 17:42:27 +0000 |
783 | @@ -24,6 +24,7 @@ |
784 | import urllib2 |
785 | import tempfile |
786 | import logging |
787 | +import time |
788 | from urlparse import urlparse |
789 | from argparse import ArgumentTypeError |
790 | |
791 | @@ -32,6 +33,8 @@ |
792 | import bzrlib.errors |
793 | |
794 | from utah.exceptions import UTAHException |
795 | +from utah.timeout import timeout |
796 | +from utah.retry import retry |
797 | |
798 | |
799 | # Inspired by: http://stackoverflow.com/a/2070916/183066 |
800 | @@ -212,8 +215,25 @@ |
801 | cmd = bzrlib.builtins.cmd_export() |
802 | assert cmd is not None |
803 | tmp_dir = tempfile.mkdtemp(prefix='utah_') |
804 | + |
805 | + def bzr_export_retriable(): |
806 | + """bzr export a URL retrying on http errors |
807 | + |
808 | + This is a workaround to launchpad problems with http URLs that |
809 | + happen from time to time |
810 | + |
811 | + """ |
812 | + try: |
813 | + cmd.run(tmp_dir, url) |
814 | + except bzrlib.errors.InvalidHttpResponse as exception: |
815 | + # Separate every retry attempt with a timeout of 3 seconds |
816 | + time.sleep(3) |
817 | + raise UTAHException(exception.path, exception.msg, retry=True) |
818 | + |
819 | try: |
820 | - cmd.run(tmp_dir, url) |
821 | + # Retry bzr export on http errors for 60 seconds |
822 | + timeout(60, retry, bzr_export_retriable, |
823 | + logmethod=bzr_logger.debug) |
824 | except bzrlib.errors.BzrError as exception: |
825 | raise ArgumentTypeError('Bazaar export error: {}' |
826 | .format(exception)) |
I see the changes merged, so this request can be approved.