perfkitbenchmarker/providers/rackspace/rackspace_virtual_machine.py (341 lines of code) (raw):

# Copyright 2015 PerfKitBenchmarker Authors. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Class to represent a Rackspace Virtual Machine object. Zones: DFW (Dallas-Fort Worth) IAD (Northern Virginia) ORD (Chicago) LON (London) SYD (Sydney) HKG (Hong Kong) Machine Types: run 'rack servers flavor list' Images: run 'rack servers image list' All VM specifics are self-contained and the class provides methods to operate on the VM: boot, shutdown, etc. """ from collections import OrderedDict import json import logging import re import tempfile from absl import flags from perfkitbenchmarker import disk_strategies from perfkitbenchmarker import errors from perfkitbenchmarker import provider_info from perfkitbenchmarker import virtual_machine from perfkitbenchmarker import vm_util from perfkitbenchmarker.configs import option_decoders from perfkitbenchmarker.providers.rackspace import rackspace_disk from perfkitbenchmarker.providers.rackspace import rackspace_network from perfkitbenchmarker.providers.rackspace import util FLAGS = flags.FLAGS CLOUD_CONFIG_TEMPLATE = """#cloud-config users: - name: {0} ssh-authorized-keys: - {1} sudo: ['ALL=(ALL) NOPASSWD:ALL'] groups: sudo shell: /bin/bash """ BLOCK_DEVICE_TEMPLATE = """ source-type=image, source-id={0}, dest=volume, size={1}, shutdown=remove, bootindex=0 """ LSBLK_REGEX = ( r'NAME="(.*)"\s+MODEL="(.*)"\s+SIZE="(.*)"' r'\s+TYPE="(.*)"\s+MOUNTPOINT="(.*)"\s+LABEL="(.*)"' ) LSBLK_PATTERN = re.compile(LSBLK_REGEX) INSTANCE_EXISTS_STATUSES = frozenset( ['BUILD', 'ACTIVE', 'PAUSED', 'SHUTOFF', 'ERROR'] ) INSTANCE_DELETED_STATUSES = frozenset(['DELETED']) INSTANCE_KNOWN_STATUSES = INSTANCE_EXISTS_STATUSES | INSTANCE_DELETED_STATUSES REMOTE_BOOT_DISK_SIZE_GB = 50 def RenderBlockDeviceTemplate(image, volume_size): """Renders template used for the block-device flag in RackCLI. Args: image: string. Image ID of the source image. volume_size: string. Size in GB of desired volume size. Returns: string value for block-device parameter used when creating a VM. """ blk_params = BLOCK_DEVICE_TEMPLATE.replace('\n', '').format( image, str(volume_size) ) return blk_params class RackspaceVmSpec(virtual_machine.BaseVmSpec): """Object containing the information needed to create a RackspaceVirtualMachine. Attributes: project: None or string. Project ID, also known as Tenant ID rackspace_region: None or string. Rackspace region to build VM resources. rack_profile: None or string. Rack CLI profile configuration. """ CLOUD = provider_info.RACKSPACE @classmethod def _ApplyFlags(cls, config_values, flag_values): """Modifies config options based on runtime flag values. Args: config_values: dict mapping config option names to provided values. May be modified by this function. flag_values: flags.FlagValues. Runtime flags that may override the provided config values. """ super()._ApplyFlags(config_values, flag_values) if flag_values['project'].present: config_values['project'] = flag_values.project if flag_values['rackspace_region'].present: config_values['rackspace_region'] = flag_values.rackspace_region if flag_values['rack_profile'].present: config_values['rack_profile'] = flag_values.rack_profile @classmethod def _GetOptionDecoderConstructions(cls): """Gets decoder classes and constructor args for each configurable option. Returns: dict. Maps option name string to a (ConfigOptionDecoder class, dict) pair. The pair specifies a decoder class and its __init__() keyword arguments to construct in order to decode the named option. """ result = super()._GetOptionDecoderConstructions() result.update({ 'project': (option_decoders.StringDecoder, {'default': None}), 'rackspace_region': ( option_decoders.StringDecoder, {'default': 'IAD'}, ), 'rack_profile': (option_decoders.StringDecoder, {'default': None}), }) return result class RackspaceVirtualMachine(virtual_machine.BaseVirtualMachine): """Object representing a Rackspace Public Cloud Virtual Machine.""" CLOUD = provider_info.RACKSPACE DEFAULT_IMAGE = None def __init__(self, vm_spec): """Initialize a Rackspace Virtual Machine Args: vm_spec: virtual_machine.BaseVirtualMachineSpec object of the VM. """ super().__init__(vm_spec) self.boot_metadata = {} self.boot_device = None self.boot_disk_allocated = False self.allocated_disks = set() self.id = None self.image = self.image or self.DEFAULT_IMAGE self.region = vm_spec.rackspace_region self.project = vm_spec.project self.profile = vm_spec.rack_profile # Isolated tenant networks are regional, not globally available. # Security groups (firewalls) apply to a network, hence they are regional. # TODO(meteorfox) Create tenant network if it doesn't exist in the region. self.firewall = rackspace_network.RackspaceFirewall.GetFirewall() def _CreateDependencies(self): """Create dependencies prior creating the VM.""" # TODO(meteorfox) Create security group (if applies) self._UploadSSHPublicKey() def _Create(self): """Creates a Rackspace VM instance and waits until it's ACTIVE.""" self._CreateInstance() self._WaitForInstanceUntilActive() @vm_util.Retry() def _PostCreate(self): """Gets the VM's information.""" get_cmd = util.RackCLICommand(self, 'servers', 'instance', 'get') get_cmd.flags['id'] = self.id stdout, _, _ = get_cmd.Issue() resp = json.loads(stdout) self.internal_ip = resp['PrivateIPv4'] self.ip_address = resp['PublicIPv4'] self.AddMetadata(**self.vm_metadata) def _Exists(self): """Returns true if the VM exists otherwise returns false.""" if self.id is None: return False get_cmd = util.RackCLICommand(self, 'servers', 'instance', 'get') get_cmd.flags['id'] = self.id stdout, _, _ = get_cmd.Issue() try: resp = json.loads(stdout) except ValueError: return False status = resp['Status'] return status in INSTANCE_EXISTS_STATUSES def _Delete(self): """Deletes a Rackspace VM instance and waits until API returns 404.""" if self.id is None: return self._DeleteInstance() self._WaitForInstanceUntilDeleted() def _DeleteDependencies(self): """Deletes dependencies that were need for the VM after the VM has been deleted. """ # TODO(meteorfox) Delete security group (if applies) self._DeleteSSHPublicKey() def _UploadSSHPublicKey(self): """Uploads SSH public key to the VM's region. 1 key per VM per Region.""" cmd = util.RackCLICommand(self, 'servers', 'keypair', 'upload') cmd.flags = OrderedDict( [('name', self.name), ('file', self.ssh_public_key)] ) cmd.Issue() def _DeleteSSHPublicKey(self): """Deletes SSH public key used for a VM.""" cmd = util.RackCLICommand(self, 'servers', 'keypair', 'delete') cmd.flags['name'] = self.name cmd.Issue() def _CreateInstance(self): """Generates and execute command for creating a Rackspace VM.""" with tempfile.NamedTemporaryFile( dir=vm_util.GetTempDir(), prefix='user-data' ) as tf: with open(self.ssh_public_key) as f: public_key = f.read().rstrip('\n') tf.write(CLOUD_CONFIG_TEMPLATE.format(self.user_name, public_key)) tf.flush() create_cmd = self._GetCreateCommand(tf) stdout, stderr, _ = create_cmd.Issue() if stderr: resp = json.loads(stderr) raise errors.Error( ''.join(( 'Non-recoverable error has occurred: %s\n' % str(resp), 'Following command caused the error: %s' % repr(create_cmd), )) ) resp = json.loads(stdout) self.id = resp['ID'] def _GetCreateCommand(self, tf): """Generates RackCLI command for creating a Rackspace VM. Args: tf: file object containing cloud-config script. Returns: RackCLICommand containing RackCLI arguments to build a Rackspace VM. """ create_cmd = util.RackCLICommand(self, 'servers', 'instance', 'create') create_cmd.flags['name'] = self.name create_cmd.flags['keypair'] = self.name create_cmd.flags['flavor-id'] = self.machine_type if FLAGS.rackspace_boot_from_cbs_volume: blk_flag = RenderBlockDeviceTemplate(self.image, REMOTE_BOOT_DISK_SIZE_GB) create_cmd.flags['block-device'] = blk_flag else: create_cmd.flags['image-id'] = self.image if FLAGS.rackspace_network_id is not None: create_cmd.flags['networks'] = ','.join([ rackspace_network.PUBLIC_NET_ID, rackspace_network.SERVICE_NET_ID, FLAGS.rackspace_network_id, ]) create_cmd.flags['user-data'] = tf.name metadata = ['owner=%s' % FLAGS.owner] for key, value in self.boot_metadata.items(): metadata.append('%s=%s' % (key, value)) create_cmd.flags['metadata'] = ','.join(metadata) return create_cmd @vm_util.Retry( poll_interval=5, max_retries=720, log_errors=False, retryable_exceptions=(errors.Resource.RetryableCreationError,), ) def _WaitForInstanceUntilActive(self): """Waits until instance achieves non-transient state.""" get_cmd = util.RackCLICommand(self, 'servers', 'instance', 'get') get_cmd.flags['id'] = self.id stdout, stderr, _ = get_cmd.Issue() if stdout: instance = json.loads(stdout) if instance['Status'] == 'ACTIVE': logging.info('VM: %s is up and running.' % self.name) return elif instance['Status'] == 'ERROR': logging.error('VM: %s failed to boot.' % self.name) raise errors.VirtualMachine.VmStateError() raise errors.Resource.RetryableCreationError( 'VM: %s is not running. Retrying to check status.' % self.name ) def _DeleteInstance(self): """Executes delete command for removing a Rackspace VM.""" cmd = util.RackCLICommand(self, 'servers', 'instance', 'delete') cmd.flags['id'] = self.id stdout, _, _ = cmd.Issue() resp = json.loads(stdout) if 'result' not in resp or 'Deleting' not in resp['result']: raise errors.Resource.RetryableDeletionError() @vm_util.Retry( poll_interval=5, max_retries=-1, timeout=300, log_errors=False, retryable_exceptions=(errors.Resource.RetryableDeletionError,), ) def _WaitForInstanceUntilDeleted(self): """Waits until instance has been fully removed, or deleted.""" get_cmd = util.RackCLICommand(self, 'servers', 'instance', 'get') get_cmd.flags['id'] = self.id stdout, stderr, _ = get_cmd.Issue() if stderr: resp = json.loads(stderr) if 'error' in resp and "couldn't find" in resp['error']: logging.info('VM: %s has been successfully deleted.' % self.name) return instance = json.loads(stdout) if instance['Status'] == 'ERROR': logging.error('VM: %s failed to delete.' % self.name) raise errors.VirtualMachine.VmStateError() if instance['Status'] == 'DELETED': logging.info('VM: %s has been successfully deleted.', self.name) else: raise errors.Resource.RetryableDeletionError( 'VM: %s has not been deleted. Retrying to check status.' % self.name ) def AddMetadata(self, **kwargs): """Adds metadata to the VM via RackCLI update-metadata command.""" if not kwargs: return cmd = util.RackCLICommand(self, 'servers', 'instance', 'update-metadata') cmd.flags['id'] = self.id cmd.flags['metadata'] = ','.join( '{}={}'.format(key, value) for key, value in kwargs.items() ) cmd.Issue() def OnStartup(self): """Executes commands on the VM immediately after it has booted.""" super().OnStartup() self.boot_device = self._GetBootDevice() def CreateScratchDisk(self, _, disk_spec): """Creates a VM's scratch disk that will be used for a benchmark. Given a data_disk_type it will either create a corresponding Disk object, or raise an error that such data disk type is not supported. Args: disk_spec: virtual_machine.BaseDiskSpec object of the disk. Raises: errors.Error indicating that the requested 'data_disk_type' is not supported. """ if disk_spec.disk_type == rackspace_disk.BOOT: # Ignore num_striped_disks self._AllocateBootDisk(disk_spec) elif disk_spec.disk_type == rackspace_disk.LOCAL: self._AllocateLocalDisks(disk_spec) elif disk_spec.disk_type in rackspace_disk.REMOTE_TYPES: self._AllocateRemoteDisks(disk_spec) else: raise errors.Error('Unsupported data disk type: %s' % disk_spec.disk_type) def _AllocateBootDisk(self, disk_spec): """Allocate the VM's boot, or system, disk as the scratch disk. Boot disk can only be allocated once. If multiple data disks are required it will raise an error. Args: disk_spec: virtual_machine.BaseDiskSpec object of the disk. Raises: errors.Error when boot disk has already been allocated as a data disk. """ if self.boot_disk_allocated: raise errors.Error('Only one boot disk can be created per VM') device_path = '/dev/%s' % self.boot_device['name'] scratch_disk = rackspace_disk.RackspaceBootDisk( disk_spec, self.zone, self.project, device_path, self.image ) self.boot_disk_allocated = True self.scratch_disks.append(scratch_disk) scratch_disk.Create() path = disk_spec.mount_point mk_cmd = 'sudo mkdir -p {0}; sudo chown -R $USER:$USER {0};'.format(path) self.RemoteCommand(mk_cmd) def _AllocateLocalDisks(self, disk_spec): """Allocate the VM's local disks (included with the VM), as a data disk(s). A local disk can only be allocated once per data disk. Args: disk_spec: virtual_machine.BaseDiskSpec object of the disk. """ block_devices = self._GetBlockDevices() free_blk_devices = self._GetFreeBlockDevices(block_devices, disk_spec) disks = [] for i in range(disk_spec.num_striped_disks): local_device = free_blk_devices[i] disk_name = '%s-local-disk-%d' % (self.name, i) device_path = '/dev/%s' % local_device['name'] local_disk = rackspace_disk.RackspaceLocalDisk( disk_spec, disk_name, self.zone, self.project, device_path ) self.allocated_disks.add(local_disk) disks.append(local_disk) self._CreateScratchDiskFromDisks(disk_spec, disks) def _AllocateRemoteDisks(self, disk_spec): """Creates and allocates Rackspace Cloud Block Storage volumes as as data disks. Args: disk_spec: virtual_machine.BaseDiskSpec object of the disk. """ scratch_disks = [] for disk_num in range(disk_spec.num_striped_disks): volume_name = '%s-volume-%d' % (self.name, disk_num) scratch_disk = rackspace_disk.RackspaceRemoteDisk( disk_spec, volume_name, self.zone, self.project, media=disk_spec.disk_type, ) scratch_disks.append(scratch_disk) scratch_disk = self._CreateScratchDiskFromDisks(disk_spec, scratch_disks) disk_strategies.PrepareScratchDiskStrategy().PrepareScratchDisk( self, scratch_disk, disk_spec ) def _GetFreeBlockDevices(self, block_devices, disk_spec): """Returns available block devices that are not in used as data disk or as a boot disk. Args: block_devices: list of dict containing information about all block devices in the VM. disk_spec: virtual_machine.BaseDiskSpec of the disk. Returns: list of dicts of only block devices that are not being used. Raises: errors.Error Whenever there are no available block devices. """ free_blk_devices = [] for dev in block_devices: if self._IsDiskAvailable(dev): free_blk_devices.append(dev) if not free_blk_devices: raise errors.Error( ''.join(( 'Machine type %s does not include' % self.machine_type, ' local disks. Please use a different disk_type,', ' or a machine_type that provides local disks.', )) ) elif len(free_blk_devices) < disk_spec.num_striped_disks: raise errors.Error( 'Not enough local data disks. ' 'Requesting %d disk(s) but only %d available.' % (disk_spec.num_striped_disks, len(free_blk_devices)) ) return free_blk_devices def _GetBlockDevices(self): """Execute command on VM to gather all block devices in the VM. Returns: list of dicts block devices in the VM. """ stdout, _ = self.RemoteCommand( 'sudo lsblk -o NAME,MODEL,SIZE,TYPE,MOUNTPOINT,LABEL -n -b -P' ) lines = stdout.splitlines() groups = [LSBLK_PATTERN.match(line) for line in lines] tuples = [g.groups() for g in groups if g] colnames = ( 'name', 'model', 'size_bytes', 'type', 'mountpoint', 'label', ) blk_devices = [dict(list(zip(colnames, t))) for t in tuples] for d in blk_devices: d['model'] = d['model'].rstrip() d['label'] = d['label'].rstrip() d['size_bytes'] = int(d['size_bytes']) return blk_devices def _GetBootDevice(self): """Returns backing block device where '/' is mounted on. Returns: dict blk device data Raises: errors.Error indicates that could not find block device with '/'. """ blk_devices = self._GetBlockDevices() boot_blk_device = None for dev in blk_devices: if dev['mountpoint'] == '/': boot_blk_device = dev break if boot_blk_device is None: # Unlikely raise errors.Error('Could not find disk with "/" root mount point.') if boot_blk_device['type'] != 'part': return boot_blk_device return self._FindBootBlockDevice(blk_devices, boot_blk_device) def _FindBootBlockDevice(self, blk_devices, boot_blk_device): """Helper method to search for backing block device of a partition.""" blk_device_name = boot_blk_device['name'].rstrip('0123456789') for dev in blk_devices: if dev['type'] == 'disk' and dev['name'] == blk_device_name: boot_blk_device = dev return boot_blk_device def _IsDiskAvailable(self, blk_device): """Returns True if a block device is available. An available disk, is a disk that has not been allocated previously as a data disk, or is not being used as boot disk. """ return ( blk_device['type'] != 'part' and blk_device['name'] != self.boot_device['name'] and 'config' not in blk_device['label'] and blk_device['name'] not in self.allocated_disks )