# -*- coding: utf-8 -*-
##############################################################################
#
# Copyright (c) 2010, 2011, 2012 Vifib SARL and Contributors.
# All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly advised to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################

import logging
import os
import shutil
import subprocess
import pkg_resources
import stat
import tempfile
from supervisor import xmlrpc
import xmlrpclib
import pwd
import utils
from svcbackend import getSupervisorRPC
from exception import BuildoutFailedError, WrongPermissionError, \
    PathDoesNotExistError
from networkcache import download_network_cached, upload_network_cached
import tarfile

REQUIRED_COMPUTER_PARTITION_PERMISSION = '0750'


class Software(object):
  """This class is responsible of installing a software release"""
  def __init__(self, url, software_root, console, buildout,
      signature_private_key_file=None, signature_certificate_list=None,
      upload_cache_url=None, upload_dir_url=None, shacache_cert_file=None,
      shacache_key_file=None, shadir_cert_file=None, shadir_key_file=None,
      download_binary_cache_url=None, upload_binary_cache_url=None,
      download_binary_dir_url=None, upload_binary_dir_url=None,
      download_from_binary_cache_url_blacklist = [],
      upload_to_binary_cache_url_blacklist = []):
    """Initialisation of class parameters
    """
    self.url = url
    self.software_root = software_root
    self.software_url_hash = utils.getSoftwareUrlHash(self.url)
    self.software_path = os.path.join(self.software_root,
                                      self.software_url_hash)
    self.buildout = buildout
    self.logger = logging.getLogger('BuildoutManager')
    self.console = console
    self.signature_private_key_file = signature_private_key_file
    self.signature_certificate_list = signature_certificate_list
    self.upload_cache_url = upload_cache_url
    self.upload_dir_url = upload_dir_url
    self.shacache_cert_file = shacache_cert_file
    self.shacache_key_file = shacache_key_file
    self.shadir_cert_file = shadir_cert_file
    self.shadir_key_file = shadir_key_file
    self.download_binary_cache_url = download_binary_cache_url
    self.upload_binary_cache_url = upload_binary_cache_url
    self.download_binary_dir_url = download_binary_dir_url
    self.upload_binary_dir_url = upload_binary_dir_url
    self.download_from_binary_cache_url_blacklist = \
        download_from_binary_cache_url_blacklist
    self.upload_to_binary_cache_url_blacklist = \
        upload_to_binary_cache_url_blacklist

  def install(self):
    """ Fetches binary cache if possible.
    Installs from buildout otherwise.
    """
    self.logger.info("Installing software release %s..." % self.url)
    tarname = self.software_url_hash
    cache_dir = tempfile.mkdtemp()
    tarpath = os.path.join(cache_dir, tarname)
    # Check if we can download from cache
    if (not os.path.exists(self.software_path)) \
        and download_network_cached(
            self.download_binary_cache_url,
            self.download_binary_dir_url,
            self.url, self.software_root,
            self.software_url_hash,
            tarpath, self.logger,
            self.signature_certificate_list,
            self.download_from_binary_cache_url_blacklist):
      tar = tarfile.open(tarpath)
      try:
        self.logger.info("Extracting archive of cached software release...")
        tar.extractall(path=self.software_root)
      finally:
        tar.close()
    else:
      self._install_from_buildout()

      # Upload to binary cache if possible
      blacklisted = False
      for url in self.upload_to_binary_cache_url_blacklist:
        if self.url.startswith(url):
          blacklisted = True
          self.logger.debug("Can't download from binary cache: "
              "Software Release URL is blacklisted.")
      if (self.software_root and self.url and self.software_url_hash \
                             and self.upload_binary_cache_url \
                             and self.upload_binary_dir_url \
                             and not blacklisted):
        self.logger.info("Creating archive of software release...")
        tar = tarfile.open(tarpath, "w:gz")
        try:
          tar.add(self.software_path, arcname=self.software_url_hash)
        finally:
          tar.close()
        self.logger.info("Trying to upload archive of software release...")
        upload_network_cached(
            self.software_root,
            self.url, self.software_url_hash,
            self.upload_binary_cache_url,
            self.upload_binary_dir_url,
            tarpath, self.logger,
            self.signature_private_key_file,
            self.shacache_cert_file,
            self.shacache_key_file,
            self.shadir_cert_file,
            self.shadir_key_file)
    shutil.rmtree(cache_dir)

  def _install_from_buildout(self):
    """ Fetches buildout configuration from the server, run buildout with
    it. If it fails, we notify the server.
    """
    root_stat_info = os.stat(self.software_root)
    os.environ = utils.getCleanEnvironment(pwd.getpwuid(root_stat_info.st_uid
      ).pw_dir)
    if not os.path.isdir(self.software_path):
      os.mkdir(self.software_path)
    extends_cache = tempfile.mkdtemp()
    if os.getuid() == 0:
      # In case when running as root copy ownership, to simplify logic
      for path in [self.software_path, extends_cache]:
        path_stat_info = os.stat(path)
        if root_stat_info.st_uid != path_stat_info.st_uid or\
             root_stat_info.st_gid != path_stat_info.st_gid:
            os.chown(path, root_stat_info.st_uid,
                root_stat_info.st_gid)
    try:
      buildout_parameter_list = [
        'buildout:extends-cache=%s' % extends_cache,
        'buildout:directory=%s' % self.software_path,]

      if self.signature_private_key_file or \
          self.upload_cache_url or \
            self.upload_dir_url is not None:
        buildout_parameter_list.append('buildout:networkcache-section=networkcache')
      for  buildout_option, value in (
         ('%ssignature-private-key-file=%s', self.signature_private_key_file),
         ('%supload-cache-url=%s', self.upload_cache_url),
         ('%supload-dir-url=%s', self.upload_dir_url),
         ('%sshacache-cert-file=%s', self.shacache_cert_file),
         ('%sshacache-key-file=%s', self.shacache_key_file),
         ('%sshadir-cert-file=%s', self.shadir_cert_file),
         ('%sshadir-key-file=%s', self.shadir_key_file),
         ):
        if value:
          buildout_parameter_list.append( \
              buildout_option % ('networkcache:', value))

      buildout_parameter_list.extend(['-c', self.url])
      utils.bootstrapBuildout(self.software_path, self.buildout,
          additional_buildout_parametr_list=buildout_parameter_list,
          console=self.console)
      utils.launchBuildout(self.software_path,
                     os.path.join(self.software_path, 'bin', 'buildout'),
                     additional_buildout_parametr_list=buildout_parameter_list,
                     console=self.console)
    finally:
      shutil.rmtree(extends_cache)

  def destroy(self):
    """Removes software release."""
    def retry(func, path, exc):
      # inspired on slapos.buildout hard remover
      if func == os.path.islink:
        os.unlink(path)
      else:
        os.chmod (path, 0600)
        func(path)
    try:
      if os.path.exists(self.software_path):
        self.logger.info('Removing path %r' % self.software_path)
        shutil.rmtree(self.software_path, onerror=retry)
      else:
        self.logger.info('Path %r does not exists, no need to remove.' %
            self.software_path)
    except IOError as error:
      error_string = "I/O error while removing software (%s): %s" % (self.url,
                                                                     error)
      raise IOError(error_string)


class Partition(object):
  """This class is responsible of the installation of a instance
  """
  def __init__(self,
               software_path,
               instance_path,
               supervisord_partition_configuration_path,
               supervisord_socket,
               computer_partition,
               computer_id,
               partition_id,
               server_url,
               software_release_url,
               buildout,
               certificate_repository_path=None,
               console=False
               ):
    """Initialisation of class parameters"""
    self.buildout = buildout
    self.software_path = software_path
    self.instance_path = instance_path
    self.run_path = os.path.join(self.instance_path, 'etc', 'run')
    self.supervisord_partition_configuration_path = \
        supervisord_partition_configuration_path
    self.supervisord_socket = supervisord_socket
    self.computer_partition = computer_partition
    self.logger = logging.getLogger('Partition')
    self.computer_id = computer_id
    self.partition_id = partition_id
    self.server_url = server_url
    self.software_release_url = software_release_url
    self.console = console

    self.key_file = ''
    self.cert_file = ''
    if certificate_repository_path is not None:
      self.key_file = os.path.join(certificate_repository_path,
          self.partition_id + '.key')
      self.cert_file = os.path.join(certificate_repository_path,
          self.partition_id + '.crt')
      self._updateCertificate()

  def _updateCertificate(self):
    if not os.path.exists(self.key_file) or \
        not os.path.exists(self.cert_file):
      self.logger.info('Certificate and key not found, downloading to %r and '
          '%r' % (self.cert_file, self.key_file))
      partition_certificate = self.computer_partition.getCertificate()
      open(self.key_file, 'w').write(partition_certificate['key'])
      open(self.cert_file, 'w').write(partition_certificate['certificate'])
    for f in [self.key_file, self.cert_file]:
      os.chmod(f, 0400)
      os.chown(f, *self.getUserGroupId())

  def getUserGroupId(self):
    """Returns tuple of (uid, gid) of partition"""
    stat_info = os.stat(self.instance_path)
    uid = stat_info.st_uid
    gid = stat_info.st_gid
    return (uid, gid)

  def install(self):
    """ Creates configuration file from template in software_path, then
    installs the software partition with the help of buildout
    """
    self.logger.info("Installing Computer Partition %s..." \
        % self.computer_partition.getId())
    # Checks existence and permissions of Partition directory
    # Note : Partitions have to be created and configured before running slapgrid
    if not os.path.isdir(self.instance_path):
      raise PathDoesNotExistError('Please create partition directory %s'
                                           % self.instance_path)
    instance_stat_info = os.stat(self.instance_path)
    permission = oct(stat.S_IMODE(instance_stat_info.st_mode))
    if permission != REQUIRED_COMPUTER_PARTITION_PERMISSION:
      raise WrongPermissionError('Wrong permissions in %s : actual ' \
                                          'permissions are : %s, wanted ' \
                                          'are %s' %
                                          (self.instance_path, permission,
                                            REQUIRED_COMPUTER_PARTITION_PERMISSION))
    os.environ = utils.getCleanEnvironment(pwd.getpwuid(
      instance_stat_info.st_uid).pw_dir)
    # Generates buildout part from template
    template_location = os.path.join(self.software_path, 'instance.cfg')
    # Backward compatibility: "instance.cfg" file was named "template.cfg".
    if not os.path.exists(template_location):
      template_location = os.path.join(self.software_path, 'template.cfg')
    config_location = os.path.join(self.instance_path, 'buildout.cfg')
    self.logger.debug("Copying %r to %r" % (template_location, config_location))
    try:
      shutil.copy(template_location, config_location)
    except IOError, e:
      # Template not found on SR, we notify user.
      raise IOError('Software Release %s is not correctly installed.\n'
          '%s' % (self.software_release_url, e))
    # fill generated buildout with additional information
    buildout_text = open(config_location).read()
    buildout_text += '\n\n' + pkg_resources.resource_string(__name__,
        'templates/buildout-tail.cfg.in') % dict(
      computer_id=self.computer_id,
      partition_id=self.partition_id,
      server_url=self.server_url,
      software_release_url=self.software_release_url,
      key_file=self.key_file,
      cert_file=self.cert_file
    )
    open(config_location, 'w').write(buildout_text)
    os.chmod(config_location, 0640)
    # Try to find the best possible buildout:
    #  *) if software_root/bin/bootstrap exists use this one to bootstrap
    #     locally
    #  *) as last resort fallback to buildout binary from software_path
    bootstrap_candidate_dir = os.path.abspath(os.path.join(self.software_path,
      'bin'))
    if os.path.isdir(bootstrap_candidate_dir):
      bootstrap_candidate_list = [q for q in os.listdir(bootstrap_candidate_dir)
        if q.startswith('bootstrap')]
    else:
      bootstrap_candidate_list = []
    uid, gid = self.getUserGroupId()
    os.chown(config_location, -1, int(gid))
    if len(bootstrap_candidate_list) == 0:
      buildout_binary = os.path.join(self.software_path, 'bin', 'buildout')
      self.logger.warning("Falling back to default buildout %r" %
        buildout_binary)
    else:
      if len(bootstrap_candidate_list) != 1:
        raise ValueError('More then one bootstrap candidate found.')
      # Reads uid/gid of path, launches buildout with thoses privileges
      bootstrap_file = os.path.abspath(os.path.join(bootstrap_candidate_dir,
        bootstrap_candidate_list[0]))

      file = open(bootstrap_file, 'r')
      line = file.readline()
      file.close()
      invocation_list = []
      if line.startswith('#!'):
        invocation_list = line[2:].split()
      invocation_list.append(bootstrap_file)
      self.logger.debug('Invoking %r in %r' % (' '.join(invocation_list),
        self.instance_path))
      kw = dict()
      if not self.console:
        kw.update(stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
      process_handler = utils.SlapPopen(invocation_list,
        preexec_fn=lambda: utils.dropPrivileges(uid, gid), cwd=self.instance_path,
        env=utils.getCleanEnvironment(pwd.getpwuid(uid).pw_dir), **kw)
      result_std = process_handler.communicate()[0]
      if self.console:
        result_std = 'Please consult messages above.'
      if process_handler.returncode is None or process_handler.returncode != 0:
        message = 'Failed to bootstrap buildout in %r:\n%s\n' % (
            self.instance_path, result_std)
        raise BuildoutFailedError(message)
      buildout_binary = os.path.join(self.instance_path, 'sbin', 'buildout')

    if not os.path.exists(buildout_binary):
      # use own buildout generation
      utils.bootstrapBuildout(self.instance_path, self.buildout,
        ['buildout:bin-directory=%s'% os.path.join(self.instance_path,
        'sbin')], console=self.console)
      buildout_binary = os.path.join(self.instance_path, 'sbin', 'buildout')
    # Launches buildout
    utils.launchBuildout(self.instance_path,
                   buildout_binary, console=self.console)
    # Generates supervisord configuration file from template
    self.logger.info("Generating supervisord config file from template...")
    # check if CP/etc/run exists and it is a directory
    # iterate over each file in CP/etc/run
    # if at least one is not 0750 raise -- partition has something funny
    runner_list = []
    if os.path.exists(self.run_path):
      if os.path.isdir(self.run_path):
        runner_list = os.listdir(self.run_path)
    if len(runner_list) == 0:
      self.logger.warning('No runners found for partition %r' %
          self.partition_id)
      if os.path.exists(self.supervisord_partition_configuration_path):
        os.unlink(self.supervisord_partition_configuration_path)
    else:
      partition_id = self.computer_partition.getId()
      program_partition_template = pkg_resources.resource_stream(__name__,
          'templates/program_partition_supervisord.conf.in').read()
      group_partition_template = pkg_resources.resource_stream(__name__,
          'templates/group_partition_supervisord.conf.in').read()
      partition_supervisor_configuration = group_partition_template % dict(
          instance_id=partition_id,
          program_list=','.join(['_'.join([partition_id, runner])
            for runner in runner_list]))
      for runner in runner_list:
        partition_supervisor_configuration += '\n' + \
            program_partition_template % dict(
          program_id='_'.join([partition_id, runner]),
          program_directory=self.instance_path,
          program_command=os.path.join(self.run_path, runner),
          program_name=runner,
          instance_path=self.instance_path,
          user_id=uid,
          group_id=gid,
          # As supervisord has no environment to inherit setup minimalistic one
          HOME=pwd.getpwuid(uid).pw_dir,
          USER=pwd.getpwuid(uid).pw_name,
        )
      utils.updateFile(self.supervisord_partition_configuration_path,
          partition_supervisor_configuration)
    self.updateSupervisor()

  def start(self):
    """Asks supervisord to start the instance. If this instance is not
    installed, we install it.
    """
    supervisor = self.getSupervisorRPC()
    partition_id = self.computer_partition.getId()
    try:
      supervisor.startProcessGroup(partition_id, False)
    except xmlrpclib.Fault, e:
      if e.faultString.startswith('BAD_NAME:'):
        self.logger.info("Nothing to start on %s..." % \
                         self.computer_partition.getId())
    else:
      self.logger.info("Requested start of %s..." % self.computer_partition.getId())

  def stop(self):
    """Asks supervisord to stop the instance."""
    supervisor = self.getSupervisorRPC()
    partition_id = self.computer_partition.getId()
    try:
      supervisor.stopProcessGroup(partition_id, False)
    except xmlrpclib.Fault, e:
      if e.faultString.startswith('BAD_NAME:'):
        self.logger.info('Partition %s not known in supervisord, ignoring' % partition_id)
    else:
      self.logger.info("Requested stop of %s..." % self.computer_partition.getId())

  def destroy(self):
    """Destroys the partition and makes it available for subsequent use."
    """
    self.logger.info("Destroying Computer Partition %s..." \
        % self.computer_partition.getId())
    # Gets actual buildout binary
    buildout_binary = os.path.join(self.instance_path, 'sbin', 'buildout')
    if not os.path.exists(buildout_binary):
      buildout_binary = os.path.join(self.software_path, 'bin', 'buildout')
    # Launches "destroy" binary if exists
    destroy_executable_location = os.path.join(self.instance_path, 'sbin',
        'destroy')
    if os.path.exists(destroy_executable_location):
      # XXX: we should factorize this code
      uid, gid = None, None
      stat_info = os.stat(self.instance_path)
      uid = stat_info.st_uid
      gid = stat_info.st_gid
      self.logger.debug('Invoking %r' % destroy_executable_location)
      kw = dict()
      if not self.console:
        kw.update(stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
      process_handler = utils.SlapPopen([destroy_executable_location],
        preexec_fn=lambda: utils.dropPrivileges(uid, gid), cwd=self.instance_path,
        env=utils.getCleanEnvironment(pwd.getpwuid(uid).pw_dir), **kw)
      result_std = process_handler.communicate()[0]
      if self.console:
        result_std = 'Please consult messages above'
      if process_handler.returncode is None or process_handler.returncode != 0:
        message = 'Failed to destroy Computer Partition in %r:\n%s\n' % (
            self.instance_path, result_std)
        raise subprocess.CalledProcessError(message)
    # Manually cleans what remains
    try:
      for f in [self.key_file, self.cert_file]:
        if f:
          if os.path.exists(f):
            os.unlink(f)
      for root, dirs, file_list in os.walk(self.instance_path):
        for directory in dirs:
          shutil.rmtree(os.path.join(self.instance_path, directory))
        for file in file_list:
          os.remove(os.path.join(self.instance_path, file))
        if os.path.exists(self.supervisord_partition_configuration_path):
          os.remove(self.supervisord_partition_configuration_path)
        self.updateSupervisor()
    except IOError as error:
      error_string = "I/O error while freeing partition (%s): %s" \
                     % (self.instance_path, error)
      raise IOError(error_string)

  def fetchInformations(self):
    """Fetch usage informations with buildout, returns it.
    """
    raise NotImplementedError

  def getSupervisorRPC(self):
    return getSupervisorRPC(self.supervisord_socket)

  def updateSupervisor(self):
    """Forces supervisord to reload its configuration"""
    # Note: This method shall wait for results from supervisord
    #       In future it will be not needed, as update command
    #       is going to be implemented on server side.
    self.logger.debug('Updating supervisord')
    supervisor = self.getSupervisorRPC()
    # took from supervisord.supervisorctl.do_update
    result = supervisor.reloadConfig()
    added, changed, removed = result[0]

    for gname in removed:
      results = supervisor.stopProcessGroup(gname)
      fails = [res for res in results
               if res['status'] == xmlrpc.Faults.FAILED]
      if fails:
        self.logger.warning('Problem while stopping process %r, will try later' % gname)
      else:
        self.logger.info('Stopped %r' % gname)
      supervisor.removeProcessGroup(gname)
      self.logger.info('Removed %r' % gname)

    for gname in changed:
      results = supervisor.stopProcessGroup(gname)
      self.logger.info('Stopped %r' % gname)

      supervisor.removeProcessGroup(gname)
      supervisor.addProcessGroup(gname)
      self.logger.info('Updated %r' % gname)

    for gname in added:
      supervisor.addProcessGroup(gname)
      self.logger.info('Updated %r' % gname)
    self.logger.debug('Supervisord updated')