#
#

# Copyright (C) 2013 Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


"""Filesystem-based access functions and disk templates.

"""

import logging
import errno
import os

from ganeti import compat
from ganeti import constants
from ganeti import errors
from ganeti import pathutils
from ganeti import utils
from ganeti.utils import io
from ganeti.storage import base


class FileDeviceHelper(object):

  @classmethod
  def CreateFile(cls, path, size, create_folders=False,
                 _file_path_acceptance_fn=None):
    """Create a new file and its file device helper.

    @param size: the size in MiBs the file should be truncated to.
    @param create_folders: create the directories for the path if necessary
                           (using L{ganeti.utils.io.Makedirs})

    @rtype: FileDeviceHelper
    @return: The FileDeviceHelper object representing the object.
    @raise errors.FileStoragePathError: if the file path is disallowed by policy

    """

    if not _file_path_acceptance_fn:
      _file_path_acceptance_fn = CheckFileStoragePathAcceptance
    _file_path_acceptance_fn(path)

    if create_folders:
      folder = os.path.dirname(path)
      io.Makedirs(folder)

    try:
      fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_EXCL)
      f = os.fdopen(fd, "w")
      f.truncate(size * 1024 * 1024)
      f.close()
    except EnvironmentError as err:
      base.ThrowError("%s: can't create: %s", path, str(err))

    return FileDeviceHelper(path,
                            _file_path_acceptance_fn=_file_path_acceptance_fn)

  def __init__(self, path, _file_path_acceptance_fn=None):
    """Create a new file device helper.

    @raise errors.FileStoragePathError: if the file path is disallowed by policy

    """
    if not _file_path_acceptance_fn:
      _file_path_acceptance_fn = CheckFileStoragePathAcceptance
    _file_path_acceptance_fn(path)

    self.file_path_acceptance_fn = _file_path_acceptance_fn
    self.path = path

  def Exists(self, assert_exists=None):
    """Check for the existence of the given file.

    @param assert_exists: creates an assertion on the result value:
      - if true, raise errors.BlockDeviceError if the file doesn't exist
      - if false, raise errors.BlockDeviceError if the file does exist
    @rtype: boolean
    @return: True if the file exists

    """

    exists = os.path.isfile(self.path)

    if not exists and assert_exists is True:
      raise base.ThrowError("%s: No such file", self.path)
    if exists and assert_exists is False:
      raise base.ThrowError("%s: File exists", self.path)

    return exists

  def Remove(self):
    """Remove the file backing the block device.

    @rtype: boolean
    @return: True if the removal was successful

    """
    try:
      os.remove(self.path)
      return True
    except OSError as err:
      if err.errno != errno.ENOENT:
        base.ThrowError("%s: can't remove: %s", self.path, err)
      return False

  def Size(self):
    """Return the actual disk size in bytes.

    @rtype: int
    @return: The file size in bytes.

    """
    self.Exists(assert_exists=True)
    try:
      return os.stat(self.path).st_size
    except OSError as err:
      base.ThrowError("%s: can't stat: %s", self.path, err)

  def Grow(self, amount, dryrun, backingstore, _excl_stor):
    """Grow the file

    @param amount: the amount (in mebibytes) to grow by.

    """
    # Check that the file exists
    self.Exists(assert_exists=True)

    if amount < 0:
      base.ThrowError("%s: can't grow by negative amount", self.path)

    if dryrun:
      return
    if not backingstore:
      return

    current_size = self.Size()
    new_size = current_size + amount * 1024 * 1024
    try:
      f = open(self.path, "a+")
      f.truncate(new_size)
      f.close()
    except EnvironmentError as err:
      base.ThrowError("%s: can't grow: ", self.path, str(err))

  def Move(self, new_path):
    """Move file to a location inside the file storage dir.

    """
    # Check that the file exists
    self.Exists(assert_exists=True)
    self.file_path_acceptance_fn(new_path)
    try:
      os.rename(self.path, new_path)
      self.path = new_path
    except OSError as err:
      base.ThrowError("%s: can't rename to %s: ", str(err), new_path)


class FileStorage(base.BlockDev):
  """File device.

  This class represents a file storage backend device.

  The unique_id for the file device is a (file_driver, file_path) tuple.

  """
  def __init__(self, unique_id, children, size, params, dyn_params, **kwargs):
    """Initalizes a file device backend.

    """
    if children:
      raise errors.BlockDeviceError("Invalid setup for file device")
    super(FileStorage, self).__init__(unique_id, children, size, params,
                                      dyn_params, **kwargs)
    if not isinstance(unique_id, (tuple, list)) or len(unique_id) != 2:
      raise ValueError("Invalid configuration data %s" % str(unique_id))
    self.driver = unique_id[0]
    self.dev_path = unique_id[1]
    self.file = FileDeviceHelper(self.dev_path)
    self.Attach()

  def Assemble(self):
    """Assemble the device.

    Checks whether the file device exists, raises BlockDeviceError otherwise.

    """
    self.file.Exists(assert_exists=True)

  def Shutdown(self):
    """Shutdown the device.

    This is a no-op for the file type, as we don't deactivate
    the file on shutdown.

    """
    pass

  def Open(self, force=False, exclusive=True):
    """Make the device ready for I/O.

    This is a no-op for the file type.

    """
    pass

  def Close(self):
    """Notifies that the device will no longer be used for I/O.

    This is a no-op for the file type.

    """
    pass

  def Remove(self):
    """Remove the file backing the block device.

    @rtype: boolean
    @return: True if the removal was successful

    """
    return self.file.Remove()

  def Rename(self, new_id):
    """Renames the file.

    """
    return self.file.Move(new_id[1])

  def Grow(self, amount, dryrun, backingstore, excl_stor):
    """Grow the file

    @param amount: the amount (in mebibytes) to grow with

    """
    if not backingstore:
      return
    if dryrun:
      return
    self.file.Grow(amount, dryrun, backingstore, excl_stor)

  def Attach(self, **kwargs):
    """Attach to an existing file.

    Check if this file already exists.

    @rtype: boolean
    @return: True if file exists

    """
    self.attached = self.file.Exists()
    return self.attached

  def GetActualSize(self):
    """Return the actual disk size.

    @note: the device needs to be active when this is called

    """
    return self.file.Size()

  @classmethod
  def Create(cls, unique_id, children, size, spindles, params, excl_stor,
             dyn_params, **kwargs):
    """Create a new file.

    @type size: int
    @param size: the size of file in MiB

    @rtype: L{bdev.FileStorage}
    @return: an instance of FileStorage

    """
    if excl_stor:
      raise errors.ProgrammerError("FileStorage device requested with"
                                   " exclusive_storage")
    if not isinstance(unique_id, (tuple, list)) or len(unique_id) != 2:
      raise ValueError("Invalid configuration data %s" % str(unique_id))

    dev_path = unique_id[1]

    FileDeviceHelper.CreateFile(dev_path, size)
    return FileStorage(unique_id, children, size, params, dyn_params,
                       **kwargs)


def GetFileStorageSpaceInfo(path):
  """Retrieves the free and total space of the device where the file is
     located.

     @type path: string
     @param path: Path of the file whose embracing device's capacity is
       reported.
     @return: a dictionary containing 'vg_size' and 'vg_free' given in MebiBytes

  """
  try:
    result = os.statvfs(path)
    free = (result.f_frsize * result.f_bavail) // (1024 * 1024)
    size = (result.f_frsize * result.f_blocks) // (1024 * 1024)
    return {"type": constants.ST_FILE,
            "name": path,
            "storage_size": size,
            "storage_free": free}
  except OSError as e:
    raise errors.CommandError("Failed to retrieve file system information about"
                              " path: %s - %s" % (path, e.strerror))


def _GetForbiddenFileStoragePaths():
  """Builds a list of path prefixes which shouldn't be used for file storage.

  @rtype: frozenset

  """
  paths = set([
    "/boot",
    "/dev",
    "/etc",
    "/home",
    "/proc",
    "/root",
    "/sys",
    ])

  for prefix in ["", "/usr", "/usr/local"]:
    paths.update(
        "%s/%s" % (prefix, s) for s in ["bin", "lib", "lib32", "lib64", "sbin"])

  return compat.UniqueFrozenset(map(os.path.normpath, paths))


def _ComputeWrongFileStoragePaths(paths,
                                  _forbidden=_GetForbiddenFileStoragePaths()):
  """Cross-checks a list of paths for prefixes considered bad.

  Some paths, e.g. "/bin", should not be used for file storage.

  @type paths: list
  @param paths: List of paths to be checked
  @rtype: list
  @return: Sorted list of paths for which the user should be warned

  """
  def _Check(path):
    return (not os.path.isabs(path) or
            path in _forbidden or
            [p for p in _forbidden if utils.IsBelowDir(p, path)])

  return utils.NiceSort(filter(_Check, map(os.path.normpath, paths)))


def ComputeWrongFileStoragePaths(_filename=pathutils.FILE_STORAGE_PATHS_FILE):
  """Returns a list of file storage paths whose prefix is considered bad.

  See L{_ComputeWrongFileStoragePaths}.

  """
  return _ComputeWrongFileStoragePaths(_LoadAllowedFileStoragePaths(_filename))


def _CheckFileStoragePath(path, allowed, exact_match_ok=False):
  """Checks if a path is in a list of allowed paths for file storage.

  @type path: string
  @param path: Path to check
  @type allowed: list
  @param allowed: List of allowed paths
  @type exact_match_ok: bool
  @param exact_match_ok: whether or not it is okay when the path is exactly
      equal to an allowed path and not a subdir of it
  @raise errors.FileStoragePathError: If the path is not allowed

  """
  if not os.path.isabs(path):
    raise errors.FileStoragePathError("File storage path must be absolute,"
                                      " got '%s'" % path)

  for i in allowed:
    if not os.path.isabs(i):
      logging.info("Ignoring relative path '%s' for file storage", i)
      continue

    if exact_match_ok:
      if os.path.normpath(i) == os.path.normpath(path):
        break

    if utils.IsBelowDir(i, path):
      break
  else:
    raise errors.FileStoragePathError("Path '%s' is not acceptable for file"
                                      " storage" % path)


def _LoadAllowedFileStoragePaths(filename):
  """Loads file containing allowed file storage paths.

  @rtype: list
  @return: List of allowed paths (can be an empty list)

  """
  try:
    contents = utils.ReadFile(filename)
  except EnvironmentError:
    return []
  else:
    return utils.FilterEmptyLinesAndComments(contents)


def CheckFileStoragePathAcceptance(
    path, _filename=pathutils.FILE_STORAGE_PATHS_FILE,
    exact_match_ok=False):
  """Checks if a path is allowed for file storage.

  @type path: string
  @param path: Path to check
  @raise errors.FileStoragePathError: If the path is not allowed

  """
  allowed = _LoadAllowedFileStoragePaths(_filename)
  if not allowed:
    raise errors.FileStoragePathError("No paths are valid or path file '%s'"
                                      " was not accessible." % _filename)

  if _ComputeWrongFileStoragePaths([path]):
    raise errors.FileStoragePathError("Path '%s' uses a forbidden prefix" %
                                      path)

  _CheckFileStoragePath(path, allowed, exact_match_ok=exact_match_ok)


def _CheckFileStoragePathExistance(path):
  """Checks whether the given path is usable on the file system.

  This checks wether the path is existing, a directory and writable.

  @type path: string
  @param path: path to check

  """
  if not os.path.isdir(path):
    raise errors.FileStoragePathError("Path '%s' does not exist or is not a"
                                      " directory." % path)
  if not os.access(path, os.W_OK):
    raise errors.FileStoragePathError("Path '%s' is not writable" % path)


def CheckFileStoragePath(
    path, _allowed_paths_file=pathutils.FILE_STORAGE_PATHS_FILE):
  """Checks whether the path exists and is acceptable to use.

  Can be used for any file-based storage, for example shared-file storage.

  @type path: string
  @param path: path to check
  @rtype: string
  @returns: error message if the path is not ready to use

  """
  try:
    CheckFileStoragePathAcceptance(path, _filename=_allowed_paths_file,
                                   exact_match_ok=True)
  except errors.FileStoragePathError as e:
    return str(e)
  if not os.path.isdir(path):
    return "Path '%s' is not existing or not a directory." % path
  if not os.access(path, os.W_OK):
    return "Path '%s' is not writable" % path
