#!/usr/bin/env python
### Copyright 1999-2015. Parallels IP Holdings GmbH. All Rights Reserved.
""" phpinimng - Plesk backend utility for management of custom php.ini files
    and php-fpm pools.
"""

import os
import sys
import glob
import re
from optparse import OptionParser
from time import sleep

import php_layout
import php_ini
import plesk_log
import plesk_service

log = plesk_log.getLogger(__name__)

class PhpFpmService:
    """ php-fpm service manager. """
    def __init__(self, service_name=None, pool_dir=None):
        self.service = service_name if service_name else php_layout.PHP_FPM_SERVICE_NAME
        self.pool_dir = pool_dir if pool_dir 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
            sleep(1)
        # Some init scripts don't report failure properly
        if act in ('start', 'restart') 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 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)
        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 PhpIniConfigParser(php_ini.PhpIniConfigParser):
    def is_section_retained(self, section):
        """ Override section retention policy to allow 'php-fpm-pool-settings' as separate section. """
        return (super(PhpIniConfigParser, self).is_section_retained(section) or 
                section == PhpFpmPoolConfig.pool_settings_section)

    def remove_pool_settings_section(self):
        """ Removes 'php-fpm-pool-settings' section if present. """
        self.remove_section(PhpFpmPoolConfig.pool_settings_section)


def merge_input_configs(server_wide_filename=None, override_filename=None, allow_pool_settings=False):
    """ Merge all supplied php.ini configuration files and return 
        SafeConfigParser object.
        Files are merged in the following order: server-wide, stdin, override.
    """
    config = PhpIniConfigParser()

    if server_wide_filename:
        try:
            config.read(server_wide_filename)
        except Exception, ex:
            log.debug("Reading server-wide config '%s' failed: %s", server_wide_filename, ex)

    try:
        config.readfp(sys.stdin)
    except:
        raise RuntimeError( "Cannot parse php.ini: %s" % str(sys.exc_info()[:2]) )

    # Pool settings may be configured only via override file
    config.remove_pool_settings_section()

    if override_filename:
        try:
            config.read(override_filename)
        except Exception, ex:
            log.debug("Reading override config '%s' failed: %s", override_filename, ex)

    if not allow_pool_settings:
        config.remove_pool_settings_section()

    return config


class CgiPhpIniConfig:
    """ Custom php.ini for fastcgi/cgi PHP handers configuration class. """
    def __init__(self, virtual_host, server_wide_php_ini=None):
        self.vhost = virtual_host
        self.server_wide_php_ini = server_wide_php_ini if server_wide_php_ini else php_layout.SERVER_WIDE_PHP_INI

    def merge(self, override_file=None):
        self.config = merge_input_configs(self.server_wide_php_ini, override_file)

    def infer_config_path(self):
        vhosts_system_d = php_layout.get_vhosts_system_d()
        return os.path.join(vhosts_system_d, self.vhost, 'etc', 'php.ini')

    def open(self, config_path):
        parent_dir = os.path.dirname(config_path)
        if not os.path.exists(parent_dir):
            os.mkdir(parent_dir)
            os.chown(parent_dir, 0, 0)

        return open(config_path, 'wb')

    def write(self, fileobject):
        fileobject.write(php_layout.AUTOGENERATED_CONFIGS)
        fileobject.write('\n')
        self.config.writefp(fileobject)


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 = ('pm',
                         'pm.max_children',
                         'pm.start_servers',
                         'pm.min_spare_servers',
                         'pm.max_spare_servers',
                         'pm.process_idle_timeout',
                         'pm.max_requests',
                         'pm.status_path',
                         'ping.path',
                         'ping.response',
                         'access.log',
                         'access.format',
                         'slowlog',
                         'request_slowlog_timeout',
                         'request_terminate_timeout',
                         'rlimit_files',
                         'rlimit_core',
                         'chdir',
                         'catch_workers_output',
                         'security.limit_extensions',   # This one is tricky, don't override blindly!
                        )
    allowed_override_prefixes = ('env',
                                 'php_value',
                                 'php_flag',
                                 'php_admin_value',
                                 'php_admin_flag',
                                )

    def __init__(self, virtual_host, sysuser, pool_dir):
        self.vhost = virtual_host
        self.user = sysuser
        self.pool_d = pool_dir if pool_dir is not None else php_layout.PHP_FPM_INCLUDE_DIR

    def merge(self, override_file=None):
        self.config = merge_input_configs(override_filename=override_file, allow_pool_settings=True)

    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 = /

; By default use ondemand spawning (this requires php-fpm >= 5.3.9)
pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 10s
; Following pm.* options are used only when 'pm = dynamic'
pm.start_servers = 1
pm.min_spare_servers = 1
pm.max_spare_servers = 1

; 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 self.config.items('PHP'):
                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 override default pool configuration\n")
            for name, value in self.config.items(self.pool_settings_section):
                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))


class PhpIniMngApp:
    def __init__(self):
        self.options = None
        self.config_path = None

    def parse_args(self, argv):
        """ Parse command line arguments, check their sanity and provide help. """
        usage = "usage: %prog [options] [php_ini_file]"
        parser = OptionParser(usage=usage)
        parser.add_option('-g', '--global-config',
                          help="Load custom global (server-wide) directives (takes lowest precedence, "
                               "this argument is used only for cgi handler type)")
        parser.add_option('-o', '--override', 
                          help="Load custom directives (takes precedence over all others)")
        parser.add_option('-t', '--type', type='choice', choices=['cgi', 'fpm'], default='cgi',
                          help="Type of php handler - 'cgi' (default) or 'fpm'")
        parser.add_option('-v', '--virtual-host',
                          help="Virtual host to apply changes to")
        parser.add_option('-u', '--sysuser',
                          help="System user name for a given virtual host "
                               "(this argument is required only for fpm handler type)")

        parser.add_option('-r', '--remove', action='store_true',
                          help="Remove configuration file for specified virtual host and type")
        parser.add_option('-n', '--no-reload', action='store_true',
                          help="Don't reload php-fpm service")

        parser.add_option('--enable-service', 
                          action='store_const', const='enable', dest='service_action',
                          help="Enable service (valid only for php-fpm)")
        parser.add_option('--disable-service', 
                          action='store_const', const='disable', dest='service_action',
                          help="Disable service (valid only for php-fpm)")

        parser.add_option('--start', action='store_const', const='start', dest='service_action',
                          help="Start service (valid only for php-fpm)")
        parser.add_option('--stop', action='store_const', const='stop', dest='service_action',
                          help="Stop service (valid only for php-fpm)")
        parser.add_option('--restart', action='store_const', const='restart', dest='service_action',
                          help="Restart service (valid only for php-fpm)")
        parser.add_option('--status', action='store_const', const='status', dest='service_action',
                          help="Print service status (valid only for php-fpm)")

        parser.add_option('--service-name', action='store', dest='service_name',
                          help="Specify service name for fpm (valid only for php-fpm)")

        parser.add_option('--poold', action='store', dest="pool_d",
                          help="Specify pool.d path (valid only for php-fpm)")

        (self.options, args) = parser.parse_args()

        log.debug("Options: %s", self.options)
        log.debug("Args: %s", args)

        # Options sanity checks
        if not self.options.service_action:
            if self.options.type == 'cgi':
                if len(args) == 1:
                    self.config_path = args[0]
                elif not self.options.virtual_host:
                    parser.error("neither php_ini_file nor --virtual-host is specified - don't know where to write config to")
                if self.options.sysuser:
                    parser.error("cgi handler type doesn't need --sysuser option - it will be ignored")
            elif self.options.type == 'fpm':
                if not self.options.virtual_host:
                    parser.error("fpm handler type requires --virtual-host to be specified")
                if not self.options.sysuser and not self.options.remove:
                    parser.error("fpm handler type requires --sysuer to be specified unless with --remove")
                if self.options.global_config:
                    parser.error("fpm handler type doesn't need --global-config option - it will be ignored")
            else:
                parser.error("unknown php handler type '%s'" % self.options.type)
        elif self.options.service_action and (self.options.remove or self.options.virtual_host):
            parser.error("using service and configuration management options at the same time is not supported")

        return self.options, args

    def main(self, argv):
        """ Entire phpinimng utility, but in a callable form. """
        self.parse_args(argv[1:])

        if self.options.type == 'cgi':
            config = CgiPhpIniConfig(self.options.virtual_host, self.options.global_config)
        elif self.options.type == 'fpm':
            config = PhpFpmPoolConfig(self.options.virtual_host, self.options.sysuser, self.options.pool_d)

        service = PhpFpmService(self.options.service_name, self.options.pool_d)

        if self.options.service_action:
            # Service management
            if self.options.service_action == 'enable':
                service.enable()
            elif self.options.service_action == 'disable':
                service.disable()
            elif self.options.service_action == 'status':
                print(service.status() and "is running" or "is stopped")
            elif self.options.service_action == 'restart':
                service.smart_reload()
            else:
                service.action(self.options.service_action)

        else:
            if not self.config_path:
                self.config_path = config.infer_config_path()
            log.debug("config_path: %s", self.config_path)

            needs_reload = True

            if self.options.remove:
                # Configs removal
                try:
                    os.unlink(self.config_path)
                    log.debug("Configuration file '%s' for '%s' removed successfully", 
                              self.config_path, self.options.type)
                except Exception, ex:
                    log.warning("Removal of configuration file failed: %s", ex)
                    needs_reload = False
            else:
                # Configs writing
                os.umask(022)
                config.merge(self.options.override)

                with config.open(self.config_path) as conffile:
                    config.write(conffile)
            # Notify php-fpm about changed configuration
            # Note that invalid configuration may cause php-fpm to go down on reload
            if self.options.type == 'fpm' and needs_reload and not self.options.no_reload:
                service.smart_reload()


def main():
    """ phpinimng main entry point for command-line execution. """
    try:
        PhpIniMngApp().main(sys.argv)
    except SystemExit:
        raise
    except Exception, ex:
        sys.stderr.write('%s\n' % ex)
        log.debug('This exception happened at:', exc_info=sys.exc_info())
        sys.exit(1)

if __name__ == '__main__':
    main()

# vim: ts=4 sts=4 sw=4 et :
