#!/usr/bin/python
# -*- python -*-
# $Id: zopectl,v 1.27 2003/03/16 04:53:50 david Exp $
#
# /usr/sbin/zopectl
#
# Written by Gregor Hoffleit <flight@debian.org> for the Debian Zope package.
#
# Rewritten in python, reimplemented to work with Zope 2.6 and extended to handle multiple instances
# with independent config files, by David Coe <davidc@debian.org>.
#
# License:
# Copyright 2003 by David Coe for the Debian project.
# Released under the terms of the GNU General Public License, version 2.1.
# Please see the file COPYING.
#
#
# This is a comfortable replacement for Zope's start and stop scripts
# It lets you start, stop, restart and examine Zope instances.
#

####################################
# Maintainer note:
# THE FOLLOWING getopt() PARAMETER VALUES *MUST* AGREE WITH WHAT'S IN z2.py; I'm too lazy to
# write something generic, so we must remember to keep these up to date... (otherwise
# we'll report errors that aren't errors when parsing the 'options' files)
Z2_GETOPT_SHORT = 'hz:Z:t:i:a:d:u:w:W:f:p:m:Sl:2DP:rF:L:XM:C'
Z2_GETOPT_LONG = ['icp=', 'force-http-connection-close']
####################################

import sys
import os
import re
import getopt
import pwd
import signal
import time

NAME='zopectl'

# default values that can be changed with command-line args to zopectl:
TIMEOUT=120 	# how long (in seconds) we wait for state to change, e.g. when stopping or starting
CONCURRENT=0 	# whether we do instances one at a time or all at once (not yet supported) ????


# There may be multiple zope instances; if passed an instance name
# this script operates only on that instance; if passed no instance
# names, it operates on *all* instances (one at a time).  Each
# instance has a name (a directory name) and its own directory
# tree under each of the following 'BASE's.  The instances may
# be run with different userids, each driven by its own configuration

#
# (These defaults are
# valid for Debian's Zope installation):
#
CONFIGURATION_BASE=os.environ.get('_ZOPECTL__CONFIGURATION_BASE','/etc/zope/instance')
DEFAULT_INSTANCE_BASE=os.environ.get('_ZOPECTL__DEFAULT_INSTANCE_BASE','/var/lib/zope/instance')
LOG_BASE=os.environ.get('_ZOPECTL__LOG_BASE','/var/log/zope/instance')

MANPAGE_ENVIRONMENT = [
    "ENVIRONMENT VARIABLES",
    "  The following environment variables determine where zopectl expects",
    "  to find and put certain things. The default values are appropriate for",
    "  Debian systems, and do not normally need to be changed:",
    "",
    "  _ZOPECTL__CONFIGURATION_BASE  ",
    "    The default is /etc/zope/instance. This tells zopectl where to look for the",
    "    Zope instance definitions (which are sudirectories of this directory).",
    "  _ZOPECTL__DEFAULT_INSTANCE_BASE  ",
    "    The default is /var/lib/zope/instance. This specifies the directory to use",
    "    for a Zope INSTANCE_HOME that is not specified or not absolute",
    "    in the instance's definition. The instance name, or its",
    "    relative INSTANCE_HOME value, will be joined to this path.",
    "  _ZOPECTL__LOG_BASE  ",
    "    The default is /var/log/zope/instance. This specifies the directory to use",
    "    for instance log files that are not absolute in the",
    "    instance's definition. The instance name and the relative log file name will",
    "    be joined to this path.",
    ]

class Logger:
    """a mess for logging various details to stdout and stderr as appropriate;
    this could stand a bit of cleanup/rewrite, but it works"""

    getopt_options = ['quiet', 'verbose', 'debug']
    usage = '[--quiet|--verbose|--debug]'
    MANPAGE_OPTIONS = [
    "  --quiet  ",
    "    Suppress everything but error messages (you'll see no warnings and no",
    "    progress indication.).",
    "  --verbose  ",
    "    Provide more than the usual amount of detail.",
    "  --debug  ",
    "    Provide additional information that might be useful for testing and",
    "    debugging. This implies --verbose as well.",        
        ]
    
    def __init__(self, name):
        self.QUIET=0  	# set true to suppress less than critical messages
        self.VERBOSE=0 	# set true to be verbose (overrides quiet)
        self.DEBUG=0 	# set true to show debug msgs too (implies verbose)
        self.NAME=name  # name of the program shown to the user

    def handle_getopt(self, o, v):
        # process the passed getopt option,value; return false if it's not one of ours
        if o == '--quiet':
            self.QUIET = 1
            self.VERBOSE = 0
            return 1
        elif o == '--verbose':
            self.VERBOSE = 1
            self.QUIET = 0
            return 1
        elif o == '--debug':
            self.DEBUG = 1
            self.VERBOSE = 1
            self.QUIET = 0
            return 1
        
        return 0

    def log(self, stuff, debug=0, error=0, warn=0, info=0, nl='\n', noprefix=0, file=None):
        if ((warn or noprefix) and not self.QUIET) or (info and self.VERBOSE) or error or self.DEBUG:
            if noprefix:
                prefix = ''
                if not file: file = sys.stdout
            else:

                if error:
                    eyecatcher = ' (ERROR)'
                    if not file: file = sys.stderr
                elif warn:
                    eyecatcher = ' (warning)'
                    if not file: file = sys.stderr
                elif info:
                    eyecatcher = ' (info)'
                    if not file: file = sys.stdout
                elif debug:
                    eyecatcher = ' (debug)'
                    if not file: file = sys.stderr
                else:
                    eyecatcher = ''
                    if not file: file = sys.stdout

                prefix = '%s%s: ' % (self.NAME, eyecatcher)

            file.write('%s%s%s' % (prefix, stuff, nl))
            file.flush() # because they're buffered by python


    def normal(self, stuff, nl='\n'):
        # for normal, debian policy-compliant, start/stop/etc. messges
        self.log(stuff, nl=nl, noprefix=1)

    def blip(self, file=sys.stdout):
        # for ... progress dots
        if not self.QUIET:
            file.write('.')
            file.flush()

    def debug(self, stuff):
        self.log(stuff, debug=1)

    def error(self, stuff):
        self.log(stuff, error=1)

    def warn(self, stuff):
        self.log(stuff, warn=1)

    def info(self, stuff):
        self.log(stuff, info=1)

# the global 'MANPAGE_*' arrays will contain the manpage sections in plain text,
# constructed as this program text is read.  This is a very-poor-man's version of
# literate programming ;).  These sections are output in the correct order when
# zopectl is run with the --help option, and the text can be viewed directly
# or fed to txt2man to create a real man page.

MANPAGE_NAME = [
    "NAME",
    "  zopectl - A Zope control script",
    ]
MANPAGE_SYNOPSIS = [
    "SYNOPSIS",
    "  zopectl [--help] | [option \\.\\.\\.] command [instance_name \\.\\.\\.]",
    ]
MANPAGE_DESCRIPTION = [
    "DESCRIPTION",
    "  zopectl is a utility to start, stop, manage, and examine Zope application",
    "  instances. It executes command for each Zope instance_name specified.",
    "  If no instance_names are specified, it executes the command for each valid",
    "  instance defined in /etc/zope/instance (by default). See INSTANCE NAMES, below.).",
    ]

def usage(logger):

    logger.VERBOSE = 1  # user must see this, and I call logger.info() just because I'm lazy -- no harm as long as we exit after calling usage(), which we do.
    logger.info(' '.join('$Id: zopectl,v 1.27 2003/03/16 04:53:50 david Exp $'.split()[1:3]))
    logger.info('Usage: %s [--help] | %s [--concurrent] [--timeout=120] {start|stop|restart|reload|force-reload|status|reopenlogs} [instance_name ...]' % (NAME, Logger.usage))
    logger.info('See zopectl(8) for details.')

def help():
    # output the man page sections; suitable for input to txt2man
    for section in (
        MANPAGE_NAME,
        MANPAGE_SYNOPSIS,
        MANPAGE_DESCRIPTION,
        MANPAGE_COMMANDS,
        MANPAGE_INSTANCE_NAMES,
        MANPAGE_OPTIONS,
        MANPAGE_SEE_ALSO,
        MANPAGE_EXIT_STATUS,
        MANPAGE_ENVIRONMENT,
        MANPAGE_BUGS,
        MANPAGE_AUTHORS,
        ):
        sys.stdout.write('\n')
        for line in section:
            sys.stdout.write('%s\n' % line)


MANPAGE_COMMANDS = [
    "COMMANDS",
    "  The command must be any one of the following:",
    "",
    "  start  ",
    "    Start the Zope instances that are not already running.",
    "  stop  ",
    "    Stop the Zope instances that are not already stopped (send the TERM signal).",
    "    For a running Zope instance, this is equivalent to using the \"Shutdown\"",
    "    button in the Zope Control Panel.",
    "  restart  ",
    "    This is equivalent to stop followed by start.",
    "  reload  ",
    "    Ask the running Zope instances to reload themselves (send the HUP signal).",
    "  force-reload  ",
    "    Same as reload.",
    "  status  ",
    "    Display the current status of each instance's zdaemon and z2 processes.",
    "  reopenlogs  ",
    "    Ask the running Zope instances to close and reopen their log files (send the USR2 signal).",
    ]

MANPAGE_INSTANCE_NAMES = [
    "INSTANCE NAMES",
    "  Any parameters supplied after the command should be the names of Zope",
    "  instances (i.e. the names of directories in /etc/zope/instance/).",
    "  If no instance names are given, the command is executed",
    "  for each instance name (directory) in /etc/zope/instance. (/etc/zope/instance is the",
    "  default location; see ENVIRONMENT VARIABLES). Missing or misconfigured",
    "  instances do not prevent correct operation on the other instances.",
    ]


class Zope_Instance:
    """A Zope_Instance holds the configuration and state of a defined instance"""

    dic = {}  # a dictionary of valid Zope_Instance objects
    
    def __init__(self, instance_name, logger):
        self.instance_name = instance_name  # (unqualified /etc/zope/instance/ directory name)
        self.logger = logger
        self.opts = []  # configured options recognized by getopt; a list of (k,v) tuples
        self.args = []  # configured args not recognized by getopt; a list of strings
        self.environ = os.environ.copy() # instance environment settings will be overlayed onto this

        self.opts_file = None # set later: my options config file (only used in error messages?)
        self.env_file = None # set later: my environment config file (only used in error messages?)
        self.username = None # set later: the validated username for this instance

        self.current_state = 'invalid' # last known state; 'invalid' means this instance is not (yet) valid -- see the state() function
        
        if self.load_configuration():
            if self.prepare():
                # no errors, so...
                if Zope_Instance.dic.has_key(self.instance_name):
                    self.logger.error('Duplicate instance name "%s" -- yecch.' % self.instance_name)
                else:
                    self.current_state = 'configured'
                    Zope_Instance.dic[self.instance_name] = self  # needed??

    _poundstrip = re.compile(r'(^.*?[^\\])#')  # group 1 in match will be all chars up to the first non-escaped '#' 

    def load_configuration(self):
        # get the configuration of this instance, without modifying it,
        # from (? CONFIGURATION_BASE and ?) CONFIGURATION_BASE/instance_name;
        # report errors only for unparseable contents; we'll check and
        # fix things up later if this succeeds

        # maybe we should also have config file(s) common to all instances, like
        # defaults_file = os.path.join(CONFIGURATION_BASE, 'defaults')

        # return false if we encounter errors
        have_no_errors = 1 #true for now

        config_dir = os.path.join(CONFIGURATION_BASE, self.instance_name)
        self.opts_file = os.path.join(config_dir, 'options')
        self.env_file = os.path.join(config_dir, 'environment')
 

        # get options from the options file.

        # the opts_file contains z2 command-line parameters, possibly on multiple lines,
        # and supports #-preceded comments.
        # note: this simple comment-removal code works fine as long as none of the
        # options to zope takes '#' as a valid character.  As of zope 2.6.0 that
        # is OK.

        try:
            opts_handle = open(self.opts_file)
        except EnvironmentError, exc:
            self.logger.error('Unable to open %s -- error %s.' % (self.opts_file, repr(exc.args)))
            return 0 #false 
    
        line_number = 0
        for unmodified_line in opts_handle.readlines():
            line_number = line_number + 1
            line = unmodified_line.strip() # remove leading and trailing whitespace
            if len(line) == 0 or line[0] == '#':
                # ignore empty and comment lines
                continue
            # strip off the leftmost non-escaped '#' and trailing characters
            matched = Zope_Instance._poundstrip.match(line)
            if matched:
                line = matched.group(1)

            # we split by spaces -- does that always work (no quoted space-containing args)?  better check ???
            params = line.split()

            try:
                opts, args = getopt.getopt(params, Z2_GETOPT_SHORT, Z2_GETOPT_LONG)
            except getopt.GetoptError, err:
                self.logger.error('%s on line %d of %s.' % (err, line_number, self.opts_file)) 
                self.logger.error('"%s"' % line)
                if len(line) < len(unmodified_line):
                    self.logger.debug('(Read as) "%s".' % unmodified_line)
                have_no_errors = 0 # false
                continue
            
            self.opts.extend(opts)
            self.args.extend(args)

        opts_handle.close()


        # the env_file contains environment variables in a sh-like syntax; #-preceded comments are supported
        # and $NAME and ${NAME} expansions are supported 

        try:
            env_handle = open(self.env_file)
        except EnvironmentError, exc:
            self.logger.error('Unable to open %s -- error %s.' % (self.env_file, repr(exc.args)))
            return 0
    
        line_number = 0
        for unmodified_line in env_handle.readlines():
            line_number = line_number + 1
            line = unmodified_line.strip() # remove leading and trailing whitespace
            if len(line) == 0 or line[0] == '#':
                # ignore empty and comment lines
                continue
            # strip off the leftmost non-escaped '#' and trailing characters
            matched = Zope_Instance._poundstrip.match(line)
            if matched:
                line = matched.group(1)
            eq = line.find('=')
            if eq < 1:
                # no eq or eq at 0 is not valid
                self.logger.error('Invalid environment syntax (missing or misplaced "=") on line %d of %s:' % (line_number, self.env_file))
                self.logger.error('"%s"' % line)
                if len(line) < len(unmodified_line):
                    self.logger.debug('(Read as) "%s".' % unmodified_line)
                have_no_errors = 0 # false
                continue


            key = line[:eq].strip()
            val = line[eq+1:].strip()

            val = expandvars(val,self.environ)
            self.environ[key] = val

        env_handle.close()

        return have_no_errors


    def prepare(self):
        # add and adjust needed options and environment variables; ensure that the instance directories
        # exist; return false if we encounter problems

        # Implementation note:
        # the "environment" settings used by z2.py can come from two places: the actual environment and the arguments passed to z2;
        # furthermore, some options, e.g. LOG_FILE, can also be set using short options to z2.
        # E.g. one could say
        #    LOG_FILE="a" z2.py -l "b" LOG_FILE="c"
        # providing three conflicting values for LOG_FILE (respectively in the environment, options, and args).
        # Rather than writing code here that presumes knowledge of what z2 will always do, we
        # maintian the three things separately, and fiddle with them all the same way.

        # first, be sure we have a user we can use; no point in wasting time if we don't.

        # pull the rightmost match
        username = ''
        for k,v in self.opts:
            if k == '-u':
                username = v

        if not username:
            self.logger.error ('The "-u username|UID" option is required -- please edit %s.' % os.path.join(CONFIGURATION_BASE, self.instance_name, 'options'))
            return 0

        try:
            pwtuple = pwd.getpwnam(username)
            # Return the password database entry for the given user name.
        except:
            # maybe it's a uid
            try:
                uid = int(username)
            except:
                self.logger.error ("There's no user named \"%s\" -- please add it [see adduser(8)] or edit %s and correct the -u option." % (username, self.opts_file))
                return 0

            try:
                pwtuple = pwd.getpwuid(uid)
                # Return the password database entry for the given numeric user ID.
            except:
                self.logger.error ("There's no user with uid \"%s\" -- please add it [see adduser(8)] or edit %s and correct the -u option." % (username, self.opts_file))
                return 0

        pw_name, pw_passwd, pw_uid, pw_gid, pw_gecos, pw_dir, pw_shell = pwtuple
        self.username = pw_name


        # if any of the following opts/args/environment values were supplied nonabsolute path names,
        # we change them as follows:

        # Log files

        log_directory = os.path.join(LOG_BASE, self.instance_name)
        
        # qualify the file names, if not already an absolute path; for the ones default values, add them (to environment) if not supplied anywhere
        for k, default in (
            ('LOG_FILE', 'Z2.log'),
            ('DETAILED_LOG_FILE', ''),
            ('STUPID_LOG_FILE', ''),
            ('EVENT_LOG_FILE', ''),
            ('PROFILE_PUBLISHER', ''),
            ):

            v = self.environ.get(k, default)
            if v and not os.path.isabs(v):
                new_v = os.path.join(log_directory, v)
                self.environ[k] = new_v
                self.logger.debug('Changed environment variable %s from %s to %s' % (k, v, new_v))
        
            for n in range(len(self.args)):
                arg = self.args[n]
                eq = arg.find('=')
                if eq > 0:
                    arg_k = arg[:eq]
                    if arg_k == k: 
                        v = arg[eq+1:]
                        if v and not os.path.isabs(v):
                            new_v = os.path.join(log_directory, v)
                            new_arg = '%s=%s' % (arg_k, new_v)
                            self.args[n] = new_arg
                            self.logger.debug('Changed argument `%s\' to `%s\'.' % (arg, new_arg))



        # qualify the -l and -M (log_file, detailed_log_file) if they're nonempty and not absolute
        for n in range(len(self.opts)):
            o, v = self.opts[n]
            if o in ('-l', '-M') and len(v):
                if not os.path.isabs(v):
                    new_v=os.path.join(log_directory, v)
                    self.opts[n] = (o, new_v)
                    self.logger.debug('Changed option %s from `%s\' to `%s\'.' % (o, v, new_v))

        for k, v in (
            ('INSTANCE_HOME', os.path.join(DEFAULT_INSTANCE_BASE, self.instance_name)),
            ):
            if not self.environ.has_key(k):
                self.environ[k] = v
                self.logger.debug('Added to environment: `%s=%s\'.' % (k, v))

        # check that the INSTANCE_HOME directory and subdirectories exist
        instance_home = self.environ['INSTANCE_HOME']
        if not os.path.isdir(instance_home):
            self.logger.error('INSTANCE_HOME directory (%s) not found.  Please create it (e.g. by running zope-make-instance)' % instance_home)
            self.logger.error('or correct the configuration in %s.' % self.env_file)
            return 0
        
        for subdirname in ('Extensions', 'Products'):  # do we need 'imports' too ?
            if not os.path.isdir(os.path.join(instance_home, subdirname)):
                self.logger.info('The recommended "%s" subdirectory doesn\'t exist in %s.' % (subdirname, instance_home))

        errors = 0
        for subdirname in ('var', ): 
            if not os.path.isdir(os.path.join(instance_home, subdirname)):
                self.logger.error('The required "%s" subdirectory doesn\'t exist in %s.' % (subdirname, instance_home))
                errors = errors + 1
        if errors:
            return 0
            
        return 1


    def get_pid(self):
        # read this instance's pid file from the pid file, and return its number iff that process
        # (well, technically, any process with that pid) is running  --  be smarter about that ??

        pidfile_name = os.path.join(self.environ['INSTANCE_HOME'], 'var', 'Z2.pid')
        # self.logger.debug('pidfile_name is %s' % pidfile_name)
        try:
            pidfile_handle = open(pidfile_name)
        except EnvironmentError, exc:
            if exc.errno != 2:
                # errno 2 means file not found, shut up about that
                self.logger.warn('Unable to open %s: %s.' % (pidfile_name, repr(exc.args)))            
            return None

        pid_text = pidfile_handle.readline()
        pidfile_handle.close()

        if pid_text:
            try:
                pid = int(pid_text.split()[0])
            except:
                self.logger.warn('Unable to recognize pid in "%s" read from %s.' % (pid_text, pidfile_name))
                return None

        try:
            os.kill(pid, 0)  # signal 0 doesn't send a signal, just does error checking
        except EnvironmentError, exc:
            if exc.errno == 3: #No Such Process
                return None
            else:
                self.logger.warn('Testing for pid %d (signal 0) got error %s.' % (pid, repr(exc.args)))
                return None

        return pid
    
    # -----------------
    def is_valid(self):
        return self.current_state != 'invalid'


    def wait_for_state(self, desired_state):
        # check state and wait until it has changed to desired_state
        # or timeout seconds have passed

        give_up_time = time.time() + TIMEOUT  #time.time() is seconds since the epoch (floating point)
        
        while time.time() < give_up_time and self.state() != desired_state:
            self.logger.blip() # this is not strictly policy-compliant, but has been standard for a while
            time.sleep(2)

        if self.state() != desired_state:
            self.logger.normal('')
            self.logger.error('"%s" timeout after %d seconds waiting for state "%s"' % (self.instance_name, TIMEOUT, desired_state))
            return 1 # error

        self.logger.normal('.') # debian policy chapter 10.4
        return 0 # no error


    def state(self, pidtoo=0):
        # determine and answer the current state of this instance; if pidtoo is true, answer (state, pid) [pid may be None]
        # state will be one of 'unconfigured', 'nonexistent', 'stopped', 'starting', 'started', 'stopping'
        # (nonexistent means the instance home does not yet exist, i.e. it has never been started)

        # invalid -> configured  -> stopped  -> starting -> started -> stopping -+
        #                              ^                                         |
        #                              |                                         |
        #                              +-----------------------------------------+

        pid = None
        if self.is_valid():
            # go figure out what the current state really is
            pid = self.get_pid()
            if pid:
                # must be starting, started, or stopping...
                if self.current_state != 'stopping': #stopping with a pid is still stopping
                    offspring = pids_in_session(pid)
                    if len(offspring) > 1:
                        self.current_state = 'started'
                    else:
                        self.current_state = 'starting'
            else:
                self.current_state = 'stopped'

        if pidtoo:
            return (self.current_state, pid)
        else:
            return self.current_state
    
    def status(self):
        # report the current status (state and process information) for this instance

        state, pid = self.state(pidtoo=1)
        if pid:
            self.logger.normal('Zope instance "%s" is %s -- PID %d.' % (self.instance_name, state, pid))
            if self.logger.VERBOSE:  # blah, shouldn't access the logger object's attributes; improve this??
                # ps_output = pids_in_session(pid, ps_format='pid,user,cputime,pcpu,vsz,rss,pmem,stat,start,ucomm')
                ps_output = pids_in_session(pid, ps_format='user,pid,pcpu,pmem,vsz,rss,stat,start,cputime,ucomm')
                if len(ps_output) > 1: # i.e. it's not just a heading line
                    for line in ps_output:
                        self.logger.normal(line,nl='')
        else:
            self.logger.normal('Zope instance "%s" is %s.' % (self.instance_name, state))

        return 0
        

    def start(self):
        # try to start (if not already running)
        
        stat = self.state()

        if stat == 'started':
            self.logger.info('Instance "%s" has already been started.' % self.instance_name)
            return 0
        
        if stat != 'stopped':
            self.logger.error('Cannot start instance "%s", it is currently %s.' % (self.instance_name, stat))
            return 1

        # here we have a 'stopped' instance; before starting it, check/adjust a few things... 
        

        # be sure the instance home is owned by the correct user and group (assuming we're root, so we can do this)
        'chown -R username:zope instancehome'  # <---??-- # make that work

        # determine whether we must start this instance as root (i.e. if zope needs to access low-numbered ports)

        lowport_list = lowports(self.opts)
        if lowport_list:
            self.logger.info('Will start zope instance "%s" as root because of the following option(s): %s.' % (self.instance_name, ' '.join(lowport_list)))
            setuid=None   # do we need to ensure that if we're not root, we're username?  ???  I think so.... later....
        else:
            setuid=self.username
            self.logger.info('Will start zope instance "%s" as %s.' % (self.instance_name, setuid))

        # log files for stdout and stderr....
        log_directory = os.path.join(LOG_BASE, self.instance_name)
        outlog = os.path.join(log_directory, 'zope.stdout.log')
        errlog = os.path.join(log_directory, 'zope.stderr.log')



        # create the argument list for the process to be created

        arglist = []
        arglist.append('zope-z2')
        for (o,v) in self.opts:
            sep = ''
            for long_opt in Z2_GETOPT_LONG:
                if long_opt[-1] == '=':
                    if o == '--%s' % long_opt[:-1]:
                        sep = '='
                        break
            arglist.append('%s%s%s' % (o, sep, v))
        arglist.extend(self.args)
        self.logger.debug ('Arglist: %s.' % repr(arglist))

        self.current_state = 'starting'
        self.logger.normal('Starting Zope instance "%s": zope-z2' % self.instance_name, nl='')

        errs = self.fork_and_exec(arglist, stdout=outlog, stderr=errlog, suid=setuid)
        if errs:
            return errs

        return self.wait_for_state('started')


    def stop(self):
        # no error if already stopped
        stat, pid = self.state(pidtoo=1)
        if pid:
            self.logger.normal('Stopping Zope instance "%s": zope-z2 PID %d' % (self.instance_name, pid), nl='')
            try:
                os.kill(pid, signal.SIGTERM)
                self.current_state = 'stopping'
            except EnvironmentError, exc:
                self.logger.normal('')
                self.logger.error('Attempt to kill pid %d got error %s.' % (pid, repr(exc.args)))
                return 1
                    
            if self.current_state == 'stopping':
                return self.wait_for_state('stopped')

        self.logger.info('Zope instance "%s" was not running (it\'s %s)' % (self.instance_name, stat))
        return 0

    def restart(self):
        # stop, wait, start
        if not self.stop():
            self.logger.error ('Zope instance "%s" didn\'t stop, so can\'t restart.' % self.instance_name)
            return 1 # error
        return self.start()

    def reload(self):
        # cause the configuration of the service to be reloaded without
        # actually stopping and restarting the service
        
        # SIGNALS.txt suggests:  """kill -HUP `cat ZOPE_HOME/var/z2.pid`""", so...

        stat, pid = self.state(pidtoo=1)
        if pid:
            if stat != 'started':
                self.logger.warn ('Cannot reload %s because it is currently %s.' % (self.instance_name, stat))
                return 0

            self.logger.normal('Reloading Zope instance "%s": zope-z2 PID %d' % (self.instance_name, pid), nl='')

            try:
                os.kill(pid, signal.SIGHUP)
            except EnvironmentError, exc:
                self.logger.normal('')
                self.logger.error('Attempt to signal pid %d got error %s.' % (pid, repr(exc.args)))
                return 1
        
            self.logger.normal('.')
            
        return 0

    def force_reload(self):
        # cause the configuration to be reloaded if the service supports
        # this, otherwise restart the service.
        return self.reload()
    
    def reopenlogs(self):
        # cause zope to close and re-open its log files (for convenience; used after logrotate)

        stat, pid = self.state(pidtoo=1)
        if pid:
            if stat != 'started':
                self.logger.warn ('Cannot signal %s because it is currently %s.' % (self.instance_name, stat))
                return 0

            self.logger.normal('Asking Zope instance "%s" to close and reopen log files: zope-z2 PID %d' % (self.instance_name, pid), nl='')

            try:
                os.kill(pid, signal.SIGUSR2)
            except EnvironmentError, exc:
                self.logger.error('Attempt to signal pid %d got error %s.' % (pid, repr(exc.args)))
                self.logger.normal('')
                return 1
        
            self.logger.normal('.')
            
        return 0


    def fork_and_exec(self, args, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null', suid=None):
        # this is probably uglier and more complicated than it needs to be, but it works for me;
        # improvements/replacements are welcome
        # ... suid if requested, run args[0] with args and environ, replace stdin/out/err, and forget about it

        self.logger.debug('fork_and_exec(%s, %s, %s, %s, %s)'%(repr(args), stdin, stdout, stderr, repr(suid)))  

        #### for now we don't suid at all; z2 will do it anyway; is there an advantage to doing it here instead
        #### ?????
        if suid:
            self.logger.info("ummm, ignoring preemptive suid for now; letting z2 do it as normal.")
        # if suid:
        #     try:
        #        os.seteuid(suid)  #????
        #    except:
        #        logger.error("Unable to suid to %s; doing nothing" % suid)
        #        return 1
        ####
        #### see below too, because we'll have to come back to root after doing this

        try:
            new_stdin=os.open(stdin, os.O_RDONLY)
            new_stdout=os.open(stdout, os.O_WRONLY|os.O_APPEND|os.O_CREAT|os.O_SYNC, 0666)  ## <--\
            new_stderr=os.open(stderr, os.O_WRONLY|os.O_APPEND|os.O_CREAT|os.O_SYNC, 0666)  ## <-- is 0666 too loose??  necessary?
        except EnvironmentError, exc:
            self.logger.error('Unable to open %s -- error %s.' % (exc.filename, repr(exc.args)))
            return 1

        newpid = os.fork()
        if newpid <> 0:
            self.logger.debug('Started pid %d.' % newpid)
            # i'm the parent, and i don't care any more
            try:
                os.close(new_stdin)
                os.close(new_stdout)
                os.close(new_stderr)
            except EnvironmentError, exc:
                self.logger.warn('Parent unable to close %s -- error %s.' % (exc.filename, repr(exc.args)))

            ### if we suid'd how do we get back ... ???
            # if suid:
            #    os.seteuid(0)  ## ??
            ###

            return 0

        # i'm the child
        os.dup2(new_stdin, 0)
        os.close(new_stdin)
        os.dup2(new_stdout, 1)
        os.close(new_stdout)
        os.dup2(new_stderr, 2)
        os.close(new_stderr)
        os.execvpe(args[0],args,self.environ) # we're out of here
        ##########
        ### I wonder, because zope-z2 is a python script, would it be
        ### more efficient to just import and call it??  Probably not,
        ### because of the baggage the forked process has.
        ##########
        return None # this return never happens, but it keeps pychecker happy



# ---------------------------------
class Zope_Instance_Nowait(Zope_Instance):
    """ This subclass of Zope_Instance is used when CONCURRENT is true, and
    does its best to not wait unnecessarily, e.g. 'start' will initiate the creation of a zope instance process
    and return, without waiting for it to finish starting, and will come back and check after all instances
    have been 'start'-ed (or whatever) and then wait for the completion of all of them."""

    def __init__(self, instance_name, logger):
        Zope_Instance.__init__(self, instance_name, logger)
        # add to state for concurrent waiting; when this is not None it means
        # we've initiated a change of state and it has not yet reached the desired state
        self.currently_waiting_for_state = None
        
    def wait_for_state(self, desired_state):

        if self.currently_waiting_for_state:
            self.logger.warn('Instance "%s" was already waiting for state %s, now waiting for state %s.' % (self.instance_name, self.currently_waiting_for_state, desired_state))
        self.currently_waiting_for_state = desired_state

    def still_waiting_for(self):
        # check to see if we are still waiting
        if self.currently_waiting_for_state:
            stat = self.state()
            if stat == self.currently_waiting_for_state:
                self.currently_waiting_for_state = None

        return self.currently_waiting_for_state

    ##### the following methods (or something like them) would be class methods (static methods) if there were
    ##### such a thing in python.  

    def pending_instances(self):
        # answer a list of instances that are still pending, if any
        answer = []
        
        for inst in Zope_Instance_Nowait.dic.values():
            if inst.still_waiting_for():
                answer.append(inst)

        return answer

    def wait_for_all(self):
        # wait for any incomplete instances; return 0 when done or number instances still pending after timeout
        give_up_time = time.time() + TIMEOUT  #time.time() is seconds since the epoch (floating point)
        
        while time.time() < give_up_time and len(self.pending_instances()):
            self.logger.blip() # this is not strictly policy-compliant, but has been standard for a while
            time.sleep(1)

        stuck_ones = self.pending_instances()
        if stuck_ones:
 
            self.logger.normal('')
            self.logger.error('Timeout after %d seconds waiting for the following instances -- try a longer `--timeout=\'?' % (TIMEOUT,))
            for inst in stuck_ones:
                self.logger.error('%s is still %s, was waiting for %s' % (inst.name, inst.current_state, inst.currently_waiting_for_state))
            return len(stuck_ones) # error

        self.logger.normal('.') # debian policy chapter 10.4
        return 0 # no error

        

# ---------------------------------



def subdirectories_of(dir):
    # return a sorted list of directory names (nonrecursive) in dir, excluding "." and ".."
    names = os.listdir(dir) # listdir excludes . and ..
    answer = []
    for name in names:
        if os.path.isdir(os.path.join(dir,name)):
            answer.append(name)
    answer.sort()
    return answer

def configured_instances(specific_names, logger):
    # answer a tuple containing the list of names in specific_names with apparently-valid instance configurations in CONFIGURATION_BASE (all of them if specific_names is [])
    # and the number of names tried (for error reporting)
    answer = []
    if specific_names:
        names = specific_names
    else:
        names = subdirectories_of(CONFIGURATION_BASE)
        logger.debug('subdirectories_of(%s): %s' % (CONFIGURATION_BASE, repr(names)))
        
    for instance_dir in names:
        absolute_instance_dir = os.path.join(CONFIGURATION_BASE, instance_dir)
        if not os.path.isdir(absolute_instance_dir):
            logger.error('There is no instance (directory) named "%s" in %s.' % (instance_dir, CONFIGURATION_BASE))
            continue
        if os.path.isfile(os.path.join(absolute_instance_dir, 'environment')) and os.path.isfile(os.path.join(absolute_instance_dir, 'options')):
            answer.append(instance_dir)
        else:
            logger.error("The required files (`environment\' and `options\') don't exist in %s." % absolute_instance_dir)
    
    return (answer, len(names))


##/########### unused ###########\##
def created_instances(specific_names, logger):
    # answer the names of (previously-created) instances in DEFAULT_INSTANCE_BASE; these may or may not be running and may or may not be valid
    answer = []
    if specific_names:
        names = specific_names
    else:
        names = subdirectories_of(DEFAULT_INSTANCE_BASE)
        
    for instance_dir in names:
        if not os.path.isdir('%s/%s' % (DEFAULT_INSTANCE_BASE, instance_dir)):
            logger.error("There is no instance (directory) named %s in %s." % (instance_dir, DEFAULT_INSTANCE_BASE))
            continue
        if os.path.isfile('%s/%s/access' % (DEFAULT_INSTANCE_BASE, instance_dir)) and os.path.isdir('%s/%s/var' % (DEFAULT_INSTANCE_BASE, instance_dir)):
            answer.append(instance_dir)
    return answer
##\########### unused ###########/##

def pids_in_session(pid, ps_format=None):
    # answer a list of pids (or, if ps_format is supplied, ps output lines)
    # in pid's session (itself, children, grandchildren, ...)
    # if pid is gone or not a session, it's an empty list (or, with ps_format, just the heading line)
    
    # for now, I'm lazy and let ps do the work -- is there a better pythonic way??
    answer = []

    if ps_format:
        command = "ps --sid %d -H --format '%s'" % (pid, ps_format)
    else:
        command = "ps --sid %d --format pid --no-headers" % pid

    pipe = os.popen(command)
    for line in pipe.readlines():
        answer.append(line) # strip the trailing \n
    pipe.close()

    return answer


MANPAGE_OPTIONS = [
    "OPTIONS",
    "  The following options affect zopectl's behavior:",
    "",
    "  --help  ",
    "    Show this help text; do nothing else, even if a command is specified.",
    ] + Logger.MANPAGE_OPTIONS + [
    "  --timeout=secs  ",
    "    Override the default timeout (120 seconds) used by zopectl when waiting",
    "    for Zope instances to start or stop.",
    "  --concurrent  ",
    "    Rather than waiting for command to finish for each instance before",
    "    operating on the next instance, zopectl will execute command for",
    "    each instance in turn and then wait for them all to finish. This may be",
    "    faster on systems with many Zope instances and sufficient resources.",
    ]
    
def main(argv):
    # we should be called with valid args (per usage())
    # this function returns 0 for success, nonzero for error, so its result can be passed back to sys.exit()
    argc = len(argv)
    global NAME, TIMEOUT, CONCURRENT
    if argc:
        NAME = argv[0]

    logger = Logger(NAME)

    try:
        opts, args = getopt.getopt(argv[1:], 'I:', ['help',] + Logger.getopt_options + ['concurrent', 'timeout='])
    except getopt.GetoptError, err:
        logger.error(err)
        usage(logger)
        return 1

    for o,v in opts:
        if o == '--help':
            help()
            return 0
        elif o == '--concurrent':
            logger.warn('This version of %s doesn\'t support "--concurrent" and is behaving as if you hadn\'t said that ;).' % NAME) 
            CONCURRENT = 0  # ?? change to 1 when it works
        elif o == '--timeout':
            try:
                int_v = int(v)
            except:
                int_v = -1 #nfg
            if int_v < 1:
                logger.error('Argument "--timeout=%s" is not valid; must be a positive integer (number of seconds).' % v)
                usage(logger)
                return 1
            TIMEOUT = v
        elif o == '-I':
            logger.info(' '.join('$Id: zopectl,v 1.27 2003/03/16 04:53:50 david Exp $'.split()[1:3]))
            logger.error( "zopectl no longer supports the '-I' (instance home) parameter.")
            logger.error( "Please create an instance subdirectory for each instance (in %s), and set the" % CONFIGURATION_BASE)
            logger.error( "desired instance options and environment variables there,")
            logger.error( "i.e. in %s/<instance_name>/environment and %s/<instance_name>/options" % (CONFIGURATION_BASE, CONFIGURATION_BASE))
            logger.error( "... see zopectl(8) for more details.")
            return 1
        else:
            if not logger.handle_getopt(o, v):
                logger.error('unhandled option "%s" -- please file a bug report')
                return 1

    logger.info(' '.join('$Id: zopectl,v 1.27 2003/03/16 04:53:50 david Exp $'.split()[1:3]))

    if not args:
        usage(logger)
        return 1
    
    cmd = args[0]
    if cmd not in ('start', 'stop', 'restart', 'reload', 'force-reload', 'status', 'reopenlogs'):
        logger.error('Command `%s\' unrecognized; must be start|stop|restart|reload|force-reload|status|reopenlogs.' % cmd)
        usage(logger)
        return 1

    specific_instance_names = args[1:]

    func_name = cmd.replace('-','_')
    errors = do_instances(specific_instance_names, func_name, logger)  # number of failed instances

    if errors:
        logger.error('*** %s failed for %d of the instances; see ERRORs above.' % (cmd, errors))
        return 1

    return 0


def do_instances(instance_names, cmd, logger):
    # attempt to call cmd on each instance in instances_names;
    # if specific_instances is empty, attempt it for all instances.
    # return the number of instances with errors

    logger.debug('do_instances(%s, %s)' % (repr(instance_names), cmd))

    apparently_valid_names, original_count = configured_instances(instance_names, logger)

    errors = original_count - len(apparently_valid_names)

    for name in apparently_valid_names:
        if CONCURRENT:
            instance = Zope_Instance_Nowait(name, logger)
        else:
            instance = Zope_Instance(name, logger)
        if not instance.is_valid():
            errors = errors + 1
            continue
        if eval('instance.%s()' % cmd):
            errors = errors + 1

    if CONCURRENT:
        if Zope_Instance_Nowait.dic:
            # ugly -- choose an arbitrary instance to ask about them all
            errors = errors + Zope_Instance_Nowait.dic.values()[0].wait_for_all()
        
    return errors


_varprog = re.compile(r'\$(\w+|\{[^}]*\})')
def expandvars(text, dic):
    """Expand variables of form $var and ${var} by looking them up in dic.  Unknown variables
    are left unchanged."""
    # based on python's posixpath.py expandvars(path)
    global _varprog
    if '$' not in text:
        return text
    i = 0
    while 1:
        m = _varprog.search(text, i)
        if not m:
            break
        i, j = m.span(0)
        name = m.group(1)
        if name[:1] == '{' and name[-1:] == '}':
            name = name[1:-1]
        if dic.has_key(name):
            tail = text[j:]
            text = text[:i] + dic[name]
            i = len(text)
            text = text + tail
        else:
            i = j
    return text




def is_lowport(stringvalue, min_port_value):
    # returns true iff the port number in the specified parameter value is present, 
    # a number, nonzero, and less than min_value.  z2 parameter syntax is assumed,
    # i.e. '80' or 'zippy.org:80' or '202.100.100.3:80' all mean port 80.
    # ?? ---> caution: is "zippy.org:http" supported by z2?  if so, we need to
    # look it up in /etc/services...  No, as of zope 2.6.0 z2 requires a numeric port.

    port = stringvalue[stringvalue.rfind(':')+1:] # drop all through ':', if any

    try:
        portnum=int(port)
    except:
        return 0

    if portnum < min_port_value:
        return 1

    return 0

def lowports(opts_list):

    opts_dic = {}
    for k,v in opts_list:
        if v:
            opts_dic[k] = v
        
    # answer a list of options in opts_dic that specify low-numbered ports
    # (we use this to determine whether we need to start as root, and to show the user which option(s) were set that way)

    # options that provide [ip_address:]port where port may be a number or "-" for disabled
    # see z2.py for documentation about each of these
    ip_port_opts = ("-w", "-f", "-m", "-W", "--icp")
    
    # option that provides [ip_address:]port as a base (yielding ports base+21, base+80, base+99)
    ip_portbase_opt = "-P"

    answer=[]
    
    for opt in ip_port_opts:
        v = opts_dic.get(opt,'')
        if is_lowport(v, 1024):
            answer.append('%s %s' % (opt, v))

    opt = ip_portbase_opt
    v = opts_dic.get(opt,'')
    if is_lowport(v, 1045):   # 1045 = 1024+21
        answer.append('%s %s' % (opt, v))

    return answer


#--------------------

MANPAGE_EXIT_STATUS = [
    "EXIT STATUS AND OUTPUT",
    "  zopectl exits with error 0 if no errors were encountered, and",
    "  with error 1 if any errors were encountered.   Normal output is",
    "  written to stdout; all error, warning, verbose information,",
    "  and debugging messages are written to stderr.",
    ]
MANPAGE_BUGS = [
    "BUGS",
    "  None known at this time; please report problems to the Debian bug",
    "  tracking system. (Use, e.g., Debian reportbug(1) to report them.)",
    ]
MANPAGE_SEE_ALSO = [
    "SEE ALSO",
    "  zope-make-instance(8), zope-z2(8), zope-zpasswd(8)",
    ]
MANPAGE_AUTHORS = [
    "AUTHORS AND LICENSE",
    "  zopectl and its manual page were originally written by Gregor Hoffleit",
    "  <flight@debian.org> for the Debian project. David Coe <davidc@debian.org>",
    "  rewrote zopectl in python for Zope version 2.6.x, and extended it to",
    "  handle multiple Zope instances. Feel free to use, copy and modify under",
    "  the terms of the GNU General Public License, version 2.1.",
    ]




if __name__ == '__main__':
    sys.exit(main(sys.argv))


#--------------------


"""
zope_pidfile=$INSTANCE_HOME/var/Z2.pid
zope_logfile=$INSTANCE_HOME/var/Z2.log

OPTION=$1
shift


export INSTANCE_HOME

test_zope() {
    if [ -f $zope_pidfile ]; then
        zope_pid=`cat $zope_pidfile | cut -d' ' -f2`
	if [ ! "x$zope_pid" = "x" ] && kill -0 $zope_pid 2>/dev/null; then
	    zope=1
	    true
	else
	    zope=-1
	    false
	fi
    else
        zope=0
	false
    fi
}

waitfor_zope() {
    timeout=60
    test_zope
    while [ $timeout -gt 0 -a ! $zope -eq 1 ]; do
	sleep 1
	echo -n '.'
	test_zope
	timeout=`expr $timeout - 1`
    done
    if [ $zope -eq 1 ]; then
        true
    else
        false
    fi
}

start_zope() {
    /usr/sbin/zope-z2 $@
}


stop_zope() {
    test_zope
    if [ $zope -eq 1 ]; then
        kill $zope_pid 2>/dev/null && true
	sleep 2
	test_zope
        if [ $zope -eq -1 ]; then
            rm -f $zope_pidfile
        fi
    else
        if [ $zope -eq -1 ]; then
            rm -f $zope_pidfile
	    echo "Removed stale z2 pidfile (pid $zope_pid)."
        fi
    fi
}

restart_zope() {
    test_zope
    if [ $zope -eq 1 ]; then
        kill -HUP $zope_pid 2>/dev/null && true
        test_zope
    else
        if [ $zope -eq -1 ]; then
	    echo "Removed stale z2 pidfile (pid $zope_pid)."
        fi
    fi
}

case $OPTION in

status)
    test_zope
    if [ $zope -eq 1 ]; then
	echo "Zope running (pid=$zope_pid)."
    else
	echo "Zope is not running."
    fi
    ;;

start)
    test_zope
    echo -n "Starting Zope..."
    if [ ! $zope -eq 1 ]; then
        start_zope $@
        if waitfor_zope; then
            echo " done."
        else
            echo " failed."
            false
        fi
    else
        echo " done (Zope already running)."
    fi
    ;;

stop)
    echo -n "Stopping Zope..."
    test_zope
    if [ $zope -eq 1 ]; then
        stop_zope
	not_running=0
	for i in 1 2 3 4 5; do
	    test_zope
	    if [ $zope -eq 1 ]; then
		echo -n '.'
		sleep 3
	    else
		rm -f $zope_pidfile
		not_running=1
		echo " done."
		break
	    fi
	done
	if [ $not_running -eq 0 ]; then
	    echo " failed."
	    false
	fi
    else
        if [ $zope -eq -1 ]; then
	    rm -f $zope_pidfile
	    echo -n " [removed stale zope pidfile (pid $zope_pid)]"
	fi
    fi
    ;;

restart)
    test_zope
    if [ $zope -eq 1 ]; then
        echo -n "Restarting Zope..."
	restart_zope
	echo " done."
    else
	echo  " failed. (Zope isn't running)"
    fi
    ;;

force-reload)
    /usr/sbin/zopectl stop || true
    /usr/sbin/zopectl start $@ || true
    ;;

*)
    echo "Usage: $0 (start|stop|restart|force-reload|status)"
    ;;

esac
"""
