# Copyright (c) 2013 Red Hat, 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.

"""Flat network GlusterFS Driver.

Manila shares are subdirectories within a GlusterFS volume. The access to the
shares is currently mediated by the Gluster-NFS server running in the GlusterFS
backend storage pool. The Gluster-NFS server supports only NFSv3 protocol so
it's the only protocol that can be used to access the shares.

TODO(rraja): support SMB protocol.
"""

import errno
import os
import re
import sys
import xml.etree.cElementTree as etree

from oslo_config import cfg
from oslo_log import log
import six

from manila import exception
from manila.i18n import _
from manila.i18n import _LE
from manila.i18n import _LW
from manila.share import driver
from manila.share.drivers import ganesha
from manila.share.drivers.ganesha import utils as ganesha_utils

LOG = log.getLogger(__name__)

GlusterfsManilaShare_opts = [
    cfg.StrOpt('glusterfs_target',
               help='Specifies the GlusterFS volume to be mounted on the '
                    'Manila host. It is of the form '
                    '[remoteuser@]<volserver>:<volid>.'),
    cfg.StrOpt('glusterfs_mount_point_base',
               default='$state_path/mnt',
               help='Base directory containing mount points for Gluster '
                    'volumes.'),
    cfg.StrOpt('glusterfs_nfs_server_type',
               default='Gluster',
               help='Type of NFS server that mediate access to the Gluster '
                    'volumes (Gluster or Ganesha).'),
    cfg.StrOpt('glusterfs_server_password',
               default=None,
               secret=True,
               help="Remote GlusterFS server node's login password. "
                    "This is not required if 'glusterfs_path_to_private_key'"
                    ' is configured.'),
    cfg.StrOpt('glusterfs_path_to_private_key',
               default=None,
               help='Path of Manila host\'s private SSH key file.'),
    cfg.StrOpt('glusterfs_ganesha_server_ip',
               default=None,
               help="Remote Ganesha server node's IP address."),
    cfg.StrOpt('glusterfs_ganesha_server_username',
               default='root',
               help="Remote Ganesha server node's username."),
    cfg.StrOpt('glusterfs_ganesha_server_password',
               default=None,
               secret=True,
               help="Remote Ganesha server node's login password. "
                    "This is not required if 'glusterfs_path_to_private_key'"
                    ' is configured.'),
]

CONF = cfg.CONF
CONF.register_opts(GlusterfsManilaShare_opts)

NFS_EXPORT_DIR = 'nfs.export-dir'
NFS_EXPORT_VOL = 'nfs.export-volumes'

GLUSTERFS_VERSION_MIN = (3, 5)


class GlusterManager(object):
    """Interface with a GlusterFS volume."""

    scheme = re.compile('\A(?:(?P<user>[^:@/]+)@)?'
                        '(?P<host>[^:@/]+)'
                        '(?::/(?P<vol>.+))?')

    def __init__(self, address, execf, path_to_private_key=None,
                 remote_server_password=None, has_volume=True):
        """Initialize a GlusterManager instance.

        :param address: the Gluster URI (in [<user>@]<host>:/<vol> format).
        :param execf: executor function for management commands.
        :param path_to_private_key: path to private ssh key of remote server.
        :param remote_server_password: ssh password for remote server.
        :param has_volume: instruction to uri parser regarding how to deal
                           with the optional volume part (True: require its
                           presence, False: require its absence, None: don't
                           require anything about volume).
        """
        m = self.scheme.search(address)
        if m:
            self.volume = m.group('vol')
            if (has_volume is True and not self.volume) or (
               has_volume is False and self.volume):
                m = None
        if not m:
            raise exception.GlusterfsException(
                _('Invalid gluster address %s.') % address)
        self.remote_user = m.group('user')
        self.host = m.group('host')
        self.qualified = address
        if self.volume:
            self.export = ':/'.join([self.host, self.volume])
        else:
            self.export = None
        self.path_to_private_key = path_to_private_key
        self.remote_server_password = remote_server_password
        self.gluster_call = self.make_gluster_call(execf)

    def make_gluster_call(self, execf):
        """Execute a Gluster command locally or remotely."""
        if self.remote_user:
            gluster_execf = ganesha_utils.SSHExecutor(
                self.host, 22, None, self.remote_user,
                password=self.remote_server_password,
                privatekey=self.path_to_private_key)
        else:
            gluster_execf = ganesha_utils.RootExecutor(execf)
        return lambda *args, **kwargs: gluster_execf(*(('gluster',) + args),
                                                     **kwargs)

    def get_gluster_vol_option(self, option):
        """Get the value of an option set on a GlusterFS volume."""
        args = ('--xml', 'volume', 'info', self.volume)
        try:
            out, err = self.gluster_call(*args)
        except exception.ProcessExecutionError as exc:
            LOG.error(_LE("Error retrieving volume info: %s"), exc.stderr)
            raise exception.GlusterfsException("gluster %s failed" %
                                               ' '.join(args))

        if not out:
            raise exception.GlusterfsException(
                'gluster volume info %s: no data received' %
                self.volume
            )

        vix = etree.fromstring(out)
        if int(vix.find('./volInfo/volumes/count').text) != 1:
            raise exception.InvalidShare('Volume name ambiguity')
        for e in vix.findall(".//option"):
            o, v = (e.find(a).text for a in ('name', 'value'))
            if o == option:
                return v

    def get_gluster_version(self):
        """Retrieve GlusterFS version.

        :returns: version (as tuple of strings, example: ('3', '6', '0beta2'))
        """
        try:
            out, err = self.gluster_call('--version')
        except exception.ProcessExecutionError as exc:
            raise exception.GlusterfsException(
                _("'gluster version' failed on server "
                  "%(server)s: %(message)s") %
                {'server': self.host, 'message': exc.message})
        try:
            owords = out.split()
            if owords[0] != 'glusterfs':
                raise RuntimeError
            vers = owords[1].split('.')
            # provoke an exception if vers does not start with two numerals
            int(vers[0])
            int(vers[1])
        except Exception:
            raise exception.GlusterfsException(
                _("Cannot parse version info obtained from server "
                  "%(server)s, version info: %(info)s") %
                {'server': self.host, 'info': out})
        return vers

    def check_gluster_version(self, minvers):
        """Retrieve and check GlusterFS version.

        :param minvers: minimum version to require
                        (given as tuple of integers, example: (3, 6))
        """
        vers = self.get_gluster_version()
        if self.numreduct(vers) < minvers:
            raise exception.GlusterfsException(_(
                "Unsupported GlusterFS version %(version)s on server "
                "%(server)s, minimum requirement: %(minvers)s") % {
                'server': self.host,
                'version': '.'.join(vers),
                'minvers': '.'.join(six.text_type(c) for c in minvers)})

    @staticmethod
    def numreduct(vers):
        """The numeric reduct of a tuple of strings.

        That is, applying an integer conversion map on the longest
        initial segment of vers which consists of numerals.
        """
        numvers = []
        for c in vers:
            try:
                numvers.append(int(c))
            except ValueError:
                break
        return tuple(numvers)


class GlusterfsShareDriver(driver.ExecuteMixin, driver.GaneshaMixin,
                           driver.ShareDriver,):
    """Execute commands relating to Shares."""

    def __init__(self, db, *args, **kwargs):
        super(GlusterfsShareDriver, self).__init__(False, *args, **kwargs)
        self.db = db
        self._helpers = {}
        self.gluster_manager = None
        self.configuration.append_config_values(GlusterfsManilaShare_opts)
        self.backend_name = self.configuration.safe_get(
            'share_backend_name') or 'GlusterFS'

    def do_setup(self, context):
        """Prepares the backend and appropriate NAS helpers."""
        super(GlusterfsShareDriver, self).do_setup(context)
        if not self.configuration.glusterfs_target:
            raise exception.GlusterfsException(
                _('glusterfs_target configuration that specifies the GlusterFS'
                  ' volume to be mounted on the Manila host is not set.'))
        self.gluster_manager = GlusterManager(
            self.configuration.glusterfs_target,
            self._execute,
            self.configuration.glusterfs_path_to_private_key,
            self.configuration.glusterfs_server_password,
        )
        self.gluster_manager.check_gluster_version(GLUSTERFS_VERSION_MIN)
        try:
            self._execute('mount.glusterfs', check_exit_code=False)
        except OSError as exc:
            if exc.errno == errno.ENOENT:
                raise exception.GlusterfsException(
                    _('mount.glusterfs is not installed.'))
            else:
                raise

        # enable quota options of a GlusteFS volume to allow
        # creation of shares of specific size
        args = ('volume', 'quota', self.gluster_manager.volume, 'enable')
        try:
            self.gluster_manager.gluster_call(*args)
        except exception.ProcessExecutionError as exc:
            if (self.gluster_manager.
                    get_gluster_vol_option('features.quota')) != 'on':
                LOG.error(_LE("Error in tuning GlusterFS volume to enable "
                              "creation of shares of specific size: %s"),
                          exc.stderr)
                raise exception.GlusterfsException(exc)

        self._setup_helpers()
        self._ensure_gluster_vol_mounted()

    def _setup_helpers(self):
        """Initializes protocol-specific NAS drivers."""
        # TODO(rraja): The below seems crude. Accommodate CIFS helper as well?
        nfs_helper = getattr(
            sys.modules[__name__],
            self.configuration.glusterfs_nfs_server_type + 'NFSHelper')
        self._helpers['NFS'] = nfs_helper(self._execute,
                                          self.configuration,
                                          gluster_manager=self.gluster_manager)
        for helper in self._helpers.values():
            helper.init_helper()

    def check_for_setup_error(self):
        pass

    def _get_mount_point_for_gluster_vol(self):
        """Return mount point for the GlusterFS volume."""
        return os.path.join(self.configuration.glusterfs_mount_point_base,
                            self.gluster_manager.volume)

    def _do_mount(self, cmd, ensure):
        """Execute the mount command based on 'ensure' parameter.

        :param cmd: command to do the actual mount
        :param ensure: boolean to allow remounting a volume with a warning
        """
        try:
            self._execute(*cmd, run_as_root=True)
        except exception.ProcessExecutionError as exc:
            if ensure and 'already mounted' in exc.stderr:
                LOG.warn(_LW("%s is already mounted"),
                         self.gluster_manager.export)
            else:
                raise exception.GlusterfsException(
                    'Unable to mount Gluster volume'
                )

    def _mount_gluster_vol(self, mount_path, ensure=False):
        """Mount GlusterFS volume at the specified mount path."""
        self._execute('mkdir', '-p', mount_path)
        command = ['mount', '-t', 'glusterfs', self.gluster_manager.export,
                   mount_path]
        self._do_mount(command, ensure)

    def _ensure_gluster_vol_mounted(self):
        """Ensure GlusterFS volume is native-mounted on Manila host."""
        mount_path = self._get_mount_point_for_gluster_vol()
        try:
            self._mount_gluster_vol(mount_path, ensure=True)
        except exception.GlusterfsException:
            LOG.error(_LE('Could not mount the Gluster volume %s'),
                      self.gluster_manager.volume)
            raise

    def _get_local_share_path(self, share):
        """Determine mount path of the GlusterFS volume in the Manila host."""
        local_vol_path = self._get_mount_point_for_gluster_vol()
        if not os.access(local_vol_path, os.R_OK):
            raise exception.GlusterfsException('share path %s does not exist' %
                                               local_vol_path)
        return os.path.join(local_vol_path, share['name'])

    def _update_share_stats(self):
        """Retrieve stats info from the GlusterFS volume."""

        # sanity check for gluster ctl mount
        smpb = os.stat(self.configuration.glusterfs_mount_point_base)
        smp = os.stat(self._get_mount_point_for_gluster_vol())
        if smpb.st_dev == smp.st_dev:
            raise exception.GlusterfsException(
                _("GlusterFS control mount is not available")
            )
        smpv = os.statvfs(self._get_mount_point_for_gluster_vol())

        data = dict(
            storage_protocol='NFS',
            vendor_name='Red Hat',
            share_backend_name=self.backend_name,
            reserved_percentage=self.configuration.reserved_share_percentage,
            total_capacity_gb=(smpv.f_blocks * smpv.f_frsize) >> 30,
            free_capacity_gb=(smpv.f_bavail * smpv.f_frsize) >> 30)
        super(GlusterfsShareDriver, self)._update_share_stats(data)

    def get_network_allocations_number(self):
        return 0

    def create_share(self, ctx, share, share_server=None):
        """Create a sub-directory/share in the GlusterFS volume."""
        # probe into getting a NAS protocol helper for the share in order
        # to facilitate early detection of unsupported protocol type
        self._get_helper(share)
        sizestr = six.text_type(share['size']) + 'GB'
        share_dir = '/' + share['name']
        local_share_path = self._get_local_share_path(share)
        cmd = ['mkdir', local_share_path]
        # set hard limit quota on the sub-directory/share
        args = ('volume', 'quota', self.gluster_manager.volume,
                'limit-usage', share_dir, sizestr)
        try:
            self._execute(*cmd, run_as_root=True)
            self.gluster_manager.gluster_call(*args)
        except exception.ProcessExecutionError as exc:
            self._cleanup_create_share(local_share_path, share['name'])
            LOG.error(_LE('Unable to create share %s'), share['name'])
            raise exception.GlusterfsException(exc)

        export_location = os.path.join(self.gluster_manager.qualified,
                                       share['name'])
        return export_location

    def _cleanup_create_share(self, share_path, share_name):
        """Cleanup share that errored out during its creation."""
        if os.path.exists(share_path):
            cmd = ['rm', '-rf', share_path]
            try:
                self._execute(*cmd, run_as_root=True)
            except exception.ProcessExecutionError as exc:
                LOG.error(_LE('Cannot cleanup share, %s, that errored out '
                              'during its creation, but exists in GlusterFS '
                              'volume.'), share_name)
                raise exception.GlusterfsException(exc)

    def delete_share(self, context, share, share_server=None):
        """Remove a sub-directory/share from the GlusterFS volume."""
        local_share_path = self._get_local_share_path(share)
        cmd = ['rm', '-rf', local_share_path]
        try:
            self._execute(*cmd, run_as_root=True)
        except exception.ProcessExecutionError:
            LOG.error(_LE('Unable to delete share %s'), share['name'])
            raise

    def create_snapshot(self, context, snapshot, share_server=None):
        """TBD: Is called to create snapshot."""
        raise NotImplementedError()

    def create_share_from_snapshot(self, context, share, snapshot,
                                   share_server=None):
        """Is called to create share from snapshot."""
        raise NotImplementedError()

    def delete_snapshot(self, context, snapshot, share_server=None):
        """TBD: Is called to remove snapshot."""
        raise NotImplementedError()

    def ensure_share(self, context, share, share_server=None):
        """Might not be needed?"""
        pass

    def _get_helper(self, share):
        """Choose a protocol specific helper class."""
        if share['share_proto'] == 'NFS':
            return self._helpers['NFS']
        else:
            raise exception.InvalidShare(
                reason=(_('Unsupported share type, %s.')
                        % share['share_proto']))

    def allow_access(self, context, share, access, share_server=None):
        """Allow access to the share."""
        self._get_helper(share).allow_access('/', share, access)

    def deny_access(self, context, share, access, share_server=None):
        """Deny access to the share."""
        self._get_helper(share).deny_access('/', share, access)


class GlusterNFSHelper(ganesha.NASHelperBase):
    """Manage shares with Gluster-NFS server."""

    def __init__(self, execute, config_object, **kwargs):
        self.gluster_manager = kwargs.pop('gluster_manager')
        super(GlusterNFSHelper, self).__init__(execute, config_object,
                                               **kwargs)

    def init_helper(self):
        # exporting the whole volume must be prohibited
        # to not to defeat access control
        args = ('volume', 'set', self.gluster_manager.volume, NFS_EXPORT_VOL,
                'off')
        try:
            self.gluster_manager.gluster_call(*args)
        except exception.ProcessExecutionError as exc:
            LOG.error(_LE("Error in tuning GlusterFS volume to prevent "
                          "exporting the entire volume: %s"), exc.stderr)
            raise exception.GlusterfsException("gluster %s failed" %
                                               ' '.join(args))

    def _get_export_dir_dict(self):
        """Get the export entries of shares in the GlusterFS volume."""
        export_dir = self.gluster_manager.get_gluster_vol_option(
            NFS_EXPORT_DIR)
        edh = {}
        if export_dir:
            # see
            # https://github.com/gluster/glusterfs
            #  /blob/aa19909/xlators/nfs/server/src/nfs.c#L1582
            # regarding the format of nfs.export-dir
            edl = export_dir.split(',')
            # parsing export_dir into a dict of {dir: [hostpec,..]..}
            # format
            r = re.compile('\A/(.*)\((.*)\)\Z')
            for ed in edl:
                d, e = r.match(ed).groups()
                edh[d] = e.split('|')
        return edh

    def _manage_access(self, share_name, access_type, access_to, cbk):
        """Manage share access with cbk.

        Adjust the exports of the Gluster-NFS server using cbk.

        :param share_name: name of the share
        :type share_name: string
        :param access_type: type of access allowed in Manila
        :type access_type: string
        :param access_to: ip of the guest whose share access is managed
        :type access_to: string
        :param cbk: callback to adjust the exports of NFS server

        Following is the description of cbk(ddict, edir, host).

        :param ddict: association of shares with ips that have access to them
        :type ddict: dict
        :param edir: name of share i.e. export directory
        :type edir: string
        :param host: ip address derived from the access object
        :type host: string
        :returns: bool (cbk leaves ddict intact) or None (cbk modifies ddict)
        """

        if access_type != 'ip':
            raise exception.InvalidShareAccess('only ip access type allowed')
        export_dir_dict = self._get_export_dir_dict()
        if cbk(export_dir_dict, share_name, access_to):
            return

        if export_dir_dict:
            export_dir_new = (",".join("/%s(%s)" % (d, "|".join(v))
                              for d, v in sorted(export_dir_dict.items())))
            args = ('volume', 'set', self.gluster_manager.volume,
                    NFS_EXPORT_DIR, export_dir_new)
        else:
            args = ('volume', 'reset', self.gluster_manager.volume,
                    NFS_EXPORT_DIR)
        try:
            self.gluster_manager.gluster_call(*args)
        except exception.ProcessExecutionError as exc:
            LOG.error(_LE("Error in gluster volume set: %s"), exc.stderr)
            raise

    def allow_access(self, base, share, access):
        """Allow access to a share."""
        def cbk(ddict, edir, host):
            if edir not in ddict:
                ddict[edir] = []
            if host in ddict[edir]:
                return True
            ddict[edir].append(host)
        self._manage_access(share['name'], access['access_type'],
                            access['access_to'], cbk)

    def deny_access(self, base, share, access):
        """Deny access to a share."""
        def cbk(ddict, edir, host):
            if edir not in ddict or host not in ddict[edir]:
                return True
            ddict[edir].remove(host)
            if not ddict[edir]:
                ddict.pop(edir)
        self._manage_access(share['name'], access['access_type'],
                            access['access_to'], cbk)


class GaneshaNFSHelper(ganesha.GaneshaNASHelper):

    def __init__(self, execute, config_object, **kwargs):
        self.gluster_manager = kwargs.pop('gluster_manager')
        if config_object.glusterfs_ganesha_server_ip:
            execute = ganesha_utils.SSHExecutor(
                config_object.glusterfs_ganesha_server_ip, 22, None,
                config_object.glusterfs_ganesha_server_username,
                password=config_object.glusterfs_ganesha_server_password,
                privatekey=config_object.glusterfs_path_to_private_key)
        else:
            execute = ganesha_utils.RootExecutor(execute)
        super(GaneshaNFSHelper, self).__init__(execute, config_object,
                                               **kwargs)

    def _default_config_hook(self):
        """Callback to provide default export block."""
        dconf = super(GaneshaNFSHelper, self)._default_config_hook()
        conf_dir = ganesha_utils.path_from(__file__, "glusterfs", "conf")
        ganesha_utils.patch(dconf, self._load_conf_dir(conf_dir))
        return dconf

    def _fsal_hook(self, base, share, access):
        """Callback to create FSAL subblock."""
        return {"Hostname": self.gluster_manager.host,
                "Volume": self.gluster_manager.volume,
                "Volpath": "/" + share['name']}
