~openstack-charmers-archive/charms/trusty/swift-storage/trunk

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/contrib/openstack/amulet/utils.py

  • Committer: james.page at ubuntu
  • Date: 2015-08-10 16:39:22 UTC
  • Revision ID: james.page@ubuntu.com-20150810163922-45l3t6prprlctpd2
Tags: 15.07
[gnuoy] 15.07 Charm release

Show diffs side-by-side

added added

removed removed

Lines of Context:
14
14
# You should have received a copy of the GNU Lesser General Public License
15
15
# along with charm-helpers.  If not, see <http://www.gnu.org/licenses/>.
16
16
 
 
17
import amulet
 
18
import json
17
19
import logging
18
20
import os
 
21
import six
19
22
import time
20
23
import urllib
21
24
 
 
25
import cinderclient.v1.client as cinder_client
22
26
import glanceclient.v1.client as glance_client
 
27
import heatclient.v1.client as heat_client
23
28
import keystoneclient.v2_0 as keystone_client
24
29
import novaclient.v1_1.client as nova_client
25
 
 
26
 
import six
 
30
import swiftclient
27
31
 
28
32
from charmhelpers.contrib.amulet.utils import (
29
33
    AmuletUtils
37
41
    """OpenStack amulet utilities.
38
42
 
39
43
       This class inherits from AmuletUtils and has additional support
40
 
       that is specifically for use by OpenStack charms.
 
44
       that is specifically for use by OpenStack charm tests.
41
45
       """
42
46
 
43
47
    def __init__(self, log_level=ERROR):
51
55
           Validate actual endpoint data vs expected endpoint data. The ports
52
56
           are used to find the matching endpoint.
53
57
           """
 
58
        self.log.debug('Validating endpoint data...')
 
59
        self.log.debug('actual: {}'.format(repr(endpoints)))
54
60
        found = False
55
61
        for ep in endpoints:
56
62
            self.log.debug('endpoint: {}'.format(repr(ep)))
77
83
           Validate a list of actual service catalog endpoints vs a list of
78
84
           expected service catalog endpoints.
79
85
           """
 
86
        self.log.debug('Validating service catalog endpoint data...')
80
87
        self.log.debug('actual: {}'.format(repr(actual)))
81
88
        for k, v in six.iteritems(expected):
82
89
            if k in actual:
93
100
           Validate a list of actual tenant data vs list of expected tenant
94
101
           data.
95
102
           """
 
103
        self.log.debug('Validating tenant data...')
96
104
        self.log.debug('actual: {}'.format(repr(actual)))
97
105
        for e in expected:
98
106
            found = False
114
122
           Validate a list of actual role data vs a list of expected role
115
123
           data.
116
124
           """
 
125
        self.log.debug('Validating role data...')
117
126
        self.log.debug('actual: {}'.format(repr(actual)))
118
127
        for e in expected:
119
128
            found = False
134
143
           Validate a list of actual user data vs a list of expected user
135
144
           data.
136
145
           """
 
146
        self.log.debug('Validating user data...')
137
147
        self.log.debug('actual: {}'.format(repr(actual)))
138
148
        for e in expected:
139
149
            found = False
155
165
 
156
166
           Validate a list of actual flavors vs a list of expected flavors.
157
167
           """
 
168
        self.log.debug('Validating flavor data...')
158
169
        self.log.debug('actual: {}'.format(repr(actual)))
159
170
        act = [a.name for a in actual]
160
171
        return self._validate_list_data(expected, act)
161
172
 
162
173
    def tenant_exists(self, keystone, tenant):
163
174
        """Return True if tenant exists."""
 
175
        self.log.debug('Checking if tenant exists ({})...'.format(tenant))
164
176
        return tenant in [t.name for t in keystone.tenants.list()]
165
177
 
 
178
    def authenticate_cinder_admin(self, keystone_sentry, username,
 
179
                                  password, tenant):
 
180
        """Authenticates admin user with cinder."""
 
181
        # NOTE(beisner): cinder python client doesn't accept tokens.
 
182
        service_ip = \
 
183
            keystone_sentry.relation('shared-db',
 
184
                                     'mysql:shared-db')['private-address']
 
185
        ept = "http://{}:5000/v2.0".format(service_ip.strip().decode('utf-8'))
 
186
        return cinder_client.Client(username, password, tenant, ept)
 
187
 
166
188
    def authenticate_keystone_admin(self, keystone_sentry, user, password,
167
189
                                    tenant):
168
190
        """Authenticates admin user with the keystone admin endpoint."""
 
191
        self.log.debug('Authenticating keystone admin...')
169
192
        unit = keystone_sentry
170
193
        service_ip = unit.relation('shared-db',
171
194
                                   'mysql:shared-db')['private-address']
175
198
 
176
199
    def authenticate_keystone_user(self, keystone, user, password, tenant):
177
200
        """Authenticates a regular user with the keystone public endpoint."""
 
201
        self.log.debug('Authenticating keystone user ({})...'.format(user))
178
202
        ep = keystone.service_catalog.url_for(service_type='identity',
179
203
                                              endpoint_type='publicURL')
180
204
        return keystone_client.Client(username=user, password=password,
182
206
 
183
207
    def authenticate_glance_admin(self, keystone):
184
208
        """Authenticates admin user with glance."""
 
209
        self.log.debug('Authenticating glance admin...')
185
210
        ep = keystone.service_catalog.url_for(service_type='image',
186
211
                                              endpoint_type='adminURL')
187
212
        return glance_client.Client(ep, token=keystone.auth_token)
188
213
 
 
214
    def authenticate_heat_admin(self, keystone):
 
215
        """Authenticates the admin user with heat."""
 
216
        self.log.debug('Authenticating heat admin...')
 
217
        ep = keystone.service_catalog.url_for(service_type='orchestration',
 
218
                                              endpoint_type='publicURL')
 
219
        return heat_client.Client(endpoint=ep, token=keystone.auth_token)
 
220
 
189
221
    def authenticate_nova_user(self, keystone, user, password, tenant):
190
222
        """Authenticates a regular user with nova-api."""
 
223
        self.log.debug('Authenticating nova user ({})...'.format(user))
191
224
        ep = keystone.service_catalog.url_for(service_type='identity',
192
225
                                              endpoint_type='publicURL')
193
226
        return nova_client.Client(username=user, api_key=password,
194
227
                                  project_id=tenant, auth_url=ep)
195
228
 
 
229
    def authenticate_swift_user(self, keystone, user, password, tenant):
 
230
        """Authenticates a regular user with swift api."""
 
231
        self.log.debug('Authenticating swift user ({})...'.format(user))
 
232
        ep = keystone.service_catalog.url_for(service_type='identity',
 
233
                                              endpoint_type='publicURL')
 
234
        return swiftclient.Connection(authurl=ep,
 
235
                                      user=user,
 
236
                                      key=password,
 
237
                                      tenant_name=tenant,
 
238
                                      auth_version='2.0')
 
239
 
196
240
    def create_cirros_image(self, glance, image_name):
197
 
        """Download the latest cirros image and upload it to glance."""
 
241
        """Download the latest cirros image and upload it to glance,
 
242
        validate and return a resource pointer.
 
243
 
 
244
        :param glance: pointer to authenticated glance connection
 
245
        :param image_name: display name for new image
 
246
        :returns: glance image pointer
 
247
        """
 
248
        self.log.debug('Creating glance cirros image '
 
249
                       '({})...'.format(image_name))
 
250
 
 
251
        # Download cirros image
198
252
        http_proxy = os.getenv('AMULET_HTTP_PROXY')
199
253
        self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
200
254
        if http_proxy:
203
257
        else:
204
258
            opener = urllib.FancyURLopener()
205
259
 
206
 
        f = opener.open("http://download.cirros-cloud.net/version/released")
 
260
        f = opener.open('http://download.cirros-cloud.net/version/released')
207
261
        version = f.read().strip()
208
 
        cirros_img = "cirros-{}-x86_64-disk.img".format(version)
 
262
        cirros_img = 'cirros-{}-x86_64-disk.img'.format(version)
209
263
        local_path = os.path.join('tests', cirros_img)
210
264
 
211
265
        if not os.path.exists(local_path):
212
 
            cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net",
 
266
            cirros_url = 'http://{}/{}/{}'.format('download.cirros-cloud.net',
213
267
                                                  version, cirros_img)
214
268
            opener.retrieve(cirros_url, local_path)
215
269
        f.close()
216
270
 
 
271
        # Create glance image
217
272
        with open(local_path) as f:
218
273
            image = glance.images.create(name=image_name, is_public=True,
219
274
                                         disk_format='qcow2',
220
275
                                         container_format='bare', data=f)
221
 
        count = 1
222
 
        status = image.status
223
 
        while status != 'active' and count < 10:
224
 
            time.sleep(3)
225
 
            image = glance.images.get(image.id)
226
 
            status = image.status
227
 
            self.log.debug('image status: {}'.format(status))
228
 
            count += 1
229
 
 
230
 
        if status != 'active':
231
 
            self.log.error('image creation timed out')
232
 
            return None
 
276
 
 
277
        # Wait for image to reach active status
 
278
        img_id = image.id
 
279
        ret = self.resource_reaches_status(glance.images, img_id,
 
280
                                           expected_stat='active',
 
281
                                           msg='Image status wait')
 
282
        if not ret:
 
283
            msg = 'Glance image failed to reach expected state.'
 
284
            amulet.raise_status(amulet.FAIL, msg=msg)
 
285
 
 
286
        # Re-validate new image
 
287
        self.log.debug('Validating image attributes...')
 
288
        val_img_name = glance.images.get(img_id).name
 
289
        val_img_stat = glance.images.get(img_id).status
 
290
        val_img_pub = glance.images.get(img_id).is_public
 
291
        val_img_cfmt = glance.images.get(img_id).container_format
 
292
        val_img_dfmt = glance.images.get(img_id).disk_format
 
293
        msg_attr = ('Image attributes - name:{} public:{} id:{} stat:{} '
 
294
                    'container fmt:{} disk fmt:{}'.format(
 
295
                        val_img_name, val_img_pub, img_id,
 
296
                        val_img_stat, val_img_cfmt, val_img_dfmt))
 
297
 
 
298
        if val_img_name == image_name and val_img_stat == 'active' \
 
299
                and val_img_pub is True and val_img_cfmt == 'bare' \
 
300
                and val_img_dfmt == 'qcow2':
 
301
            self.log.debug(msg_attr)
 
302
        else:
 
303
            msg = ('Volume validation failed, {}'.format(msg_attr))
 
304
            amulet.raise_status(amulet.FAIL, msg=msg)
233
305
 
234
306
        return image
235
307
 
236
308
    def delete_image(self, glance, image):
237
309
        """Delete the specified image."""
238
 
        num_before = len(list(glance.images.list()))
239
 
        glance.images.delete(image)
240
 
 
241
 
        count = 1
242
 
        num_after = len(list(glance.images.list()))
243
 
        while num_after != (num_before - 1) and count < 10:
244
 
            time.sleep(3)
245
 
            num_after = len(list(glance.images.list()))
246
 
            self.log.debug('number of images: {}'.format(num_after))
247
 
            count += 1
248
 
 
249
 
        if num_after != (num_before - 1):
250
 
            self.log.error('image deletion timed out')
251
 
            return False
252
 
 
253
 
        return True
 
310
 
 
311
        # /!\ DEPRECATION WARNING
 
312
        self.log.warn('/!\\ DEPRECATION WARNING:  use '
 
313
                      'delete_resource instead of delete_image.')
 
314
        self.log.debug('Deleting glance image ({})...'.format(image))
 
315
        return self.delete_resource(glance.images, image, msg='glance image')
254
316
 
255
317
    def create_instance(self, nova, image_name, instance_name, flavor):
256
318
        """Create the specified instance."""
 
319
        self.log.debug('Creating instance '
 
320
                       '({}|{}|{})'.format(instance_name, image_name, flavor))
257
321
        image = nova.images.find(name=image_name)
258
322
        flavor = nova.flavors.find(name=flavor)
259
323
        instance = nova.servers.create(name=instance_name, image=image,
276
340
 
277
341
    def delete_instance(self, nova, instance):
278
342
        """Delete the specified instance."""
279
 
        num_before = len(list(nova.servers.list()))
280
 
        nova.servers.delete(instance)
281
 
 
282
 
        count = 1
283
 
        num_after = len(list(nova.servers.list()))
284
 
        while num_after != (num_before - 1) and count < 10:
285
 
            time.sleep(3)
286
 
            num_after = len(list(nova.servers.list()))
287
 
            self.log.debug('number of instances: {}'.format(num_after))
288
 
            count += 1
289
 
 
290
 
        if num_after != (num_before - 1):
291
 
            self.log.error('instance deletion timed out')
292
 
            return False
293
 
 
294
 
        return True
 
343
 
 
344
        # /!\ DEPRECATION WARNING
 
345
        self.log.warn('/!\\ DEPRECATION WARNING:  use '
 
346
                      'delete_resource instead of delete_instance.')
 
347
        self.log.debug('Deleting instance ({})...'.format(instance))
 
348
        return self.delete_resource(nova.servers, instance,
 
349
                                    msg='nova instance')
 
350
 
 
351
    def create_or_get_keypair(self, nova, keypair_name="testkey"):
 
352
        """Create a new keypair, or return pointer if it already exists."""
 
353
        try:
 
354
            _keypair = nova.keypairs.get(keypair_name)
 
355
            self.log.debug('Keypair ({}) already exists, '
 
356
                           'using it.'.format(keypair_name))
 
357
            return _keypair
 
358
        except:
 
359
            self.log.debug('Keypair ({}) does not exist, '
 
360
                           'creating it.'.format(keypair_name))
 
361
 
 
362
        _keypair = nova.keypairs.create(name=keypair_name)
 
363
        return _keypair
 
364
 
 
365
    def create_cinder_volume(self, cinder, vol_name="demo-vol", vol_size=1,
 
366
                             img_id=None, src_vol_id=None, snap_id=None):
 
367
        """Create cinder volume, optionally from a glance image, OR
 
368
        optionally as a clone of an existing volume, OR optionally
 
369
        from a snapshot.  Wait for the new volume status to reach
 
370
        the expected status, validate and return a resource pointer.
 
371
 
 
372
        :param vol_name: cinder volume display name
 
373
        :param vol_size: size in gigabytes
 
374
        :param img_id: optional glance image id
 
375
        :param src_vol_id: optional source volume id to clone
 
376
        :param snap_id: optional snapshot id to use
 
377
        :returns: cinder volume pointer
 
378
        """
 
379
        # Handle parameter input and avoid impossible combinations
 
380
        if img_id and not src_vol_id and not snap_id:
 
381
            # Create volume from image
 
382
            self.log.debug('Creating cinder volume from glance image...')
 
383
            bootable = 'true'
 
384
        elif src_vol_id and not img_id and not snap_id:
 
385
            # Clone an existing volume
 
386
            self.log.debug('Cloning cinder volume...')
 
387
            bootable = cinder.volumes.get(src_vol_id).bootable
 
388
        elif snap_id and not src_vol_id and not img_id:
 
389
            # Create volume from snapshot
 
390
            self.log.debug('Creating cinder volume from snapshot...')
 
391
            snap = cinder.volume_snapshots.find(id=snap_id)
 
392
            vol_size = snap.size
 
393
            snap_vol_id = cinder.volume_snapshots.get(snap_id).volume_id
 
394
            bootable = cinder.volumes.get(snap_vol_id).bootable
 
395
        elif not img_id and not src_vol_id and not snap_id:
 
396
            # Create volume
 
397
            self.log.debug('Creating cinder volume...')
 
398
            bootable = 'false'
 
399
        else:
 
400
            # Impossible combination of parameters
 
401
            msg = ('Invalid method use - name:{} size:{} img_id:{} '
 
402
                   'src_vol_id:{} snap_id:{}'.format(vol_name, vol_size,
 
403
                                                     img_id, src_vol_id,
 
404
                                                     snap_id))
 
405
            amulet.raise_status(amulet.FAIL, msg=msg)
 
406
 
 
407
        # Create new volume
 
408
        try:
 
409
            vol_new = cinder.volumes.create(display_name=vol_name,
 
410
                                            imageRef=img_id,
 
411
                                            size=vol_size,
 
412
                                            source_volid=src_vol_id,
 
413
                                            snapshot_id=snap_id)
 
414
            vol_id = vol_new.id
 
415
        except Exception as e:
 
416
            msg = 'Failed to create volume: {}'.format(e)
 
417
            amulet.raise_status(amulet.FAIL, msg=msg)
 
418
 
 
419
        # Wait for volume to reach available status
 
420
        ret = self.resource_reaches_status(cinder.volumes, vol_id,
 
421
                                           expected_stat="available",
 
422
                                           msg="Volume status wait")
 
423
        if not ret:
 
424
            msg = 'Cinder volume failed to reach expected state.'
 
425
            amulet.raise_status(amulet.FAIL, msg=msg)
 
426
 
 
427
        # Re-validate new volume
 
428
        self.log.debug('Validating volume attributes...')
 
429
        val_vol_name = cinder.volumes.get(vol_id).display_name
 
430
        val_vol_boot = cinder.volumes.get(vol_id).bootable
 
431
        val_vol_stat = cinder.volumes.get(vol_id).status
 
432
        val_vol_size = cinder.volumes.get(vol_id).size
 
433
        msg_attr = ('Volume attributes - name:{} id:{} stat:{} boot:'
 
434
                    '{} size:{}'.format(val_vol_name, vol_id,
 
435
                                        val_vol_stat, val_vol_boot,
 
436
                                        val_vol_size))
 
437
 
 
438
        if val_vol_boot == bootable and val_vol_stat == 'available' \
 
439
                and val_vol_name == vol_name and val_vol_size == vol_size:
 
440
            self.log.debug(msg_attr)
 
441
        else:
 
442
            msg = ('Volume validation failed, {}'.format(msg_attr))
 
443
            amulet.raise_status(amulet.FAIL, msg=msg)
 
444
 
 
445
        return vol_new
 
446
 
 
447
    def delete_resource(self, resource, resource_id,
 
448
                        msg="resource", max_wait=120):
 
449
        """Delete one openstack resource, such as one instance, keypair,
 
450
        image, volume, stack, etc., and confirm deletion within max wait time.
 
451
 
 
452
        :param resource: pointer to os resource type, ex:glance_client.images
 
453
        :param resource_id: unique name or id for the openstack resource
 
454
        :param msg: text to identify purpose in logging
 
455
        :param max_wait: maximum wait time in seconds
 
456
        :returns: True if successful, otherwise False
 
457
        """
 
458
        self.log.debug('Deleting OpenStack resource '
 
459
                       '{} ({})'.format(resource_id, msg))
 
460
        num_before = len(list(resource.list()))
 
461
        resource.delete(resource_id)
 
462
 
 
463
        tries = 0
 
464
        num_after = len(list(resource.list()))
 
465
        while num_after != (num_before - 1) and tries < (max_wait / 4):
 
466
            self.log.debug('{} delete check: '
 
467
                           '{} [{}:{}] {}'.format(msg, tries,
 
468
                                                  num_before,
 
469
                                                  num_after,
 
470
                                                  resource_id))
 
471
            time.sleep(4)
 
472
            num_after = len(list(resource.list()))
 
473
            tries += 1
 
474
 
 
475
        self.log.debug('{}:  expected, actual count = {}, '
 
476
                       '{}'.format(msg, num_before - 1, num_after))
 
477
 
 
478
        if num_after == (num_before - 1):
 
479
            return True
 
480
        else:
 
481
            self.log.error('{} delete timed out'.format(msg))
 
482
            return False
 
483
 
 
484
    def resource_reaches_status(self, resource, resource_id,
 
485
                                expected_stat='available',
 
486
                                msg='resource', max_wait=120):
 
487
        """Wait for an openstack resources status to reach an
 
488
           expected status within a specified time.  Useful to confirm that
 
489
           nova instances, cinder vols, snapshots, glance images, heat stacks
 
490
           and other resources eventually reach the expected status.
 
491
 
 
492
        :param resource: pointer to os resource type, ex: heat_client.stacks
 
493
        :param resource_id: unique id for the openstack resource
 
494
        :param expected_stat: status to expect resource to reach
 
495
        :param msg: text to identify purpose in logging
 
496
        :param max_wait: maximum wait time in seconds
 
497
        :returns: True if successful, False if status is not reached
 
498
        """
 
499
 
 
500
        tries = 0
 
501
        resource_stat = resource.get(resource_id).status
 
502
        while resource_stat != expected_stat and tries < (max_wait / 4):
 
503
            self.log.debug('{} status check: '
 
504
                           '{} [{}:{}] {}'.format(msg, tries,
 
505
                                                  resource_stat,
 
506
                                                  expected_stat,
 
507
                                                  resource_id))
 
508
            time.sleep(4)
 
509
            resource_stat = resource.get(resource_id).status
 
510
            tries += 1
 
511
 
 
512
        self.log.debug('{}:  expected, actual status = {}, '
 
513
                       '{}'.format(msg, resource_stat, expected_stat))
 
514
 
 
515
        if resource_stat == expected_stat:
 
516
            return True
 
517
        else:
 
518
            self.log.debug('{} never reached expected status: '
 
519
                           '{}'.format(resource_id, expected_stat))
 
520
            return False
 
521
 
 
522
    def get_ceph_osd_id_cmd(self, index):
 
523
        """Produce a shell command that will return a ceph-osd id."""
 
524
        return ("`initctl list | grep 'ceph-osd ' | "
 
525
                "awk 'NR=={} {{ print $2 }}' | "
 
526
                "grep -o '[0-9]*'`".format(index + 1))
 
527
 
 
528
    def get_ceph_pools(self, sentry_unit):
 
529
        """Return a dict of ceph pools from a single ceph unit, with
 
530
        pool name as keys, pool id as vals."""
 
531
        pools = {}
 
532
        cmd = 'sudo ceph osd lspools'
 
533
        output, code = sentry_unit.run(cmd)
 
534
        if code != 0:
 
535
            msg = ('{} `{}` returned {} '
 
536
                   '{}'.format(sentry_unit.info['unit_name'],
 
537
                               cmd, code, output))
 
538
            amulet.raise_status(amulet.FAIL, msg=msg)
 
539
 
 
540
        # Example output: 0 data,1 metadata,2 rbd,3 cinder,4 glance,
 
541
        for pool in str(output).split(','):
 
542
            pool_id_name = pool.split(' ')
 
543
            if len(pool_id_name) == 2:
 
544
                pool_id = pool_id_name[0]
 
545
                pool_name = pool_id_name[1]
 
546
                pools[pool_name] = int(pool_id)
 
547
 
 
548
        self.log.debug('Pools on {}: {}'.format(sentry_unit.info['unit_name'],
 
549
                                                pools))
 
550
        return pools
 
551
 
 
552
    def get_ceph_df(self, sentry_unit):
 
553
        """Return dict of ceph df json output, including ceph pool state.
 
554
 
 
555
        :param sentry_unit: Pointer to amulet sentry instance (juju unit)
 
556
        :returns: Dict of ceph df output
 
557
        """
 
558
        cmd = 'sudo ceph df --format=json'
 
559
        output, code = sentry_unit.run(cmd)
 
560
        if code != 0:
 
561
            msg = ('{} `{}` returned {} '
 
562
                   '{}'.format(sentry_unit.info['unit_name'],
 
563
                               cmd, code, output))
 
564
            amulet.raise_status(amulet.FAIL, msg=msg)
 
565
        return json.loads(output)
 
566
 
 
567
    def get_ceph_pool_sample(self, sentry_unit, pool_id=0):
 
568
        """Take a sample of attributes of a ceph pool, returning ceph
 
569
        pool name, object count and disk space used for the specified
 
570
        pool ID number.
 
571
 
 
572
        :param sentry_unit: Pointer to amulet sentry instance (juju unit)
 
573
        :param pool_id: Ceph pool ID
 
574
        :returns: List of pool name, object count, kb disk space used
 
575
        """
 
576
        df = self.get_ceph_df(sentry_unit)
 
577
        pool_name = df['pools'][pool_id]['name']
 
578
        obj_count = df['pools'][pool_id]['stats']['objects']
 
579
        kb_used = df['pools'][pool_id]['stats']['kb_used']
 
580
        self.log.debug('Ceph {} pool (ID {}): {} objects, '
 
581
                       '{} kb used'.format(pool_name, pool_id,
 
582
                                           obj_count, kb_used))
 
583
        return pool_name, obj_count, kb_used
 
584
 
 
585
    def validate_ceph_pool_samples(self, samples, sample_type="resource pool"):
 
586
        """Validate ceph pool samples taken over time, such as pool
 
587
        object counts or pool kb used, before adding, after adding, and
 
588
        after deleting items which affect those pool attributes.  The
 
589
        2nd element is expected to be greater than the 1st; 3rd is expected
 
590
        to be less than the 2nd.
 
591
 
 
592
        :param samples: List containing 3 data samples
 
593
        :param sample_type: String for logging and usage context
 
594
        :returns: None if successful, Failure message otherwise
 
595
        """
 
596
        original, created, deleted = range(3)
 
597
        if samples[created] <= samples[original] or \
 
598
                samples[deleted] >= samples[created]:
 
599
            return ('Ceph {} samples ({}) '
 
600
                    'unexpected.'.format(sample_type, samples))
 
601
        else:
 
602
            self.log.debug('Ceph {} samples (OK): '
 
603
                           '{}'.format(sample_type, samples))
 
604
            return None