Skip to content

Commit 7301c10

Browse files
owenskclaude
andcommitted
Add [collection:xxx] config section for cross-group program organization
Collections allow programs to be logically grouped across multiple process groups without affecting lifecycle ownership. Groups still own start/stop/restart; collections are a purely organizational layer that delegates operations to the owning groups. New config format: [collection:web-tier] programs=nginx,gunicorn groups=workers New RPC methods: listCollections, getCollectionProcessInfo, startCollection, stopCollection, signalCollection. New CLI commands: collection status/start/stop. Web UI supports ?collection= filtering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abc6046 commit 7301c10

12 files changed

Lines changed: 980 additions & 6 deletions

docs/api.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,21 @@ Process Control
351351

352352
.. automethod:: removeProcessGroup
353353

354+
Collections
355+
-----------
356+
357+
.. autoclass:: SupervisorNamespaceRPCInterface
358+
359+
.. automethod:: listCollections
360+
361+
.. automethod:: getCollectionProcessInfo
362+
363+
.. automethod:: startCollection
364+
365+
.. automethod:: stopCollection
366+
367+
.. automethod:: signalCollection
368+
354369
Process Logging
355370
---------------
356371

docs/configuration.rst

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,73 @@ above constraints and additions.
15261526
environment=A="1",B="2"
15271527
serverurl=AUTO
15281528
1529+
``[collection:x]`` Section Settings
1530+
------------------------------------
1531+
1532+
Collections provide a way to organize programs into logical groups that
1533+
can span multiple process groups. Unlike ``[group:x]`` sections which
1534+
own process lifecycle, collections are purely organizational — they
1535+
allow you to view and control sets of related processes without
1536+
affecting which group owns them.
1537+
1538+
A program can belong to any number of collections. Collection
1539+
operations like start and stop delegate to the owning process groups.
1540+
1541+
To define a collection, add a ``[collection:x]`` section in your
1542+
configuration file. The header value is the word "collection",
1543+
followed by a colon, then the collection name. A header value of
1544+
``[collection:foo]`` describes a collection with the name "foo". The
1545+
name must not include a colon character or a bracket character.
1546+
1547+
``[collection:x]`` Section Values
1548+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1549+
1550+
``programs``
1551+
1552+
A comma-separated list of program names. The programs referenced
1553+
here are resolved at runtime to their owning process groups. Missing
1554+
references are silently skipped.
1555+
1556+
*Required*: No (but at least one of ``programs`` or ``groups`` is
1557+
required).
1558+
1559+
*Introduced*: 4.0
1560+
1561+
``groups``
1562+
1563+
A comma-separated list of group names. All processes in the
1564+
referenced groups become members of this collection. Missing
1565+
references are silently skipped.
1566+
1567+
*Required*: No (but at least one of ``programs`` or ``groups`` is
1568+
required).
1569+
1570+
*Introduced*: 4.0
1571+
1572+
``priority``
1573+
1574+
A priority number analogous to a ``[program:x]`` priority value.
1575+
1576+
*Default*: 999
1577+
1578+
*Required*: No.
1579+
1580+
*Introduced*: 4.0
1581+
1582+
``[collection:x]`` Section Example
1583+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1584+
1585+
.. code-block:: ini
1586+
1587+
[collection:web-tier]
1588+
programs=nginx,gunicorn,celery-worker
1589+
priority=100
1590+
1591+
[collection:monitoring]
1592+
groups=metrics
1593+
programs=nginx,prometheus-exporter
1594+
1595+
15291596
``[rpcinterface:x]`` Section Settings
15301597
-------------------------------------
15311598

supervisor/collection.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class Collection:
2+
"""A logical grouping of processes from potentially different groups.
3+
Collections do not own process lifecycle — they are purely
4+
organizational and delegate start/stop to the owning groups."""
5+
6+
def __init__(self, config, members):
7+
self.config = config # CollectionConfig
8+
self.members = members # list of (ProcessGroup, Subprocess) tuples
9+
10+
def get_processes(self):
11+
"""Return list of (group, process) tuples."""
12+
return list(self.members)

supervisor/events.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ class ProcessGroupAddedEvent(ProcessGroupEvent):
177177
class ProcessGroupRemovedEvent(ProcessGroupEvent):
178178
pass
179179

180+
class CollectionEvent(Event):
181+
def __init__(self, collection):
182+
self.collection = collection
183+
184+
def payload(self):
185+
return 'collectionname:%s\n' % self.collection
186+
187+
class CollectionAddedEvent(CollectionEvent):
188+
pass
189+
190+
class CollectionRemovedEvent(CollectionEvent):
191+
pass
192+
180193
class TickEvent(Event):
181194
""" Abstract """
182195
def __init__(self, when, supervisord):

supervisor/options.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ def __init__(self):
448448
"s", "silent", flag=1, default=0)
449449
self.pidhistory = {}
450450
self.process_group_configs = []
451+
self.collection_configs = []
451452
self.parse_criticals = []
452453
self.parse_warnings = []
453454
self.parse_infos = []
@@ -544,6 +545,9 @@ def process_config(self, do_usage=True):
544545
new = self.configroot.supervisord.process_group_configs
545546
self.process_group_configs = new
546547

548+
new_collections = self.configroot.supervisord.collection_configs
549+
self.collection_configs = new_collections
550+
547551
def read_config(self, fp):
548552
# Clear parse messages, since we may be re-reading the
549553
# config a second time after a reload.
@@ -667,6 +671,7 @@ def get(opt, default, **kwargs):
667671
env = section.environment.copy()
668672
env.update(proc.environment)
669673
proc.environment = env
674+
section.collection_configs = self.collections_from_parser(parser)
670675
section.server_configs = self.server_configs_from_parser(parser)
671676
section.profile_options = None
672677
return section
@@ -840,6 +845,46 @@ def get(section, opt, default, **kwargs):
840845
groups.sort()
841846
return groups
842847

848+
def collections_from_parser(self, parser):
849+
collections = []
850+
all_sections = parser.sections()
851+
852+
common_expansions = {'here':self.here}
853+
def get(section, opt, default, **kwargs):
854+
expansions = kwargs.get('expansions', {})
855+
expansions.update(common_expansions)
856+
kwargs['expansions'] = expansions
857+
return parser.saneget(section, opt, default, **kwargs)
858+
859+
for section in all_sections:
860+
if not section.startswith('collection:'):
861+
continue
862+
name = process_or_group_name(section.split(':', 1)[1])
863+
priority = integer(get(section, 'priority', 999))
864+
programs_str = get(section, 'programs', None)
865+
groups_str = get(section, 'groups', None)
866+
867+
program_names = []
868+
if programs_str is not None:
869+
program_names = list_of_strings(programs_str)
870+
871+
group_names = []
872+
if groups_str is not None:
873+
group_names = list_of_strings(groups_str)
874+
875+
if not program_names and not group_names:
876+
raise ValueError(
877+
'[%s] must specify at least one of programs or groups'
878+
% section)
879+
880+
collections.append(
881+
CollectionConfig(self, name, priority,
882+
program_names, group_names)
883+
)
884+
885+
collections.sort()
886+
return collections
887+
843888
def parse_fcgi_socket(self, sock, proc_uid, socket_owner, socket_mode,
844889
socket_backlog):
845890
if sock.startswith('unix://'):
@@ -2055,6 +2100,41 @@ def make_group(self):
20552100
from supervisor.process import FastCGIProcessGroup
20562101
return FastCGIProcessGroup(self)
20572102

2103+
class CollectionConfig:
2104+
def __init__(self, options, name, priority, program_names, group_names):
2105+
self.options = options
2106+
self.name = name
2107+
self.priority = priority
2108+
self.program_names = program_names # list of str
2109+
self.group_names = group_names # list of str
2110+
2111+
def __eq__(self, other):
2112+
if not isinstance(other, CollectionConfig):
2113+
return False
2114+
return (self.name == other.name and
2115+
self.priority == other.priority and
2116+
self.program_names == other.program_names and
2117+
self.group_names == other.group_names)
2118+
2119+
def __ne__(self, other):
2120+
return not self.__eq__(other)
2121+
2122+
def __lt__(self, other):
2123+
return self.priority < other.priority
2124+
2125+
def __le__(self, other):
2126+
return self.priority <= other.priority
2127+
2128+
def __gt__(self, other):
2129+
return self.priority > other.priority
2130+
2131+
def __ge__(self, other):
2132+
return self.priority >= other.priority
2133+
2134+
def __repr__(self):
2135+
return '<%s instance named %s at %s>' % (self.__class__,
2136+
self.name, id(self))
2137+
20582138
def readFile(filename, offset, length):
20592139
""" Read length bytes from the file named by filename starting at
20602140
offset """

supervisor/rpcinterface.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,16 @@ def reloadConfig(self):
198198
added = [group.name for group in added]
199199
changed = [group.name for group in changed]
200200
removed = [group.name for group in removed]
201-
return [[added, changed, removed]] # cannot return len > 1, apparently
201+
202+
coll_added, coll_changed, coll_removed = \
203+
self.supervisord.diff_collections_to_active()
204+
205+
coll_added = [c.name for c in coll_added]
206+
coll_changed = [c.name for c in coll_changed]
207+
coll_removed = [c.name for c in coll_removed]
208+
209+
return [[added, changed, removed],
210+
[coll_added, coll_changed, coll_removed]]
202211

203212
def addProcessGroup(self, name):
204213
""" Update the config for a running process from config file.
@@ -917,6 +926,128 @@ def sendProcessStdin(self, name, chars):
917926

918927
return True
919928

929+
# Collection API methods
930+
931+
def listCollections(self):
932+
""" Get info about all configured collections.
933+
934+
@return array result An array of collection info structs
935+
"""
936+
self._update('listCollections')
937+
result = []
938+
for coll in self.supervisord.collections.values():
939+
result.append({
940+
'name': coll.config.name,
941+
'priority': coll.config.priority,
942+
'program_names': coll.config.program_names,
943+
'group_names': coll.config.group_names,
944+
})
945+
return result
946+
947+
def getCollectionProcessInfo(self, name):
948+
""" Get info about all processes in a collection named name.
949+
950+
@param string name The name of the collection
951+
@return array result An array of process status results
952+
"""
953+
self._update('getCollectionProcessInfo')
954+
955+
collection = self.supervisord.collections.get(name)
956+
if collection is None:
957+
raise RPCError(Faults.BAD_NAME, name)
958+
959+
output = []
960+
seen = set()
961+
for group, process in collection.get_processes():
962+
key = (group.config.name, process.config.name)
963+
if key not in seen:
964+
seen.add(key)
965+
namespec = make_namespec(group.config.name,
966+
process.config.name)
967+
output.append(self.getProcessInfo(namespec))
968+
return output
969+
970+
def startCollection(self, name, wait=True):
971+
""" Start all processes in the collection named 'name'
972+
973+
@param string name The collection name
974+
@param boolean wait Wait for each process to be fully started
975+
@return array result An array of process status info structs
976+
"""
977+
self._update('startCollection')
978+
979+
collection = self.supervisord.collections.get(name)
980+
if collection is None:
981+
raise RPCError(Faults.BAD_NAME, name)
982+
983+
seen = set()
984+
processes = []
985+
for group, process in collection.get_processes():
986+
key = (group.config.name, process.config.name)
987+
if key not in seen:
988+
seen.add(key)
989+
processes.append((group, process))
990+
991+
startall = make_allfunc(processes, isNotRunning, self.startProcess,
992+
wait=wait)
993+
startall.delay = 0.05
994+
startall.rpcinterface = self
995+
return startall # deferred
996+
997+
def stopCollection(self, name, wait=True):
998+
""" Stop all processes in the collection named 'name'
999+
1000+
@param string name The collection name
1001+
@param boolean wait Wait for each process to be fully stopped
1002+
@return array result An array of process status info structs
1003+
"""
1004+
self._update('stopCollection')
1005+
1006+
collection = self.supervisord.collections.get(name)
1007+
if collection is None:
1008+
raise RPCError(Faults.BAD_NAME, name)
1009+
1010+
seen = set()
1011+
processes = []
1012+
for group, process in collection.get_processes():
1013+
key = (group.config.name, process.config.name)
1014+
if key not in seen:
1015+
seen.add(key)
1016+
processes.append((group, process))
1017+
1018+
killall = make_allfunc(processes, isRunning, self.stopProcess,
1019+
wait=wait)
1020+
killall.delay = 0.05
1021+
killall.rpcinterface = self
1022+
return killall # deferred
1023+
1024+
def signalCollection(self, name, signal):
1025+
""" Send a signal to all processes in the collection named 'name'
1026+
1027+
@param string name The collection name
1028+
@param string signal Signal to send, as name ('HUP') or number ('1')
1029+
@return array
1030+
"""
1031+
self._update('signalCollection')
1032+
1033+
collection = self.supervisord.collections.get(name)
1034+
if collection is None:
1035+
raise RPCError(Faults.BAD_NAME, name)
1036+
1037+
seen = set()
1038+
processes = []
1039+
for group, process in collection.get_processes():
1040+
key = (group.config.name, process.config.name)
1041+
if key not in seen:
1042+
seen.add(key)
1043+
processes.append((group, process))
1044+
1045+
sendall = make_allfunc(processes, isSignallable, self.signalProcess,
1046+
signal=signal)
1047+
result = sendall()
1048+
self._update('signalCollection')
1049+
return result
1050+
9201051
def sendRemoteCommEvent(self, type, data):
9211052
""" Send an event that will be received by event listener
9221053
subprocesses subscribing to the RemoteCommunicationEvent.

0 commit comments

Comments
 (0)