Преглед на файлове

Support expansions of ENV_* vars in all options.

Dexter Tad-y преди 12 години
родител
ревизия
2d6ca34582
променени са 3 файла, в които са добавени 451 реда и са изтрити 40 реда
  1. 67 0
      docs/configuration.rst
  2. 61 40
      supervisor/options.py
  3. 323 0
      supervisor/tests/test_options.py

+ 67 - 0
docs/configuration.rst

@@ -1417,3 +1417,70 @@ And a section in the config file meant to configure it.
    [rpcinterface:another]
    supervisor.rpcinterface_factory = my.package:make_another_rpcinterface
    retries = 1
+
+Environment Variable Interpolation
+----------------------------------
+
+There may be a time where it is necessary to avoid hardcoded values in your
+configuration file (such as paths, port numbers, username, etc). Some teams
+may also put their supervisord.conf files under source control but may want
+to avoid committing sensitive information into the repository.
+
+With this, **all** the environment variables inherited by the ``supervisord``
+process are available and can be interpolated / expanded in **any**
+configuration value, under **any** section.
+
+Your configuration values may contain Python expressions for expanding
+the environment variables using the ``ENV_`` prefix. The sample syntax is
+``foo_key=%(ENV_FOO)s``, where the value of the environment variable ``FOO``
+will be assigned to the ``foo_key``. The string values of environment
+variables will be converted properly to their correct types.
+
+.. note::
+  - some sections such as ``[program:x]`` have other extra expansion options.
+  - environment variables in the configuration will be required, otherwise
+    supervisord will refuse to start.
+  - any changes to the variable requires a restart in the ``supervisord``
+    daemon.
+
+
+An example configuration snippet with customizable values:
+
+.. code-block:: ini
+
+   [supervisord]
+   logfile = %(ENV_MYSUPERVISOR_BASEDIR)s/%(ENV_MYSUPERVISOR_LOGFILE)s
+   logfile_maxbytes = %(ENV_MYSUPERVISOR_LOGFILE_MAXBYTES)s
+   logfile_backups=10
+   loglevel = info
+   pidfile = %(ENV_MYSUPERVISOR_BASEDIR)s/supervisor.pid
+   nodaemon = false
+   minfds = 1024
+   minprocs = 200
+   umask = 022
+   user = %(ENV_USER)s
+
+   [program:cat]
+   command=/bin/cat -x -y --optz=%(ENV_CAT_OPTZ)s
+   process_name=%(program_name)s
+   numprocs=%(ENV_CAT_NUMPROCS)s
+   directory=%(ENV_CAT_DIR)s
+   umask=022
+   priority=999
+   autostart=true
+   autorestart=true
+   exitcodes=0,2
+   user=%(ENV_USER)s
+   redirect_stderr=false
+   stopwaitsecs=10
+
+The above sample config will require the following environment variables to be set:
+
+   - ``MYSUPERVISOR_BASEDIR``
+   - ``MYSUPERVISOR_LOGFILE``
+   - ``MYSUPERVISOR_LOGFILE_MAXBYTES``
+   - ``USER``
+   - ``CAT_OPTZ``
+   - ``CAT_NUMPROCS``
+   - ``CAT_DIRECTORY``
+

+ 61 - 40
supervisor/options.py

@@ -379,7 +379,14 @@ class Options:
             except ImportError:
                 raise ValueError('%s cannot be resolved within [%s]' % (
                     factory_spec, section))
-            items = parser.items(section)
+            items_tmp = parser.items(section)
+            items = []
+            for ikv in items_tmp:
+                ik, iv_tmp = ikv
+                iexpansions = {}
+                iexpansions.update(environ_expansions())
+                iv = expand(iv_tmp, iexpansions, ik)
+                items.append((ik, iv))
             items.remove((factory_key, factory_spec))
             factories.append((name, factory, dict(items)))
 
@@ -555,11 +562,14 @@ class ServerOptions(Options):
             if need_close:
                 fp.close()
 
+        expansions = {'here':self.here}
+        expansions.update(environ_expansions())
         if parser.has_section('include'):
             if not parser.has_option('include', 'files'):
                 raise ValueError(".ini file has [include] section, but no "
                 "files setting")
             files = parser.get('include', 'files')
+            files = expand(files, expansions, 'include.files')
             files = files.split()
             if hasattr(fp, 'name'):
                 base = os.path.dirname(os.path.abspath(fp.name))
@@ -583,7 +593,14 @@ class ServerOptions(Options):
         sections = parser.sections()
         if not 'supervisord' in sections:
             raise ValueError('.ini file does not include supervisord section')
-        get = parser.getdefault
+
+        common_expansions = {'here':self.here}
+        def get(opt, default, **kwargs):
+            expansions = kwargs.get('expansions', {})
+            expansions.update(common_expansions)
+            kwargs['expansions'] = expansions
+            return parser.getdefault(opt, default, **kwargs)
+
         section.minfds = integer(get('minfds', 1024))
         section.minprocs = integer(get('minprocs', 200))
 
@@ -608,8 +625,6 @@ class ServerOptions(Options):
         section.nocleanup = boolean(get('nocleanup', 'false'))
         section.strip_ansi = boolean(get('strip_ansi', 'false'))
 
-        expansions = {'here':self.here}
-        expansions.update(environ_expansions())
         environ_str = get('environment', '')
         environ_str = expand(environ_str, expansions, 'environment')
         section.environment = dict_of_key_value_pairs(environ_str)
@@ -634,7 +649,14 @@ class ServerOptions(Options):
         groups = []
         all_sections = parser.sections()
         homogeneous_exclude = []
-        get = parser.saneget
+
+        common_expansions = {'here':self.here}
+        def get(section, opt, default, **kwargs):
+            expansions = kwargs.get('expansions', {})
+            expansions.update(common_expansions)
+            kwargs['expansions'] = expansions
+            return parser.saneget(section, opt, default, **kwargs)
+
 
         # process heterogeneous groups
         for section in all_sections:
@@ -717,6 +739,7 @@ class ServerOptions(Options):
                 continue
             program_name = process_or_group_name(section.split(':', 1)[1])
             priority = integer(get(section, 'priority', 999))
+            fcgi_expansions = {'program_name': program_name}
 
             # find proc_uid from "user" option
             proc_user = get(section, 'user', None)
@@ -741,15 +764,11 @@ class ServerOptions(Options):
                     raise ValueError('Invalid socket_mode value %s'
                                                                 % socket_mode)
 
-            socket = get(section, 'socket', None)
+            socket = get(section, 'socket', None, expansions=fcgi_expansions)
             if not socket:
                 raise ValueError('[%s] section requires a "socket" line' %
                                  section)
 
-            expansions = {'here':self.here,
-                          'program_name':program_name}
-            expansions.update(environ_expansions())
-            socket = expand(socket, expansions, 'socket')
             try:
                 socket_config = self.parse_fcgi_socket(socket, proc_uid,
                                                     socket_owner, socket_mode)
@@ -803,8 +822,19 @@ class ServerOptions(Options):
         if klass is None:
             klass = ProcessConfig
         programs = []
-        get = parser.saneget
+
         program_name = process_or_group_name(section.split(':', 1)[1])
+        host_node_name = platform.node()
+        common_expansions = {'here':self.here,
+                      'program_name':program_name,
+                      'host_node_name':host_node_name,
+                      'group_name':group_name}
+        def get(section, opt, *args, **kwargs):
+            expansions = kwargs.get('expansions', {})
+            expansions.update(common_expansions)
+            kwargs['expansions'] = expansions
+            return parser.saneget(section, opt, *args, **kwargs)
+
         priority = integer(get(section, 'priority', 999))
         autostart = boolean(get(section, 'autostart', 'true'))
         autorestart = auto_restart(get(section, 'autorestart', 'unexpected'))
@@ -818,7 +848,7 @@ class ServerOptions(Options):
         redirect_stderr = boolean(get(section, 'redirect_stderr','false'))
         numprocs = integer(get(section, 'numprocs', 1))
         numprocs_start = integer(get(section, 'numprocs_start', 0))
-        environment_str = get(section, 'environment', '')
+        environment_str = get(section, 'environment', '', do_expand=False)
         stdout_cmaxbytes = byte_size(get(section,'stdout_capture_maxbytes','0'))
         stdout_events = boolean(get(section, 'stdout_events_enabled','false'))
         stderr_cmaxbytes = byte_size(get(section,'stderr_capture_maxbytes','0'))
@@ -844,7 +874,7 @@ class ServerOptions(Options):
                 'program section %s does not specify a command' % section)
 
         process_name = process_or_group_name(
-            get(section, 'process_name', '%(program_name)s'))
+            get(section, 'process_name', '%(program_name)s', do_expand=False))
 
         if numprocs > 1:
             if not '%(process_num)' in process_name:
@@ -859,13 +889,9 @@ class ServerOptions(Options):
                 "Cannot set stopasgroup=true and killasgroup=false"
                 )
 
-        host_node_name = platform.node()
         for process_num in range(numprocs_start, numprocs + numprocs_start):
-            expansions = {'here':self.here,
-                          'process_num':process_num,
-                          'program_name':program_name,
-                          'host_node_name':host_node_name,
-                          'group_name':group_name}
+            expansions = common_expansions
+            expansions.update({'process_num': process_num})
             expansions.update(environ_expansions())
 
             environment = dict_of_key_value_pairs(
@@ -1620,23 +1646,26 @@ class UnhosedConfigParser(ConfigParser.RawConfigParser):
         except AttributeError:
             return self.readfp(s)
 
-    def getdefault(self, option, default=_marker):
+    def saneget(self, section, option, default=_marker, do_expand=True,
+                expansions={}):
+        expansions.update(environ_expansions())
         try:
-            return self.get(self.mysection, option)
+            optval = self.get(section, option)
+            if isinstance(optval, basestring) and do_expand:
+                return expand(optval,
+                              expansions,
+                              "%s.%s" % (section, option))
+            return optval
         except ConfigParser.NoOptionError:
             if default is _marker:
                 raise
             else:
                 return default
 
-    def saneget(self, section, option, default=_marker):
-        try:
-            return self.get(section, option)
-        except ConfigParser.NoOptionError:
-            if default is _marker:
-                raise
-            else:
-                return default
+    def getdefault(self, option, default=_marker, expansions={}, **kwargs):
+        return self.saneget(self.mysection, option, default=default,
+                            expansions=expansions, **kwargs)
+
 
 class Config(object):
     def __ne__(self, other):
@@ -2018,24 +2047,16 @@ def expand(s, expansions, name):
             'Format string %r for %r is badly formatted' % (s, name)
             )
 
-_environ_expansions = None
-
 def environ_expansions():
     """Return dict of environment variables, suitable for use in string
     expansions.
 
     Every environment variable is prefixed by 'ENV_'.
     """
-    global _environ_expansions
-
-    if _environ_expansions:
-        return _environ_expansions
-
-    _environ_expansions = {}
+    x = {}
     for key, value in os.environ.items():
-        _environ_expansions['ENV_%s' % key] = value
-
-    return _environ_expansions
+        x['ENV_%s' % key] = value
+    return x
 
 def make_namespec(group_name, process_name):
     # we want to refer to the process by its "short name" (a process named

+ 323 - 0
supervisor/tests/test_options.py

@@ -15,6 +15,8 @@ from supervisor.compat import StringIO
 from supervisor.compat import as_bytes
 
 from supervisor.tests.base import Mock, sentinel, patch
+from supervisor.loggers import LevelsByName
+
 from supervisor.tests.base import DummySupervisor
 from supervisor.tests.base import DummyLogger
 from supervisor.tests.base import DummyOptions
@@ -332,6 +334,35 @@ class ClientOptionsTests(unittest.TestCase):
         self.assertEqual(options.password, '123')
         self.assertEqual(options.history_file, history_file)
 
+    @patch.dict('os.environ', { 'HOME': tempfile.gettempdir(),
+                                'USER': 'johndoe',
+                                'SERVER_PORT': '9210',
+                                'CLIENT_USER': 'someuser',
+                                'CLIENT_PASS': 'passwordhere',
+                                'CLIENT_PROMPT': 'xsupervisor',
+                                'CLIENT_HIST_EXT': '.hist',
+                                })
+    def test_options_with_enviroment_expansions(self):
+        s = lstrip("""[supervisorctl]
+        serverurl=http://localhost:%(ENV_SERVER_PORT)s
+        username=%(ENV_CLIENT_USER)s
+        password=%(ENV_CLIENT_PASS)s
+        prompt=%(ENV_CLIENT_PROMPT)s
+        history_file=/path/to/histdir/.supervisorctl%(ENV_CLIENT_HIST_EXT)s
+        """)
+
+        fp = StringIO(s)
+        instance = self._makeOne()
+        instance.configfile = fp
+        instance.realize(args=[])
+        self.assertEqual(instance.interactive, True)
+        options = instance.configroot.supervisorctl
+        self.assertEqual(options.prompt, 'xsupervisor')
+        self.assertEqual(options.serverurl, 'http://localhost:9210')
+        self.assertEqual(options.username, 'someuser')
+        self.assertEqual(options.password, 'passwordhere')
+        self.assertEqual(options.history_file, '/path/to/histdir/.supervisorctl.hist')
+
     def test_unreadable_config_file(self):
         fname = missing_but_potential_file()
         self.assertFalse(os.path.exists(fname))
@@ -371,6 +402,16 @@ class ClientOptionsTests(unittest.TestCase):
         instance.realize(args=['--serverurl', 'unix:///dev/null'])
         self.assertEqual(instance.serverurl, 'unix:///dev/null')
 
+    def test_options_unixsocket_configfile(self):
+        s = lstrip("""[supervisorctl]
+        serverurl=unix:///dev/null
+        """)
+        fp = StringIO(s)
+        instance = self._makeOne()
+        instance.configfile = fp
+        instance.realize()
+        self.assertEqual(instance.serverurl, 'unix:///dev/null')
+
 class ServerOptionsTests(unittest.TestCase):
     def _getTargetClass(self):
         from supervisor.options import ServerOptions
@@ -834,6 +875,54 @@ class ServerOptionsTests(unittest.TestCase):
                           instance.check_execv_args, '/',
                           ['/'], os.stat('/'))
 
+    def test_options_afunix(self):
+        instance = self._makeOne()
+        text = lstrip("""\
+        [unix_http_server]
+        file=/tmp/supvtest.sock
+        username=johndoe
+        password=passwordhere
+
+        [supervisord]
+        ; ...
+        """)
+        from supervisor.options import UnhosedConfigParser
+        config = UnhosedConfigParser()
+        config.read_string(text)
+        instance.configfile = StringIO(text)
+        conf = instance.read_config(StringIO(text))
+        instance.realize(args=[])
+        # unix_http_server
+        options = instance.configroot.supervisord
+        self.assertEqual(options.server_configs[0]['family'], socket.AF_UNIX)
+        self.assertEqual(options.server_configs[0]['file'], '/tmp/supvtest.sock')
+        self.assertEqual(options.server_configs[0]['chmod'], 448) # defaults
+        self.assertEqual(options.server_configs[0]['chown'], (-1,-1)) # defaults
+
+    def test_options_afunix_chxxx_values_valid(self):
+        instance = self._makeOne()
+        text = lstrip("""\
+        [unix_http_server]
+        file=/tmp/supvtest.sock
+        username=johndoe
+        password=passwordhere
+        chmod=0755
+
+        [supervisord]
+        ; ...
+        """)
+        from supervisor.options import UnhosedConfigParser
+        config = UnhosedConfigParser()
+        config.read_string(text)
+        instance.configfile = StringIO(text)
+        conf = instance.read_config(StringIO(text))
+        instance.realize(args=[])
+        # unix_http_server
+        options = instance.configroot.supervisord
+        self.assertEqual(options.server_configs[0]['family'], socket.AF_UNIX)
+        self.assertEqual(options.server_configs[0]['file'], '/tmp/supvtest.sock')
+        self.assertEqual(options.server_configs[0]['chmod'], 493)
+
     def test_cleanup_afunix_unlink(self):
         fn = tempfile.mktemp()
         f = open(fn, 'w')
@@ -1007,6 +1096,130 @@ class ServerOptionsTests(unittest.TestCase):
         expected = "/bin/foo --path='%s'" % os.environ['PATH']
         self.assertEqual(pconfigs[0].command, expected)
 
+    @patch.dict('os.environ', { 'HOME': tempfile.gettempdir(),
+                                'USER': 'johndoe',
+                                'HTSRV_PORT': '9210',
+                                'HTSRV_USER': 'someuser',
+                                'HTSRV_PASS': 'passwordhere',
+                                'SUPD_LOGFILE_MAXBYTES': '51MB',
+                                'SUPD_LOGFILE_BACKUPS': '10',
+                                'SUPD_LOGLEVEL': 'info',
+                                'SUPD_NODAEMON': 'false',
+                                'SUPD_MINFDS': '1024',
+                                'SUPD_MINPROCS': '200',
+                                'SUPD_UMASK': '002',
+                                'SUPD_NOCLEANUP': 'true',
+                                'SUPD_STRIP_ANSI': 'false',
+                                'CAT1_COMMAND': '/bin/customcat',
+                                'CAT1_COMMAND_LOGDIR': '/path/to/logs',
+                                'CAT1_PRIORITY': '3',
+                                'CAT1_AUTOSTART': 'true',
+                                'CAT1_USER': 'root', # resolved to uid
+                                'CAT1_STDOUT_LOGFILE': '/tmp/cat.log',
+                                'CAT1_STDOUT_LOGFILE_MAXBYTES': '78KB',
+                                'CAT1_STDOUT_LOGFILE_BACKUPS': '2',
+                                'CAT1_STOPSIGNAL': 'KILL',
+                                'CAT1_STOPWAIT': '5',
+                                'CAT1_STARTWAIT': '5',
+                                'CAT1_STARTRETRIES': '10',
+                                'CAT1_DIR': '/tmp',
+                                'CAT1_UMASK': '002',
+                                })
+    def test_options_with_environment_expansions(self):
+        instance = self._makeOne()
+        text = lstrip("""\
+        [inet_http_server]
+        port=*:%(ENV_HTSRV_PORT)s
+        username=%(ENV_HTSRV_USER)s
+        password=%(ENV_HTSRV_PASS)s
+
+        [supervisord]
+        logfile = %(ENV_HOME)s/supervisord.log
+        logfile_maxbytes = %(ENV_SUPD_LOGFILE_MAXBYTES)s
+        logfile_backups = %(ENV_SUPD_LOGFILE_BACKUPS)s
+        loglevel = %(ENV_SUPD_LOGLEVEL)s
+        nodaemon = %(ENV_SUPD_NODAEMON)s
+        minfds = %(ENV_SUPD_MINFDS)s
+        minprocs = %(ENV_SUPD_MINPROCS)s
+        umask = %(ENV_SUPD_UMASK)s
+        identifier = supervisor_%(ENV_USER)s
+        nocleanup = %(ENV_SUPD_NOCLEANUP)s
+        childlogdir = %(ENV_HOME)s
+        strip_ansi = %(ENV_SUPD_STRIP_ANSI)s
+        environment = FAKE_ENV_VAR=/some/path
+
+        [program:cat1]
+        command=%(ENV_CAT1_COMMAND)s --logdir=%(ENV_CAT1_COMMAND_LOGDIR)s
+        priority=%(ENV_CAT1_PRIORITY)s
+        autostart=%(ENV_CAT1_AUTOSTART)s
+        user=%(ENV_CAT1_USER)s
+        stdout_logfile=%(ENV_CAT1_STDOUT_LOGFILE)s
+        stdout_logfile_maxbytes = %(ENV_CAT1_STDOUT_LOGFILE_MAXBYTES)s
+        stdout_logfile_backups = %(ENV_CAT1_STDOUT_LOGFILE_BACKUPS)s
+        stopsignal=%(ENV_CAT1_STOPSIGNAL)s
+        stopwaitsecs=%(ENV_CAT1_STOPWAIT)s
+        startsecs=%(ENV_CAT1_STARTWAIT)s
+        startretries=%(ENV_CAT1_STARTRETRIES)s
+        directory=%(ENV_CAT1_DIR)s
+        umask=%(ENV_CAT1_UMASK)s
+
+        """)
+        from supervisor import datatypes
+        from supervisor.options import UnhosedConfigParser
+        config = UnhosedConfigParser()
+        config.read_string(text)
+        instance.configfile = StringIO(text)
+        conf = instance.read_config(StringIO(text))
+        instance.realize(args=[])
+        # supervisord
+        self.assertEqual(instance.logfile,
+                         '%(HOME)s/supervisord.log' % os.environ)
+        self.assertEqual(instance.identifier,
+                         'supervisor_%(USER)s' % os.environ)
+        self.assertEqual(instance.logfile_maxbytes, 53477376)
+        self.assertEqual(instance.logfile_backups, 10)
+        self.assertEqual(instance.loglevel, LevelsByName.INFO)
+        self.assertEqual(instance.nodaemon, False)
+        self.assertEqual(instance.minfds, 1024)
+        self.assertEqual(instance.minprocs, 200)
+        self.assertEqual(instance.nocleanup, True)
+        self.assertEqual(instance.childlogdir, os.environ['HOME'])
+        self.assertEqual(instance.strip_ansi, False)
+        # inet_http_server
+        options = instance.configroot.supervisord
+        self.assertEqual(options.server_configs[0]['family'], socket.AF_INET)
+        self.assertEqual(options.server_configs[0]['host'], '')
+        self.assertEqual(options.server_configs[0]['port'], 9210)
+        self.assertEqual(options.server_configs[0]['username'], 'someuser')
+        self.assertEqual(options.server_configs[0]['password'], 'passwordhere')
+        # cat1
+        cat1 = options.process_group_configs[0]
+        self.assertEqual(cat1.name, 'cat1')
+        self.assertEqual(cat1.priority, 3)
+        self.assertEqual(len(cat1.process_configs), 1)
+        proc1 = cat1.process_configs[0]
+        self.assertEqual(proc1.name, 'cat1')
+        self.assertEqual(proc1.command,
+                         '/bin/customcat --logdir=/path/to/logs')
+        self.assertEqual(proc1.priority, 3)
+        self.assertEqual(proc1.autostart, True)
+        self.assertEqual(proc1.autorestart, datatypes.RestartWhenExitUnexpected)
+        self.assertEqual(proc1.startsecs, 5)
+        self.assertEqual(proc1.startretries, 10)
+        self.assertEqual(proc1.uid, 0)
+        self.assertEqual(proc1.stdout_logfile, '/tmp/cat.log')
+        self.assertEqual(proc1.stopsignal, signal.SIGKILL)
+        self.assertEqual(proc1.stopwaitsecs, 5)
+        self.assertEqual(proc1.stopasgroup, False)
+        self.assertEqual(proc1.killasgroup, False)
+        self.assertEqual(proc1.stdout_logfile_maxbytes,
+                         datatypes.byte_size('78KB'))
+        self.assertEqual(proc1.stdout_logfile_backups, 2)
+        self.assertEqual(proc1.exitcodes, [0,2])
+        self.assertEqual(proc1.directory, '/tmp')
+        self.assertEqual(proc1.umask, 2)
+        self.assertEqual(proc1.environment, dict(FAKE_ENV_VAR='/some/path'))
+
     def test_processes_from_section_bad_program_name_spaces(self):
         instance = self._makeOne()
         text = lstrip("""\
@@ -1214,6 +1427,57 @@ class ServerOptionsTests(unittest.TestCase):
         self.assertEqual(gconfig1.result_handler, default_handler)
         self.assertEqual(len(gconfig1.process_configs), 2)
 
+    @patch.dict('os.environ', { 'HOME': tempfile.gettempdir(),
+                                'USER': 'johndoe',
+                                'EL1_PROCNAME': 'myeventlistener',
+                                'EL1_COMMAND': '/bin/dog',
+                                'EL1_NUMPROCS': '2',
+                                'EL1_PRIORITY': '1',
+                                })
+    def test_event_listener_pools_from_parser_with_environment_expansions(self):
+        text = lstrip("""\
+        [eventlistener:dog]
+        events=PROCESS_COMMUNICATION
+        process_name = %(ENV_EL1_PROCNAME)s_%(program_name)s_%(process_num)s
+        command = %(ENV_EL1_COMMAND)s
+        numprocs = %(ENV_EL1_NUMPROCS)s
+        priority = %(ENV_EL1_PRIORITY)s
+
+        [eventlistener:cat]
+        events=PROCESS_COMMUNICATION
+        process_name = %(program_name)s_%(process_num)s
+        command = /bin/cat
+        numprocs = 3
+
+        """)
+        from supervisor.options import UnhosedConfigParser
+        from supervisor.dispatchers import default_handler
+        config = UnhosedConfigParser()
+        config.read_string(text)
+        instance = self._makeOne()
+        gconfigs = instance.process_groups_from_parser(config)
+        self.assertEqual(len(gconfigs), 2)
+
+        gconfig0 = gconfigs[0]
+        self.assertEqual(gconfig0.name, 'cat')
+        self.assertEqual(gconfig0.priority, -1)
+        self.assertEqual(gconfig0.result_handler, default_handler)
+        self.assertEqual(len(gconfig0.process_configs), 3)
+
+        gconfig1 = gconfigs[1]
+        self.assertEqual(gconfig1.name, 'dog')
+        self.assertEqual(gconfig1.priority, 1)
+        self.assertEqual(gconfig1.result_handler, default_handler)
+        self.assertEqual(len(gconfig1.process_configs), 2)
+        dog0 = gconfig1.process_configs[0]
+        self.assertEqual(dog0.name, 'myeventlistener_dog_0')
+        self.assertEqual(dog0.command, '/bin/dog')
+        self.assertEqual(dog0.priority, 1)
+        dog1 = gconfig1.process_configs[1]
+        self.assertEqual(dog1.name, 'myeventlistener_dog_1')
+        self.assertEqual(dog1.command, '/bin/dog')
+        self.assertEqual(dog1.priority, 1)
+
     def test_event_listener_pool_with_event_results_handler(self):
         text = lstrip("""\
         [eventlistener:dog]
@@ -1349,6 +1613,65 @@ class ServerOptionsTests(unittest.TestCase):
         self.assertEqual(len(gconf_flub.process_configs), 1)
 
 
+    @patch.dict('os.environ', { 'HOME': '/tmp',
+                                'SERVER_PORT': '9210',
+                                'FOO_SOCKET_EXT': '.usock',
+                                'FOO_SOCKET_USER': 'testuser',
+                                'FOO_SOCKET_MODE': '0666',
+                                'FOO_PROCESS_PREFIX': 'fcgi-',
+                                'FOO_COMMAND_ARG1': 'bar',
+                                'FOO_NUMPROCS': '2',
+                                'FOO_PRIORITY': '1',
+                                })
+    def test_fcgi_programs_from_parser_with_environment_expansions(self):
+        from supervisor.options import FastCGIGroupConfig
+        from supervisor.options import FastCGIProcessConfig
+        text = lstrip("""\
+        [fcgi-program:foo]
+        socket = unix:///tmp/%(program_name)s%(ENV_FOO_SOCKET_EXT)s
+        socket_owner = %(ENV_FOO_SOCKET_USER)s:testgroup
+        socket_mode = %(ENV_FOO_SOCKET_MODE)s
+        process_name = %(ENV_FOO_PROCESS_PREFIX)s_%(program_name)s_%(process_num)s
+        command = /bin/foo --arg1=%(ENV_FOO_COMMAND_ARG1)s
+        numprocs = %(ENV_FOO_NUMPROCS)s
+        priority = %(ENV_FOO_PRIORITY)s
+        """)
+        from supervisor.options import UnhosedConfigParser
+        config = UnhosedConfigParser()
+        config.read_string(text)
+        instance = self._makeOne()
+
+        #Patch pwd and grp module functions to give us sentinel
+        #uid/gid values so that the test does not depend on
+        #any specific system users
+        pwd_mock = Mock()
+        pwd_mock.return_value = (None, None, sentinel.uid, sentinel.gid)
+        grp_mock = Mock()
+        grp_mock.return_value = (None, None, sentinel.gid)
+        @patch('pwd.getpwuid', pwd_mock)
+        @patch('pwd.getpwnam', pwd_mock)
+        @patch('grp.getgrnam', grp_mock)
+        def get_process_groups(instance, config):
+            return instance.process_groups_from_parser(config)
+
+        gconfigs = get_process_groups(instance, config)
+
+        exp_owner = (sentinel.uid, sentinel.gid)
+
+        self.assertEqual(len(gconfigs), 1)
+
+        gconf_foo = gconfigs[0]
+        self.assertEqual(gconf_foo.__class__, FastCGIGroupConfig)
+        self.assertEqual(gconf_foo.name, 'foo')
+        self.assertEqual(gconf_foo.priority, 1)
+        self.assertEqual(gconf_foo.socket_config.url,
+                                'unix:///tmp/foo.usock')
+        self.assertEqual(exp_owner, gconf_foo.socket_config.get_owner())
+        self.assertEqual(438, gconf_foo.socket_config.get_mode()) # 0666 in Py2, 0o666 in Py3
+        self.assertEqual(len(gconf_foo.process_configs), 2)
+        pconfig_foo = gconf_foo.process_configs[0]
+        self.assertEqual(pconfig_foo.__class__, FastCGIProcessConfig)
+        self.assertEqual(pconfig_foo.command, '/bin/foo --arg1=bar')
 
     def test_fcgi_program_no_socket(self):
         text = lstrip("""\