# Copyright 2012 NetApp
# Copyright 2016 Mirantis Inc.
# 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.
"""
LVM Driver for shares.

"""

import ipaddress
import math
import os
import re

from oslo_config import cfg
from oslo_log import log
from oslo_utils import importutils
from oslo_utils import timeutils
import six

from manila import exception
from manila.i18n import _
from manila.share import driver
from manila.share.drivers import generic
from manila.share import utils as share_utils
from manila import utils

LOG = log.getLogger(__name__)

share_opts = [
    cfg.StrOpt('lvm_share_export_root',
               default='$state_path/mnt',
               help='Base folder where exported shares are located.'),
    cfg.ListOpt('lvm_share_export_ips',
                help='List of IPs to export shares belonging to the LVM '
                     'storage driver.'),
    cfg.IntOpt('lvm_share_mirrors',
               default=0,
               help='If set, create LVMs with multiple mirrors. Note that '
                    'this requires lvm_mirrors + 2 PVs with available space.'),
    cfg.StrOpt('lvm_share_volume_group',
               default='lvm-shares',
               help='Name for the VG that will contain exported shares.'),
    cfg.ListOpt('lvm_share_helpers',
                default=[
                    'CIFS=manila.share.drivers.helpers.CIFSHelperUserAccess',
                    'NFS=manila.share.drivers.helpers.NFSHelper',
                ],
                help='Specify list of share export helpers.'),
]

CONF = cfg.CONF
CONF.register_opts(share_opts)
CONF.register_opts(generic.share_opts)


class LVMMixin(driver.ExecuteMixin):
    def check_for_setup_error(self):
        """Returns an error if prerequisites aren't met."""
        out, err = self._execute('vgs', '--noheadings', '-o', 'name',
                                 run_as_root=True)
        volume_groups = out.split()
        if self.configuration.lvm_share_volume_group not in volume_groups:
            msg = (_("Share volume group %s doesn't exist.")
                   % self.configuration.lvm_share_volume_group)
            raise exception.InvalidParameterValue(err=msg)

        if not self.configuration.lvm_share_export_ips:
            msg = _("The option lvm_share_export_ips must be specified.")
            raise exception.InvalidParameterValue(err=msg)

    def _allocate_container(self, share):
        sizestr = '%sG' % share['size']
        cmd = ['lvcreate', '-L', sizestr, '-n', share['name'],
               self.configuration.lvm_share_volume_group]
        if self.configuration.lvm_share_mirrors:
            cmd += ['-m', self.configuration.lvm_share_mirrors, '--nosync']
            terras = int(sizestr[:-1]) / 1024.0
            if terras >= 1.5:
                rsize = int(2 ** math.ceil(math.log(terras) / math.log(2)))
                # NOTE(vish): Next power of two for region size. See:
                #             http://red.ht/U2BPOD
                cmd += ['-R', six.text_type(rsize)]

        self._try_execute(*cmd, run_as_root=True)
        device_name = self._get_local_path(share)
        self._execute('mkfs.%s' % self.configuration.share_volume_fstype,
                      device_name, run_as_root=True)

    def _extend_container(self, share, device_name, size):
        cmd = ['lvextend', '-L', '%sG' % size, '-r', device_name]
        self._try_execute(*cmd, run_as_root=True)

    def _deallocate_container(self, share_name):
        """Deletes a logical volume for share."""
        try:
            self._try_execute('lvremove', '-f', "%s/%s" %
                              (self.configuration.lvm_share_volume_group,
                               share_name), run_as_root=True)
        except exception.ProcessExecutionError as exc:
            err_pattern = re.compile(".*failed to find.*|.*not found.*",
                                     re.IGNORECASE)
            if not err_pattern.match(exc.stderr):
                LOG.exception("Error deleting volume")
                raise
            LOG.warning("Volume not found: %s", exc.stderr)

    def _create_snapshot(self, context, snapshot):
        """Creates a snapshot."""
        orig_lv_name = "%s/%s" % (self.configuration.lvm_share_volume_group,
                                  snapshot['share_name'])
        self._try_execute(
            'lvcreate', '-L', '%sG' % snapshot['share']['size'],
            '--name', snapshot['name'],
            '--snapshot', orig_lv_name, run_as_root=True)

        self._set_random_uuid_to_device(snapshot)

    def _set_random_uuid_to_device(self, share_or_snapshot):
        # NOTE(vponomaryov): 'tune2fs' is required to make
        # filesystem of share created from snapshot have
        # unique ID, in case of LVM volumes, by default,
        # it will have the same UUID as source volume. Closes #1645751
        # NOTE(gouthamr): Executing tune2fs -U only works on
        # a recently checked filesystem.
        # See: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=857336
        device_path = self._get_local_path(share_or_snapshot)
        self._execute('e2fsck', '-y', '-f', device_path, run_as_root=True)
        self._execute(
            'tune2fs', '-U', 'random', device_path, run_as_root=True,
        )

    def create_snapshot(self, context, snapshot, share_server=None):
        self._create_snapshot(context, snapshot)

    def delete_snapshot(self, context, snapshot, share_server=None):
        """Deletes a snapshot."""
        self._deallocate_container(snapshot['name'])


class LVMShareDriver(LVMMixin, driver.ShareDriver):
    """Executes commands relating to Shares."""

    def __init__(self, *args, **kwargs):
        """Do initialization."""
        super(LVMShareDriver, self).__init__([False], *args, **kwargs)
        self.configuration.append_config_values(share_opts)
        self.configuration.append_config_values(generic.share_opts)
        self.configuration.share_mount_path = (
            self.configuration.lvm_share_export_root)
        self._helpers = None
        self.configured_ip_version = None
        self.backend_name = self.configuration.safe_get(
            'share_backend_name') or 'LVM'
        # Set of parameters used for compatibility with
        # Generic driver's helpers.
        self.share_server = {
            'instance_id': self.backend_name,
            'lock_name': 'manila_lvm',
        }
        self.share_server['public_addresses'] = (
            self.configuration.lvm_share_export_ips
        )
        self.ipv6_implemented = True

    def _ssh_exec_as_root(self, server, command, check_exit_code=True):
        kwargs = {}
        if 'sudo' in command:
            kwargs['run_as_root'] = True
            command.remove('sudo')
        kwargs['check_exit_code'] = check_exit_code
        return self._execute(*command, **kwargs)

    def do_setup(self, context):
        """Any initialization the volume driver does while starting."""
        super(LVMShareDriver, self).do_setup(context)
        self._setup_helpers()

    def _setup_helpers(self):
        """Initializes protocol-specific NAS drivers."""
        self._helpers = {}
        for helper_str in self.configuration.lvm_share_helpers:
            share_proto, _, import_str = helper_str.partition('=')
            helper = importutils.import_class(import_str)
            # TODO(rushiagr): better way to handle configuration
            #                 instead of just passing to the helper
            self._helpers[share_proto.upper()] = helper(
                self._execute, self._ssh_exec_as_root, self.configuration)

    def _get_local_path(self, share):
        # The escape characters are expected by the device mapper.
        escaped_group = (
            self.configuration.lvm_share_volume_group.replace('-', '--'))
        escaped_name = share['name'].replace('-', '--')
        return "/dev/mapper/%s-%s" % (escaped_group, escaped_name)

    def _update_share_stats(self):
        """Retrieve stats info from share volume group."""
        data = {
            'share_backend_name': self.backend_name,
            'storage_protocol': 'NFS_CIFS',
            'reserved_percentage':
                self.configuration.reserved_share_percentage,
            'reserved_snapshot_percentage':
                (self.configuration.reserved_share_from_snapshot_percentage
                    or self.configuration.reserved_share_percentage),
            'snapshot_support': True,
            'create_share_from_snapshot_support': True,
            'revert_to_snapshot_support': True,
            'mount_snapshot_support': True,
            'driver_name': 'LVMShareDriver',
            'pools': self.get_share_server_pools(),
        }
        super(LVMShareDriver, self)._update_share_stats(data)

    def get_share_server_pools(self, share_server=None):
        out, err = self._execute('vgs',
                                 self.configuration.lvm_share_volume_group,
                                 '--rows', '--units', 'g',
                                 run_as_root=True)
        total_size = re.findall(r"VSize\s[0-9.]+g", out)[0][6:-1]
        free_size = re.findall(r"VFree\s[0-9.]+g", out)[0][6:-1]
        return [{
            'pool_name': 'lvm-single-pool',
            'total_capacity_gb': float(total_size),
            'free_capacity_gb': float(free_size),
            'reserved_percentage': 0,
            'reserved_snapshot_percentage': 0,
        }, ]

    def create_share(self, context, share, share_server=None):
        self._allocate_container(share)
        # create file system
        device_name = self._get_local_path(share)
        location = self._get_helper(share).create_exports(
            self.share_server, share['name'])
        self._mount_device(share, device_name)
        return location

    def create_share_from_snapshot(self, context, share, snapshot,
                                   share_server=None, parent_share=None):
        """Is called to create share from snapshot."""
        self._allocate_container(share)
        snapshot_device_name = self._get_local_path(snapshot)
        share_device_name = self._get_local_path(share)
        self._set_random_uuid_to_device(share)
        self._copy_volume(
            snapshot_device_name, share_device_name, share['size'])
        location = self._get_helper(share).create_exports(
            self.share_server, share['name'])
        self._mount_device(share, share_device_name)
        return location

    def delete_share(self, context, share, share_server=None):
        self._unmount_device(share, raise_if_missing=False,
                             retry_busy_device=True)
        self._delete_share(context, share)
        self._deallocate_container(share['name'])

    def _unmount_device(self, share_or_snapshot, raise_if_missing=True,
                        retry_busy_device=False):
        """Unmount the filesystem of a share or snapshot LV."""
        mount_path = self._get_mount_path(share_or_snapshot)
        if os.path.exists(mount_path):

            retries = 10 if retry_busy_device else 1

            @utils.retry(retry_param=exception.ShareBusyException,
                         retries=retries)
            def _unmount_device_with_retry():
                try:
                    self._execute('umount', '-f', mount_path, run_as_root=True)
                except exception.ProcessExecutionError as exc:
                    if 'is busy' in exc.stderr.lower():
                        raise exception.ShareBusyException(
                            reason=share_or_snapshot['name'])
                    elif 'not mounted' in exc.stderr.lower():
                        if raise_if_missing:
                            LOG.error('Unable to find device: %s', exc)
                            raise
                    else:
                        LOG.error('Unable to umount: %s', exc)
                        raise

            _unmount_device_with_retry()
            # remove dir
            self._execute('rmdir', mount_path, run_as_root=True)

    def ensure_shares(self, context, shares):
        updates = {}
        for share in shares:
            updates[share['id']] = {
                'export_locations': self.ensure_share(context, share)}
        return updates

    def ensure_share(self, ctx, share, share_server=None):
        """Ensure that storage are mounted and exported."""
        device_name = self._get_local_path(share)
        self._mount_device(share, device_name)
        return self._get_helper(share).create_exports(
            self.share_server, share['name'], recreate=True)

    def _delete_share(self, ctx, share):
        """Delete a share."""
        try:
            self._get_helper(share).remove_exports(
                self.share_server, share['name'])
        except exception.ProcessExecutionError:
            LOG.warning("Can't remove share %r", share['id'])
        except exception.InvalidShare as exc:
            LOG.warning(exc)

    def update_access(self, context, share, access_rules, add_rules,
                      delete_rules, share_server=None):
        """Update access rules for given share.

        This driver has two different behaviors according to parameters:
        1. Recovery after error - 'access_rules' contains all access_rules,
        'add_rules' and 'delete_rules' shall be empty. Previously existing
        access rules are cleared and then added back according
        to 'access_rules'.

        2. Adding/Deleting of several access rules - 'access_rules' contains
        all access_rules, 'add_rules' and 'delete_rules' contain rules which
        should be added/deleted. Rules in 'access_rules' are ignored and
        only rules from 'add_rules' and 'delete_rules' are applied.

        :param context: Current context
        :param share: Share model with share data.
        :param access_rules: All access rules for given share
        :param add_rules: Empty List or List of access rules which should be
               added. access_rules already contains these rules.
        :param delete_rules: Empty List or List of access rules which should be
               removed. access_rules doesn't contain these rules.
        :param share_server: None or Share server model
        """
        self._get_helper(share).update_access(self.share_server,
                                              share['name'], access_rules,
                                              add_rules=add_rules,
                                              delete_rules=delete_rules)

    def _get_helper(self, share):
        if share['share_proto'].lower().startswith('nfs'):
            return self._helpers['NFS']
        elif share['share_proto'].lower().startswith('cifs'):
            return self._helpers['CIFS']
        else:
            raise exception.InvalidShare(reason='Wrong share protocol')

    def _mount_device(self, share_or_snapshot, device_name):
        """Mount LV for share or snapshot and ignore if already mounted."""
        mount_path = self._get_mount_path(share_or_snapshot)
        self._execute('mkdir', '-p', mount_path)
        try:
            self._execute('mount', device_name, mount_path,
                          run_as_root=True, check_exit_code=True)
            self._execute('chmod', '777', mount_path,
                          run_as_root=True, check_exit_code=True)
        except exception.ProcessExecutionError:
            out, err = self._execute('mount', '-l', run_as_root=True)
            if device_name in out:
                LOG.warning("%s is already mounted", device_name)
            else:
                raise
        return mount_path

    def _get_mount_path(self, share_or_snapshot):
        """Returns path where share or snapshot is mounted."""
        return os.path.join(self.configuration.share_mount_path,
                            share_or_snapshot['name'])

    def _copy_volume(self, srcstr, deststr, size_in_g):
        # Use O_DIRECT to avoid thrashing the system buffer cache
        extra_flags = ['iflag=direct', 'oflag=direct']

        # Check whether O_DIRECT is supported
        try:
            self._execute('dd', 'count=0', 'if=%s' % srcstr, 'of=%s' % deststr,
                          *extra_flags, run_as_root=True)
        except exception.ProcessExecutionError:
            extra_flags = []

        # Perform the copy
        self._execute('dd', 'if=%s' % srcstr, 'of=%s' % deststr,
                      'count=%d' % (size_in_g * 1024), 'bs=1M',
                      *extra_flags, run_as_root=True)

    def extend_share(self, share, new_size, share_server=None):
        device_name = self._get_local_path(share)
        self._extend_container(share, device_name, new_size)

    def revert_to_snapshot(self, context, snapshot, share_access_rules,
                           snapshot_access_rules, share_server=None):
        share = snapshot['share']
        # Temporarily remove all access rules
        self._get_helper(share).update_access(self.share_server,
                                              snapshot['name'], [], [], [])
        self._get_helper(share).update_access(self.share_server,
                                              share['name'], [], [], [])
        # Unmount the snapshot filesystem
        self._unmount_device(snapshot)
        # Unmount the share filesystem
        self._unmount_device(share)
        # Merge the snapshot LV back into the share, reverting it
        snap_lv_name = "%s/%s" % (self.configuration.lvm_share_volume_group,
                                  snapshot['name'])
        self._execute('lvconvert', '--merge', snap_lv_name, run_as_root=True)

        # Now recreate the snapshot that was destroyed by the merge
        self._create_snapshot(context, snapshot)
        # At this point we can mount the share again
        device_name = self._get_local_path(share)
        self._mount_device(share, device_name)
        # Also remount the snapshot
        device_name = self._get_local_path(snapshot)
        self._mount_device(snapshot, device_name)
        # Lastly we add all the access rules back
        self._get_helper(share).update_access(self.share_server,
                                              share['name'],
                                              share_access_rules,
                                              [], [])
        snapshot_access_rules, __, __ = share_utils.change_rules_to_readonly(
            snapshot_access_rules, [], [])
        self._get_helper(share).update_access(self.share_server,
                                              snapshot['name'],
                                              snapshot_access_rules,
                                              [], [])

    def create_snapshot(self, context, snapshot, share_server=None):
        self._create_snapshot(context, snapshot)

        device_name = self._get_local_path(snapshot)
        self._mount_device(snapshot, device_name)

        helper = self._get_helper(snapshot['share'])
        exports = helper.create_exports(self.share_server, snapshot['name'])

        return {'export_locations': exports}

    def delete_snapshot(self, context, snapshot, share_server=None):
        self._unmount_device(snapshot, raise_if_missing=False)

        super(LVMShareDriver, self).delete_snapshot(context, snapshot,
                                                    share_server)

    def get_configured_ip_versions(self):
        if self.configured_ip_version is None:
            try:
                self.configured_ip_version = []
                for ip in self.configuration.lvm_share_export_ips:
                    self.configured_ip_version.append(
                        ipaddress.ip_address(six.text_type(ip)).version)
            except Exception:
                message = (_("Invalid 'lvm_share_export_ips' option supplied "
                             "%s.") % self.configuration.lvm_share_export_ips)
                raise exception.InvalidInput(reason=message)
        return self.configured_ip_version

    def snapshot_update_access(self, context, snapshot, access_rules,
                               add_rules, delete_rules, share_server=None):
        """Update access rules for given snapshot.

        This driver has two different behaviors according to parameters:
        1. Recovery after error - 'access_rules' contains all access_rules,
        'add_rules' and 'delete_rules' shall be empty. Previously existing
        access rules are cleared and then added back according
        to 'access_rules'.

        2. Adding/Deleting of several access rules - 'access_rules' contains
        all access_rules, 'add_rules' and 'delete_rules' contain rules which
        should be added/deleted. Rules in 'access_rules' are ignored and
        only rules from 'add_rules' and 'delete_rules' are applied.

        :param context: Current context
        :param snapshot: Snapshot model with snapshot data.
        :param access_rules: All access rules for given snapshot
        :param add_rules: Empty List or List of access rules which should be
               added. access_rules already contains these rules.
        :param delete_rules: Empty List or List of access rules which should be
               removed. access_rules doesn't contain these rules.
        :param share_server: None or Share server model
        """
        helper = self._get_helper(snapshot['share'])
        access_rules, add_rules, delete_rules = (
            share_utils.change_rules_to_readonly(
                access_rules, add_rules, delete_rules)
        )

        helper.update_access(self.share_server,
                             snapshot['name'], access_rules,
                             add_rules=add_rules, delete_rules=delete_rules)

    def update_share_usage_size(self, context, shares):
        updated_shares = []
        out, err = self._execute(
            'df', '-l', '--output=target,used',
            '--block-size=g')
        gathered_at = timeutils.utcnow()

        for share in shares:
            try:
                mount_path = self._get_mount_path(share)
                if os.path.exists(mount_path):
                    used_size = (re.findall(
                        mount_path + r"\s*[0-9.]+G", out)[0].
                        split(' ')[-1][:-1])
                    updated_shares.append({'id': share['id'],
                                           'used_size': used_size,
                                           'gathered_at': gathered_at})
                else:
                    raise exception.NotFound(
                        _("Share mount path %s could not be "
                          "found.") % mount_path)
            except Exception:
                LOG.exception("Failed to gather 'used_size' for share %s.",
                              share['id'])

        return updated_shares

    def get_backend_info(self, context):
        return {
            'export_ips': ','.join(self.share_server['public_addresses']),
            'db_version': share_utils.get_recent_db_migration_id(),
        }
