# Copyright 1999-2017. Plesk International GmbH. All rights reserved.
# vim: ts=4 sts=4 sw=4 et :

import os
import glob
import re

from time import sleep

import php_merge
import php_layout
import plesk_log
import plesk_service

log = plesk_log.getLogger(__name__)

class PhpFpmSanityCheck:
    __options = None
    __accepted_options = ["type", "virtual_host", "override", "remove", "sysuser", "no_reload", "service_action", "service_name", "pool_d", "global_config", "cgi_bin"]

    def __init__(self, options):
        self.__options = options

    def check(self):
        if not self.__options.type or self.__options.type != "fpm":
            raise Exception("Internal Error: Wrong php service type(%s) for php fpm" % self.__options.type)

        """ Check the options are valid """
        for key in vars(self.__options):
            attr = getattr(self.__options, key)
            if attr and key not in self.__accepted_options:
                    raise Exception("The option '%s' is not acceptable for php fpm" % key)

        if self.__options.service_action:
                if self.__options.remove or self.__options.virtual_host:
                        raise Exception("Using service and configuration management options at the same time is not supported.")

                if not self.__options.service_name:
                        raise Exception("Service name is not specified for fpm service action '%s'" % self.__options.service_action)

                return True

        if not self.__options.virtual_host:
            raise Exception("fpm handler type requires --virtual-host to be specified")
        if not self.__options.sysuser and not self.__options.remove:
            raise Exception("fpm handler type requires --sysuser to be specified unless with --remove")

class PhpFpmService:
    pool_dir = None
    service = None

    """ php-fpm service manager. """
    def __init__(self, options):
        self.service = options.service_name if options.service_name else php_layout.PHP_FPM_SERVICE_NAME
        self.pool_dir = options.pool_d if options.pool_d else php_layout.PHP_FPM_INCLUDE_DIR

    def action(self, act):
        if act not in ('start', 'stop', 'restart', 'reload'):
            raise ValueError("Unsupported php-fpm service action '%s'" % act)
        if act == 'reload':
            act = php_layout.PHP_FPM_RELOAD_CMD
        if not plesk_service.action(self.service, act):
            raise RuntimeError("Failed to %s %s service" % (act, self.service))
        if act == php_layout.PHP_FPM_RELOAD_CMD:
            # In order to minimize occasional implications (race conditions) of reloading
            # the service by sending a signal (an asynchronous operation)
            if not self.is_running():
                # resurrect fpm service if race condition killed one
                if not plesk_service.action(self.service, 'restart') or not self.is_running():
                    raise RuntimeError("Failed to %s %s service" % (act, self.service))

        # Some init scripts don't report failure properly
        if act in ('start', 'restart', php_layout.PHP_FPM_RELOAD_CMD) and not self.status():
            raise RuntimeError("Service %s is down after attempt to %s it" % (self.service, act))
        log.debug("%s service %s succeeded", self.service, act)

    def is_running(self):
        for delay in [1, 1, 2]:
            if self.status():
                sleep(1)
                if self.status():
                    return True
            sleep(delay)
        return False

    def status(self):
        st = plesk_service.action(self.service, 'status')
        log.debug("%s service status = %s", self.service, st)
        return st

    def enable(self):
        plesk_service.register(self.service, with_resource_controller=True)
        log.debug("%s service registered successfully", self.service)

    def disable(self):
        plesk_service.deregister(self.service)
        log.debug("%s service deregistered successfully", self.service)

    def smart_reload(self):
        """ 'Smart' reload. Suits perfectly if you want to update fpm pools configuration:
            reloads fpm-service in normal cases, stops service if last pool was removed,
            starts service if first pool is created."""
        running = self.status()
        have_pools = self._has_pools()

        act = None
        register = None
        if running:
            if have_pools:
                act = "reload"
            else:
                (act, register) = ("stop", "disable")
        elif have_pools:
            (act, register) = ("start", "enable")

        log.debug("perform smart_reload action for service %s : %s", self.service, act)
        if act:
            self.action(act)
        if register == "enable":
            self.enable()
        elif register == "disable":
            self.disable()

    def _has_pools(self):
        for path in glob.glob(os.path.join(self.pool_dir, "*.conf")):
            with open(path) as f:
                for line in f:
                    if re.match(r"^\s*\[.+\]", line):
                        log.debug("Found active pool %s for service %s", path, self.service)
                        return True
        log.debug("No pools found in %s for service %s", self.pool_dir, self.service)
        return False

class PhpFpmPoolConfig:
    """ php-fpm pools configuration class.
        For a given virtual host a separate pool is configured with the same name.
        It will also contain all custom php.ini settings changed from the server-wide
        php.ini file.
    """

    pool_settings_section = 'php-fpm-pool-settings'
    allowed_overrides = (
        'access.format',
        'access.log',
        'catch_workers_output',
        'chdir',
        'ping.path',
        'ping.response',
        'pm',
        'pm.max_children',
        'pm.max_requests',
        'pm.max_spare_servers',
        'pm.min_spare_servers',
        'pm.process_idle_timeout',
        'pm.start_servers',
        'pm.status_path',
        'request_slowlog_timeout',
        'request_terminate_timeout',
        'rlimit_core',
        'rlimit_files',
        'security.limit_extensions',   # This one is tricky, don't override blindly!
        'slowlog',
    )

    allowed_override_prefixes = (
        'env',
        'php_value',
        'php_flag',
        'php_admin_value',
        'php_admin_flag',
    )

    def __init__(self, options):
        self.vhost = options.virtual_host
        self.user = options.sysuser
        self.cgi_bin = options.cgi_bin if options.cgi_bin else None
        self.pool_d = options.pool_d if options.pool_d is not None else php_layout.PHP_FPM_INCLUDE_DIR
        self.server_wide_php_ini = options.global_config if options.global_config else php_layout.SERVER_WIDE_PHP_INI

    def merge(self, override_file=None, has_input_stream=True):
        default_data = """
[php-fpm-pool-settings]
pm = ondemand
pm.max_children = 5
pm.max_spare_servers = 1
pm.min_spare_servers = 1
pm.process_idle_timeout = 10s
pm.start_servers = 1
"""
        self.config = php_merge.merge_input_configs(override_filename=override_file, allow_pool_settings=True, has_input_stream=has_input_stream, input_string=default_data)

    def infer_config_path(self):
        return os.path.join(self.pool_d, self.vhost + '.conf')

    def open(self, config_path):
        return open(config_path, 'w')

    def write(self, fileobject):
        """ Writes php-fpm pool configuration.

            All custom php.ini directives are included via php_value[] which is
            slightly incorrect, since this doesn't respect boolean settings (but doesn't
            break them either) and effectively allows modification of any customized 
            php.ini settings by php scripts on this vhost. This implies a need for 
            php.ini directives classifier based on their name and possibly php version.

            On-demand process spawning is used. It was introduced in php-fpm 5.3.9 and
            allows 0 processes on startup (useful for shared hosting). Looks like all 
            available php-fpm packages in popular repositories are >= 5.3.10, so we 
            don't check php-fpm version here.

            Also note that php-fpm will ignore any zend_extension directives.
        """
        fileobject.write(php_layout.AUTOGENERATED_CONFIGS)
        # default pool configuration
        fileobject.write("""
; If you need to customize this file, use either custom PHP settings tab in
; Panel or override settings in %(vhosts_d)s/%(vhost)s/conf/php.ini.
; To override pool configuration options, specify them in [%(pool_section)s]
; section of %(vhosts_d)s/%(vhost)s/conf/php.ini file.

[%(vhost)s]
; Don't override following options, they are relied upon by Plesk internally
prefix = %(vhosts_d)s/$pool
user = %(user)s
group = psacln

listen = php-fpm.sock
listen.owner = root
listen.group = psaserv
listen.mode = 0660

; Following options can be overridden
chdir = /

; Uses for log facility
; If php_value[error_log] is not defined error output will be send for nginx
catch_workers_output = yes

""" %
                         {
                             'vhost': self.vhost,
                             'vhosts_d': php_layout.get_vhosts_system_d(),
                             'user': self.user,
                             'pool_section': self.pool_settings_section,
                         })

        # php.ini settings overrides
        try:
            # Note that we don't process 'HOST=' and 'PATH=' sections here
            # Also zend_extension directives are not filtered out as php-fpm ignores them anyway
            fileobject.write("; php.ini custom configuration directives\n")
            for name, value in sorted(self.config.items('PHP'), key=lambda x: x[0]):
                fileobject.write("php_value[%s] = %s\n" % (name, value))
            fileobject.write("\n")
        except Exception, ex:
            log.warning("Processing of additional PHP directives for php-fpm failed: %s", ex)

        # pool configuration overrides
        if self.config.has_section(self.pool_settings_section):
            fileobject.write("; Following directives define pool configuration\n")
            for name, value in sorted(self.config.items(self.pool_settings_section), key=lambda x: x[0]):
                if ((name not in self.allowed_overrides and
                     name.split('[', 1)[0] not in self.allowed_override_prefixes)):

                    log.warning("Following pool configuration override is not permitted and was ignored: %s = %s", name, value)
                else:
                    fileobject.write("%s = %s\n" % (name, value))

        fileobject.flush()
        os.fsync(fileobject.fileno())

    def check(self):
        if not self.cgi_bin:
            return True

        p = os.system(self.cgi_bin + " -t >/dev/null 2>&1")
        if p != 0:
            return False

        return True

DECLARES = {
    "init": PhpFpmSanityCheck,
    "config": PhpFpmPoolConfig,
    "service": PhpFpmService
}