~anton-skriptsov/charms/trusty/cinder-nexentaedge/trunk

« back to all changes in this revision

Viewing changes to tests/charmhelpers/contrib/amulet/utils.py

  • Committer: anton.skriptsov at nexenta
  • Date: 2016-04-15 12:20:00 UTC
  • Revision ID: anton.skriptsov@nexenta.com-20160415122000-v6ml4rqq0l9duj6t
update wheel

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2014-2015 Canonical Limited.
 
2
#
 
3
# This file is part of charm-helpers.
 
4
#
 
5
# charm-helpers is free software: you can redistribute it and/or modify
 
6
# it under the terms of the GNU Lesser General Public License version 3 as
 
7
# published by the Free Software Foundation.
 
8
#
 
9
# charm-helpers is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU Lesser General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU Lesser General Public License
 
15
# along with charm-helpers.  If not, see <http://www.gnu.org/licenses/>.
 
16
 
 
17
import io
 
18
import json
 
19
import logging
 
20
import os
 
21
import re
 
22
import socket
 
23
import subprocess
 
24
import sys
 
25
import time
 
26
import uuid
 
27
 
 
28
import amulet
 
29
import distro_info
 
30
import six
 
31
from six.moves import configparser
 
32
if six.PY3:
 
33
    from urllib import parse as urlparse
 
34
else:
 
35
    import urlparse
 
36
 
 
37
 
 
38
class AmuletUtils(object):
 
39
    """Amulet utilities.
 
40
 
 
41
       This class provides common utility functions that are used by Amulet
 
42
       tests.
 
43
       """
 
44
 
 
45
    def __init__(self, log_level=logging.ERROR):
 
46
        self.log = self.get_logger(level=log_level)
 
47
        self.ubuntu_releases = self.get_ubuntu_releases()
 
48
 
 
49
    def get_logger(self, name="amulet-logger", level=logging.DEBUG):
 
50
        """Get a logger object that will log to stdout."""
 
51
        log = logging
 
52
        logger = log.getLogger(name)
 
53
        fmt = log.Formatter("%(asctime)s %(funcName)s "
 
54
                            "%(levelname)s: %(message)s")
 
55
 
 
56
        handler = log.StreamHandler(stream=sys.stdout)
 
57
        handler.setLevel(level)
 
58
        handler.setFormatter(fmt)
 
59
 
 
60
        logger.addHandler(handler)
 
61
        logger.setLevel(level)
 
62
 
 
63
        return logger
 
64
 
 
65
    def valid_ip(self, ip):
 
66
        if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
 
67
            return True
 
68
        else:
 
69
            return False
 
70
 
 
71
    def valid_url(self, url):
 
72
        p = re.compile(
 
73
            r'^(?:http|ftp)s?://'
 
74
            r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'  # noqa
 
75
            r'localhost|'
 
76
            r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
 
77
            r'(?::\d+)?'
 
78
            r'(?:/?|[/?]\S+)$',
 
79
            re.IGNORECASE)
 
80
        if p.match(url):
 
81
            return True
 
82
        else:
 
83
            return False
 
84
 
 
85
    def get_ubuntu_release_from_sentry(self, sentry_unit):
 
86
        """Get Ubuntu release codename from sentry unit.
 
87
 
 
88
        :param sentry_unit: amulet sentry/service unit pointer
 
89
        :returns: list of strings - release codename, failure message
 
90
        """
 
91
        msg = None
 
92
        cmd = 'lsb_release -cs'
 
93
        release, code = sentry_unit.run(cmd)
 
94
        if code == 0:
 
95
            self.log.debug('{} lsb_release: {}'.format(
 
96
                sentry_unit.info['unit_name'], release))
 
97
        else:
 
98
            msg = ('{} `{}` returned {} '
 
99
                   '{}'.format(sentry_unit.info['unit_name'],
 
100
                               cmd, release, code))
 
101
        if release not in self.ubuntu_releases:
 
102
            msg = ("Release ({}) not found in Ubuntu releases "
 
103
                   "({})".format(release, self.ubuntu_releases))
 
104
        return release, msg
 
105
 
 
106
    def validate_services(self, commands):
 
107
        """Validate that lists of commands succeed on service units.  Can be
 
108
           used to verify system services are running on the corresponding
 
109
           service units.
 
110
 
 
111
        :param commands: dict with sentry keys and arbitrary command list vals
 
112
        :returns: None if successful, Failure string message otherwise
 
113
        """
 
114
        self.log.debug('Checking status of system services...')
 
115
 
 
116
        # /!\ DEPRECATION WARNING (beisner):
 
117
        # New and existing tests should be rewritten to use
 
118
        # validate_services_by_name() as it is aware of init systems.
 
119
        self.log.warn('DEPRECATION WARNING:  use '
 
120
                      'validate_services_by_name instead of validate_services '
 
121
                      'due to init system differences.')
 
122
 
 
123
        for k, v in six.iteritems(commands):
 
124
            for cmd in v:
 
125
                output, code = k.run(cmd)
 
126
                self.log.debug('{} `{}` returned '
 
127
                               '{}'.format(k.info['unit_name'],
 
128
                                           cmd, code))
 
129
                if code != 0:
 
130
                    return "command `{}` returned {}".format(cmd, str(code))
 
131
        return None
 
132
 
 
133
    def validate_services_by_name(self, sentry_services):
 
134
        """Validate system service status by service name, automatically
 
135
           detecting init system based on Ubuntu release codename.
 
136
 
 
137
        :param sentry_services: dict with sentry keys and svc list values
 
138
        :returns: None if successful, Failure string message otherwise
 
139
        """
 
140
        self.log.debug('Checking status of system services...')
 
141
 
 
142
        # Point at which systemd became a thing
 
143
        systemd_switch = self.ubuntu_releases.index('vivid')
 
144
 
 
145
        for sentry_unit, services_list in six.iteritems(sentry_services):
 
146
            # Get lsb_release codename from unit
 
147
            release, ret = self.get_ubuntu_release_from_sentry(sentry_unit)
 
148
            if ret:
 
149
                return ret
 
150
 
 
151
            for service_name in services_list:
 
152
                if (self.ubuntu_releases.index(release) >= systemd_switch or
 
153
                        service_name in ['rabbitmq-server', 'apache2']):
 
154
                    # init is systemd (or regular sysv)
 
155
                    cmd = 'sudo service {} status'.format(service_name)
 
156
                    output, code = sentry_unit.run(cmd)
 
157
                    service_running = code == 0
 
158
                elif self.ubuntu_releases.index(release) < systemd_switch:
 
159
                    # init is upstart
 
160
                    cmd = 'sudo status {}'.format(service_name)
 
161
                    output, code = sentry_unit.run(cmd)
 
162
                    service_running = code == 0 and "start/running" in output
 
163
 
 
164
                self.log.debug('{} `{}` returned '
 
165
                               '{}'.format(sentry_unit.info['unit_name'],
 
166
                                           cmd, code))
 
167
                if not service_running:
 
168
                    return u"command `{}` returned {} {}".format(
 
169
                        cmd, output, str(code))
 
170
        return None
 
171
 
 
172
    def _get_config(self, unit, filename):
 
173
        """Get a ConfigParser object for parsing a unit's config file."""
 
174
        file_contents = unit.file_contents(filename)
 
175
 
 
176
        # NOTE(beisner):  by default, ConfigParser does not handle options
 
177
        # with no value, such as the flags used in the mysql my.cnf file.
 
178
        # https://bugs.python.org/issue7005
 
179
        config = configparser.ConfigParser(allow_no_value=True)
 
180
        config.readfp(io.StringIO(file_contents))
 
181
        return config
 
182
 
 
183
    def validate_config_data(self, sentry_unit, config_file, section,
 
184
                             expected):
 
185
        """Validate config file data.
 
186
 
 
187
           Verify that the specified section of the config file contains
 
188
           the expected option key:value pairs.
 
189
 
 
190
           Compare expected dictionary data vs actual dictionary data.
 
191
           The values in the 'expected' dictionary can be strings, bools, ints,
 
192
           longs, or can be a function that evaluates a variable and returns a
 
193
           bool.
 
194
           """
 
195
        self.log.debug('Validating config file data ({} in {} on {})'
 
196
                       '...'.format(section, config_file,
 
197
                                    sentry_unit.info['unit_name']))
 
198
        config = self._get_config(sentry_unit, config_file)
 
199
 
 
200
        if section != 'DEFAULT' and not config.has_section(section):
 
201
            return "section [{}] does not exist".format(section)
 
202
 
 
203
        for k in expected.keys():
 
204
            if not config.has_option(section, k):
 
205
                return "section [{}] is missing option {}".format(section, k)
 
206
 
 
207
            actual = config.get(section, k)
 
208
            v = expected[k]
 
209
            if (isinstance(v, six.string_types) or
 
210
                    isinstance(v, bool) or
 
211
                    isinstance(v, six.integer_types)):
 
212
                # handle explicit values
 
213
                if actual != v:
 
214
                    return "section [{}] {}:{} != expected {}:{}".format(
 
215
                           section, k, actual, k, expected[k])
 
216
            # handle function pointers, such as not_null or valid_ip
 
217
            elif not v(actual):
 
218
                return "section [{}] {}:{} != expected {}:{}".format(
 
219
                       section, k, actual, k, expected[k])
 
220
        return None
 
221
 
 
222
    def _validate_dict_data(self, expected, actual):
 
223
        """Validate dictionary data.
 
224
 
 
225
           Compare expected dictionary data vs actual dictionary data.
 
226
           The values in the 'expected' dictionary can be strings, bools, ints,
 
227
           longs, or can be a function that evaluates a variable and returns a
 
228
           bool.
 
229
           """
 
230
        self.log.debug('actual: {}'.format(repr(actual)))
 
231
        self.log.debug('expected: {}'.format(repr(expected)))
 
232
 
 
233
        for k, v in six.iteritems(expected):
 
234
            if k in actual:
 
235
                if (isinstance(v, six.string_types) or
 
236
                        isinstance(v, bool) or
 
237
                        isinstance(v, six.integer_types)):
 
238
                    # handle explicit values
 
239
                    if v != actual[k]:
 
240
                        return "{}:{}".format(k, actual[k])
 
241
                # handle function pointers, such as not_null or valid_ip
 
242
                elif not v(actual[k]):
 
243
                    return "{}:{}".format(k, actual[k])
 
244
            else:
 
245
                return "key '{}' does not exist".format(k)
 
246
        return None
 
247
 
 
248
    def validate_relation_data(self, sentry_unit, relation, expected):
 
249
        """Validate actual relation data based on expected relation data."""
 
250
        actual = sentry_unit.relation(relation[0], relation[1])
 
251
        return self._validate_dict_data(expected, actual)
 
252
 
 
253
    def _validate_list_data(self, expected, actual):
 
254
        """Compare expected list vs actual list data."""
 
255
        for e in expected:
 
256
            if e not in actual:
 
257
                return "expected item {} not found in actual list".format(e)
 
258
        return None
 
259
 
 
260
    def not_null(self, string):
 
261
        if string is not None:
 
262
            return True
 
263
        else:
 
264
            return False
 
265
 
 
266
    def _get_file_mtime(self, sentry_unit, filename):
 
267
        """Get last modification time of file."""
 
268
        return sentry_unit.file_stat(filename)['mtime']
 
269
 
 
270
    def _get_dir_mtime(self, sentry_unit, directory):
 
271
        """Get last modification time of directory."""
 
272
        return sentry_unit.directory_stat(directory)['mtime']
 
273
 
 
274
    def _get_proc_start_time(self, sentry_unit, service, pgrep_full=None):
 
275
        """Get start time of a process based on the last modification time
 
276
           of the /proc/pid directory.
 
277
 
 
278
        :sentry_unit:  The sentry unit to check for the service on
 
279
        :service:  service name to look for in process table
 
280
        :pgrep_full:  [Deprecated] Use full command line search mode with pgrep
 
281
        :returns:  epoch time of service process start
 
282
        :param commands:  list of bash commands
 
283
        :param sentry_units:  list of sentry unit pointers
 
284
        :returns:  None if successful; Failure message otherwise
 
285
        """
 
286
        if pgrep_full is not None:
 
287
            # /!\ DEPRECATION WARNING (beisner):
 
288
            # No longer implemented, as pidof is now used instead of pgrep.
 
289
            # https://bugs.launchpad.net/charm-helpers/+bug/1474030
 
290
            self.log.warn('DEPRECATION WARNING:  pgrep_full bool is no '
 
291
                          'longer implemented re: lp 1474030.')
 
292
 
 
293
        pid_list = self.get_process_id_list(sentry_unit, service)
 
294
        pid = pid_list[0]
 
295
        proc_dir = '/proc/{}'.format(pid)
 
296
        self.log.debug('Pid for {} on {}: {}'.format(
 
297
            service, sentry_unit.info['unit_name'], pid))
 
298
 
 
299
        return self._get_dir_mtime(sentry_unit, proc_dir)
 
300
 
 
301
    def service_restarted(self, sentry_unit, service, filename,
 
302
                          pgrep_full=None, sleep_time=20):
 
303
        """Check if service was restarted.
 
304
 
 
305
           Compare a service's start time vs a file's last modification time
 
306
           (such as a config file for that service) to determine if the service
 
307
           has been restarted.
 
308
           """
 
309
        # /!\ DEPRECATION WARNING (beisner):
 
310
        # This method is prone to races in that no before-time is known.
 
311
        # Use validate_service_config_changed instead.
 
312
 
 
313
        # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
 
314
        # used instead of pgrep.  pgrep_full is still passed through to ensure
 
315
        # deprecation WARNS.  lp1474030
 
316
        self.log.warn('DEPRECATION WARNING:  use '
 
317
                      'validate_service_config_changed instead of '
 
318
                      'service_restarted due to known races.')
 
319
 
 
320
        time.sleep(sleep_time)
 
321
        if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
 
322
                self._get_file_mtime(sentry_unit, filename)):
 
323
            return True
 
324
        else:
 
325
            return False
 
326
 
 
327
    def service_restarted_since(self, sentry_unit, mtime, service,
 
328
                                pgrep_full=None, sleep_time=20,
 
329
                                retry_count=30, retry_sleep_time=10):
 
330
        """Check if service was been started after a given time.
 
331
 
 
332
        Args:
 
333
          sentry_unit (sentry): The sentry unit to check for the service on
 
334
          mtime (float): The epoch time to check against
 
335
          service (string): service name to look for in process table
 
336
          pgrep_full: [Deprecated] Use full command line search mode with pgrep
 
337
          sleep_time (int): Initial sleep time (s) before looking for file
 
338
          retry_sleep_time (int): Time (s) to sleep between retries
 
339
          retry_count (int): If file is not found, how many times to retry
 
340
 
 
341
        Returns:
 
342
          bool: True if service found and its start time it newer than mtime,
 
343
                False if service is older than mtime or if service was
 
344
                not found.
 
345
        """
 
346
        # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
 
347
        # used instead of pgrep.  pgrep_full is still passed through to ensure
 
348
        # deprecation WARNS.  lp1474030
 
349
 
 
350
        unit_name = sentry_unit.info['unit_name']
 
351
        self.log.debug('Checking that %s service restarted since %s on '
 
352
                       '%s' % (service, mtime, unit_name))
 
353
        time.sleep(sleep_time)
 
354
        proc_start_time = None
 
355
        tries = 0
 
356
        while tries <= retry_count and not proc_start_time:
 
357
            try:
 
358
                proc_start_time = self._get_proc_start_time(sentry_unit,
 
359
                                                            service,
 
360
                                                            pgrep_full)
 
361
                self.log.debug('Attempt {} to get {} proc start time on {} '
 
362
                               'OK'.format(tries, service, unit_name))
 
363
            except IOError as e:
 
364
                # NOTE(beisner) - race avoidance, proc may not exist yet.
 
365
                # https://bugs.launchpad.net/charm-helpers/+bug/1474030
 
366
                self.log.debug('Attempt {} to get {} proc start time on {} '
 
367
                               'failed\n{}'.format(tries, service,
 
368
                                                   unit_name, e))
 
369
                time.sleep(retry_sleep_time)
 
370
                tries += 1
 
371
 
 
372
        if not proc_start_time:
 
373
            self.log.warn('No proc start time found, assuming service did '
 
374
                          'not start')
 
375
            return False
 
376
        if proc_start_time >= mtime:
 
377
            self.log.debug('Proc start time is newer than provided mtime'
 
378
                           '(%s >= %s) on %s (OK)' % (proc_start_time,
 
379
                                                      mtime, unit_name))
 
380
            return True
 
381
        else:
 
382
            self.log.warn('Proc start time (%s) is older than provided mtime '
 
383
                          '(%s) on %s, service did not '
 
384
                          'restart' % (proc_start_time, mtime, unit_name))
 
385
            return False
 
386
 
 
387
    def config_updated_since(self, sentry_unit, filename, mtime,
 
388
                             sleep_time=20, retry_count=30,
 
389
                             retry_sleep_time=10):
 
390
        """Check if file was modified after a given time.
 
391
 
 
392
        Args:
 
393
          sentry_unit (sentry): The sentry unit to check the file mtime on
 
394
          filename (string): The file to check mtime of
 
395
          mtime (float): The epoch time to check against
 
396
          sleep_time (int): Initial sleep time (s) before looking for file
 
397
          retry_sleep_time (int): Time (s) to sleep between retries
 
398
          retry_count (int): If file is not found, how many times to retry
 
399
 
 
400
        Returns:
 
401
          bool: True if file was modified more recently than mtime, False if
 
402
                file was modified before mtime, or if file not found.
 
403
        """
 
404
        unit_name = sentry_unit.info['unit_name']
 
405
        self.log.debug('Checking that %s updated since %s on '
 
406
                       '%s' % (filename, mtime, unit_name))
 
407
        time.sleep(sleep_time)
 
408
        file_mtime = None
 
409
        tries = 0
 
410
        while tries <= retry_count and not file_mtime:
 
411
            try:
 
412
                file_mtime = self._get_file_mtime(sentry_unit, filename)
 
413
                self.log.debug('Attempt {} to get {} file mtime on {} '
 
414
                               'OK'.format(tries, filename, unit_name))
 
415
            except IOError as e:
 
416
                # NOTE(beisner) - race avoidance, file may not exist yet.
 
417
                # https://bugs.launchpad.net/charm-helpers/+bug/1474030
 
418
                self.log.debug('Attempt {} to get {} file mtime on {} '
 
419
                               'failed\n{}'.format(tries, filename,
 
420
                                                   unit_name, e))
 
421
                time.sleep(retry_sleep_time)
 
422
                tries += 1
 
423
 
 
424
        if not file_mtime:
 
425
            self.log.warn('Could not determine file mtime, assuming '
 
426
                          'file does not exist')
 
427
            return False
 
428
 
 
429
        if file_mtime >= mtime:
 
430
            self.log.debug('File mtime is newer than provided mtime '
 
431
                           '(%s >= %s) on %s (OK)' % (file_mtime,
 
432
                                                      mtime, unit_name))
 
433
            return True
 
434
        else:
 
435
            self.log.warn('File mtime is older than provided mtime'
 
436
                          '(%s < on %s) on %s' % (file_mtime,
 
437
                                                  mtime, unit_name))
 
438
            return False
 
439
 
 
440
    def validate_service_config_changed(self, sentry_unit, mtime, service,
 
441
                                        filename, pgrep_full=None,
 
442
                                        sleep_time=20, retry_count=30,
 
443
                                        retry_sleep_time=10):
 
444
        """Check service and file were updated after mtime
 
445
 
 
446
        Args:
 
447
          sentry_unit (sentry): The sentry unit to check for the service on
 
448
          mtime (float): The epoch time to check against
 
449
          service (string): service name to look for in process table
 
450
          filename (string): The file to check mtime of
 
451
          pgrep_full: [Deprecated] Use full command line search mode with pgrep
 
452
          sleep_time (int): Initial sleep in seconds to pass to test helpers
 
453
          retry_count (int): If service is not found, how many times to retry
 
454
          retry_sleep_time (int): Time in seconds to wait between retries
 
455
 
 
456
        Typical Usage:
 
457
            u = OpenStackAmuletUtils(ERROR)
 
458
            ...
 
459
            mtime = u.get_sentry_time(self.cinder_sentry)
 
460
            self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
 
461
            if not u.validate_service_config_changed(self.cinder_sentry,
 
462
                                                     mtime,
 
463
                                                     'cinder-api',
 
464
                                                     '/etc/cinder/cinder.conf')
 
465
                amulet.raise_status(amulet.FAIL, msg='update failed')
 
466
        Returns:
 
467
          bool: True if both service and file where updated/restarted after
 
468
                mtime, False if service is older than mtime or if service was
 
469
                not found or if filename was modified before mtime.
 
470
        """
 
471
 
 
472
        # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
 
473
        # used instead of pgrep.  pgrep_full is still passed through to ensure
 
474
        # deprecation WARNS.  lp1474030
 
475
 
 
476
        service_restart = self.service_restarted_since(
 
477
            sentry_unit, mtime,
 
478
            service,
 
479
            pgrep_full=pgrep_full,
 
480
            sleep_time=sleep_time,
 
481
            retry_count=retry_count,
 
482
            retry_sleep_time=retry_sleep_time)
 
483
 
 
484
        config_update = self.config_updated_since(
 
485
            sentry_unit,
 
486
            filename,
 
487
            mtime,
 
488
            sleep_time=sleep_time,
 
489
            retry_count=retry_count,
 
490
            retry_sleep_time=retry_sleep_time)
 
491
 
 
492
        return service_restart and config_update
 
493
 
 
494
    def get_sentry_time(self, sentry_unit):
 
495
        """Return current epoch time on a sentry"""
 
496
        cmd = "date +'%s'"
 
497
        return float(sentry_unit.run(cmd)[0])
 
498
 
 
499
    def relation_error(self, name, data):
 
500
        return 'unexpected relation data in {} - {}'.format(name, data)
 
501
 
 
502
    def endpoint_error(self, name, data):
 
503
        return 'unexpected endpoint data in {} - {}'.format(name, data)
 
504
 
 
505
    def get_ubuntu_releases(self):
 
506
        """Return a list of all Ubuntu releases in order of release."""
 
507
        _d = distro_info.UbuntuDistroInfo()
 
508
        _release_list = _d.all
 
509
        return _release_list
 
510
 
 
511
    def file_to_url(self, file_rel_path):
 
512
        """Convert a relative file path to a file URL."""
 
513
        _abs_path = os.path.abspath(file_rel_path)
 
514
        return urlparse.urlparse(_abs_path, scheme='file').geturl()
 
515
 
 
516
    def check_commands_on_units(self, commands, sentry_units):
 
517
        """Check that all commands in a list exit zero on all
 
518
        sentry units in a list.
 
519
 
 
520
        :param commands:  list of bash commands
 
521
        :param sentry_units:  list of sentry unit pointers
 
522
        :returns: None if successful; Failure message otherwise
 
523
        """
 
524
        self.log.debug('Checking exit codes for {} commands on {} '
 
525
                       'sentry units...'.format(len(commands),
 
526
                                                len(sentry_units)))
 
527
        for sentry_unit in sentry_units:
 
528
            for cmd in commands:
 
529
                output, code = sentry_unit.run(cmd)
 
530
                if code == 0:
 
531
                    self.log.debug('{} `{}` returned {} '
 
532
                                   '(OK)'.format(sentry_unit.info['unit_name'],
 
533
                                                 cmd, code))
 
534
                else:
 
535
                    return ('{} `{}` returned {} '
 
536
                            '{}'.format(sentry_unit.info['unit_name'],
 
537
                                        cmd, code, output))
 
538
        return None
 
539
 
 
540
    def get_process_id_list(self, sentry_unit, process_name,
 
541
                            expect_success=True):
 
542
        """Get a list of process ID(s) from a single sentry juju unit
 
543
        for a single process name.
 
544
 
 
545
        :param sentry_unit: Amulet sentry instance (juju unit)
 
546
        :param process_name: Process name
 
547
        :param expect_success: If False, expect the PID to be missing,
 
548
            raise if it is present.
 
549
        :returns: List of process IDs
 
550
        """
 
551
        cmd = 'pidof -x {}'.format(process_name)
 
552
        if not expect_success:
 
553
            cmd += " || exit 0 && exit 1"
 
554
        output, code = sentry_unit.run(cmd)
 
555
        if code != 0:
 
556
            msg = ('{} `{}` returned {} '
 
557
                   '{}'.format(sentry_unit.info['unit_name'],
 
558
                               cmd, code, output))
 
559
            amulet.raise_status(amulet.FAIL, msg=msg)
 
560
        return str(output).split()
 
561
 
 
562
    def get_unit_process_ids(self, unit_processes, expect_success=True):
 
563
        """Construct a dict containing unit sentries, process names, and
 
564
        process IDs.
 
565
 
 
566
        :param unit_processes: A dictionary of Amulet sentry instance
 
567
            to list of process names.
 
568
        :param expect_success: if False expect the processes to not be
 
569
            running, raise if they are.
 
570
        :returns: Dictionary of Amulet sentry instance to dictionary
 
571
            of process names to PIDs.
 
572
        """
 
573
        pid_dict = {}
 
574
        for sentry_unit, process_list in six.iteritems(unit_processes):
 
575
            pid_dict[sentry_unit] = {}
 
576
            for process in process_list:
 
577
                pids = self.get_process_id_list(
 
578
                    sentry_unit, process, expect_success=expect_success)
 
579
                pid_dict[sentry_unit].update({process: pids})
 
580
        return pid_dict
 
581
 
 
582
    def validate_unit_process_ids(self, expected, actual):
 
583
        """Validate process id quantities for services on units."""
 
584
        self.log.debug('Checking units for running processes...')
 
585
        self.log.debug('Expected PIDs: {}'.format(expected))
 
586
        self.log.debug('Actual PIDs: {}'.format(actual))
 
587
 
 
588
        if len(actual) != len(expected):
 
589
            return ('Unit count mismatch.  expected, actual: {}, '
 
590
                    '{} '.format(len(expected), len(actual)))
 
591
 
 
592
        for (e_sentry, e_proc_names) in six.iteritems(expected):
 
593
            e_sentry_name = e_sentry.info['unit_name']
 
594
            if e_sentry in actual.keys():
 
595
                a_proc_names = actual[e_sentry]
 
596
            else:
 
597
                return ('Expected sentry ({}) not found in actual dict data.'
 
598
                        '{}'.format(e_sentry_name, e_sentry))
 
599
 
 
600
            if len(e_proc_names.keys()) != len(a_proc_names.keys()):
 
601
                return ('Process name count mismatch.  expected, actual: {}, '
 
602
                        '{}'.format(len(expected), len(actual)))
 
603
 
 
604
            for (e_proc_name, e_pids_length), (a_proc_name, a_pids) in \
 
605
                    zip(e_proc_names.items(), a_proc_names.items()):
 
606
                if e_proc_name != a_proc_name:
 
607
                    return ('Process name mismatch.  expected, actual: {}, '
 
608
                            '{}'.format(e_proc_name, a_proc_name))
 
609
 
 
610
                a_pids_length = len(a_pids)
 
611
                fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
 
612
                            '{}, {} ({})'.format(e_sentry_name, e_proc_name,
 
613
                                                 e_pids_length, a_pids_length,
 
614
                                                 a_pids))
 
615
 
 
616
                # If expected is not bool, ensure PID quantities match
 
617
                if not isinstance(e_pids_length, bool) and \
 
618
                        a_pids_length != e_pids_length:
 
619
                    return fail_msg
 
620
                # If expected is bool True, ensure 1 or more PIDs exist
 
621
                elif isinstance(e_pids_length, bool) and \
 
622
                        e_pids_length is True and a_pids_length < 1:
 
623
                    return fail_msg
 
624
                # If expected is bool False, ensure 0 PIDs exist
 
625
                elif isinstance(e_pids_length, bool) and \
 
626
                        e_pids_length is False and a_pids_length != 0:
 
627
                    return fail_msg
 
628
                else:
 
629
                    self.log.debug('PID check OK: {} {} {}: '
 
630
                                   '{}'.format(e_sentry_name, e_proc_name,
 
631
                                               e_pids_length, a_pids))
 
632
        return None
 
633
 
 
634
    def validate_list_of_identical_dicts(self, list_of_dicts):
 
635
        """Check that all dicts within a list are identical."""
 
636
        hashes = []
 
637
        for _dict in list_of_dicts:
 
638
            hashes.append(hash(frozenset(_dict.items())))
 
639
 
 
640
        self.log.debug('Hashes: {}'.format(hashes))
 
641
        if len(set(hashes)) == 1:
 
642
            self.log.debug('Dicts within list are identical')
 
643
        else:
 
644
            return 'Dicts within list are not identical'
 
645
 
 
646
        return None
 
647
 
 
648
    def validate_sectionless_conf(self, file_contents, expected):
 
649
        """A crude conf parser.  Useful to inspect configuration files which
 
650
        do not have section headers (as would be necessary in order to use
 
651
        the configparser).  Such as openstack-dashboard or rabbitmq confs."""
 
652
        for line in file_contents.split('\n'):
 
653
            if '=' in line:
 
654
                args = line.split('=')
 
655
                if len(args) <= 1:
 
656
                    continue
 
657
                key = args[0].strip()
 
658
                value = args[1].strip()
 
659
                if key in expected.keys():
 
660
                    if expected[key] != value:
 
661
                        msg = ('Config mismatch.  Expected, actual:  {}, '
 
662
                               '{}'.format(expected[key], value))
 
663
                        amulet.raise_status(amulet.FAIL, msg=msg)
 
664
 
 
665
    def get_unit_hostnames(self, units):
 
666
        """Return a dict of juju unit names to hostnames."""
 
667
        host_names = {}
 
668
        for unit in units:
 
669
            host_names[unit.info['unit_name']] = \
 
670
                str(unit.file_contents('/etc/hostname').strip())
 
671
        self.log.debug('Unit host names: {}'.format(host_names))
 
672
        return host_names
 
673
 
 
674
    def run_cmd_unit(self, sentry_unit, cmd):
 
675
        """Run a command on a unit, return the output and exit code."""
 
676
        output, code = sentry_unit.run(cmd)
 
677
        if code == 0:
 
678
            self.log.debug('{} `{}` command returned {} '
 
679
                           '(OK)'.format(sentry_unit.info['unit_name'],
 
680
                                         cmd, code))
 
681
        else:
 
682
            msg = ('{} `{}` command returned {} '
 
683
                   '{}'.format(sentry_unit.info['unit_name'],
 
684
                               cmd, code, output))
 
685
            amulet.raise_status(amulet.FAIL, msg=msg)
 
686
        return str(output), code
 
687
 
 
688
    def file_exists_on_unit(self, sentry_unit, file_name):
 
689
        """Check if a file exists on a unit."""
 
690
        try:
 
691
            sentry_unit.file_stat(file_name)
 
692
            return True
 
693
        except IOError:
 
694
            return False
 
695
        except Exception as e:
 
696
            msg = 'Error checking file {}: {}'.format(file_name, e)
 
697
            amulet.raise_status(amulet.FAIL, msg=msg)
 
698
 
 
699
    def file_contents_safe(self, sentry_unit, file_name,
 
700
                           max_wait=60, fatal=False):
 
701
        """Get file contents from a sentry unit.  Wrap amulet file_contents
 
702
        with retry logic to address races where a file checks as existing,
 
703
        but no longer exists by the time file_contents is called.
 
704
        Return None if file not found. Optionally raise if fatal is True."""
 
705
        unit_name = sentry_unit.info['unit_name']
 
706
        file_contents = False
 
707
        tries = 0
 
708
        while not file_contents and tries < (max_wait / 4):
 
709
            try:
 
710
                file_contents = sentry_unit.file_contents(file_name)
 
711
            except IOError:
 
712
                self.log.debug('Attempt {} to open file {} from {} '
 
713
                               'failed'.format(tries, file_name,
 
714
                                               unit_name))
 
715
                time.sleep(4)
 
716
                tries += 1
 
717
 
 
718
        if file_contents:
 
719
            return file_contents
 
720
        elif not fatal:
 
721
            return None
 
722
        elif fatal:
 
723
            msg = 'Failed to get file contents from unit.'
 
724
            amulet.raise_status(amulet.FAIL, msg)
 
725
 
 
726
    def port_knock_tcp(self, host="localhost", port=22, timeout=15):
 
727
        """Open a TCP socket to check for a listening sevice on a host.
 
728
 
 
729
        :param host: host name or IP address, default to localhost
 
730
        :param port: TCP port number, default to 22
 
731
        :param timeout: Connect timeout, default to 15 seconds
 
732
        :returns: True if successful, False if connect failed
 
733
        """
 
734
 
 
735
        # Resolve host name if possible
 
736
        try:
 
737
            connect_host = socket.gethostbyname(host)
 
738
            host_human = "{} ({})".format(connect_host, host)
 
739
        except socket.error as e:
 
740
            self.log.warn('Unable to resolve address: '
 
741
                          '{} ({}) Trying anyway!'.format(host, e))
 
742
            connect_host = host
 
743
            host_human = connect_host
 
744
 
 
745
        # Attempt socket connection
 
746
        try:
 
747
            knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
748
            knock.settimeout(timeout)
 
749
            knock.connect((connect_host, port))
 
750
            knock.close()
 
751
            self.log.debug('Socket connect OK for host '
 
752
                           '{} on port {}.'.format(host_human, port))
 
753
            return True
 
754
        except socket.error as e:
 
755
            self.log.debug('Socket connect FAIL for'
 
756
                           ' {} port {} ({})'.format(host_human, port, e))
 
757
            return False
 
758
 
 
759
    def port_knock_units(self, sentry_units, port=22,
 
760
                         timeout=15, expect_success=True):
 
761
        """Open a TCP socket to check for a listening sevice on each
 
762
        listed juju unit.
 
763
 
 
764
        :param sentry_units: list of sentry unit pointers
 
765
        :param port: TCP port number, default to 22
 
766
        :param timeout: Connect timeout, default to 15 seconds
 
767
        :expect_success: True by default, set False to invert logic
 
768
        :returns: None if successful, Failure message otherwise
 
769
        """
 
770
        for unit in sentry_units:
 
771
            host = unit.info['public-address']
 
772
            connected = self.port_knock_tcp(host, port, timeout)
 
773
            if not connected and expect_success:
 
774
                return 'Socket connect failed.'
 
775
            elif connected and not expect_success:
 
776
                return 'Socket connected unexpectedly.'
 
777
 
 
778
    def get_uuid_epoch_stamp(self):
 
779
        """Returns a stamp string based on uuid4 and epoch time.  Useful in
 
780
        generating test messages which need to be unique-ish."""
 
781
        return '[{}-{}]'.format(uuid.uuid4(), time.time())
 
782
 
 
783
# amulet juju action helpers:
 
784
    def run_action(self, unit_sentry, action,
 
785
                   _check_output=subprocess.check_output):
 
786
        """Run the named action on a given unit sentry.
 
787
 
 
788
        _check_output parameter is used for dependency injection.
 
789
 
 
790
        @return action_id.
 
791
        """
 
792
        unit_id = unit_sentry.info["unit_name"]
 
793
        command = ["juju", "action", "do", "--format=json", unit_id, action]
 
794
        self.log.info("Running command: %s\n" % " ".join(command))
 
795
        output = _check_output(command, universal_newlines=True)
 
796
        data = json.loads(output)
 
797
        action_id = data[u'Action queued with id']
 
798
        return action_id
 
799
 
 
800
    def wait_on_action(self, action_id, _check_output=subprocess.check_output):
 
801
        """Wait for a given action, returning if it completed or not.
 
802
 
 
803
        _check_output parameter is used for dependency injection.
 
804
        """
 
805
        command = ["juju", "action", "fetch", "--format=json", "--wait=0",
 
806
                   action_id]
 
807
        output = _check_output(command, universal_newlines=True)
 
808
        data = json.loads(output)
 
809
        return data.get(u"status") == "completed"
 
810
 
 
811
    def status_get(self, unit):
 
812
        """Return the current service status of this unit."""
 
813
        raw_status, return_code = unit.run(
 
814
            "status-get --format=json --include-data")
 
815
        if return_code != 0:
 
816
            return ("unknown", "")
 
817
        status = json.loads(raw_status)
 
818
        return (status["status"], status["message"])