~james-page/charms/trusty/glance/tox

« back to all changes in this revision

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

  • Committer: james.page at ubuntu
  • Date: 2015-07-07 14:02:07 UTC
  • mfrom: (121.1.5 glance)
  • Revision ID: james.page@ubuntu.com-20150707140207-k0wvswzi7qq6e94t
Update amulet tests for Kilo, prep for wily. Sync hooks/charmhelpers; Sync tests/charmhelpers.

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 json
17
18
import logging
18
19
import os
19
20
import six
20
21
import time
21
22
import urllib
22
23
 
 
24
import cinderclient.v1.client as cinder_client
23
25
import glanceclient.v1.client as glance_client
24
26
import heatclient.v1.client as heat_client
25
27
import keystoneclient.v2_0 as keystone_client
26
28
import novaclient.v1_1.client as nova_client
 
29
import swiftclient
27
30
 
28
31
from charmhelpers.contrib.amulet.utils import (
29
32
    AmuletUtils
171
174
        self.log.debug('Checking if tenant exists ({})...'.format(tenant))
172
175
        return tenant in [t.name for t in keystone.tenants.list()]
173
176
 
 
177
    def authenticate_cinder_admin(self, keystone_sentry, username,
 
178
                                  password, tenant):
 
179
        """Authenticates admin user with cinder."""
 
180
        service_ip = \
 
181
            keystone_sentry.relation('shared-db',
 
182
                                     'mysql:shared-db')['private-address']
 
183
        ept = "http://{}:5000/v2.0".format(service_ip.strip().decode('utf-8'))
 
184
        return cinder_client.Client(username, password, tenant, ept)
 
185
 
174
186
    def authenticate_keystone_admin(self, keystone_sentry, user, password,
175
187
                                    tenant):
176
188
        """Authenticates admin user with the keystone admin endpoint."""
212
224
        return nova_client.Client(username=user, api_key=password,
213
225
                                  project_id=tenant, auth_url=ep)
214
226
 
 
227
    def authenticate_swift_user(self, keystone, user, password, tenant):
 
228
        """Authenticates a regular user with swift api."""
 
229
        self.log.debug('Authenticating swift user ({})...'.format(user))
 
230
        ep = keystone.service_catalog.url_for(service_type='identity',
 
231
                                              endpoint_type='publicURL')
 
232
        return swiftclient.Connection(authurl=ep,
 
233
                                      user=user,
 
234
                                      key=password,
 
235
                                      tenant_name=tenant,
 
236
                                      auth_version='2.0')
 
237
 
215
238
    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))
 
239
        """Download the latest cirros image and upload it to glance,
 
240
        validate and return a resource pointer.
 
241
 
 
242
        :param glance: pointer to authenticated glance connection
 
243
        :param image_name: display name for new image
 
244
        :returns: glance image pointer
 
245
        """
 
246
        self.log.debug('Creating glance cirros image '
 
247
                       '({})...'.format(image_name))
 
248
 
 
249
        # Download cirros image
218
250
        http_proxy = os.getenv('AMULET_HTTP_PROXY')
219
251
        self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
220
252
        if http_proxy:
223
255
        else:
224
256
            opener = urllib.FancyURLopener()
225
257
 
226
 
        f = opener.open("http://download.cirros-cloud.net/version/released")
 
258
        f = opener.open('http://download.cirros-cloud.net/version/released')
227
259
        version = f.read().strip()
228
 
        cirros_img = "cirros-{}-x86_64-disk.img".format(version)
 
260
        cirros_img = 'cirros-{}-x86_64-disk.img'.format(version)
229
261
        local_path = os.path.join('tests', cirros_img)
230
262
 
231
263
        if not os.path.exists(local_path):
232
 
            cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net",
 
264
            cirros_url = 'http://{}/{}/{}'.format('download.cirros-cloud.net',
233
265
                                                  version, cirros_img)
234
266
            opener.retrieve(cirros_url, local_path)
235
267
        f.close()
236
268
 
 
269
        # Create glance image
237
270
        with open(local_path) as f:
238
271
            image = glance.images.create(name=image_name, is_public=True,
239
272
                                         disk_format='qcow2',
240
273
                                         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
 
274
 
 
275
        # Wait for image to reach active status
 
276
        img_id = image.id
 
277
        ret = self.resource_reaches_status(glance.images, img_id,
 
278
                                           expected_stat='active',
 
279
                                           msg='Image status wait')
 
280
        if not ret:
 
281
            msg = 'Glance image failed to reach expected state.'
 
282
            raise RuntimeError(msg)
 
283
 
 
284
        # Re-validate new image
 
285
        self.log.debug('Validating image attributes...')
 
286
        val_img_name = glance.images.get(img_id).name
 
287
        val_img_stat = glance.images.get(img_id).status
 
288
        val_img_pub = glance.images.get(img_id).is_public
 
289
        val_img_cfmt = glance.images.get(img_id).container_format
 
290
        val_img_dfmt = glance.images.get(img_id).disk_format
 
291
        msg_attr = ('Image attributes - name:{} public:{} id:{} stat:{} '
 
292
                    'container fmt:{} disk fmt:{}'.format(
 
293
                        val_img_name, val_img_pub, img_id,
 
294
                        val_img_stat, val_img_cfmt, val_img_dfmt))
 
295
 
 
296
        if val_img_name == image_name and val_img_stat == 'active' \
 
297
                and val_img_pub is True and val_img_cfmt == 'bare' \
 
298
                and val_img_dfmt == 'qcow2':
 
299
            self.log.debug(msg_attr)
 
300
        else:
 
301
            msg = ('Volume validation failed, {}'.format(msg_attr))
 
302
            raise RuntimeError(msg)
253
303
 
254
304
        return image
255
305
 
260
310
        self.log.warn('/!\\ DEPRECATION WARNING:  use '
261
311
                      'delete_resource instead of delete_image.')
262
312
        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
 
313
        return self.delete_resource(glance.images, image, msg='glance image')
279
314
 
280
315
    def create_instance(self, nova, image_name, instance_name, flavor):
281
316
        """Create the specified instance."""
308
343
        self.log.warn('/!\\ DEPRECATION WARNING:  use '
309
344
                      'delete_resource instead of delete_instance.')
310
345
        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
 
346
        return self.delete_resource(nova.servers, instance,
 
347
                                    msg='nova instance')
327
348
 
328
349
    def create_or_get_keypair(self, nova, keypair_name="testkey"):
329
350
        """Create a new keypair, or return pointer if it already exists."""
339
360
        _keypair = nova.keypairs.create(name=keypair_name)
340
361
        return _keypair
341
362
 
 
363
    def create_cinder_volume(self, cinder, vol_name="demo-vol", vol_size=1,
 
364
                             img_id=None, src_vol_id=None, snap_id=None):
 
365
        """Create cinder volume, optionally from a glance image, or
 
366
        optionally as a clone of an existing volume, or optionally
 
367
        from a snapshot.  Wait for the new volume status to reach
 
368
        the expected status, validate and return a resource pointer.
 
369
 
 
370
        :param vol_name: cinder volume display name
 
371
        :param vol_size: size in gigabytes
 
372
        :param img_id: optional glance image id
 
373
        :param src_vol_id: optional source volume id to clone
 
374
        :param snap_id: optional snapshot id to use
 
375
        :returns: cinder volume pointer
 
376
        """
 
377
        # Handle parameter input
 
378
        if img_id and not src_vol_id and not snap_id:
 
379
            self.log.debug('Creating cinder volume from glance image '
 
380
                           '({})...'.format(img_id))
 
381
            bootable = 'true'
 
382
        elif src_vol_id and not img_id and not snap_id:
 
383
            self.log.debug('Cloning cinder volume...')
 
384
            bootable = cinder.volumes.get(src_vol_id).bootable
 
385
        elif snap_id and not src_vol_id and not img_id:
 
386
            self.log.debug('Creating cinder volume from snapshot...')
 
387
            snap = cinder.volume_snapshots.find(id=snap_id)
 
388
            vol_size = snap.size
 
389
            snap_vol_id = cinder.volume_snapshots.get(snap_id).volume_id
 
390
            bootable = cinder.volumes.get(snap_vol_id).bootable
 
391
        elif not img_id and not src_vol_id and not snap_id:
 
392
            self.log.debug('Creating cinder volume...')
 
393
            bootable = 'false'
 
394
        else:
 
395
            msg = ('Invalid method use - name:{} size:{} img_id:{} '
 
396
                   'src_vol_id:{} snap_id:{}'.format(vol_name, vol_size,
 
397
                                                     img_id, src_vol_id,
 
398
                                                     snap_id))
 
399
            raise RuntimeError(msg)
 
400
 
 
401
        # Create new volume
 
402
        try:
 
403
            vol_new = cinder.volumes.create(display_name=vol_name,
 
404
                                            imageRef=img_id,
 
405
                                            size=vol_size,
 
406
                                            source_volid=src_vol_id,
 
407
                                            snapshot_id=snap_id)
 
408
            vol_id = vol_new.id
 
409
        except Exception as e:
 
410
            msg = 'Failed to create volume: {}'.format(e)
 
411
            raise RuntimeError(msg)
 
412
 
 
413
        # Wait for volume to reach available status
 
414
        ret = self.resource_reaches_status(cinder.volumes, vol_id,
 
415
                                           expected_stat="available",
 
416
                                           msg="Volume status wait")
 
417
        if not ret:
 
418
            msg = 'Cinder volume failed to reach expected state.'
 
419
            raise RuntimeError(msg)
 
420
 
 
421
        # Re-validate new volume
 
422
        self.log.debug('Validating volume attributes...')
 
423
        val_vol_name = cinder.volumes.get(vol_id).display_name
 
424
        val_vol_boot = cinder.volumes.get(vol_id).bootable
 
425
        val_vol_stat = cinder.volumes.get(vol_id).status
 
426
        val_vol_size = cinder.volumes.get(vol_id).size
 
427
        msg_attr = ('Volume attributes - name:{} id:{} stat:{} boot:'
 
428
                    '{} size:{}'.format(val_vol_name, vol_id,
 
429
                                        val_vol_stat, val_vol_boot,
 
430
                                        val_vol_size))
 
431
 
 
432
        if val_vol_boot == bootable and val_vol_stat == 'available' \
 
433
                and val_vol_name == vol_name and val_vol_size == vol_size:
 
434
            self.log.debug(msg_attr)
 
435
        else:
 
436
            msg = ('Volume validation failed, {}'.format(msg_attr))
 
437
            raise RuntimeError(msg)
 
438
 
 
439
        return vol_new
 
440
 
342
441
    def delete_resource(self, resource, resource_id,
343
442
                        msg="resource", max_wait=120):
344
443
        """Delete one openstack resource, such as one instance, keypair,
350
449
        :param max_wait: maximum wait time in seconds
351
450
        :returns: True if successful, otherwise False
352
451
        """
 
452
        self.log.debug('Deleting OpenStack resource '
 
453
                       '{} ({})'.format(resource_id, msg))
353
454
        num_before = len(list(resource.list()))
354
455
        resource.delete(resource_id)
355
456
 
411
512
            self.log.debug('{} never reached expected status: '
412
513
                           '{}'.format(resource_id, expected_stat))
413
514
            return False
 
515
 
 
516
    def get_ceph_osd_id_cmd(self, index):
 
517
        """Produce a shell command that will return a ceph-osd id."""
 
518
        cmd = ("`initctl list | grep 'ceph-osd ' | awk 'NR=={} {{ print $2 }}'"
 
519
               " | grep -o '[0-9]*'`".format(index + 1))
 
520
        return cmd
 
521
 
 
522
    def get_ceph_pools(self, sentry_unit):
 
523
        """Return a dict of ceph pools from a single ceph unit, with
 
524
        pool name as keys, pool id as vals."""
 
525
        pools = {}
 
526
        cmd = 'sudo ceph osd lspools'
 
527
        output, code = sentry_unit.run(cmd)
 
528
        if code != 0:
 
529
            msg = ('{} `{}` returned {} '
 
530
                   '{}'.format(sentry_unit.info['unit_name'],
 
531
                               cmd, code, output))
 
532
            raise RuntimeError(msg)
 
533
 
 
534
        # Example output: 0 data,1 metadata,2 rbd,3 cinder,4 glance,
 
535
        for pool in str(output).split(','):
 
536
            pool_id_name = pool.split(' ')
 
537
            if len(pool_id_name) == 2:
 
538
                pool_id = pool_id_name[0]
 
539
                pool_name = pool_id_name[1]
 
540
                pools[pool_name] = int(pool_id)
 
541
 
 
542
        self.log.debug('Pools on {}: {}'.format(sentry_unit.info['unit_name'],
 
543
                                                pools))
 
544
        return pools
 
545
 
 
546
    def get_ceph_df(self, sentry_unit):
 
547
        """Return dict of ceph df json output, including ceph pool state.
 
548
 
 
549
        :param sentry_unit: Pointer to amulet sentry instance (juju unit)
 
550
        :returns: Dict of ceph df output
 
551
        """
 
552
        cmd = 'sudo ceph df --format=json'
 
553
        output, code = sentry_unit.run(cmd)
 
554
        if code != 0:
 
555
            msg = ('{} `{}` returned {} '
 
556
                   '{}'.format(sentry_unit.info['unit_name'],
 
557
                               cmd, code, output))
 
558
            raise RuntimeError(msg)
 
559
        return json.loads(output)
 
560
 
 
561
    def get_ceph_pool_sample(self, sentry_unit, pool_id=0):
 
562
        """Take a sample of attributes of a ceph pool, returning ceph
 
563
        pool name, object count and disk space used for the specified
 
564
        pool ID number.
 
565
 
 
566
        :param sentry_unit: Pointer to amulet sentry instance (juju unit)
 
567
        :param pool_id: Ceph pool ID
 
568
        :returns: List of pool name, object count, kb disk space used
 
569
        """
 
570
        df = self.get_ceph_df(sentry_unit)
 
571
        pool_name = df['pools'][pool_id]['name']
 
572
        obj_count = df['pools'][pool_id]['stats']['objects']
 
573
        kb_used = df['pools'][pool_id]['stats']['kb_used']
 
574
        self.log.debug('Ceph {} pool (ID {}): {} objects, '
 
575
                       '{} kb used'.format(pool_name,
 
576
                                           pool_id,
 
577
                                           obj_count,
 
578
                                           kb_used))
 
579
        return pool_name, obj_count, kb_used
 
580
 
 
581
    def validate_ceph_pool_samples(self, samples, sample_type="resource pool"):
 
582
        """Validate ceph pool samples taken over time, such as pool
 
583
        object counts or pool kb used, before adding, after adding, and
 
584
        after deleting items which affect those pool attributes.  The
 
585
        2nd element is expected to be greater than the 1st; 3rd is expected
 
586
        to be less than the 2nd.
 
587
 
 
588
        :param samples: List containing 3 data samples
 
589
        :param sample_type: String for logging and usage context
 
590
        :returns: None if successful, Failure message otherwise
 
591
        """
 
592
        original, created, deleted = range(3)
 
593
        if samples[created] <= samples[original] or \
 
594
                samples[deleted] >= samples[created]:
 
595
            msg = ('Ceph {} samples ({}) '
 
596
                   'unexpected.'.format(sample_type, samples))
 
597
            return msg
 
598
        else:
 
599
            self.log.debug('Ceph {} samples (OK): '
 
600
                           '{}'.format(sample_type, samples))
 
601
            return None