import os import textwrap from collections import OrderedDict from django.template import Template, Context from django.utils.translation import gettext_lazy as _ from orchestra.settings import NEW_SERVERS from orchestra.contrib.orchestration import ServiceController from . import WebAppServiceMixin from .. import settings, utils class PHPController(WebAppServiceMixin, ServiceController): """ PHP support for apache-mod-fcgid and php-fpm. It handles switching between these two PHP process management systemes. """ MERGE = settings.WEBAPPS_MERGE_PHP_WEBAPPS verbose_name = _("PHP FPM/FCGID") default_route_match = "webapp.type.endswith('php')" doc_settings = (settings, ( 'WEBAPPS_MERGE_PHP_WEBAPPS', 'WEBAPPS_FPM_DEFAULT_MAX_CHILDREN', 'WEBAPPS_PHP_CGI_BINARY_PATH', 'WEBAPPS_PHP_CGI_RC_DIR', 'WEBAPPS_PHP_CGI_INI_SCAN_DIR', 'WEBAPPS_FCGID_CMD_OPTIONS_PATH', 'WEBAPPS_PHPFPM_POOL_PATH', 'WEBAPPS_PHP_MAX_REQUESTS', )) def save(self, webapp): self.delete_old_config(webapp) context = self.get_context(webapp) if context.get('target_server').name in NEW_SERVERS: self.check_webapp_dir(context) else: self.create_webapp_dir(context) if webapp.type_instance.is_fpm: self.save_fpm(webapp, context) elif webapp.type_instance.is_fcgid: self.save_fcgid(webapp, context) else: raise TypeError("Unknown PHP execution type") # LEGACY CLEANUP FUNCTIONS. TODO REMOVE WHEN SURE NOT NEEDED. # self.delete_fcgid(webapp, context, preserve=True) # self.delete_fpm(webapp, context, preserve=True) self.set_under_construction(context) def delete_config(self,webapp): context = self.get_context(webapp) to_delete = [] if webapp.type_instance.is_fpm: to_delete.append(settings.WEBAPPS_PHPFPM_POOL_PATH % context) to_delete.append(settings.WEBAPPS_FPM_LISTEN % context) elif webapp.type_instance.is_fcgid: to_delete.append(settings.WEBAPPS_FCGID_WRAPPER_PATH % context) to_delete.append(settings.WEBAPPS_FCGID_CMD_OPTIONS_PATH % context) for item in to_delete: self.append('rm -f "{}"'.format(item)) def delete_old_config(self,webapp): # Check if we loaded the old version of the webapp. If so, we're updating # rather than creating, so we must make sure the old config files are removed. if hasattr(webapp, '_old_self'): self.append("# Clean old configuration files") self.delete_config(webapp._old_self) else: self.append("# No old config files to delete") def save_fpm(self, webapp, context): context['reload_pool'] = settings.WEBAPPS_PHPFPM_RELOAD_POOL self.append(textwrap.dedent(""" # Generate FPM configuration read -r -d '' fpm_config << 'EOF' || true %(fpm_config)s EOF { echo -e "${fpm_config}" | diff -N -I'^\s*;;' %(fpm_path)s - } || { echo -e "${fpm_config}" > %(fpm_path)s UPDATED_FPM=1 } # Apply changes if needed if [[ $UPDATED_FPM -eq 1 ]]; then %(reload_pool)s # Check that the socket was created if [[ ! -e %(fpm_listen)s ]]; then %(reload_pool)s fi fi """) % context ) def save_fcgid(self, webapp, context): self.append("mkdir -p %(wrapper_dir)s" % context) self.append(textwrap.dedent(""" # Generate FCGID configuration read -r -d '' wrapper << 'EOF' || true %(wrapper)s EOF { echo -e "${wrapper}" | diff -N -I'^\s*#' %(wrapper_path)s - } || { echo -e "${wrapper}" > %(wrapper_path)s if [[ %(is_mounted)i -eq 1 ]]; then # Reload fcgid wrapper (All PHP versions, because of version changing support) pkill -SIGHUP -U %(user)s "^php[0-9\.]+-cgi$" || true fi } chmod 550 %(wrapper_dir)s chmod 550 %(wrapper_path)s chown -R %(user)s:%(group)s %(wrapper_dir)s""") % context ) if context['cmd_options']: self.append(textwrap.dedent("""\ # FCGID options read -r -d '' cmd_options << 'EOF' || true %(cmd_options)s EOF { echo -e "${cmd_options}" | diff -N -I'^\s*#' %(cmd_options_path)s - } || { echo -e "${cmd_options}" > %(cmd_options_path)s [[ ${UPDATED_APACHE} -eq 0 ]] && UPDATED_APACHE=%(is_mounted)i } """ ) % context ) else: self.append("rm -f %(cmd_options_path)s\n" % context) def delete(self, webapp): context = self.get_context(webapp) self.delete_old_config(webapp) if context.get('target_server').name in NEW_SERVERS: webapp.sftpuser.delete() else: self.delete_webapp_dir(context) def has_sibilings(self, webapp, context): return type(webapp).objects.filter( account=webapp.account_id, data__contains='"php_version":"%s"' % context['php_version'], ).exclude(id=webapp.pk).exists() def all_versions_to_delete(self, webapp, context, preserve=False): context_copy = dict(context) for php_version, verbose in settings.WEBAPPS_PHP_VERSIONS: if preserve and php_version == context['php_version']: continue php_version_number = utils.extract_version_number(php_version) context_copy['php_version'] = php_version context_copy['php_version_number'] = php_version_number if not self.MERGE or not self.has_sibilings(webapp, context_copy): yield context_copy def delete_fpm(self, webapp, context, preserve=False): """ delete all pools in order to efectively support changing php-fpm version """ for context_copy in self.all_versions_to_delete(webapp, context, preserve): context_copy['fpm_path'] = settings.WEBAPPS_PHPFPM_POOL_PATH % context_copy self.append("rm -f %(fpm_path)s" % context_copy) def delete_fcgid(self, webapp, context, preserve=False): """ delete all pools in order to efectively support changing php-fcgid version """ for context_copy in self.all_versions_to_delete(webapp, context, preserve): context_copy.update({ 'wrapper_path': settings.WEBAPPS_FCGID_WRAPPER_PATH % context_copy, 'cmd_options_path': settings.WEBAPPS_FCGID_CMD_OPTIONS_PATH % context_copy, }) self.append("rm -f %(wrapper_path)s" % context_copy) self.append("rm -f %(cmd_options_path)s" % context_copy) def prepare(self): super(PHPController, self).prepare() self.append(textwrap.dedent(""" BACKEND="PHPController" echo "$BACKEND" >> /dev/shm/reload.apache2 function coordinate_apache_reload () { # Coordinate Apache reload with other concurrent backends (e.g. Apache2Controller) is_last=0 counter=0 while ! mv /dev/shm/reload.apache2 /dev/shm/reload.apache2.locked; do sleep 0.1; if [[ $counter -gt 4 ]]; then echo "[ERROR]: Apache reload synchronization deadlocked!" >&2 exit 10 fi counter=$(($counter+1)) done state="$(grep -v -E "^$BACKEND($|\s)" /dev/shm/reload.apache2.locked)" || is_last=1 [[ $is_last -eq 0 ]] && { echo "$state" | grep -v ' RELOAD$' || is_last=1 } if [[ $is_last -eq 1 ]]; then echo "[DEBUG]: Last backend to run, update: $UPDATED_APACHE, state: '$state'" if [[ $UPDATED_APACHE -eq 1 || "$state" =~ .*RELOAD$ ]]; then if service apache2 status > /dev/null; then service apache2 reload else service apache2 start fi fi rm /dev/shm/reload.apache2.locked else echo "$state" > /dev/shm/reload.apache2.locked if [[ $UPDATED_APACHE -eq 1 ]]; then echo -e "[DEBUG]: Apache will be reloaded by another backend:\\n${state}" echo "$BACKEND RELOAD" >> /dev/shm/reload.apache2.locked fi mv /dev/shm/reload.apache2.locked /dev/shm/reload.apache2 fi }""") ) def commit(self): self.append(textwrap.dedent(""" # reload apache coordinate_apache_reload """) ) super(PHPController, self).commit() def get_fpm_config(self, webapp, context): options = webapp.type_instance.get_options() context.update({ 'init_vars': webapp.type_instance.get_php_init_vars(merge=self.MERGE), 'max_children': options.get('processes', settings.WEBAPPS_FPM_DEFAULT_MAX_CHILDREN), 'request_terminate_timeout': options.get('timeout', False), }) context['fpm_listen'] = webapp.type_instance.FPM_LISTEN % context fpm_config = Template(textwrap.dedent("""\ ;; {{ banner }} [{{ user }}-{{app_name}}] {% if sftpuser %} user = {{ sftpuser }} group = {{ sftpuser }} listen = {{ fpm_listen | safe }} listen.owner = root listen.group = {{ sftpuser }} {% else %} user = {{ user }} group = {{ group }} listen = {{ fpm_listen | safe }} listen.owner = {{ user }} listen.group = {{ group }} {% endif %} pm = ondemand pm.max_requests = {{ max_requests }} pm.max_children = {{ max_children }} {% if request_terminate_timeout %} request_terminate_timeout = {{ request_terminate_timeout }}{% endif %} {% for name, value in init_vars.items %} php_admin_value[{{ name | safe }}] = {{ value | safe }}{% endfor %} """ )) return fpm_config.render(Context(context)) def get_fcgid_wrapper(self, webapp, context): opt = webapp.type_instance # Format PHP init vars init_vars = opt.get_php_init_vars(merge=self.MERGE) if init_vars: init_vars = [ " \\\n -d %s='%s'" % (k, v.replace("'", '"')) for k,v in init_vars.items() ] init_vars = ''.join(init_vars) context.update({ 'php_binary_path': os.path.normpath(settings.WEBAPPS_PHP_CGI_BINARY_PATH % context), 'php_rc': os.path.normpath(settings.WEBAPPS_PHP_CGI_RC_DIR % context), 'php_ini_scan': os.path.normpath(settings.WEBAPPS_PHP_CGI_INI_SCAN_DIR % context), 'php_init_vars': init_vars, }) context['php_binary'] = os.path.basename(context['php_binary_path']) return textwrap.dedent("""\ #!/bin/sh # %(banner)s export PHPRC=%(php_rc)s export PHP_INI_SCAN_DIR=%(php_ini_scan)s export PHP_FCGI_MAX_REQUESTS=%(max_requests)s exec %(php_binary_path)s%(php_init_vars)s""") % context def get_fcgid_cmd_options(self, webapp, context): options = webapp.type_instance.get_options() maps = OrderedDict( MaxProcesses=options.get('processes', None), IOTimeout=options.get('timeout', None), ) cmd_options = [] for directive, value in maps.items(): if value: cmd_options.append( "%s %s" % (directive, value.replace("'", '"')) ) if cmd_options: head = ( '# %(banner)s\n' 'FcgidCmdOptions %(wrapper_path)s' ) % context cmd_options.insert(0, head) return ' \\\n '.join(cmd_options) def update_fcgid_context(self, webapp, context): wrapper_path = settings.WEBAPPS_FCGID_WRAPPER_PATH % context context.update({ 'wrapper': self.get_fcgid_wrapper(webapp, context), 'wrapper_path': wrapper_path, 'wrapper_dir': os.path.dirname(wrapper_path), }) context.update({ 'cmd_options': self.get_fcgid_cmd_options(webapp, context), 'cmd_options_path': settings.WEBAPPS_FCGID_CMD_OPTIONS_PATH % context, }) return context def update_fpm_context(self, webapp, context): context.update({ 'fpm_config': self.get_fpm_config(webapp, context), 'fpm_path': settings.WEBAPPS_PHPFPM_POOL_PATH % context, }) return context def get_context(self, webapp): context = super().get_context(webapp) context.update({ 'max_requests': settings.WEBAPPS_PHP_MAX_REQUESTS, 'target_server': webapp.target_server, }) self.update_fpm_context(webapp, context) self.update_fcgid_context(webapp, context) return context