cubicweb/pyramid/pyramidctl.py
changeset 11681 b23d58050076
parent 11680 e1caf133b81c
child 11813 8a04a2cb5ba4
equal deleted inserted replaced
11631:faf279e33298 11681:b23d58050076
       
     1 """
       
     2 Provides a 'pyramid' command as a replacement to the 'start' command.
       
     3 
       
     4 The reloading strategy is heavily inspired by (and partially copied from)
       
     5 the pyramid script 'pserve'.
       
     6 """
       
     7 from __future__ import print_function
       
     8 
       
     9 import atexit
       
    10 import errno
       
    11 import os
       
    12 import signal
       
    13 import sys
       
    14 import tempfile
       
    15 import time
       
    16 import threading
       
    17 import subprocess
       
    18 
       
    19 from cubicweb import BadCommandUsage, ExecutionError
       
    20 from cubicweb.__pkginfo__ import numversion as cwversion
       
    21 from cubicweb.cwconfig import CubicWebConfiguration as cwcfg
       
    22 from cubicweb.cwctl import CWCTL, InstanceCommand, init_cmdline_log_threshold
       
    23 from cubicweb.pyramid import wsgi_application_from_cwconfig
       
    24 from cubicweb.server import set_debug
       
    25 
       
    26 import waitress
       
    27 
       
    28 MAXFD = 1024
       
    29 
       
    30 DBG_FLAGS = ('RQL', 'SQL', 'REPO', 'HOOKS', 'OPS', 'SEC', 'MORE')
       
    31 LOG_LEVELS = ('debug', 'info', 'warning', 'error')
       
    32 
       
    33 
       
    34 class PyramidStartHandler(InstanceCommand):
       
    35     """Start an interactive pyramid server.
       
    36 
       
    37     This command requires http://hg.logilab.org/review/pyramid_cubicweb/
       
    38 
       
    39     <instance>
       
    40       identifier of the instance to configure.
       
    41     """
       
    42     name = 'pyramid'
       
    43 
       
    44     options = (
       
    45         ('no-daemon',
       
    46          {'action': 'store_true',
       
    47           'help': 'Run the server in the foreground.'}),
       
    48         ('debug-mode',
       
    49          {'action': 'store_true',
       
    50           'help': 'Activate the repository debug mode ('
       
    51                   'logs in the console and the debug toolbar).'
       
    52                   ' Implies --no-daemon'}),
       
    53         ('debug',
       
    54          {'short': 'D', 'action': 'store_true',
       
    55           'help': 'Equals to "--debug-mode --no-daemon --reload"'}),
       
    56         ('reload',
       
    57          {'action': 'store_true',
       
    58           'help': 'Restart the server if any source file is changed'}),
       
    59         ('reload-interval',
       
    60          {'type': 'int', 'default': 1,
       
    61           'help': 'Interval, in seconds, between file modifications checks'}),
       
    62         ('loglevel',
       
    63          {'short': 'l', 'type': 'choice', 'metavar': '<log level>',
       
    64           'default': None, 'choices': LOG_LEVELS,
       
    65           'help': 'debug if -D is set, error otherwise; '
       
    66                   'one of %s' % (LOG_LEVELS,),
       
    67           }),
       
    68         ('dbglevel',
       
    69          {'type': 'multiple_choice', 'metavar': '<dbg level>',
       
    70           'default': None,
       
    71           'choices': DBG_FLAGS,
       
    72           'help': ('Set the server debugging flags; you may choose several '
       
    73                    'values in %s; imply "debug" loglevel' % (DBG_FLAGS,)),
       
    74           }),
       
    75         ('profile',
       
    76          {'action': 'store_true',
       
    77           'default': False,
       
    78           'help': 'Enable profiling'}),
       
    79         ('profile-output',
       
    80          {'type': 'string',
       
    81           'default': None,
       
    82           'help': 'Profiling output file (default: "program.prof")'}),
       
    83         ('profile-dump-every',
       
    84          {'type': 'int',
       
    85           'default': None,
       
    86           'metavar': 'N',
       
    87           'help': 'Dump profile stats to ouput every N requests '
       
    88                   '(default: 100)'}),
       
    89     )
       
    90     if cwversion >= (3, 21, 0):
       
    91         options = options + (
       
    92             ('param',
       
    93              {'short': 'p',
       
    94               'type': 'named',
       
    95               'metavar': 'key1:value1,key2:value2',
       
    96               'default': {},
       
    97               'help': 'override <key> configuration file option with <value>.',
       
    98               }),
       
    99         )
       
   100 
       
   101     _reloader_environ_key = 'CW_RELOADER_SHOULD_RUN'
       
   102     _reloader_filelist_environ_key = 'CW_RELOADER_FILELIST'
       
   103 
       
   104     def debug(self, msg):
       
   105         print('DEBUG - %s' % msg)
       
   106 
       
   107     def info(self, msg):
       
   108         print('INFO - %s' % msg)
       
   109 
       
   110     def ordered_instances(self):
       
   111         instances = super(PyramidStartHandler, self).ordered_instances()
       
   112         if (self['debug-mode'] or self['debug'] or self['reload']) \
       
   113                 and len(instances) > 1:
       
   114             raise BadCommandUsage(
       
   115                 '--debug-mode, --debug and --reload can be used on a single '
       
   116                 'instance only')
       
   117         return instances
       
   118 
       
   119     def quote_first_command_arg(self, arg):
       
   120         """
       
   121         There's a bug in Windows when running an executable that's
       
   122         located inside a path with a space in it.  This method handles
       
   123         that case, or on non-Windows systems or an executable with no
       
   124         spaces, it just leaves well enough alone.
       
   125         """
       
   126         if (sys.platform != 'win32' or ' ' not in arg):
       
   127             # Problem does not apply:
       
   128             return arg
       
   129         try:
       
   130             import win32api
       
   131         except ImportError:
       
   132             raise ValueError(
       
   133                 "The executable %r contains a space, and in order to "
       
   134                 "handle this issue you must have the win32api module "
       
   135                 "installed" % arg)
       
   136         arg = win32api.GetShortPathName(arg)
       
   137         return arg
       
   138 
       
   139     def _remove_pid_file(self, written_pid, filename):
       
   140         current_pid = os.getpid()
       
   141         if written_pid != current_pid:
       
   142             # A forked process must be exiting, not the process that
       
   143             # wrote the PID file
       
   144             return
       
   145         if not os.path.exists(filename):
       
   146             return
       
   147         with open(filename) as f:
       
   148             content = f.read().strip()
       
   149         try:
       
   150             pid_in_file = int(content)
       
   151         except ValueError:
       
   152             pass
       
   153         else:
       
   154             if pid_in_file != current_pid:
       
   155                 msg = "PID file %s contains %s, not expected PID %s"
       
   156                 self.out(msg % (filename, pid_in_file, current_pid))
       
   157                 return
       
   158         self.info("Removing PID file %s" % filename)
       
   159         try:
       
   160             os.unlink(filename)
       
   161             return
       
   162         except OSError as e:
       
   163             # Record, but don't give traceback
       
   164             self.out("Cannot remove PID file: (%s)" % e)
       
   165         # well, at least lets not leave the invalid PID around...
       
   166         try:
       
   167             with open(filename, 'w') as f:
       
   168                 f.write('')
       
   169         except OSError as e:
       
   170             self.out('Stale PID left in file: %s (%s)' % (filename, e))
       
   171         else:
       
   172             self.out('Stale PID removed')
       
   173 
       
   174     def record_pid(self, pid_file):
       
   175         pid = os.getpid()
       
   176         self.debug('Writing PID %s to %s' % (pid, pid_file))
       
   177         with open(pid_file, 'w') as f:
       
   178             f.write(str(pid))
       
   179         atexit.register(
       
   180             self._remove_pid_file, pid, pid_file)
       
   181 
       
   182     def daemonize(self, pid_file):
       
   183         pid = live_pidfile(pid_file)
       
   184         if pid:
       
   185             raise ExecutionError(
       
   186                 "Daemon is already running (PID: %s from PID file %s)"
       
   187                 % (pid, pid_file))
       
   188 
       
   189         self.debug('Entering daemon mode')
       
   190         pid = os.fork()
       
   191         if pid:
       
   192             # The forked process also has a handle on resources, so we
       
   193             # *don't* want proper termination of the process, we just
       
   194             # want to exit quick (which os._exit() does)
       
   195             os._exit(0)
       
   196         # Make this the session leader
       
   197         os.setsid()
       
   198         # Fork again for good measure!
       
   199         pid = os.fork()
       
   200         if pid:
       
   201             os._exit(0)
       
   202 
       
   203         # @@: Should we set the umask and cwd now?
       
   204 
       
   205         import resource  # Resource usage information.
       
   206         maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
       
   207         if (maxfd == resource.RLIM_INFINITY):
       
   208             maxfd = MAXFD
       
   209         # Iterate through and close all file descriptors.
       
   210         for fd in range(0, maxfd):
       
   211             try:
       
   212                 os.close(fd)
       
   213             except OSError:  # ERROR, fd wasn't open to begin with (ignored)
       
   214                 pass
       
   215 
       
   216         if (hasattr(os, "devnull")):
       
   217             REDIRECT_TO = os.devnull
       
   218         else:
       
   219             REDIRECT_TO = "/dev/null"
       
   220         os.open(REDIRECT_TO, os.O_RDWR)  # standard input (0)
       
   221         # Duplicate standard input to standard output and standard error.
       
   222         os.dup2(0, 1)  # standard output (1)
       
   223         os.dup2(0, 2)  # standard error (2)
       
   224 
       
   225     def restart_with_reloader(self):
       
   226         self.debug('Starting subprocess with file monitor')
       
   227 
       
   228         with tempfile.NamedTemporaryFile(delete=False) as f:
       
   229             filelist_path = f.name
       
   230 
       
   231         while True:
       
   232             args = [self.quote_first_command_arg(sys.executable)] + sys.argv
       
   233             new_environ = os.environ.copy()
       
   234             new_environ[self._reloader_environ_key] = 'true'
       
   235             new_environ[self._reloader_filelist_environ_key] = filelist_path
       
   236             proc = None
       
   237             try:
       
   238                 try:
       
   239                     proc = subprocess.Popen(args, env=new_environ)
       
   240                     exit_code = proc.wait()
       
   241                     proc = None
       
   242                     print("Process exited with", exit_code)
       
   243                 except KeyboardInterrupt:
       
   244                     self.info('^C caught in monitor process')
       
   245                     return 1
       
   246             finally:
       
   247                 if proc is not None:
       
   248                     proc.terminate()
       
   249                     self.info(
       
   250                         'Waiting for the server to stop. Hit CTRL-C to exit')
       
   251                     exit_code = proc.wait()
       
   252 
       
   253             if exit_code != 3:
       
   254                 with open(filelist_path) as f:
       
   255                     filelist = [line.strip() for line in f]
       
   256                 if filelist:
       
   257                     self.info("Reloading failed. Waiting for a file to change")
       
   258                     mon = Monitor(extra_files=filelist, nomodules=True)
       
   259                     while mon.check_reload():
       
   260                         time.sleep(1)
       
   261                 else:
       
   262                     return exit_code
       
   263 
       
   264             self.info('%s %s %s' % ('-' * 20, 'Restarting', '-' * 20))
       
   265 
       
   266     def set_needreload(self):
       
   267         self._needreload = True
       
   268 
       
   269     def install_reloader(self, poll_interval, extra_files, filelist_path):
       
   270         mon = Monitor(
       
   271             poll_interval=poll_interval, extra_files=extra_files,
       
   272             atexit=self.set_needreload, filelist_path=filelist_path)
       
   273         mon_thread = threading.Thread(target=mon.periodic_reload)
       
   274         mon_thread.daemon = True
       
   275         mon_thread.start()
       
   276 
       
   277     def configfiles(self, cwconfig):
       
   278         """Generate instance configuration filenames"""
       
   279         yield cwconfig.main_config_file()
       
   280         for f in (
       
   281                 'sources', 'logging.conf', 'pyramid.ini', 'pyramid-debug.ini'):
       
   282             f = os.path.join(cwconfig.apphome, f)
       
   283             if os.path.exists(f):
       
   284                 yield f
       
   285 
       
   286     def i18nfiles(self, cwconfig):
       
   287         """Generate instance i18n files"""
       
   288         i18ndir = os.path.join(cwconfig.apphome, 'i18n')
       
   289         if os.path.exists(i18ndir):
       
   290             for lang in cwconfig.available_languages():
       
   291                 f = os.path.join(i18ndir, lang, 'LC_MESSAGES', 'cubicweb.mo')
       
   292                 if os.path.exists(f):
       
   293                     yield f
       
   294 
       
   295     def pyramid_instance(self, appid):
       
   296         self._needreload = False
       
   297 
       
   298         debugmode = self['debug-mode'] or self['debug']
       
   299         autoreload = self['reload'] or self['debug']
       
   300         daemonize = not (self['no-daemon'] or debugmode or autoreload)
       
   301 
       
   302         if autoreload and not os.environ.get(self._reloader_environ_key):
       
   303             return self.restart_with_reloader()
       
   304 
       
   305         cwconfig = cwcfg.config_for(appid, debugmode=debugmode)
       
   306         if cwversion >= (3, 21, 0):
       
   307             cwconfig.cmdline_options = self.config.param
       
   308         if autoreload:
       
   309             _turn_sigterm_into_systemexit()
       
   310             self.debug('Running reloading file monitor')
       
   311             extra_files = [sys.argv[0]]
       
   312             extra_files.extend(self.configfiles(cwconfig))
       
   313             extra_files.extend(self.i18nfiles(cwconfig))
       
   314             self.install_reloader(
       
   315                 self['reload-interval'], extra_files,
       
   316                 filelist_path=os.environ.get(
       
   317                     self._reloader_filelist_environ_key))
       
   318 
       
   319         if daemonize:
       
   320             self.daemonize(cwconfig['pid-file'])
       
   321             self.record_pid(cwconfig['pid-file'])
       
   322 
       
   323         if self['dbglevel']:
       
   324             self['loglevel'] = 'debug'
       
   325             set_debug('|'.join('DBG_' + x.upper() for x in self['dbglevel']))
       
   326         init_cmdline_log_threshold(cwconfig, self['loglevel'])
       
   327 
       
   328         app = wsgi_application_from_cwconfig(
       
   329             cwconfig, profile=self['profile'],
       
   330             profile_output=self['profile-output'],
       
   331             profile_dump_every=self['profile-dump-every']
       
   332         )
       
   333 
       
   334         host = cwconfig['interface']
       
   335         port = cwconfig['port'] or 8080
       
   336         repo = app.application.registry['cubicweb.repository']
       
   337         try:
       
   338             repo.start_looping_tasks()
       
   339             waitress.serve(app, host=host, port=port)
       
   340         finally:
       
   341             repo.shutdown()
       
   342         if self._needreload:
       
   343             return 3
       
   344         return 0
       
   345 
       
   346 CWCTL.register(PyramidStartHandler)
       
   347 
       
   348 
       
   349 def live_pidfile(pidfile):  # pragma: no cover
       
   350     """(pidfile:str) -> int | None
       
   351     Returns an int found in the named file, if there is one,
       
   352     and if there is a running process with that process id.
       
   353     Return None if no such process exists.
       
   354     """
       
   355     pid = read_pidfile(pidfile)
       
   356     if pid:
       
   357         try:
       
   358             os.kill(int(pid), 0)
       
   359             return pid
       
   360         except OSError as e:
       
   361             if e.errno == errno.EPERM:
       
   362                 return pid
       
   363     return None
       
   364 
       
   365 
       
   366 def read_pidfile(filename):
       
   367     if os.path.exists(filename):
       
   368         try:
       
   369             with open(filename) as f:
       
   370                 content = f.read()
       
   371             return int(content.strip())
       
   372         except (ValueError, IOError):
       
   373             return None
       
   374     else:
       
   375         return None
       
   376 
       
   377 
       
   378 def _turn_sigterm_into_systemexit():
       
   379     """Attempts to turn a SIGTERM exception into a SystemExit exception."""
       
   380     try:
       
   381         import signal
       
   382     except ImportError:
       
   383         return
       
   384 
       
   385     def handle_term(signo, frame):
       
   386         raise SystemExit
       
   387     signal.signal(signal.SIGTERM, handle_term)
       
   388 
       
   389 
       
   390 class Monitor(object):
       
   391     """A file monitor and server stopper.
       
   392 
       
   393     It is a simplified version of pyramid pserve.Monitor, with little changes:
       
   394 
       
   395     -   The constructor takes extra_files, atexit, nomodules and filelist_path
       
   396     -   The process is stopped by auto-kill with signal SIGTERM
       
   397     """
       
   398 
       
   399     def __init__(self, poll_interval=1, extra_files=[], atexit=None,
       
   400                  nomodules=False, filelist_path=None):
       
   401         self.module_mtimes = {}
       
   402         self.keep_running = True
       
   403         self.poll_interval = poll_interval
       
   404         self.extra_files = extra_files
       
   405         self.atexit = atexit
       
   406         self.nomodules = nomodules
       
   407         self.filelist_path = filelist_path
       
   408 
       
   409     def _exit(self):
       
   410         if self.atexit:
       
   411             self.atexit()
       
   412         os.kill(os.getpid(), signal.SIGTERM)
       
   413 
       
   414     def periodic_reload(self):
       
   415         while True:
       
   416             if not self.check_reload():
       
   417                 self._exit()
       
   418                 break
       
   419             time.sleep(self.poll_interval)
       
   420 
       
   421     def check_reload(self):
       
   422         filenames = list(self.extra_files)
       
   423 
       
   424         if not self.nomodules:
       
   425             for module in list(sys.modules.values()):
       
   426                 try:
       
   427                     filename = module.__file__
       
   428                 except (AttributeError, ImportError):
       
   429                     continue
       
   430                 if filename is not None:
       
   431                     filenames.append(filename)
       
   432 
       
   433         for filename in filenames:
       
   434             try:
       
   435                 stat = os.stat(filename)
       
   436                 if stat:
       
   437                     mtime = stat.st_mtime
       
   438                 else:
       
   439                     mtime = 0
       
   440             except (OSError, IOError):
       
   441                 continue
       
   442             if filename.endswith('.pyc') and os.path.exists(filename[:-1]):
       
   443                 mtime = max(os.stat(filename[:-1]).st_mtime, mtime)
       
   444             if filename not in self.module_mtimes:
       
   445                 self.module_mtimes[filename] = mtime
       
   446             elif self.module_mtimes[filename] < mtime:
       
   447                 print('%s changed; reloading...' % filename)
       
   448                 return False
       
   449 
       
   450         if self.filelist_path:
       
   451             with open(self.filelist_path) as f:
       
   452                 filelist = set((line.strip() for line in f))
       
   453 
       
   454             filelist.update(filenames)
       
   455 
       
   456             with open(self.filelist_path, 'w') as f:
       
   457                 for filename in filelist:
       
   458                     f.write('%s\n' % filename)
       
   459 
       
   460         return True