~niedbalski/charms/trusty/rabbitmq-server/fix-lp-1489053

« back to all changes in this revision

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

  • Committer: Liam Young
  • Date: 2015-09-09 13:12:47 UTC
  • mfrom: (110.1.4 rabbitmq-server)
  • Revision ID: liam.young@canonical.com-20150909131247-16hxw74o91c57kpg
[1chb1n, r=gnuoy] Refactor amulet tests, deprecate old tests

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=2, retry_sleep_time=30):
 
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): Seconds to sleep before looking for process
 
338
          retry_count (int): If service is not found, how many times to retry
 
339
 
 
340
        Returns:
 
341
          bool: True if service found and its start time it newer than mtime,
 
342
                False if service is older than mtime or if service was
 
343
                not found.
 
344
        """
 
345
        # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
 
346
        # used instead of pgrep.  pgrep_full is still passed through to ensure
 
347
        # deprecation WARNS.  lp1474030
 
348
 
 
349
        unit_name = sentry_unit.info['unit_name']
 
350
        self.log.debug('Checking that %s service restarted since %s on '
 
351
                       '%s' % (service, mtime, unit_name))
 
352
        time.sleep(sleep_time)
 
353
        proc_start_time = None
 
354
        tries = 0
 
355
        while tries <= retry_count and not proc_start_time:
 
356
            try:
 
357
                proc_start_time = self._get_proc_start_time(sentry_unit,
 
358
                                                            service,
 
359
                                                            pgrep_full)
 
360
                self.log.debug('Attempt {} to get {} proc start time on {} '
 
361
                               'OK'.format(tries, service, unit_name))
 
362
            except IOError:
 
363
                # NOTE(beisner) - race avoidance, proc may not exist yet.
 
364
                # https://bugs.launchpad.net/charm-helpers/+bug/1474030
 
365
                self.log.debug('Attempt {} to get {} proc start time on {} '
 
366
                               'failed'.format(tries, service, unit_name))
 
367
                time.sleep(retry_sleep_time)
 
368
                tries += 1
 
369
 
 
370
        if not proc_start_time:
 
371
            self.log.warn('No proc start time found, assuming service did '
 
372
                          'not start')
 
373
            return False
 
374
        if proc_start_time >= mtime:
 
375
            self.log.debug('Proc start time is newer than provided mtime'
 
376
                           '(%s >= %s) on %s (OK)' % (proc_start_time,
 
377
                                                      mtime, unit_name))
 
378
            return True
 
379
        else:
 
380
            self.log.warn('Proc start time (%s) is older than provided mtime '
 
381
                          '(%s) on %s, service did not '
 
382
                          'restart' % (proc_start_time, mtime, unit_name))
 
383
            return False
 
384
 
 
385
    def config_updated_since(self, sentry_unit, filename, mtime,
 
386
                             sleep_time=20):
 
387
        """Check if file was modified after a given time.
 
388
 
 
389
        Args:
 
390
          sentry_unit (sentry): The sentry unit to check the file mtime on
 
391
          filename (string): The file to check mtime of
 
392
          mtime (float): The epoch time to check against
 
393
          sleep_time (int): Seconds to sleep before looking for process
 
394
 
 
395
        Returns:
 
396
          bool: True if file was modified more recently than mtime, False if
 
397
                file was modified before mtime,
 
398
        """
 
399
        self.log.debug('Checking %s updated since %s' % (filename, mtime))
 
400
        time.sleep(sleep_time)
 
401
        file_mtime = self._get_file_mtime(sentry_unit, filename)
 
402
        if file_mtime >= mtime:
 
403
            self.log.debug('File mtime is newer than provided mtime '
 
404
                           '(%s >= %s)' % (file_mtime, mtime))
 
405
            return True
 
406
        else:
 
407
            self.log.warn('File mtime %s is older than provided mtime %s'
 
408
                          % (file_mtime, mtime))
 
409
            return False
 
410
 
 
411
    def validate_service_config_changed(self, sentry_unit, mtime, service,
 
412
                                        filename, pgrep_full=None,
 
413
                                        sleep_time=20, retry_count=2,
 
414
                                        retry_sleep_time=30):
 
415
        """Check service and file were updated after mtime
 
416
 
 
417
        Args:
 
418
          sentry_unit (sentry): The sentry unit to check for the service on
 
419
          mtime (float): The epoch time to check against
 
420
          service (string): service name to look for in process table
 
421
          filename (string): The file to check mtime of
 
422
          pgrep_full: [Deprecated] Use full command line search mode with pgrep
 
423
          sleep_time (int): Initial sleep in seconds to pass to test helpers
 
424
          retry_count (int): If service is not found, how many times to retry
 
425
          retry_sleep_time (int): Time in seconds to wait between retries
 
426
 
 
427
        Typical Usage:
 
428
            u = OpenStackAmuletUtils(ERROR)
 
429
            ...
 
430
            mtime = u.get_sentry_time(self.cinder_sentry)
 
431
            self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
 
432
            if not u.validate_service_config_changed(self.cinder_sentry,
 
433
                                                     mtime,
 
434
                                                     'cinder-api',
 
435
                                                     '/etc/cinder/cinder.conf')
 
436
                amulet.raise_status(amulet.FAIL, msg='update failed')
 
437
        Returns:
 
438
          bool: True if both service and file where updated/restarted after
 
439
                mtime, False if service is older than mtime or if service was
 
440
                not found or if filename was modified before mtime.
 
441
        """
 
442
 
 
443
        # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
 
444
        # used instead of pgrep.  pgrep_full is still passed through to ensure
 
445
        # deprecation WARNS.  lp1474030
 
446
 
 
447
        service_restart = self.service_restarted_since(
 
448
            sentry_unit, mtime,
 
449
            service,
 
450
            pgrep_full=pgrep_full,
 
451
            sleep_time=sleep_time,
 
452
            retry_count=retry_count,
 
453
            retry_sleep_time=retry_sleep_time)
 
454
 
 
455
        config_update = self.config_updated_since(
 
456
            sentry_unit,
 
457
            filename,
 
458
            mtime,
 
459
            sleep_time=0)
 
460
 
 
461
        return service_restart and config_update
 
462
 
 
463
    def get_sentry_time(self, sentry_unit):
 
464
        """Return current epoch time on a sentry"""
 
465
        cmd = "date +'%s'"
 
466
        return float(sentry_unit.run(cmd)[0])
 
467
 
 
468
    def relation_error(self, name, data):
 
469
        return 'unexpected relation data in {} - {}'.format(name, data)
 
470
 
 
471
    def endpoint_error(self, name, data):
 
472
        return 'unexpected endpoint data in {} - {}'.format(name, data)
 
473
 
 
474
    def get_ubuntu_releases(self):
 
475
        """Return a list of all Ubuntu releases in order of release."""
 
476
        _d = distro_info.UbuntuDistroInfo()
 
477
        _release_list = _d.all
 
478
        return _release_list
 
479
 
 
480
    def file_to_url(self, file_rel_path):
 
481
        """Convert a relative file path to a file URL."""
 
482
        _abs_path = os.path.abspath(file_rel_path)
 
483
        return urlparse.urlparse(_abs_path, scheme='file').geturl()
 
484
 
 
485
    def check_commands_on_units(self, commands, sentry_units):
 
486
        """Check that all commands in a list exit zero on all
 
487
        sentry units in a list.
 
488
 
 
489
        :param commands:  list of bash commands
 
490
        :param sentry_units:  list of sentry unit pointers
 
491
        :returns: None if successful; Failure message otherwise
 
492
        """
 
493
        self.log.debug('Checking exit codes for {} commands on {} '
 
494
                       'sentry units...'.format(len(commands),
 
495
                                                len(sentry_units)))
 
496
        for sentry_unit in sentry_units:
 
497
            for cmd in commands:
 
498
                output, code = sentry_unit.run(cmd)
 
499
                if code == 0:
 
500
                    self.log.debug('{} `{}` returned {} '
 
501
                                   '(OK)'.format(sentry_unit.info['unit_name'],
 
502
                                                 cmd, code))
 
503
                else:
 
504
                    return ('{} `{}` returned {} '
 
505
                            '{}'.format(sentry_unit.info['unit_name'],
 
506
                                        cmd, code, output))
 
507
        return None
 
508
 
 
509
    def get_process_id_list(self, sentry_unit, process_name,
 
510
                            expect_success=True):
 
511
        """Get a list of process ID(s) from a single sentry juju unit
 
512
        for a single process name.
 
513
 
 
514
        :param sentry_unit: Amulet sentry instance (juju unit)
 
515
        :param process_name: Process name
 
516
        :param expect_success: If False, expect the PID to be missing,
 
517
            raise if it is present.
 
518
        :returns: List of process IDs
 
519
        """
 
520
        cmd = 'pidof -x {}'.format(process_name)
 
521
        if not expect_success:
 
522
            cmd += " || exit 0 && exit 1"
 
523
        output, code = sentry_unit.run(cmd)
 
524
        if code != 0:
 
525
            msg = ('{} `{}` returned {} '
 
526
                   '{}'.format(sentry_unit.info['unit_name'],
 
527
                               cmd, code, output))
 
528
            amulet.raise_status(amulet.FAIL, msg=msg)
 
529
        return str(output).split()
 
530
 
 
531
    def get_unit_process_ids(self, unit_processes, expect_success=True):
 
532
        """Construct a dict containing unit sentries, process names, and
 
533
        process IDs.
 
534
 
 
535
        :param unit_processes: A dictionary of Amulet sentry instance
 
536
            to list of process names.
 
537
        :param expect_success: if False expect the processes to not be
 
538
            running, raise if they are.
 
539
        :returns: Dictionary of Amulet sentry instance to dictionary
 
540
            of process names to PIDs.
 
541
        """
 
542
        pid_dict = {}
 
543
        for sentry_unit, process_list in six.iteritems(unit_processes):
 
544
            pid_dict[sentry_unit] = {}
 
545
            for process in process_list:
 
546
                pids = self.get_process_id_list(
 
547
                    sentry_unit, process, expect_success=expect_success)
 
548
                pid_dict[sentry_unit].update({process: pids})
 
549
        return pid_dict
 
550
 
 
551
    def validate_unit_process_ids(self, expected, actual):
 
552
        """Validate process id quantities for services on units."""
 
553
        self.log.debug('Checking units for running processes...')
 
554
        self.log.debug('Expected PIDs: {}'.format(expected))
 
555
        self.log.debug('Actual PIDs: {}'.format(actual))
 
556
 
 
557
        if len(actual) != len(expected):
 
558
            return ('Unit count mismatch.  expected, actual: {}, '
 
559
                    '{} '.format(len(expected), len(actual)))
 
560
 
 
561
        for (e_sentry, e_proc_names) in six.iteritems(expected):
 
562
            e_sentry_name = e_sentry.info['unit_name']
 
563
            if e_sentry in actual.keys():
 
564
                a_proc_names = actual[e_sentry]
 
565
            else:
 
566
                return ('Expected sentry ({}) not found in actual dict data.'
 
567
                        '{}'.format(e_sentry_name, e_sentry))
 
568
 
 
569
            if len(e_proc_names.keys()) != len(a_proc_names.keys()):
 
570
                return ('Process name count mismatch.  expected, actual: {}, '
 
571
                        '{}'.format(len(expected), len(actual)))
 
572
 
 
573
            for (e_proc_name, e_pids_length), (a_proc_name, a_pids) in \
 
574
                    zip(e_proc_names.items(), a_proc_names.items()):
 
575
                if e_proc_name != a_proc_name:
 
576
                    return ('Process name mismatch.  expected, actual: {}, '
 
577
                            '{}'.format(e_proc_name, a_proc_name))
 
578
 
 
579
                a_pids_length = len(a_pids)
 
580
                fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
 
581
                            '{}, {} ({})'.format(e_sentry_name, e_proc_name,
 
582
                                                 e_pids_length, a_pids_length,
 
583
                                                 a_pids))
 
584
 
 
585
                # If expected is not bool, ensure PID quantities match
 
586
                if not isinstance(e_pids_length, bool) and \
 
587
                        a_pids_length != e_pids_length:
 
588
                    return fail_msg
 
589
                # If expected is bool True, ensure 1 or more PIDs exist
 
590
                elif isinstance(e_pids_length, bool) and \
 
591
                        e_pids_length is True and a_pids_length < 1:
 
592
                    return fail_msg
 
593
                # If expected is bool False, ensure 0 PIDs exist
 
594
                elif isinstance(e_pids_length, bool) and \
 
595
                        e_pids_length is False and a_pids_length != 0:
 
596
                    return fail_msg
 
597
                else:
 
598
                    self.log.debug('PID check OK: {} {} {}: '
 
599
                                   '{}'.format(e_sentry_name, e_proc_name,
 
600
                                               e_pids_length, a_pids))
 
601
        return None
 
602
 
 
603
    def validate_list_of_identical_dicts(self, list_of_dicts):
 
604
        """Check that all dicts within a list are identical."""
 
605
        hashes = []
 
606
        for _dict in list_of_dicts:
 
607
            hashes.append(hash(frozenset(_dict.items())))
 
608
 
 
609
        self.log.debug('Hashes: {}'.format(hashes))
 
610
        if len(set(hashes)) == 1:
 
611
            self.log.debug('Dicts within list are identical')
 
612
        else:
 
613
            return 'Dicts within list are not identical'
 
614
 
 
615
        return None
 
616
 
 
617
    def validate_sectionless_conf(self, file_contents, expected):
 
618
        """A crude conf parser.  Useful to inspect configuration files which
 
619
        do not have section headers (as would be necessary in order to use
 
620
        the configparser).  Such as openstack-dashboard or rabbitmq confs."""
 
621
        for line in file_contents.split('\n'):
 
622
            if '=' in line:
 
623
                args = line.split('=')
 
624
                if len(args) <= 1:
 
625
                    continue
 
626
                key = args[0].strip()
 
627
                value = args[1].strip()
 
628
                if key in expected.keys():
 
629
                    if expected[key] != value:
 
630
                        msg = ('Config mismatch.  Expected, actual:  {}, '
 
631
                               '{}'.format(expected[key], value))
 
632
                        amulet.raise_status(amulet.FAIL, msg=msg)
 
633
 
 
634
    def get_unit_hostnames(self, units):
 
635
        """Return a dict of juju unit names to hostnames."""
 
636
        host_names = {}
 
637
        for unit in units:
 
638
            host_names[unit.info['unit_name']] = \
 
639
                str(unit.file_contents('/etc/hostname').strip())
 
640
        self.log.debug('Unit host names: {}'.format(host_names))
 
641
        return host_names
 
642
 
 
643
    def run_cmd_unit(self, sentry_unit, cmd):
 
644
        """Run a command on a unit, return the output and exit code."""
 
645
        output, code = sentry_unit.run(cmd)
 
646
        if code == 0:
 
647
            self.log.debug('{} `{}` command returned {} '
 
648
                           '(OK)'.format(sentry_unit.info['unit_name'],
 
649
                                         cmd, code))
 
650
        else:
 
651
            msg = ('{} `{}` command returned {} '
 
652
                   '{}'.format(sentry_unit.info['unit_name'],
 
653
                               cmd, code, output))
 
654
            amulet.raise_status(amulet.FAIL, msg=msg)
 
655
        return str(output), code
 
656
 
 
657
    def file_exists_on_unit(self, sentry_unit, file_name):
 
658
        """Check if a file exists on a unit."""
 
659
        try:
 
660
            sentry_unit.file_stat(file_name)
 
661
            return True
 
662
        except IOError:
 
663
            return False
 
664
        except Exception as e:
 
665
            msg = 'Error checking file {}: {}'.format(file_name, e)
 
666
            amulet.raise_status(amulet.FAIL, msg=msg)
 
667
 
 
668
    def file_contents_safe(self, sentry_unit, file_name,
 
669
                           max_wait=60, fatal=False):
 
670
        """Get file contents from a sentry unit.  Wrap amulet file_contents
 
671
        with retry logic to address races where a file checks as existing,
 
672
        but no longer exists by the time file_contents is called.
 
673
        Return None if file not found. Optionally raise if fatal is True."""
 
674
        unit_name = sentry_unit.info['unit_name']
 
675
        file_contents = False
 
676
        tries = 0
 
677
        while not file_contents and tries < (max_wait / 4):
 
678
            try:
 
679
                file_contents = sentry_unit.file_contents(file_name)
 
680
            except IOError:
 
681
                self.log.debug('Attempt {} to open file {} from {} '
 
682
                               'failed'.format(tries, file_name,
 
683
                                               unit_name))
 
684
                time.sleep(4)
 
685
                tries += 1
 
686
 
 
687
        if file_contents:
 
688
            return file_contents
 
689
        elif not fatal:
 
690
            return None
 
691
        elif fatal:
 
692
            msg = 'Failed to get file contents from unit.'
 
693
            amulet.raise_status(amulet.FAIL, msg)
 
694
 
 
695
    def port_knock_tcp(self, host="localhost", port=22, timeout=15):
 
696
        """Open a TCP socket to check for a listening sevice on a host.
 
697
 
 
698
        :param host: host name or IP address, default to localhost
 
699
        :param port: TCP port number, default to 22
 
700
        :param timeout: Connect timeout, default to 15 seconds
 
701
        :returns: True if successful, False if connect failed
 
702
        """
 
703
 
 
704
        # Resolve host name if possible
 
705
        try:
 
706
            connect_host = socket.gethostbyname(host)
 
707
            host_human = "{} ({})".format(connect_host, host)
 
708
        except socket.error as e:
 
709
            self.log.warn('Unable to resolve address: '
 
710
                          '{} ({}) Trying anyway!'.format(host, e))
 
711
            connect_host = host
 
712
            host_human = connect_host
 
713
 
 
714
        # Attempt socket connection
 
715
        try:
 
716
            knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
717
            knock.settimeout(timeout)
 
718
            knock.connect((connect_host, port))
 
719
            knock.close()
 
720
            self.log.debug('Socket connect OK for host '
 
721
                           '{} on port {}.'.format(host_human, port))
 
722
            return True
 
723
        except socket.error as e:
 
724
            self.log.debug('Socket connect FAIL for'
 
725
                           ' {} port {} ({})'.format(host_human, port, e))
 
726
            return False
 
727
 
 
728
    def port_knock_units(self, sentry_units, port=22,
 
729
                         timeout=15, expect_success=True):
 
730
        """Open a TCP socket to check for a listening sevice on each
 
731
        listed juju unit.
 
732
 
 
733
        :param sentry_units: list of sentry unit pointers
 
734
        :param port: TCP port number, default to 22
 
735
        :param timeout: Connect timeout, default to 15 seconds
 
736
        :expect_success: True by default, set False to invert logic
 
737
        :returns: None if successful, Failure message otherwise
 
738
        """
 
739
        for unit in sentry_units:
 
740
            host = unit.info['public-address']
 
741
            connected = self.port_knock_tcp(host, port, timeout)
 
742
            if not connected and expect_success:
 
743
                return 'Socket connect failed.'
 
744
            elif connected and not expect_success:
 
745
                return 'Socket connected unexpectedly.'
 
746
 
 
747
    def get_uuid_epoch_stamp(self):
 
748
        """Returns a stamp string based on uuid4 and epoch time.  Useful in
 
749
        generating test messages which need to be unique-ish."""
 
750
        return '[{}-{}]'.format(uuid.uuid4(), time.time())
 
751
 
 
752
# amulet juju action helpers:
 
753
    def run_action(self, unit_sentry, action,
 
754
                   _check_output=subprocess.check_output):
 
755
        """Run the named action on a given unit sentry.
 
756
 
 
757
        _check_output parameter is used for dependency injection.
 
758
 
 
759
        @return action_id.
 
760
        """
 
761
        unit_id = unit_sentry.info["unit_name"]
 
762
        command = ["juju", "action", "do", "--format=json", unit_id, action]
 
763
        self.log.info("Running command: %s\n" % " ".join(command))
 
764
        output = _check_output(command, universal_newlines=True)
 
765
        data = json.loads(output)
 
766
        action_id = data[u'Action queued with id']
 
767
        return action_id
 
768
 
 
769
    def wait_on_action(self, action_id, _check_output=subprocess.check_output):
 
770
        """Wait for a given action, returning if it completed or not.
 
771
 
 
772
        _check_output parameter is used for dependency injection.
 
773
        """
 
774
        command = ["juju", "action", "fetch", "--format=json", "--wait=0",
 
775
                   action_id]
 
776
        output = _check_output(command, universal_newlines=True)
 
777
        data = json.loads(output)
 
778
        return data.get(u"status") == "completed"