# 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
    )
