~hopem/charms/trusty/cinder-ceph/lp1535062

« back to all changes in this revision

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

  • Committer: Liam Young
  • Date: 2015-07-29 10:50:52 UTC
  • Revision ID: liam.young@canonical.com-20150729105052-hus83ph7t07wlfsd
[gnuoy,trivial] Pre-release charmhelper sync

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
19
21
import six
20
22
import time
21
23
import urllib
22
24
 
 
25
import cinderclient.v1.client as cinder_client
23
26
import glanceclient.v1.client as glance_client
24
27
import heatclient.v1.client as heat_client
25
28
import keystoneclient.v2_0 as keystone_client
26
29
import novaclient.v1_1.client as nova_client
 
30
import swiftclient
27
31
 
28
32
from charmhelpers.contrib.amulet.utils import (
29
33
    AmuletUtils
171
175
        self.log.debug('Checking if tenant exists ({})...'.format(tenant))
172
176
        return tenant in [t.name for t in keystone.tenants.list()]
173
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
 
174
188
    def authenticate_keystone_admin(self, keystone_sentry, user, password,
175
189
                                    tenant):
176
190
        """Authenticates admin user with the keystone admin endpoint."""
212
226
        return nova_client.Client(username=user, api_key=password,
213
227
                                  project_id=tenant, auth_url=ep)
214
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
 
215
240
    def create_cirros_image(self, glance, image_name):
216
 
        """Download the latest cirros image and upload it to glance."""
217
 
        self.log.debug('Creating glance image ({})...'.format(image_name))
 
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
218
252
        http_proxy = os.getenv('AMULET_HTTP_PROXY')
219
253
        self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
220
254
        if http_proxy:
223
257
        else:
224
258
            opener = urllib.FancyURLopener()
225
259
 
226
 
        f = opener.open("http://download.cirros-cloud.net/version/released")
 
260
        f = opener.open('http://download.cirros-cloud.net/version/released')
227
261
        version = f.read().strip()
228
 
        cirros_img = "cirros-{}-x86_64-disk.img".format(version)
 
262
        cirros_img = 'cirros-{}-x86_64-disk.img'.format(version)
229
263
        local_path = os.path.join('tests', cirros_img)
230
264
 
231
265
        if not os.path.exists(local_path):
232
 
            cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net",
 
266
            cirros_url = 'http://{}/{}/{}'.format('download.cirros-cloud.net',
233
267
                                                  version, cirros_img)
234
268
            opener.retrieve(cirros_url, local_path)
235
269
        f.close()
236
270
 
 
271
        # Create glance image
237
272
        with open(local_path) as f:
238
273
            image = glance.images.create(name=image_name, is_public=True,
239
274
                                         disk_format='qcow2',
240
275
                                         container_format='bare', data=f)
241
 
        count = 1
242
 
        status = image.status
243
 
        while status != 'active' and count < 10:
244
 
            time.sleep(3)
245
 
            image = glance.images.get(image.id)
246
 
            status = image.status
247
 
            self.log.debug('image status: {}'.format(status))
248
 
            count += 1
249
 
 
250
 
        if status != 'active':
251
 
            self.log.error('image creation timed out')
252
 
            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)
253
305
 
254
306
        return image
255
307
 
260
312
        self.log.warn('/!\\ DEPRECATION WARNING:  use '
261
313
                      'delete_resource instead of delete_image.')
262
314
        self.log.debug('Deleting glance image ({})...'.format(image))
263
 
        num_before = len(list(glance.images.list()))
264
 
        glance.images.delete(image)
265
 
 
266
 
        count = 1
267
 
        num_after = len(list(glance.images.list()))
268
 
        while num_after != (num_before - 1) and count < 10:
269
 
            time.sleep(3)
270
 
            num_after = len(list(glance.images.list()))
271
 
            self.log.debug('number of images: {}'.format(num_after))
272
 
            count += 1
273
 
 
274
 
        if num_after != (num_before - 1):
275
 
            self.log.error('image deletion timed out')
276
 
            return False
277
 
 
278
 
        return True
 
315
        return self.delete_resource(glance.images, image, msg='glance image')
279
316
 
280
317
    def create_instance(self, nova, image_name, instance_name, flavor):
281
318
        """Create the specified instance."""
308
345
        self.log.warn('/!\\ DEPRECATION WARNING:  use '
309
346
                      'delete_resource instead of delete_instance.')
310
347
        self.log.debug('Deleting instance ({})...'.format(instance))
311
 
        num_before = len(list(nova.servers.list()))
312
 
        nova.servers.delete(instance)
313
 
 
314
 
        count = 1
315
 
        num_after = len(list(nova.servers.list()))
316
 
        while num_after != (num_before - 1) and count < 10:
317
 
            time.sleep(3)
318
 
            num_after = len(list(nova.servers.list()))
319
 
            self.log.debug('number of instances: {}'.format(num_after))
320
 
            count += 1
321
 
 
322
 
        if num_after != (num_before - 1):
323
 
            self.log.error('instance deletion timed out')
324
 
            return False
325
 
 
326
 
        return True
 
348
        return self.delete_resource(nova.servers, instance,
 
349
                                    msg='nova instance')
327
350
 
328
351
    def create_or_get_keypair(self, nova, keypair_name="testkey"):
329
352
        """Create a new keypair, or return pointer if it already exists."""
339
362
        _keypair = nova.keypairs.create(name=keypair_name)
340
363
        return _keypair
341
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
 
342
447
    def delete_resource(self, resource, resource_id,
343
448
                        msg="resource", max_wait=120):
344
449
        """Delete one openstack resource, such as one instance, keypair,
350
455
        :param max_wait: maximum wait time in seconds
351
456
        :returns: True if successful, otherwise False
352
457
        """
 
458
        self.log.debug('Deleting OpenStack resource '
 
459
                       '{} ({})'.format(resource_id, msg))
353
460
        num_before = len(list(resource.list()))
354
461
        resource.delete(resource_id)
355
462
 
411
518
            self.log.debug('{} never reached expected status: '
412
519
                           '{}'.format(resource_id, expected_stat))
413
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