diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..e51c29608 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,212 @@ +name: Run all tests + +on: [push, pull_request] + +env: + PIP: "env PIP_DISABLE_PIP_VERSION_CHECK=1 + PYTHONWARNINGS=ignore:DEPRECATION + pip --no-cache-dir" + +jobs: + tests_py2x: + runs-on: ubuntu-22.04 + container: + image: python:2.7 + strategy: + fail-fast: false + matrix: + toxenv: [py27, py27-configparser] + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: $PIP install virtualenv tox + + - name: Run the unit tests + run: TOXENV=${{ matrix.toxenv }} tox + + - name: Run the end-to-end tests + run: TOXENV=${{ matrix.toxenv }} END_TO_END=1 tox + + tests_py34: + runs-on: ubuntu-22.04 + container: + image: ubuntu:20.04 + env: + LANG: C.UTF-8 + + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + apt-get update + apt-get install -y build-essential unzip wget \ + libncurses5-dev libgdbm-dev libnss3-dev \ + libreadline-dev zlib1g-dev + + - name: Build OpenSSL 1.0.2 (required by Python 3.4) + run: | + cd $RUNNER_TEMP + wget https://github.com/openssl/openssl/releases/download/OpenSSL_1_0_2u/openssl-1.0.2u.tar.gz + tar -xf openssl-1.0.2u.tar.gz + cd openssl-1.0.2u + ./config --prefix=/usr/local/ssl --openssldir=/usr/local/ssl shared zlib-dynamic + make + make install + + echo CFLAGS="-I/usr/local/ssl/include $CFLAGS" >> $GITHUB_ENV + echo LDFLAGS="-L/usr/local/ssl/lib $LDFLAGS" >> $GITHUB_ENV + echo LD_LIBRARY_PATH="/usr/local/ssl/lib:$LD_LIBRARY_PATH" >> $GITHUB_ENV + + ln -s /usr/local/ssl/lib/libssl.so.1.0.0 /usr/lib/libssl.so.1.0.0 + ln -s /usr/local/ssl/lib/libcrypto.so.1.0.0 /usr/lib/libcrypto.so.1.0.0 + ldconfig + + - name: Build Python 3.4 + run: | + cd $RUNNER_TEMP + wget -O cpython-3.4.10.zip https://github.com/python/cpython/archive/refs/tags/v3.4.10.zip + unzip cpython-3.4.10.zip + cd cpython-3.4.10 + ./configure --with-ensurepip=install + make + make install + + python3.4 --version + python3.4 -c 'import ssl' + pip3.4 --version + + ln -s /usr/local/bin/python3.4 /usr/local/bin/python + ln -s /usr/local/bin/pip3.4 /usr/local/bin/pip + + - name: Install Python dependencies + run: | + $PIP install virtualenv==20.4.7 tox==3.14.0 + + - name: Run the unit tests + run: TOXENV=py34 tox + + - name: Run the end-to-end tests + run: TOXENV=py34 END_TO_END=1 tox + + tests_py35: + runs-on: ubuntu-22.04 + container: + image: python:3.5 + strategy: + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: $PIP install virtualenv tox + + - name: Run the unit tests + run: TOXENV=py35 tox + + - name: Run the end-to-end tests + run: TOXENV=py35 END_TO_END=1 tox + + tests_py36: + runs-on: ubuntu-22.04 + container: + image: python:3.6 + strategy: + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: $PIP install virtualenv tox + + - name: Run the unit tests + run: TOXENV=py36 tox + + - name: Run the end-to-end tests + run: TOXENV=py36 END_TO_END=1 tox + + tests_py3x: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: [3.7, 3.8, 3.9, "3.10", 3.11, 3.12, 3.13, 3.14] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: $PIP install virtualenv tox + + - name: Set variable for TOXENV based on Python version + id: toxenv + run: python -c 'import sys; print("TOXENV=py%d%d" % (sys.version_info.major, sys.version_info.minor))' | tee -a $GITHUB_OUTPUT + + - name: Run the unit tests + run: TOXENV=${{steps.toxenv.outputs.TOXENV}} tox + + - name: Run the end-to-end tests + run: TOXENV=${{steps.toxenv.outputs.TOXENV}} END_TO_END=1 tox + + coverage_py27: + runs-on: ubuntu-22.04 + container: + image: python:2.7 + strategy: + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: $PIP install virtualenv tox + + - name: Run unit test coverage + run: TOXENV=cover tox + + coverage_py3x: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: $PIP install virtualenv tox + + - name: Run unit test coverage + run: TOXENV=cover3 tox + + docs: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: "3.8" + + - name: Install dependencies + run: $PIP install virtualenv tox>=4.0.0 + + - name: Build the docs + run: TOXENV=docs tox diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..4a775cbd3 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8e7548606..000000000 --- a/.travis.yml +++ /dev/null @@ -1,57 +0,0 @@ -language: python - -matrix: - include: - - python: 2.7 - env: TOXENV=cover - - python: 3.7 - env: TOXENV=cover3 - dist: xenial # required for Python >= 3.7 - - python: 3.7 - env: TOXENV=docs - dist: xenial # required for Python >= 3.7 - - python: 2.7 - env: - - TOXENV=py27 - - END_TO_END=1 - - python: 2.7 - env: - - TOXENV=py27-configparser - - END_TO_END=1 - - python: 3.4 - env: - - TOXENV=py34 - - END_TO_END=1 - - python: 3.5 - env: - - TOXENV=py35 - - END_TO_END=1 - - python: 3.6 - env: - - TOXENV=py36 - - END_TO_END=1 - - python: 3.7 - env: - - TOXENV=py37 - - END_TO_END=1 - dist: xenial # required for Python >= 3.7 - - - python: 3.8 - env: - - TOXENV=py38 - - END_TO_END=1 - dist: xenial # required for Python >= 3.7 - - - name: "Python: 3.7 on macOS" - language: sh # 'language: python' is not yet supported on Travis CI macOS - os: osx - osx_image: xcode10.2 # Python 3.7.2 running on macOS 10.14.3 - env: - - TOXENV=py37 - - END_TO_END=1 - -install: - - travis_retry pip install virtualenv tox - -script: - - travis_retry tox diff --git a/CHANGES.rst b/CHANGES.rst index 8b64b408b..bf9ea7536 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,101 @@ -4.3.0.dev0 (Next Release) +4.4.0.dev0 (Next Release) ------------------------- +- Fixed a bug where ``supervisord`` would wait 1 second on startup before + starting any programs. Patch by Stepan Blyshchak. + +- Fixed a bug where the XML-RPC method ``supervisor.getAllConfigInfo()`` + did not return the value of the ``autorestart`` program option. + +- Fixed a bug where an escaped percent sign (``%%``) could not be used + in ``environment=`` in the ``[supervisord]`` section of the config file. + The bug did not affect ``[program:x]`` sections, where an escaped + percent sign in ``environment=`` already worked. Patch by yuk1pedia. + +- Parsing ``environment=`` in the config file now uses ``shlex`` in POSIX + mode instead of legacy mode to allow for escaped quotes in the values. + However, on Python 2 before 2.7.13 and Python 3 before 3.5.3, POSIX mode + can't be used because of a `bug `_ + in ``shlex``. If ``supervisord`` is run on a Python version with the bug, + it will fall back to legacy mode. Patch by Stefan Friesel. + +- The old example scripts in the ``supervisor/scripts/`` directory of + the package, which were largely undocumented, had no test coverage, and + were last updated over a decade ago, have been removed. + +4.3.0 (2025-08-23) +------------------ + +- Fixed a bug where the poller would not unregister a closed + file descriptor under some circumstances, which caused excessive + polling, resulting in higher CPU usage. Patch by aftersnow. + +- Fixed a bug where restarting ``supervisord`` may have failed with + the message ``Error: Another program is already listening + on a port that one of our HTTP servers is configured to use.`` + if an HTTP request was made during restart. Patch by Julien Le Cléach. + +- Fixed a unit test that failed only on Python 3.13. Only test code was + changed; no changes to ``supervisord`` itself. Patch by Colin Watson. + +- On Python 3.8 and later, ``setuptools`` is no longer a runtime + dependency. Patch by Ofek Lev. + +- On Python versions before 3.8, ``setuptools`` is still a runtime + dependency (for ``pkg_resources``) but it is no longer declared in + ``setup.py`` as such. This is because adding a conditional dependency + with an environment marker (``setuptools; python_version < '3.8'``) + breaks installation in some scenarios, e.g. ``setup.py install`` or + older versions of ``pip``. Ensure that ``setuptools`` is installed + if using Python before 3.8. + +4.2.5 (2022-12-23) +------------------ + +- Fixed a bug where the XML-RPC method ``supervisor.startProcess()`` would + return 500 Internal Server Error instead of an XML-RPC fault response + if the command could not be parsed. Patch by Julien Le Cléach. + +- Fixed a bug on Python 2.7 where a ``UnicodeDecodeError`` may have occurred + when using the web interface. Patch by Vinay Sajip. + +- Removed use of ``urllib.parse`` functions ``splithost``, ``splitport``, and + ``splittype`` deprecated in Python 3.8. + +- Removed use of ``asynchat`` and ``asyncore`` deprecated in Python 3.10. + +- The return value of the XML-RPC method ``supervisor.getAllConfigInfo()`` + now includes the ``directory``, ``uid``, and ``serverurl`` of the + program. Patch by Yellmean. + +- If a subprocess exits with a unexpected exit code (one not listed in + ``exitcodes=`` in a ``[program:x]`` section) then the exit will now be logged + at the ``WARN`` level instead of ``INFO``. Patch by Precy Lee. + +- ``supervisorctl shutdown`` now shows an error message if an argument is + given. + +- File descriptors are now closed using the faster ``os.closerange()`` instead + of calling ``os.close()`` in a loop. Patch by tyong920. + +4.2.4 (2021-12-30) +------------------ + +- Fixed a bug where the ``--identifier`` command line argument was ignored. + It was broken since at least 3.0a7 (released in 2009) and probably earlier. + Patch by Julien Le Cléach. + +4.2.3 (2021-12-27) +------------------ + +- Fixed a race condition where an ``rpcinterface`` extension that subscribed + to events would not see the correct process state if it accessed the + the ``state`` attribute on a ``Subprocess`` instance immediately in the + event callback. Patch by Chao Wang. + +- Added the ``setuptools`` package to the list of dependencies in + ``setup.py`` because it is a runtime dependency. Patch by Louis Sautier. + - The web interface will now return a 404 Not Found response if a log file is missing. Previously, it would return 410 Gone. It was changed because 410 is intended to mean that the condition is likely to be permanent. A @@ -1350,7 +1445,7 @@ supervisorctl. To start a group, use ``start groupname:*``. To start multiple groups, use ``start groupname1:* groupname2:*``. Equivalent commands work for "stop" and "restart". You can mix and match short - processnames, fullly-specified group:process names, and groupsplats on the + processnames, fully-specified group:process names, and groupsplats on the same line for any of these commands. - Added ``directory`` option to process config. If you set this @@ -1406,7 +1501,7 @@ supervisor will fail to start. - The Python string expression ``%(here)s`` (referring to the directory in - which the the configuration file was found) can be used within the + which the configuration file was found) can be used within the following sections/options within the config file:: unix_http_server:file @@ -1560,7 +1655,7 @@ channel. The keys "log_stderr" and "log_stdout" have been removed. -- ``[program:x]`` config file sections now represent "homgeneous process +- ``[program:x]`` config file sections now represent "homogeneous process groups" instead of single processes. A "numprocs" key in the section represents the number of processes that are in the group. A "process_name" key in the section allows composition of the each process' name within the @@ -1675,7 +1770,7 @@ - Processes which started successfully after failing to start initially are no longer reported in BACKOFF state once they are - started successfully (thanks to Damjan from Macdonia for the bug + started successfully (thanks to Damjan from Macedonia for the bug report). - Add new 'maintail' command to supervisorctl shell, which allows diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt index 20f4e35fc..ec2ec06f5 100644 --- a/COPYRIGHT.txt +++ b/COPYRIGHT.txt @@ -1,27 +1,6 @@ Supervisor is Copyright (c) 2006-2015 Agendaless Consulting and Contributors. (http://www.agendaless.com), All Rights Reserved - This software is subject to the provisions of the license at - http://www.repoze.org/LICENSE.txt . A copy of this license should - accompany this distribution. THIS SOFTWARE IS PROVIDED "AS IS" AND - ANY AND ALL EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED, INCLUDING, - BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF TITLE, - MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS FOR A PARTICULAR - PURPOSE. - medusa was (is?) Copyright (c) Sam Rushing. http_client.py code Copyright (c) by Daniel Krech, http://eikeon.com/. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/MANIFEST.in b/MANIFEST.in index ace534625..1fe78768f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,7 +4,6 @@ include LICENSES.txt include README.rst include tox.ini include supervisor/version.txt -include supervisor/scripts/*.py include supervisor/skel/*.conf recursive-include supervisor/tests/fixtures *.conf *.py recursive-include supervisor/ui *.html *.css *.png *.gif diff --git a/docs/api.rst b/docs/api.rst index 226f45a6e..aadb26605 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -297,7 +297,7 @@ Process Control .. describe:: stderr_logfile - Absolute path and filename to the STDOUT logfile + Absolute path and filename to the STDERR logfile .. describe:: spawnerr @@ -321,6 +321,8 @@ Process Control same elements as the struct returned by ``getProcessInfo``. If the process table is empty, an empty array is returned. + .. automethod:: getAllConfigInfo + .. automethod:: startProcess .. automethod:: startAllProcesses diff --git a/docs/configuration.rst b/docs/configuration.rst index 129ce0cc1..ce05d34f2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -291,7 +291,7 @@ follows. activity log. One of ``critical``, ``error``, ``warn``, ``info``, ``debug``, ``trace``, or ``blather``. Note that at log level ``debug``, the supervisord log file will record the stderr/stdout - output of its child processes and extended info info about process + output of its child processes and extended info about process state changes, which is useful for debugging a process which isn't starting properly. See also: :ref:`activity_log_levels`. @@ -695,7 +695,7 @@ where specified. ``numprocs_start`` An integer offset that is used to compute the number at which - ``numprocs`` starts. + ``process_num`` starts. *Default*: 0 @@ -817,8 +817,9 @@ where specified. ``stopsignal`` - The signal used to kill the program when a stop is requested. This - can be any of TERM, HUP, INT, QUIT, KILL, USR1, or USR2. + The signal used to kill the program when a stop is requested. This can be + specified using the signal's name or its number. It is normally one of: + ``TERM``, ``HUP``, ``INT``, ``QUIT``, ``KILL``, ``USR1``, or ``USR2``. *Default*: TERM @@ -1479,9 +1480,9 @@ sections do not have. ``result_handler`` - A `pkg_resources entry point string - `_ that - resolves to a Python callable. The default value is + An `entry point object reference + `_ + string that resolves to a Python callable. The default value is ``supervisor.dispatchers:default_handler``. Specifying an alternate result handler is a very uncommon thing to need to do, and as a result, how to create one is not documented. @@ -1582,7 +1583,7 @@ And a section in the config file meant to configure it. ``supervisor.rpcinterface_factory`` - ``pkg_resources`` "entry point" dotted name to your RPC interface's + ``entry point object reference`` dotted name to your RPC interface's factory function. *Default*: N/A diff --git a/docs/events.rst b/docs/events.rst index 8b0d01ec5..1c303b859 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -93,9 +93,9 @@ follows. An advanced feature, specifying an alternate "result handler" for a pool, can be specified via the ``result_handler`` parameter of an - ``[eventlistener:x]`` section in the form of a `pkg_resources - `_ "entry - point" string. The default result handler is + ``[eventlistener:x]`` section in the form of an `entry point object reference + `_ + string. The default result handler is ``supervisord.dispatchers:default_handler``. Creating an alternate result handler is not currently documented. @@ -177,7 +177,7 @@ pool The name of the event listener pool which myeventpool poolserial An integer assigned to each event by the 30 eventlistener pool which it is being sent from. No two events generated by the same - eventlister pool during the lifetime of a + eventlistener pool during the lifetime of a :program:`supervisord` process will have the same ``poolserial`` number. This value can be used to detect event ordering anomalies. @@ -375,7 +375,7 @@ including "process state change", "process communication", and these event types. In the below list, we indicate that some event types have a "body" -which is a a *token set*. A token set consists of a set of charaters +which is a a *token set*. A token set consists of a set of characters with space-separated tokens. Each token represents a key-value pair. The key and value are separated by a colon. For example: @@ -660,7 +660,7 @@ Body Description .. code-block:: text - processname:name groupname:name pid:pid + processname:name groupname:name pid:pid channel:stdout data ``PROCESS_LOG_STDERR`` Event Type @@ -680,7 +680,7 @@ Body Description .. code-block:: text - processname:name groupname:name pid:pid + processname:name groupname:name pid:pid channel:stderr data ``PROCESS_COMMUNICATION`` Event Type @@ -897,4 +897,3 @@ Indicates that a process group has been removed from Supervisor's configuration. .. code-block:: text groupname:cat - diff --git a/docs/installing.rst b/docs/installing.rst index 2e1723a62..0de601e77 100644 --- a/docs/installing.rst +++ b/docs/installing.rst @@ -22,6 +22,12 @@ to be the root user to install Supervisor successfully using You can also install supervisor in a virtualenv via ``pip``. +.. note:: + + If installing on a Python version before 3.8, first ensure that the + ``setuptools`` package is installed because it is a runtime + dependency of Supervisor. + Internet-Installing Without Pip ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -39,6 +45,12 @@ finally install Supervisor itself. need to be the root user to successfully invoke ``python setup.py install``. +.. note:: + + The ``setuptools`` package is required to run ``python setup.py install``. + On Python versions before 3.8, ``setuptools`` is also a runtime + dependency of Supervisor. + Installing To A System Without Internet Access ---------------------------------------------- @@ -48,11 +60,8 @@ differently. Since both ``pip`` and ``python setup.py install`` depend on internet access to perform downloads of dependent software, neither will work on machines without internet access until dependencies are installed. To install to a machine which is not -internet-connected, obtain the following dependencies on a machine -which is internet-connected: - -- setuptools (latest) from `https://pypi.org/pypi/setuptools/ - `_. +internet-connected, obtain the dependencies listed in ``setup.py`` +using a machine which is internet-connected. Copy these files to removable media and put them on the target machine. Install each onto the target machine as per its @@ -66,6 +75,12 @@ Finally, run supervisor's ``python setup.py install``. need to be the root user to invoke ``python setup.py install`` successfully for each package. +.. note:: + + The ``setuptools`` package is required to run ``python setup.py install``. + On Python versions before 3.8, ``setuptools`` is also a runtime + dependency of Supervisor. + Installing a Distribution Package --------------------------------- diff --git a/docs/introduction.rst b/docs/introduction.rst index bb63b4d25..ccd1c0c45 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -134,7 +134,7 @@ Supervisor Components The command-line client talks to the server across a UNIX domain socket or an internet (TCP) socket. The server can assert that the user of a client should present authentication credentials before it - allows him to perform commands. The client process typically uses + allows them to perform commands. The client process typically uses the same configuration file as the server but any configuration file with a ``[supervisorctl]`` section in it will work. diff --git a/docs/logging.rst b/docs/logging.rst index e509bd8b5..bec8ec33d 100644 --- a/docs/logging.rst +++ b/docs/logging.rst @@ -192,10 +192,6 @@ In this circumstance, :program:`supervisord` will emit a ``PROCESS_COMMUNICATIONS_STDOUT`` event with data in the payload of "Hello!". -An example of a script (written in Python) which emits a process -communication event is in the :file:`scripts` directory of the -supervisor package, named :file:`sample_commevent.py`. - The output of processes specified as "event listeners" (``[eventlistener:x]`` sections) is not processed this way. Output from these processes cannot enter capture mode. diff --git a/docs/plugins.rst b/docs/plugins.rst index 0b04700bf..47df50fe1 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -25,6 +25,9 @@ instances running on different servers. `Supervisord-Monitor `_ Web-based dashboard written in PHP. +`Supervisord-Monitor 2 `_ + Modern and adaptive next gen web-based dashboard written in PHP. + `SupervisorUI `_ Another Web-based dashboard written in PHP. @@ -49,6 +52,11 @@ instances running on different servers. Web-based dashboard and command line tool written in Python using PostgreSQL with a REST API, event monitoring, and configuration management. +`Polyvisor `_ + Web-based dashboard written in Python using `flask `_ web server. + Frontend based on `Svelte `_ result in lightweighted packages. Communicate via supervisor's event-listener. + Providing system resource management via visualized charts & easy to config processes configs via web interface. + Third Party Plugins and Libraries for Supervisor ------------------------------------------------ diff --git a/docs/subprocess.rst b/docs/subprocess.rst index c358818e7..5a6b46713 100644 --- a/docs/subprocess.rst +++ b/docs/subprocess.rst @@ -248,7 +248,7 @@ per the following directed graph. Subprocess State Transition Graph A process is in the ``STOPPED`` state if it has been stopped -adminstratively or if it has never been started. +administratively or if it has never been started. When an autorestarting process is in the ``BACKOFF`` state, it will be automatically restarted by :program:`supervisord`. It will switch @@ -263,7 +263,7 @@ exceeded the maximum, at which point it will transition to the So if you set ``startretries=3``, :program:`supervisord` will wait one, two and then three seconds between each restart attempt, for a total of - 5 seconds. + 6 seconds. When a process is in the ``EXITED`` state, it will automatically restart: diff --git a/setup.cfg b/setup.cfg index a980de220..8d41ae6c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,12 @@ [easy_install] zip_ok = false -[aliases] -dev = develop easy_install supervisor[testing] - +;Marking a wheel as universal with "universal = 1" was deprecated +;in Setuptools 75.1.0. Setting "python_tag = py2.py3" should do +;the equivalent on Setuptools 30.3.0 or later. +; +;https://github.com/pypa/setuptools/pull/4617 +;https://github.com/pypa/setuptools/pull/4939 +; [bdist_wheel] -universal = 1 +python_tag = py2.py3 diff --git a/setup.py b/setup.py index dd87f7d06..ea2425ee1 100644 --- a/setup.py +++ b/setup.py @@ -22,22 +22,28 @@ elif (3, 0) < py_version < (3, 4): raise RuntimeError('On Python 3, Supervisor requires Python 3.4 or later') -requires = [] -tests_require = [] -if py_version < (3, 3): - tests_require.append('mock<4.0.0.dev0') - -testing_extras = tests_require + [ - 'pytest', - 'pytest-cov', - ] +# setuptools is required as a runtime dependency only on Python < 3.8. +# See the comments in supervisor/compat.py. An environment marker +# like "setuptools; python_version < '3.8'" is not used here because +# it breaks installation via "python setup.py install". See also the +# discussion at: https://github.com/Supervisor/supervisor/issues/1692 +if py_version < (3, 8): + try: + import pkg_resources + except ImportError: + raise RuntimeError( + "On Python < 3.8, Supervisor requires setuptools as a runtime" + " dependency because pkg_resources is used to load plugins" + ) from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) try: - README = open(os.path.join(here, 'README.rst')).read() - CHANGES = open(os.path.join(here, 'CHANGES.rst')).read() -except: + with open(os.path.join(here, 'README.rst'), 'r') as f: + README = f.read() + with open(os.path.join(here, 'CHANGES.rst'), 'r') as f: + CHANGES = f.read() +except Exception: README = """\ Supervisor is a client/server system that allows its users to control a number of processes on UNIX-like operating systems. """ @@ -61,30 +67,40 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] version_txt = os.path.join(here, 'supervisor/version.txt') -supervisor_version = open(version_txt).read().strip() +with open(version_txt, 'r') as f: + supervisor_version = f.read().strip() dist = setup( name='supervisor', version=supervisor_version, license='BSD-derived (http://www.repoze.org/LICENSE.txt)', url='http://supervisord.org/', + project_urls={ + 'Changelog': 'http://supervisord.org/changelog', + 'Documentation': 'http://supervisord.org', + 'Issue Tracker': 'https://github.com/Supervisor/supervisor', + }, description="A system for controlling process state under UNIX", long_description=README + '\n\n' + CHANGES, classifiers=CLASSIFIERS, author="Chris McDonough", author_email="chrism@plope.com", packages=find_packages(), - install_requires=requires, + install_requires=[], extras_require={ - 'testing': testing_extras, - }, - tests_require=tests_require, + 'test': ['pytest', 'pytest-cov'] + }, include_package_data=True, zip_safe=False, - test_suite="supervisor.tests", entry_points={ 'console_scripts': [ 'supervisord = supervisor.supervisord:main', diff --git a/supervisor/compat.py b/supervisor/compat.py index e741f47fb..004c00e0f 100644 --- a/supervisor/compat.py +++ b/supervisor/compat.py @@ -149,3 +149,72 @@ def is_text_stream(stream): from html.parser import HTMLParser except ImportError: # pragma: no cover from HTMLParser import HTMLParser + +# Begin check for working shlex posix mode + +# https://github.com/Supervisor/supervisor/issues/328 +# https://github.com/Supervisor/supervisor/issues/873 +# https://bugs.python.org/issue21999 + +from shlex import shlex as _shlex + +_shlex_posix_expectations = { + 'foo="",bar=a': ['foo', '=', '', ',', 'bar', '=', 'a'], + "'')abc": ['', ')', 'abc'] +} + +shlex_posix_works = all( + list(_shlex(_input, posix=True)) == _expected + for _input, _expected in _shlex_posix_expectations.items() +) + +# End check for working shlex posix mode + +# Begin importlib/setuptools compatibility code + +# Supervisor used pkg_resources (a part of setuptools) to load package +# resources for 15 years, until setuptools 67.5.0 (2023-03-05) deprecated +# the use of pkg_resources. On Python 3.8 or later, Supervisor now uses +# importlib (part of Python 3 stdlib). Unfortunately, on Python < 3.8, +# Supervisor needs to use pkg_resources despite its deprecation. The PyPI +# backport packages "importlib-resources" and "importlib-metadata" couldn't +# be added as dependencies to Supervisor because they require even more +# dependencies that would likely cause some Supervisor installs to fail. +from warnings import filterwarnings as _fw +_fw("ignore", message="pkg_resources is deprecated as an API") + +try: # pragma: no cover + from importlib.metadata import EntryPoint as _EntryPoint + + def import_spec(spec): + return _EntryPoint(None, spec, None).load() + +except ImportError: # pragma: no cover + from pkg_resources import EntryPoint as _EntryPoint + + def import_spec(spec): + ep = _EntryPoint.parse("x=" + spec) + if hasattr(ep, 'resolve'): + # this is available on setuptools >= 10.2 + return ep.resolve() + else: + # this causes a DeprecationWarning on setuptools >= 11.3 + return ep.load(False) + +try: # pragma: no cover + import importlib.resources as _importlib_resources + + if hasattr(_importlib_resources, "files"): + def resource_filename(package, path): + return str(_importlib_resources.files(package).joinpath(path)) + + else: + # fall back to deprecated .path if .files is not available + def resource_filename(package, path): + with _importlib_resources.path(package, '__init__.py') as p: + return str(p.parent.joinpath(path)) + +except ImportError: # pragma: no cover + from pkg_resources import resource_filename + +# End importlib/setuptools compatibility code diff --git a/supervisor/confecho.py b/supervisor/confecho.py index c137b75a5..a8655d98d 100644 --- a/supervisor/confecho.py +++ b/supervisor/confecho.py @@ -1,7 +1,8 @@ -import pkg_resources import sys from supervisor.compat import as_string +from supervisor.compat import resource_filename + def main(out=sys.stdout): - config = pkg_resources.resource_string(__name__, 'skel/sample.conf') - out.write(as_string(config)) + with open(resource_filename(__package__, 'skel/sample.conf'), 'r') as f: + out.write(as_string(f.read())) diff --git a/supervisor/datatypes.py b/supervisor/datatypes.py index 8de9ce5c4..7fa0dc2fe 100644 --- a/supervisor/datatypes.py +++ b/supervisor/datatypes.py @@ -5,6 +5,7 @@ import socket import shlex +from supervisor.compat import shlex_posix_works from supervisor.compat import urlparse from supervisor.compat import long from supervisor.loggers import getLevelNumByDescription @@ -68,7 +69,7 @@ def dict_of_key_value_pairs(arg): """ parse KEY=val,KEY2=val2 into {'KEY':'val', 'KEY2':'val2'} Quotes can be used to allow commas in the value """ - lexer = shlex.shlex(str(arg)) + lexer = shlex.shlex(str(arg), posix=shlex_posix_works) lexer.wordchars += '/.+-():' tokens = list(lexer) @@ -81,7 +82,12 @@ def dict_of_key_value_pairs(arg): if len(k_eq_v) != 3 or k_eq_v[1] != '=': raise ValueError( "Unexpected end of key/value pairs in value '%s'" % arg) - D[k_eq_v[0]] = k_eq_v[2].strip('\'"') + + k, v = k_eq_v[0], k_eq_v[2] + if not shlex_posix_works: + v = v.strip('\'"') + + D[k] = v i += 4 return D @@ -135,7 +141,7 @@ def inet_address(s): # returns (host, port) tuple host = '' if ":" in s: - host, s = s.split(":", 1) + host, s = s.rsplit(":", 1) if not s: raise ValueError("no port number specified in %r" % s) port = port_number(s) @@ -391,10 +397,7 @@ def __call__(self, v): 'gb': 1024*1024*long(1024),}) def url(value): - # earlier Python 2.6 urlparse (2.6.4 and under) can't parse unix:// URLs, - # later ones can but we need to straddle - uri = value.replace('unix://', 'http://', 1).strip() - scheme, netloc, path, params, query, fragment = urlparse.urlparse(uri) + scheme, netloc, path, params, query, fragment = urlparse.urlparse(value) if scheme and (netloc or path): return value raise ValueError("value %r is not a URL" % value) diff --git a/supervisor/dispatchers.py b/supervisor/dispatchers.py index f0e17104c..0718a5868 100644 --- a/supervisor/dispatchers.py +++ b/supervisor/dispatchers.py @@ -1,4 +1,5 @@ import errno +from supervisor.medusa.asynchat_25 import find_prefix_at_end from supervisor.medusa.asyncore_25 import compact_traceback from supervisor.compat import as_string @@ -10,12 +11,6 @@ from supervisor.states import getEventListenerStateDescription from supervisor import loggers -def find_prefix_at_end(haystack, needle): - l = len(needle) - 1 - while l and not haystack.endswith(needle[:l]): - l -= 1 - return l - class PDispatcher: """ Asyncore dispatcher for mainloop, representing a process channel (stdin, stdout, or stderr). This class is abstract. """ diff --git a/supervisor/http.py b/supervisor/http.py index a38086335..af3e3da87 100644 --- a/supervisor/http.py +++ b/supervisor/http.py @@ -739,7 +739,7 @@ def handle_request(self, request): if logfile is None or not os.path.exists(logfile): # we return 404 because no logfile is a temporary condition. # if the process has never been started, no logfile will exist - # on disk. a logfile of None is also a temporay condition, + # on disk. a logfile of None is also a temporary condition, # since the config file can be reloaded. request.error(404) # not found return diff --git a/supervisor/http_client.py b/supervisor/http_client.py index 1ce2275a1..1c1ff756d 100644 --- a/supervisor/http_client.py +++ b/supervisor/http_client.py @@ -1,4 +1,4 @@ -# this code based on Daniel Krech's RDFLib HTTP client code (see rdflib.net) +# this code based on Daniel Krech's RDFLib HTTP client code (see rdflib.dev) import sys import socket diff --git a/supervisor/medusa/CHANGES.txt b/supervisor/medusa/CHANGES.txt index b66b8ba60..1b6307405 100644 --- a/supervisor/medusa/CHANGES.txt +++ b/supervisor/medusa/CHANGES.txt @@ -16,7 +16,7 @@ Version 0.5.5: method. * [Patch #855695] Bugfix for filesys.msdos_date * [Patch from Jason Sibre] Improve performance of xmlrpc_handler - by avoiding string concatentation + by avoiding string concatenation * [Patch from Jason Sibre] Add interface to http_request for multiple headers with the same name. diff --git a/supervisor/medusa/asyncore_25.py b/supervisor/medusa/asyncore_25.py index a0fb8d95f..d3efdf7a5 100644 --- a/supervisor/medusa/asyncore_25.py +++ b/supervisor/medusa/asyncore_25.py @@ -358,7 +358,16 @@ def recv(self, buffer_size): def close(self): self.del_channel() - self.socket.close() + + try: + self.socket.shutdown(socket.SHUT_RDWR) + except socket.error: + # must swallow exception from already-closed socket + # (at least with Python 3.11.7 on macOS 14.2.1) + pass + + # does not raise if called on already-closed socket + self.socket.close() # cheap inheritance, used to pass all other attribute # references to the underlying socket object. diff --git a/supervisor/medusa/producers.py b/supervisor/medusa/producers.py index 9570d8bd3..5e5198240 100644 --- a/supervisor/medusa/producers.py +++ b/supervisor/medusa/producers.py @@ -11,7 +11,7 @@ producer, then wrap this with the 'chunked' transfer-encoding producer. """ -from asynchat import find_prefix_at_end +from supervisor.medusa.asynchat_25 import find_prefix_at_end from supervisor.compat import as_bytes class simple_producer: diff --git a/supervisor/options.py b/supervisor/options.py index 7b53cc760..672dba113 100644 --- a/supervisor/options.py +++ b/supervisor/options.py @@ -10,7 +10,6 @@ import grp import resource import stat -import pkg_resources import glob import platform import warnings @@ -22,6 +21,7 @@ from supervisor.compat import xmlrpclib from supervisor.compat import StringIO from supervisor.compat import basestring +from supervisor.compat import import_spec from supervisor.medusa import asyncore_25 as asyncore @@ -374,7 +374,7 @@ def get_plugins(self, parser, factory_key, section_prefix): (section, factory_key)) try: factory = self.import_spec(factory_spec) - except ImportError: + except (AttributeError, ImportError): raise ValueError('%s cannot be resolved within [%s]' % ( factory_spec, section)) @@ -387,13 +387,8 @@ def get_plugins(self, parser, factory_key, section_prefix): return factories def import_spec(self, spec): - ep = pkg_resources.EntryPoint.parse("x=" + spec) - if hasattr(ep, 'resolve'): - # this is available on setuptools >= 10.2 - return ep.resolve() - else: - # this causes a DeprecationWarning on setuptools >= 11.3 - return ep.load(False) + """On failure, raises either AttributeError or ImportError""" + return import_spec(spec) class ServerOptions(Options): @@ -543,8 +538,6 @@ def realize(self, *arg, **kw): # self.serverurl may still be None if no servers at all are # configured in the config file - self.identifier = section.identifier - def process_config(self, do_usage=True): Options.process_config(self, do_usage=do_usage) @@ -653,7 +646,7 @@ def get(opt, default, **kwargs): section.nocleanup = boolean(get('nocleanup', 'false')) section.strip_ansi = boolean(get('strip_ansi', 'false')) - environ_str = get('environment', '') + environ_str = get('environment', '', do_expand=False) environ_str = expand(environ_str, expansions, 'environment') section.environment = dict_of_key_value_pairs(environ_str) @@ -750,7 +743,7 @@ def get(section, opt, default, **kwargs): 'supervisor.dispatchers:default_handler') try: result_handler = self.import_spec(result_handler) - except ImportError: + except (AttributeError, ImportError): raise ValueError('%s cannot be resolved within [%s]' % ( result_handler, section)) @@ -1316,11 +1309,7 @@ def get_socket_map(self): def cleanup_fds(self): # try to close any leaked file descriptors (for reload) start = 5 - for x in range(start, self.minfds): - try: - os.close(x) - except OSError: - pass + os.closerange(start, self.minfds) def kill(self, pid, signal): os.kill(pid, signal) diff --git a/supervisor/process.py b/supervisor/process.py index d6f60f3e2..b394be812 100644 --- a/supervisor/process.py +++ b/supervisor/process.py @@ -165,17 +165,16 @@ def change_state(self, new_state, expected=True): # exists for unit tests return False - event_class = self.event_map.get(new_state) - if event_class is not None: - event = event_class(self, old_state, expected) - events.notify(event) - + self.state = new_state if new_state == ProcessStates.BACKOFF: now = time.time() self.backoff += 1 self.delay = now + self.backoff - self.state = new_state + event_class = self.event_map.get(new_state) + if event_class is not None: + event = event_class(self, old_state, expected) + events.notify(event) def _assertInState(self, *states): if self.state not in states: @@ -569,6 +568,11 @@ def finish(self, pid, sts): msg = "stopped: %s (%s)" % (processname, msg) self._assertInState(ProcessStates.STOPPING) self.change_state(ProcessStates.STOPPED) + if exit_expected: + self.config.options.logger.info(msg) + else: + self.config.options.logger.warn(msg) + elif too_quickly: # the program did not stay up long enough to make it to RUNNING @@ -578,6 +582,7 @@ def finish(self, pid, sts): msg = "exited: %s (%s)" % (processname, msg + "; not expected") self._assertInState(ProcessStates.STARTING) self.change_state(ProcessStates.BACKOFF) + self.config.options.logger.warn(msg) else: # this finish was not the result of a stop request, the @@ -599,13 +604,13 @@ def finish(self, pid, sts): # expected exit code msg = "exited: %s (%s)" % (processname, msg + "; expected") self.change_state(ProcessStates.EXITED, expected=True) + self.config.options.logger.info(msg) else: # unexpected exit code self.spawnerr = 'Bad exit code %s' % es msg = "exited: %s (%s)" % (processname, msg + "; not expected") self.change_state(ProcessStates.EXITED, expected=False) - - self.config.options.logger.info(msg) + self.config.options.logger.warn(msg) self.pid = 0 self.config.options.close_parent_pipes(self.pipes) diff --git a/supervisor/rpcinterface.py b/supervisor/rpcinterface.py index 854b7285f..f1c7c2ce9 100644 --- a/supervisor/rpcinterface.py +++ b/supervisor/rpcinterface.py @@ -10,11 +10,13 @@ from supervisor.datatypes import ( Automatic, + RestartWhenExitUnexpected, signal_number, ) from supervisor.options import readFile from supervisor.options import tailFile +from supervisor.options import BadCommand from supervisor.options import NotExecutable from supervisor.options import NotFound from supervisor.options import NoPermission @@ -59,7 +61,7 @@ def _update(self, text): def getAPIVersion(self): """ Return the version of the RPC API used by supervisord - @return string version version id + @return string version id """ self._update('getAPIVersion') return API_VERSION @@ -69,7 +71,7 @@ def getAPIVersion(self): def getSupervisorVersion(self): """ Return the version of the supervisor package in use by supervisord - @return string version version id + @return string version id """ self._update('getSupervisorVersion') return VERSION @@ -293,7 +295,7 @@ def startProcess(self, name, wait=True): filename, argv = process.get_execv_args() except NotFound as why: raise RPCError(Faults.NO_FILE, why.args[0]) - except (NotExecutable, NoPermission) as why: + except (BadCommand, NotExecutable, NoPermission) as why: raise RPCError(Faults.NOT_EXECUTABLE, why.args[0]) if process.get_state() in RUNNING_STATES: @@ -567,6 +569,9 @@ def getAllConfigInfo(self): inuse = gconfig.name in self.supervisord.process_groups for pconfig in gconfig.process_configs: d = {'autostart': pconfig.autostart, + 'autorestart': pconfig.autorestart, + 'directory': pconfig.directory, + 'uid': pconfig.uid, 'command': pconfig.command, 'exitcodes': pconfig.exitcodes, 'group': gconfig.name, @@ -592,10 +597,18 @@ def getAllConfigInfo(self): 'stderr_logfile_backups': pconfig.stderr_logfile_backups, 'stderr_logfile_maxbytes': pconfig.stderr_logfile_maxbytes, 'stderr_syslog': pconfig.stderr_syslog, + 'serverurl': pconfig.serverurl, } + # no support for these types in xml-rpc - d.update((k, 'auto') for k, v in d.items() if v is Automatic) - d.update((k, 'none') for k, v in d.items() if v is None) + for k, v in d.items(): + if v is Automatic: + d[k] = "auto" + elif v is None: + d[k] = "none" + elif v is RestartWhenExitUnexpected: + d[k] = "unexpected" + configinfo.append(d) configinfo.sort(key=lambda r: r['name']) diff --git a/supervisor/scripts/loop_eventgen.py b/supervisor/scripts/loop_eventgen.py deleted file mode 100755 index 3a167ac55..000000000 --- a/supervisor/scripts/loop_eventgen.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python - -# A process which emits a process communications event on its stdout, -# and subsequently waits for a line to be sent back to its stdin by -# loop_listener.py. - -import sys -import time -from supervisor import childutils - -def main(max): - start = time.time() - report = open('/tmp/report', 'w') - i = 0 - while 1: - childutils.pcomm.stdout('the_data') - sys.stdin.readline() - report.write(str(i) + ' @ %s\n' % childutils.get_asctime()) - report.flush() - i+=1 - if max and i >= max: - end = time.time() - report.write('%s per second\n' % (i / (end - start))) - sys.exit(0) - -if __name__ == '__main__': - max = 0 - if len(sys.argv) > 1: - max = int(sys.argv[1]) - main(max) - - diff --git a/supervisor/scripts/loop_listener.py b/supervisor/scripts/loop_listener.py deleted file mode 100755 index 74f9f1a8d..000000000 --- a/supervisor/scripts/loop_listener.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python -u - -# An event listener that listens for process communications events -# from loop_eventgen.py and uses RPC to write data to the event -# generator's stdin. - -import os -from supervisor import childutils - -def main(): - rpcinterface = childutils.getRPCInterface(os.environ) - while 1: - headers, payload = childutils.listener.wait() - if headers['eventname'].startswith('PROCESS_COMMUNICATION'): - pheaders, pdata = childutils.eventdata(payload) - pname = '%s:%s' % (pheaders['processname'], pheaders['groupname']) - rpcinterface.supervisor.sendProcessStdin(pname, 'Got it yo\n') - childutils.listener.ok() - -if __name__ == '__main__': - main() diff --git a/supervisor/scripts/sample_commevent.py b/supervisor/scripts/sample_commevent.py deleted file mode 100755 index a7e6ad5ce..000000000 --- a/supervisor/scripts/sample_commevent.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python - -# An example process which emits a stdout process communication event every -# second (or every number of seconds specified as a single argument). - -import sys -import time - -def write_stdout(s): - sys.stdout.write(s) - sys.stdout.flush() - -def main(sleep): - while 1: - write_stdout('') - write_stdout('the data') - write_stdout('') - time.sleep(sleep) - -if __name__ == '__main__': - if len(sys.argv) > 1: - main(float(sys.argv[1])) - else: - main(1) - diff --git a/supervisor/scripts/sample_eventlistener.py b/supervisor/scripts/sample_eventlistener.py deleted file mode 100755 index 8da5eaf2d..000000000 --- a/supervisor/scripts/sample_eventlistener.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -u - -# A sample long-running supervisor event listener which demonstrates -# how to accept event notifications from supervisor and how to respond -# properly. This demonstration does *not* use the -# supervisor.childutils module, which wraps the specifics of -# communications in higher-level API functions. If your listeners are -# implemented using Python, it is recommended that you use the -# childutils module API instead of modeling your scripts on the -# lower-level protocol example below. - -import sys - -def write_stdout(s): - sys.stdout.write(s) - sys.stdout.flush() - -def write_stderr(s): - sys.stderr.write(s) - sys.stderr.flush() - -def main(): - while 1: - write_stdout('READY\n') # transition from ACKNOWLEDGED to READY - line = sys.stdin.readline() # read header line from stdin - write_stderr(line) # print it out to stderr (testing only) - headers = dict([ x.split(':') for x in line.split() ]) - data = sys.stdin.read(int(headers['len'])) # read the event payload - write_stderr(data) # print the event payload to stderr (testing only) - write_stdout('RESULT 2\nOK') # transition from BUSY to ACKNOWLEDGED - #write_stdout('RESULT 4\nFAIL') # transition from BUSY TO ACKNOWLEDGED - -if __name__ == '__main__': - main() diff --git a/supervisor/scripts/sample_exiting_eventlistener.py b/supervisor/scripts/sample_exiting_eventlistener.py deleted file mode 100755 index 90f953415..000000000 --- a/supervisor/scripts/sample_exiting_eventlistener.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python - -# A sample long-running supervisor event listener which demonstrates -# how to accept event notifications from supervisor and how to respond -# properly. It is the same as the sample_eventlistener.py script -# except it exits after each request (presumably to be restarted by -# supervisor). This demonstration does *not* use the -# supervisor.childutils module, which wraps the specifics of -# communications in higher-level API functions. If your listeners are -# implemented using Python, it is recommended that you use the -# childutils module API instead of modeling your scripts on the -# lower-level protocol example below. - -import sys - -def write_stdout(s): - sys.stdout.write(s) - sys.stdout.flush() - -def write_stderr(s): - sys.stderr.write(s) - sys.stderr.flush() - -def main(): - write_stdout('READY\n') # transition from ACKNOWLEDGED to READY - line = sys.stdin.readline() # read a line from stdin from supervisord - write_stderr(line) # print it out to stderr (testing only) - headers = dict([ x.split(':') for x in line.split() ]) - data = sys.stdin.read(int(headers['len'])) # read the event payload - write_stderr(data) # print the event payload to stderr (testing only) - write_stdout('RESULT 2\nOK') # transition from READY to ACKNOWLEDGED - # exit, if the eventlistener process config has autorestart=true, - # it will be restarted by supervisord. - -if __name__ == '__main__': - main() - diff --git a/supervisor/supervisorctl.py b/supervisor/supervisorctl.py index 455c1e37e..5d73e4cca 100755 --- a/supervisor/supervisorctl.py +++ b/supervisor/supervisorctl.py @@ -977,6 +977,12 @@ def help_restart(self): " see reread and update.") def do_shutdown(self, arg): + if arg: + self.ctl.output('Error: shutdown accepts no arguments') + self.ctl.exitstatus = LSBInitExitStatuses.GENERIC + self.help_shutdown() + return + if self.ctl.options.interactive: yesno = raw_input('Really shut the remote supervisord process ' 'down y/N? ') diff --git a/supervisor/supervisord.py b/supervisor/supervisord.py index 138732dd4..2a7935ca5 100755 --- a/supervisor/supervisord.py +++ b/supervisor/supervisord.py @@ -174,6 +174,7 @@ def ordered_stop_groups_phase_2(self): def runforever(self): events.notify(events.SupervisorRunningEvent()) timeout = 1 # this cannot be fewer than the smallest TickEvent (5) + first_poll = True socket_map = self.options.get_socket_map() @@ -206,7 +207,12 @@ def runforever(self): if dispatcher.writable(): self.options.poller.register_writable(fd) - r, w = self.options.poller.poll(timeout) + if first_poll: + # initial timeout of 0 avoids delaying supervisord startup + r, w = self.options.poller.poll(0) + first_poll = False + else: + r, w = self.options.poller.poll(timeout) for fd in r: if fd in combined_map: @@ -222,6 +228,14 @@ def runforever(self): raise except: combined_map[fd].handle_error() + else: + # if the fd is not in combined_map, we should unregister it. otherwise, + # it will be polled every time, which may cause 100% cpu usage + self.options.logger.blather('unexpected read event from fd %r' % fd) + try: + self.options.poller.unregister_readable(fd) + except: + pass for fd in w: if fd in combined_map: @@ -237,6 +251,12 @@ def runforever(self): raise except: combined_map[fd].handle_error() + else: + self.options.logger.blather('unexpected write event from fd %r' % fd) + try: + self.options.poller.unregister_writable(fd) + except: + pass for group in pgroups: group.transition() @@ -282,7 +302,7 @@ def reap(self, once=False, recursionguard=0): del self.options.pidhistory[pid] if not once: # keep reaping until no more kids to reap, but don't recurse - # infintely + # infinitely self.reap(once=False, recursionguard=recursionguard+1) def handle_signal(self): diff --git a/supervisor/templating.py b/supervisor/templating.py index edc9ebd42..8e018f24e 100644 --- a/supervisor/templating.py +++ b/supervisor/templating.py @@ -511,7 +511,7 @@ def replace(self, text, structure=False): parent = self.parent i = self.deparent() if i is not None: - # reduce function call overhead by not calliing self.insert + # reduce function call overhead by not calling self.insert node = Replace(text, structure) parent._children.insert(i, node) node.parent = parent diff --git a/supervisor/tests/base.py b/supervisor/tests/base.py index bef82e964..f608b2bea 100644 --- a/supervisor/tests/base.py +++ b/supervisor/tests/base.py @@ -1,9 +1,7 @@ _NOW = 1151365354 _TIMEFORMAT = '%b %d %I:%M %p' -import errno import functools -import os from supervisor.compat import Fault from supervisor.compat import as_bytes @@ -235,7 +233,6 @@ def mktempfile(self, prefix, suffix, dir): return self.tempfile_name def remove(self, path): - import os if self.remove_exception is not None: raise self.remove_exception self.removed.append(path) @@ -596,8 +593,8 @@ def makeExecutable(file, substitutions=None): for key in substitutions.keys(): data = data.replace('<<%s>>' % key.upper(), substitutions[key]) - tmpnam = tempfile.mktemp(prefix=last) - with open(tmpnam, 'w') as f: + with tempfile.NamedTemporaryFile(prefix=last, delete=False) as f: + tmpnam = f.name f.write(data) os.chmod(tmpnam, 0o755) return tmpnam diff --git a/supervisor/tests/fixtures/issue-1483a.conf b/supervisor/tests/fixtures/issue-1483a.conf new file mode 100644 index 000000000..1fe93ade4 --- /dev/null +++ b/supervisor/tests/fixtures/issue-1483a.conf @@ -0,0 +1,11 @@ +[supervisord] +loglevel=info ; log level; default info; others: debug,warn,trace +logfile=/tmp/issue-1483a.log ; main log file; default $CWD/supervisord.log +pidfile=/tmp/issue-1483a.pid ; supervisord pidfile; default supervisord.pid +nodaemon=true ; start in foreground if true; default false + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[unix_http_server] +file=/tmp/issue-1483a.sock ; the path to the socket file diff --git a/supervisor/tests/fixtures/issue-1483b.conf b/supervisor/tests/fixtures/issue-1483b.conf new file mode 100644 index 000000000..cac9ccd18 --- /dev/null +++ b/supervisor/tests/fixtures/issue-1483b.conf @@ -0,0 +1,12 @@ +[supervisord] +loglevel=info ; log level; default info; others: debug,warn,trace +logfile=/tmp/issue-1483b.log ; main log file; default $CWD/supervisord.log +pidfile=/tmp/issue-1483b.pid ; supervisord pidfile; default supervisord.pid +nodaemon=true ; start in foreground if true; default false +identifier=from_config_file + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[unix_http_server] +file=/tmp/issue-1483b.sock ; the path to the socket file diff --git a/supervisor/tests/fixtures/issue-1483c.conf b/supervisor/tests/fixtures/issue-1483c.conf new file mode 100644 index 000000000..e7ffd7e03 --- /dev/null +++ b/supervisor/tests/fixtures/issue-1483c.conf @@ -0,0 +1,12 @@ +[supervisord] +loglevel=info ; log level; default info; others: debug,warn,trace +logfile=/tmp/issue-1483c.log ; main log file; default $CWD/supervisord.log +pidfile=/tmp/issue-1483c.pid ; supervisord pidfile; default supervisord.pid +nodaemon=true ; start in foreground if true; default false +identifier=from_config_file + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[unix_http_server] +file=/tmp/issue-1483c.sock ; the path to the socket file diff --git a/supervisor/tests/fixtures/issue-1596.conf b/supervisor/tests/fixtures/issue-1596.conf new file mode 100644 index 000000000..750214bbe --- /dev/null +++ b/supervisor/tests/fixtures/issue-1596.conf @@ -0,0 +1,12 @@ +[supervisord] +loglevel=info ; log level; default info; others: debug,warn,trace +logfile=/tmp/issue-1596.log ; main log file; default $CWD/supervisord.log +pidfile=/tmp/issue-1596.pid ; supervisord pidfile; default supervisord.pid +nodaemon=true ; start in foreground if true; default false +identifier=from_config_file + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[unix_http_server] +file=/tmp/issue-1596.sock ; the path to the socket file diff --git a/supervisor/tests/test_childutils.py b/supervisor/tests/test_childutils.py index f2b39d821..94193fc6c 100644 --- a/supervisor/tests/test_childutils.py +++ b/supervisor/tests/test_childutils.py @@ -132,10 +132,3 @@ def test_send(self): listener.send(msg, stdout) expected = '%s%s\n%s' % (begin, len(msg), msg) self.assertEqual(stdout.getvalue(), expected) - - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_confecho.py b/supervisor/tests/test_confecho.py index 6ae510824..975bbd59d 100644 --- a/supervisor/tests/test_confecho.py +++ b/supervisor/tests/test_confecho.py @@ -1,6 +1,5 @@ """Test suite for supervisor.confecho""" -import sys import unittest from supervisor.compat import StringIO from supervisor import confecho @@ -12,10 +11,3 @@ def test_main_writes_data_out_that_looks_like_a_config_file(self): output = sio.getvalue() self.assertTrue("[supervisord]" in output) - - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_datatypes.py b/supervisor/tests/test_datatypes.py index ff2c966f3..b7f092f7c 100644 --- a/supervisor/tests/test_datatypes.py +++ b/supervisor/tests/test_datatypes.py @@ -8,6 +8,7 @@ from supervisor.tests.base import Mock, patch, sentinel from supervisor.compat import maxint +from supervisor.compat import shlex_posix_works from supervisor import datatypes @@ -19,7 +20,7 @@ def test_strips_surrounding_whitespace(self): name = " foo\t" self.assertEqual(self._callFUT(name), "foo") - def test_disallows_inner_spaces_for_eventlister_protocol(self): + def test_disallows_inner_spaces_for_eventlistener_protocol(self): name = "foo bar" self.assertRaises(ValueError, self._callFUT, name) @@ -164,6 +165,16 @@ def test_handles_newlines_inside_quotes(self): expected = {'foo': 'a\nb\nc'} self.assertEqual(actual, expected) + def test_handles_quotes_inside_quotes(self): + func = lambda: datatypes.dict_of_key_value_pairs('foo="\'\\""') + + if shlex_posix_works: + actual = func() + expected = {'foo': '\'"'} + self.assertEqual(actual, expected) + else: + self.assertRaises(ValueError, func) + def test_handles_empty_inside_quotes(self): actual = datatypes.dict_of_key_value_pairs('foo=""') expected = {'foo': ''} @@ -632,14 +643,24 @@ def test_bad_port_number(self): self.assertRaises(ValueError, self._callFUT, 'a') def test_default_host(self): - host, port = self._callFUT('*:8080') + host, port = self._callFUT('*:9001') self.assertEqual(host, '') - self.assertEqual(port, 8080) + self.assertEqual(port, 9001) - def test_boring(self): - host, port = self._callFUT('localhost:80') + def test_hostname_and_port(self): + host, port = self._callFUT('localhost:9001') self.assertEqual(host, 'localhost') - self.assertEqual(port, 80) + self.assertEqual(port, 9001) + + def test_ipv4_address_and_port(self): + host, port = self._callFUT('127.0.0.1:9001') + self.assertEqual(host, '127.0.0.1') + self.assertEqual(port, 9001) + + def test_ipv6_address_and_port(self): + host, port = self._callFUT('2001:db8:ff:55:0:0:0:138:9001') + self.assertEqual(host, '2001:db8:ff:55:0:0:0:138') + self.assertEqual(port, 9001) class SocketAddressTests(unittest.TestCase): def _getTargetClass(self): diff --git a/supervisor/tests/test_dispatchers.py b/supervisor/tests/test_dispatchers.py index 87692e21f..92b43bad1 100644 --- a/supervisor/tests/test_dispatchers.py +++ b/supervisor/tests/test_dispatchers.py @@ -1,6 +1,5 @@ import unittest import os -import sys from supervisor.compat import as_bytes @@ -1227,9 +1226,3 @@ def test_ansi(self): def test_noansi(self): noansi = b'Hello world... this is longer than a token!' self.assertEqual(self._callFUT(noansi), noansi) - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_end_to_end.py b/supervisor/tests/test_end_to_end.py index 0a9b37026..25b71d2a2 100644 --- a/supervisor/tests/test_end_to_end.py +++ b/supervisor/tests/test_end_to_end.py @@ -5,11 +5,10 @@ import signal import sys import unittest -import pkg_resources +from supervisor.compat import resource_filename from supervisor.compat import xmlrpclib from supervisor.xmlrpc import SupervisorTransport - # end-to-test tests are slow so only run them when asked if 'END_TO_END' in os.environ: import pexpect @@ -26,7 +25,7 @@ def test_issue_291a_percent_signs_in_original_env_are_preserved(self): passed to the child without the percent sign being mangled.""" key = "SUPERVISOR_TEST_1441B" val = "foo_%s_%_%%_%%%_%2_bar" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-291a.conf') + filename = resource_filename(__package__, 'fixtures/issue-291a.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] try: os.environ[key] = val @@ -39,7 +38,7 @@ def test_issue_291a_percent_signs_in_original_env_are_preserved(self): def test_issue_550(self): """When an environment variable is set in the [supervisord] section, it should be put into the environment of the subprocess.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-550.conf') + filename = resource_filename(__package__, 'fixtures/issue-550.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -55,7 +54,7 @@ def test_issue_550(self): def test_issue_565(self): """When a log file has Unicode characters in it, 'supervisorctl tail -f name' should still work.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-565.conf') + filename = resource_filename(__package__, 'fixtures/issue-565.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -72,7 +71,7 @@ def test_issue_565(self): def test_issue_638(self): """When a process outputs something on its stdout or stderr file descriptor that is not valid UTF-8, supervisord should not crash.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-638.conf') + filename = resource_filename(__package__, 'fixtures/issue-638.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -91,7 +90,7 @@ def test_issue_638(self): def test_issue_663(self): """When Supervisor is run on Python 3, the eventlistener protocol should work.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-663.conf') + filename = resource_filename(__package__, 'fixtures/issue-663.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -103,7 +102,7 @@ def test_issue_664(self): """When a subprocess name has Unicode characters, 'supervisord' should not send incomplete XML-RPC responses and 'supervisorctl status' should work.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-664.conf') + filename = resource_filename(__package__, 'fixtures/issue-664.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -122,7 +121,7 @@ def test_issue_664(self): def test_issue_733(self): """When a subprocess enters the FATAL state, a one-line eventlistener can be used to signal supervisord to shut down.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-733.conf') + filename = resource_filename(__package__, 'fixtures/issue-733.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -131,7 +130,7 @@ def test_issue_733(self): supervisord.expect(pexpect.EOF) def test_issue_835(self): - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-835.conf') + filename = resource_filename(__package__, 'fixtures/issue-835.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -147,7 +146,7 @@ def test_issue_835(self): transport.connection.close() def test_issue_836(self): - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-836.conf') + filename = resource_filename(__package__, 'fixtures/issue-836.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -170,7 +169,7 @@ def test_issue_836(self): def test_issue_986_command_string_with_double_percent(self): """A percent sign can be used in a command= string without being expanded if it is escaped by a second percent sign.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-986.conf') + filename = resource_filename(__package__, 'fixtures/issue-986.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -179,7 +178,7 @@ def test_issue_986_command_string_with_double_percent(self): def test_issue_1054(self): """When run on Python 3, the 'supervisorctl avail' command should work.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1054.conf') + filename = resource_filename(__package__, 'fixtures/issue-1054.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -197,7 +196,7 @@ def test_issue_1170a(self): """When the [supervisord] section has a variable defined in environment=, that variable should be able to be used in an %(ENV_x) expansion in a [program] section.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1170a.conf') + filename = resource_filename(__package__, 'fixtures/issue-1170a.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -208,7 +207,7 @@ def test_issue_1170b(self): environment=, and a variable by the same name is defined in enviroment= of a [program] section, the one in the [program] section should be used.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1170b.conf') + filename = resource_filename(__package__, 'fixtures/issue-1170b.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -219,7 +218,7 @@ def test_issue_1170c(self): environment=, and a variable by the same name is defined in enviroment= of an [eventlistener] section, the one in the [eventlistener] section should be used.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1170c.conf') + filename = resource_filename(__package__, 'fixtures/issue-1170c.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -230,7 +229,7 @@ def test_issue_1224(self): then the non-rotating logger will be used to avoid an IllegalSeekError in the case that the user has configured a non-seekable file like /dev/stdout.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1224.conf') + filename = resource_filename(__package__, 'fixtures/issue-1224.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -239,7 +238,7 @@ def test_issue_1224(self): def test_issue_1231a(self): """When 'supervisorctl tail -f name' is run and the log contains unicode, it should not fail.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1231a.conf') + filename = resource_filename(__package__, 'fixtures/issue-1231a.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -256,7 +255,7 @@ def test_issue_1231a(self): def test_issue_1231b(self): """When 'supervisorctl tail -f name' is run and the log contains unicode, it should not fail.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1231b.conf') + filename = resource_filename(__package__, 'fixtures/issue-1231b.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -290,7 +289,7 @@ def test_issue_1231b(self): def test_issue_1231c(self): """When 'supervisorctl tail -f name' is run and the log contains unicode, it should not fail.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1231c.conf') + filename = resource_filename(__package__, 'fixtures/issue-1231c.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -332,7 +331,7 @@ def test_issue_1298(self): """When the output of 'supervisorctl tail -f worker' is piped such as 'supervisor tail -f worker | grep something', 'supervisorctl' should not crash.""" - filename = pkg_resources.resource_filename(__name__, 'fixtures/issue-1298.conf') + filename = resource_filename(__package__, 'fixtures/issue-1298.conf') args = ['-m', 'supervisor.supervisord', '-c', filename] supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') self.addCleanup(supervisord.kill, signal.SIGINT) @@ -363,9 +362,96 @@ def test_issue_1418_pidproxy_cmd_with_args(self): pidproxy.expect(pexpect.EOF) self.assertEqual(pidproxy.before.strip(), "1 2") + def test_issue_1483a_identifier_default(self): + """When no identifier is supplied on the command line or in the config + file, the default is used.""" + filename = resource_filename(__package__, 'fixtures/issue-1483a.conf') + args = ['-m', 'supervisor.supervisord', '-c', filename] + supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') + self.addCleanup(supervisord.kill, signal.SIGINT) + supervisord.expect_exact('supervisord started with pid') + + from supervisor.compat import xmlrpclib + from supervisor.xmlrpc import SupervisorTransport + transport = SupervisorTransport('', '', 'unix:///tmp/issue-1483a.sock') + try: + server = xmlrpclib.ServerProxy('http://transport.ignores.host/RPC2', transport) + ident = server.supervisor.getIdentification() + finally: + transport.close() + self.assertEqual(ident, "supervisor") + + def test_issue_1483b_identifier_from_config_file(self): + """When the identifier is supplied in the config file only, that + identifier is used instead of the default.""" + filename = resource_filename(__package__, 'fixtures/issue-1483b.conf') + args = ['-m', 'supervisor.supervisord', '-c', filename] + supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') + self.addCleanup(supervisord.kill, signal.SIGINT) + supervisord.expect_exact('supervisord started with pid') + + from supervisor.compat import xmlrpclib + from supervisor.xmlrpc import SupervisorTransport + transport = SupervisorTransport('', '', 'unix:///tmp/issue-1483b.sock') + try: + server = xmlrpclib.ServerProxy('http://transport.ignores.host/RPC2', transport) + ident = server.supervisor.getIdentification() + finally: + transport.close() + self.assertEqual(ident, "from_config_file") + + def test_issue_1483c_identifier_from_command_line(self): + """When an identifier is supplied in both the config file and on the + command line, the one from the command line is used.""" + filename = resource_filename(__package__, 'fixtures/issue-1483c.conf') + args = ['-m', 'supervisor.supervisord', '-c', filename, '-i', 'from_command_line'] + supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') + self.addCleanup(supervisord.kill, signal.SIGINT) + supervisord.expect_exact('supervisord started with pid') + + from supervisor.compat import xmlrpclib + from supervisor.xmlrpc import SupervisorTransport + transport = SupervisorTransport('', '', 'unix:///tmp/issue-1483c.sock') + try: + server = xmlrpclib.ServerProxy('http://transport.ignores.host/RPC2', transport) + ident = server.supervisor.getIdentification() + finally: + transport.close() + self.assertEqual(ident, "from_command_line") + + def test_pull_request_1578_echo_supervisord_conf(self): + """The command echo_supervisord_conf, whose implementation depends on + importlib.resources to work, should print the example config.""" + args = ['-c', 'from supervisor import confecho; confecho.main()'] + echo_supervisord_conf = pexpect.spawn(sys.executable, args, encoding='utf-8') + self.addCleanup(echo_supervisord_conf.kill, signal.SIGKILL) + echo_supervisord_conf.expect_exact('Sample supervisor config file') + + def test_issue_1596_asyncore_close_does_not_crash(self): + """If the socket is already closed when socket.shutdown(socket.SHUT_RDWR) + is called in the close() method of an asyncore dispatcher, an exception + will be raised (at least with Python 3.11.7 on macOS 14.2.1). If it is + not caught in that method, supervisord will crash.""" + filename = resource_filename(__package__, 'fixtures/issue-1596.conf') + args = ['-m', 'supervisor.supervisord', '-c', filename] + supervisord = pexpect.spawn(sys.executable, args, encoding='utf-8') + self.addCleanup(supervisord.kill, signal.SIGINT) + supervisord.expect_exact('supervisord started with pid') + + from supervisor.compat import xmlrpclib + from supervisor.xmlrpc import SupervisorTransport + + socket_url = 'unix:///tmp/issue-1596.sock' + dummy_url = 'http://transport.ignores.host/RPC2' -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) + # supervisord will crash after close() if it has the bug + t1 = SupervisorTransport('', '', socket_url) + s1 = xmlrpclib.ServerProxy(dummy_url, t1) + s1.system.listMethods() + t1.close() -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') + # this call will only succeed if supervisord did not crash + t2 = SupervisorTransport('', '', socket_url) + s2 = xmlrpclib.ServerProxy(dummy_url, t2) + s2.system.listMethods() + t2.close() diff --git a/supervisor/tests/test_events.py b/supervisor/tests/test_events.py index faa8be142..0498f7f62 100644 --- a/supervisor/tests/test_events.py +++ b/supervisor/tests/test_events.py @@ -1,4 +1,3 @@ -import sys import unittest from supervisor.tests.base import DummyOptions @@ -72,7 +71,7 @@ def test_ProcessLogEvent_attributes(self): self.assertEqual(inst.pid, 2) self.assertEqual(inst.data, 3) - def test_ProcessLogEvent_inheritence(self): + def test_ProcessLogEvent_inheritance(self): from supervisor.events import ProcessLogEvent from supervisor.events import Event self.assertTrue( @@ -87,7 +86,7 @@ def test_ProcessLogStdoutEvent_attributes(self): self.assertEqual(inst.data, 3) self.assertEqual(inst.channel, 'stdout') - def test_ProcessLogStdoutEvent_inheritence(self): + def test_ProcessLogStdoutEvent_inheritance(self): from supervisor.events import ProcessLogStdoutEvent from supervisor.events import ProcessLogEvent self.assertTrue( @@ -102,7 +101,7 @@ def test_ProcessLogStderrEvent_attributes(self): self.assertEqual(inst.data, 3) self.assertEqual(inst.channel, 'stderr') - def test_ProcessLogStderrEvent_inheritence(self): + def test_ProcessLogStderrEvent_inheritance(self): from supervisor.events import ProcessLogStderrEvent from supervisor.events import ProcessLogEvent self.assertTrue( @@ -116,7 +115,7 @@ def test_ProcessCommunicationEvent_attributes(self): self.assertEqual(inst.pid, 2) self.assertEqual(inst.data, 3) - def test_ProcessCommunicationEvent_inheritence(self): + def test_ProcessCommunicationEvent_inheritance(self): from supervisor.events import ProcessCommunicationEvent from supervisor.events import Event self.assertTrue( @@ -131,7 +130,7 @@ def test_ProcessCommunicationStdoutEvent_attributes(self): self.assertEqual(inst.data, 3) self.assertEqual(inst.channel, 'stdout') - def test_ProcessCommunicationStdoutEvent_inheritence(self): + def test_ProcessCommunicationStdoutEvent_inheritance(self): from supervisor.events import ProcessCommunicationStdoutEvent from supervisor.events import ProcessCommunicationEvent self.assertTrue( @@ -147,7 +146,7 @@ def test_ProcessCommunicationStderrEvent_attributes(self): self.assertEqual(inst.data, 3) self.assertEqual(inst.channel, 'stderr') - def test_ProcessCommunicationStderrEvent_inheritence(self): + def test_ProcessCommunicationStderrEvent_inheritance(self): from supervisor.events import ProcessCommunicationStderrEvent from supervisor.events import ProcessCommunicationEvent self.assertTrue( @@ -161,7 +160,7 @@ def test_RemoteCommunicationEvent_attributes(self): self.assertEqual(inst.type, 1) self.assertEqual(inst.data, 2) - def test_RemoteCommunicationEvent_inheritence(self): + def test_RemoteCommunicationEvent_inheritance(self): from supervisor.events import RemoteCommunicationEvent from supervisor.events import Event self.assertTrue( @@ -508,10 +507,3 @@ class FooEvent(events.Event): self.assertTrue(events.EventTypes.FOO is FooEvent) finally: del events.EventTypes.FOO - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') - diff --git a/supervisor/tests/test_http.py b/supervisor/tests/test_http.py index fee1f89a1..7409f6224 100644 --- a/supervisor/tests/test_http.py +++ b/supervisor/tests/test_http.py @@ -1,7 +1,6 @@ import base64 import os import stat -import sys import socket import tempfile import unittest @@ -169,8 +168,8 @@ def test_handle_more_follow_file_recreated(self): def test_handle_more_follow_file_gone(self): request = DummyRequest('/logtail/foo', None, None, None) - filename = tempfile.mktemp() - with open(filename, 'wb') as f: + with tempfile.NamedTemporaryFile(delete=False) as f: + filename = f.name f.write(b'a' * 80) try: producer = self._makeOne(request, f.name, 80) @@ -605,7 +604,7 @@ def _make_http_servers(self, sconfigs): if socketfile is not None: os.unlink(socketfile) finally: - from asyncore import socket_map + from supervisor.medusa.asyncore_25 import socket_map socket_map.clear() return servers @@ -620,7 +619,10 @@ def test_make_http_servers_socket_type_error(self): self.assertEqual(exc.args[0], 'Cannot determine socket type 999') def test_make_http_servers_noauth(self): - socketfile = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=True) as f: + socketfile = f.name + self.assertFalse(os.path.exists(socketfile)) + inet = {'family':socket.AF_INET, 'host':'localhost', 'port':17735, 'username':None, 'password':None, 'section':'inet_http_server'} unix = {'family':socket.AF_UNIX, 'file':socketfile, 'chmod':0o700, @@ -647,7 +649,10 @@ def test_make_http_servers_noauth(self): self.assertEqual([x.IDENT for x in server.handlers], idents) def test_make_http_servers_withauth(self): - socketfile = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=True) as f: + socketfile = f.name + self.assertFalse(os.path.exists(socketfile)) + inet = {'family':socket.AF_INET, 'host':'localhost', 'port':17736, 'username':'username', 'password':'password', 'section':'inet_http_server'} @@ -678,9 +683,3 @@ def more(self): return self.data.pop(0) else: return b'' - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_loggers.py b/supervisor/tests/test_loggers.py index 0742c1766..a9ae297fd 100644 --- a/supervisor/tests/test_loggers.py +++ b/supervisor/tests/test_loggers.py @@ -599,9 +599,3 @@ def emit(self, record): self.records.append(record) def close(self): self.closed = True - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_options.py b/supervisor/tests/test_options.py index 5c9a35f6d..769d2cb70 100644 --- a/supervisor/tests/test_options.py +++ b/supervisor/tests/test_options.py @@ -1153,6 +1153,16 @@ def record_usage(message): self.assertEqual(len(recorder), 1) self.assertEqual(recorder[0], "option --bad not recognized") + def test_realize_prefers_identifier_from_args(self): + text = lstrip(""" + [supervisord] + identifier=from_config_file + """) + instance = self._makeOne() + instance.configfile = StringIO(text) + instance.realize(args=['-i', 'from_args']) + self.assertEqual(instance.identifier, "from_args") + def test_options_afunix(self): instance = self._makeOne() text = lstrip("""\ @@ -1378,9 +1388,9 @@ def test_options_afinet_no_port(self): "section [inet_http_server] has no port value") def test_cleanup_afunix_unlink(self): - fn = tempfile.mktemp() - with open(fn, 'w') as f: - f.write('foo') + with tempfile.NamedTemporaryFile(delete=False) as f: + fn = f.name + f.write(b'foo') instance = self._makeOne() instance.unlink_socketfiles = True class Server: @@ -1392,10 +1402,10 @@ class Server: self.assertFalse(os.path.exists(fn)) def test_cleanup_afunix_nounlink(self): - fn = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=False) as f: + fn = f.name + f.write(b'foo') try: - with open(fn, 'w') as f: - f.write('foo') instance = self._makeOne() class Server: pass @@ -1413,10 +1423,10 @@ class Server: def test_cleanup_afunix_ignores_oserror_enoent(self): notfound = os.path.join(os.path.dirname(__file__), 'notfound') - socketname = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=False) as f: + socketname = f.name + f.write(b'foo') try: - with open(socketname, 'w') as f: - f.write('foo') instance = self._makeOne() instance.unlink_socketfiles = True class Server: @@ -1435,10 +1445,10 @@ class Server: pass def test_cleanup_removes_pidfile(self): - pidfile = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=False) as f: + pidfile = f.name + f.write(b'2') try: - with open(pidfile, 'w') as f: - f.write('2') instance = self._makeOne() instance.pidfile = pidfile instance.logger = DummyLogger() @@ -1459,10 +1469,9 @@ def test_cleanup_pidfile_ignores_oserror_enoent(self): instance.cleanup() # shouldn't raise def test_cleanup_does_not_remove_pidfile_from_another_supervisord(self): - pidfile = tempfile.mktemp() - - with open(pidfile, 'w') as f: - f.write('1234') + with tempfile.NamedTemporaryFile(delete=False) as f: + pidfile = f.name + f.write(b'1234') try: instance = self._makeOne() @@ -1480,10 +1489,10 @@ def test_cleanup_does_not_remove_pidfile_from_another_supervisord(self): pass def test_cleanup_closes_poller(self): - pidfile = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=False) as f: + pidfile = f.name + f.write(b'2') try: - with open(pidfile, 'w') as f: - f.write('2') instance = self._makeOne() instance.pidfile = pidfile @@ -1498,21 +1507,15 @@ def test_cleanup_closes_poller(self): except OSError: pass - def test_cleanup_fds_closes_5_upto_minfds_ignores_oserror(self): + @patch('os.closerange', Mock()) + def test_cleanup_fds_closes_5_upto_minfds(self): instance = self._makeOne() instance.minfds = 10 - closed = [] - def close(fd): - if fd == 7: - raise OSError - closed.append(fd) - - @patch('os.close', close) def f(): instance.cleanup_fds() f() - self.assertEqual(closed, [5,6,8,9]) + os.closerange.assert_called_with(5, 10) def test_close_httpservers(self): instance = self._makeOne() @@ -1584,7 +1587,10 @@ def test_reopenlogs(self): self.assertEqual(logger.data[0], 'supervisord logreopen') def test_write_pidfile_ok(self): - fn = tempfile.mktemp() + with tempfile.NamedTemporaryFile(delete=True) as f: + fn = f.name + self.assertFalse(os.path.exists(fn)) + try: instance = self._makeOne() instance.logger = DummyLogger() @@ -1678,6 +1684,20 @@ def test_processes_from_section(self): self.assertEqual(pconfig.environment, {'KEY1':'val1', 'KEY2':'val2', 'KEY3':'0'}) + def test_processes_from_section_environment_with_escaped_chars(self): + instance = self._makeOne() + text = lstrip("""\ + [program:foo] + command = /bin/foo + environment=VAR_WITH_P="some_value_%%_end" + """) + from supervisor.options import UnhosedConfigParser + config = UnhosedConfigParser() + config.read_string(text) + pconfigs = instance.processes_from_section(config, 'program:foo', 'bar') + expected = {'VAR_WITH_P': 'some_value_%_end'} + self.assertEqual(pconfigs[0].environment, expected) + def test_processes_from_section_host_node_name_expansion(self): instance = self._makeOne() text = lstrip("""\ @@ -1844,6 +1864,20 @@ def test_processes_from_section_redirect_stderr_with_auto(self): self.assertEqual(instance.parse_warnings, []) self.assertEqual(pconfigs[0].stderr_logfile, None) + def test_processes_from_section_accepts_number_for_stopsignal(self): + instance = self._makeOne() + text = lstrip("""\ + [program:foo] + command = /bin/foo + stopsignal = %d + """ % signal.SIGQUIT) + from supervisor.options import UnhosedConfigParser + config = UnhosedConfigParser() + config.read_string(text) + pconfigs = instance.processes_from_section(config, 'program:foo', 'bar') + self.assertEqual(instance.parse_warnings, []) + self.assertEqual(pconfigs[0].stopsignal, signal.SIGQUIT) + def test_options_with_environment_expansions(self): text = lstrip("""\ [supervisord] @@ -1859,7 +1893,7 @@ def test_options_with_environment_expansions(self): nocleanup = %(ENV_SUPD_NOCLEANUP)s childlogdir = %(ENV_HOME)s strip_ansi = %(ENV_SUPD_STRIP_ANSI)s - environment = FAKE_ENV_VAR=/some/path + environment = GLOBAL_ENV_VAR=%(ENV_SUPR_ENVIRONMENT_VALUE)s [inet_http_server] port=*:%(ENV_HTSRV_PORT)s @@ -1880,6 +1914,7 @@ def test_options_with_environment_expansions(self): startretries=%(ENV_CAT1_STARTRETRIES)s directory=%(ENV_CAT1_DIR)s umask=%(ENV_CAT1_UMASK)s + environment = PROGRAM_ENV_VAR=%(ENV_CAT1_ENVIRONMENT_VALUE)s """) from supervisor import datatypes from supervisor.options import UnhosedConfigParser @@ -1890,6 +1925,7 @@ def test_options_with_environment_expansions(self): 'ENV_HTSRV_PORT': '9210', 'ENV_HTSRV_USER': 'someuser', 'ENV_HTSRV_PASS': 'passwordhere', + 'ENV_SUPR_ENVIRONMENT_VALUE': 'from_supervisord_section', 'ENV_SUPD_LOGFILE_MAXBYTES': '51MB', 'ENV_SUPD_LOGFILE_BACKUPS': '10', 'ENV_SUPD_LOGLEVEL': 'info', @@ -1904,6 +1940,7 @@ def test_options_with_environment_expansions(self): 'ENV_CAT1_COMMAND_LOGDIR': '/path/to/logs', 'ENV_CAT1_PRIORITY': '3', 'ENV_CAT1_AUTOSTART': 'true', + 'ENV_CAT1_ENVIRONMENT_VALUE': 'from_program_section', 'ENV_CAT1_USER': 'root', # resolved to uid 'ENV_CAT1_STDOUT_LOGFILE': '/tmp/cat.log', 'ENV_CAT1_STDOUT_LOGFILE_MAXBYTES': '78KB', @@ -1969,7 +2006,11 @@ def test_options_with_environment_expansions(self): self.assertEqual(proc1.exitcodes, [0]) self.assertEqual(proc1.directory, '/tmp') self.assertEqual(proc1.umask, 2) - self.assertEqual(proc1.environment, dict(FAKE_ENV_VAR='/some/path')) + expected_env = { + 'GLOBAL_ENV_VAR': 'from_supervisord_section', + 'PROGRAM_ENV_VAR': 'from_program_section' + } + self.assertEqual(proc1.environment, expected_env) def test_options_supervisord_section_expands_here(self): instance = self._makeOne() @@ -2586,7 +2627,26 @@ def test_event_listener_pool_with_event_result_handler(self): gconfig1 = gconfigs[0] self.assertEqual(gconfig1.result_handler, dummy_handler) - def test_event_listener_pool_result_handler_unimportable(self): + def test_event_listener_pool_result_handler_unimportable_ImportError(self): + text = lstrip("""\ + [eventlistener:cat] + events=PROCESS_COMMUNICATION + command = /bin/cat + result_handler = thisishopefullynotanimportablepackage:nonexistent + """) + from supervisor.options import UnhosedConfigParser + config = UnhosedConfigParser() + config.read_string(text) + instance = self._makeOne() + try: + instance.process_groups_from_parser(config) + self.fail('nothing raised') + except ValueError as exc: + self.assertEqual(exc.args[0], + 'thisishopefullynotanimportablepackage:nonexistent cannot be ' + 'resolved within [eventlistener:cat]') + + def test_event_listener_pool_result_handler_unimportable_AttributeError(self): text = lstrip("""\ [eventlistener:cat] events=PROCESS_COMMUNICATION @@ -3316,6 +3376,19 @@ def test_daemonize_notifies_poller_before_and_after_fork(self): instance.poller.before_daemonize.assert_called_once_with() instance.poller.after_daemonize.assert_called_once_with() + def test_options_environment_of_supervisord_with_escaped_chars(self): + text = lstrip(""" + [supervisord] + environment=VAR_WITH_P="some_value_%%_end" + """) + + instance = self._makeOne() + instance.configfile = StringIO(text) + instance.realize(args=[]) + options = instance.configroot.supervisord + self.assertEqual(options.environment, dict(VAR_WITH_P="some_value_%_end")) + + class ProcessConfigTests(unittest.TestCase): def _getTargetClass(self): from supervisor.options import ProcessConfig @@ -3784,10 +3857,3 @@ def test_split_namespec(self): self.assertEqual(s('process'), ('process', 'process')) self.assertEqual(s('group:'), ('group', None)) self.assertEqual(s('group:*'), ('group', None)) - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') - diff --git a/supervisor/tests/test_poller.py b/supervisor/tests/test_poller.py index 1b12a8e1d..6d6049e92 100644 --- a/supervisor/tests/test_poller.py +++ b/supervisor/tests/test_poller.py @@ -1,4 +1,3 @@ -import sys import unittest import errno import select @@ -437,10 +436,3 @@ class FakeKEvent(object): def __init__(self, ident, filter): self.ident = ident self.filter = filter - - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_process.py b/supervisor/tests/test_process.py index bc9ade41c..24643b4dc 100644 --- a/supervisor/tests/test_process.py +++ b/supervisor/tests/test_process.py @@ -39,7 +39,7 @@ def test_getProcessStateDescription(self): from supervisor.states import ProcessStates from supervisor.process import getProcessStateDescription for statename, code in ProcessStates.__dict__.items(): - if isinstance(code, int): + if not statename.startswith("__"): self.assertEqual(getProcessStateDescription(code), statename) def test_ctor(self): @@ -783,7 +783,7 @@ def test_stop_report_laststopreport_in_future(self): # Sleep for 2 seconds time.sleep(2) - # This iteration of stop_report() should actaully trigger the report + # This iteration of stop_report() should actually trigger the report instance.stop_report() self.assertEqual(len(options.logger.data), 1) @@ -1322,8 +1322,8 @@ def test_cmp_bypriority(self): def test_transition_stopped_to_starting_supervisor_stopping(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, emitted_events.append) from supervisor.states import ProcessStates, SupervisorStates options = DummyOptions() options.mood = SupervisorStates.SHUTDOWN @@ -1335,12 +1335,14 @@ def test_transition_stopped_to_starting_supervisor_stopping(self): process.state = ProcessStates.STOPPED process.transition() self.assertEqual(process.state, ProcessStates.STOPPED) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_transition_stopped_to_starting_supervisor_running(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates, SupervisorStates options = DummyOptions() options.mood = SupervisorStates.RUNNING @@ -1351,14 +1353,16 @@ def test_transition_stopped_to_starting_supervisor_running(self): process.state = ProcessStates.STOPPED process.transition() self.assertEqual(process.state, ProcessStates.STARTING) - self.assertEqual(len(L), 1) - event = L[0] + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] self.assertEqual(event.__class__, events.ProcessStateStartingEvent) + self.assertEqual(event.from_state, ProcessStates.STOPPED) + self.assertEqual(state_when_event_emitted, ProcessStates.STARTING) def test_transition_exited_to_starting_supervisor_stopping(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, emitted_events.append) from supervisor.states import ProcessStates, SupervisorStates options = DummyOptions() options.mood = SupervisorStates.SHUTDOWN @@ -1374,12 +1378,14 @@ def test_transition_exited_to_starting_supervisor_stopping(self): process.transition() self.assertEqual(process.state, ProcessStates.EXITED) self.assertTrue(process.system_stop) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_transition_exited_to_starting_uncond_supervisor_running(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates options = DummyOptions() @@ -1391,14 +1397,18 @@ def test_transition_exited_to_starting_uncond_supervisor_running(self): process.state = ProcessStates.EXITED process.transition() self.assertEqual(process.state, ProcessStates.STARTING) - self.assertEqual(len(L), 1) - event = L[0] + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] self.assertEqual(event.__class__, events.ProcessStateStartingEvent) + self.assertEqual(event.from_state, ProcessStates.EXITED) + self.assertEqual(state_when_event_emitted, ProcessStates.STARTING) def test_transition_exited_to_starting_condit_supervisor_running(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates options = DummyOptions() @@ -1411,14 +1421,16 @@ def test_transition_exited_to_starting_condit_supervisor_running(self): process.exitstatus = 'bogus' process.transition() self.assertEqual(process.state, ProcessStates.STARTING) - self.assertEqual(len(L), 1) - event = L[0] + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] self.assertEqual(event.__class__, events.ProcessStateStartingEvent) + self.assertEqual(event.from_state, ProcessStates.EXITED) + self.assertEqual(state_when_event_emitted, ProcessStates.STARTING) def test_transition_exited_to_starting_condit_fls_supervisor_running(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, emitted_events.append) from supervisor.states import ProcessStates options = DummyOptions() @@ -1431,12 +1443,12 @@ def test_transition_exited_to_starting_condit_fls_supervisor_running(self): process.exitstatus = 0 process.transition() self.assertEqual(process.state, ProcessStates.EXITED) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_transition_backoff_to_starting_supervisor_stopping(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, lambda x: emitted_events.append(x)) from supervisor.states import ProcessStates, SupervisorStates options = DummyOptions() options.mood = SupervisorStates.SHUTDOWN @@ -1449,12 +1461,14 @@ def test_transition_backoff_to_starting_supervisor_stopping(self): process.state = ProcessStates.BACKOFF process.transition() self.assertEqual(process.state, ProcessStates.BACKOFF) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_transition_backoff_to_starting_supervisor_running(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates, SupervisorStates options = DummyOptions() options.mood = SupervisorStates.RUNNING @@ -1467,13 +1481,16 @@ def test_transition_backoff_to_starting_supervisor_running(self): process.state = ProcessStates.BACKOFF process.transition() self.assertEqual(process.state, ProcessStates.STARTING) - self.assertEqual(len(L), 1) - self.assertEqual(L[0].__class__, events.ProcessStateStartingEvent) + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] + self.assertEqual(event.__class__, events.ProcessStateStartingEvent) + self.assertEqual(event.from_state, ProcessStates.BACKOFF) + self.assertEqual(state_when_event_emitted, ProcessStates.STARTING) def test_transition_backoff_to_starting_supervisor_running_notyet(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, lambda x: emitted_events.append(x)) from supervisor.states import ProcessStates, SupervisorStates options = DummyOptions() options.mood = SupervisorStates.RUNNING @@ -1486,12 +1503,14 @@ def test_transition_backoff_to_starting_supervisor_running_notyet(self): process.state = ProcessStates.BACKOFF process.transition() self.assertEqual(process.state, ProcessStates.BACKOFF) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_transition_starting_to_running(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates options = DummyOptions() @@ -1516,14 +1535,18 @@ def test_transition_starting_to_running(self): self.assertEqual(options.logger.data[0], 'success: process entered RUNNING state, process has ' 'stayed up for > than 10 seconds (startsecs)') - self.assertEqual(len(L), 1) - event = L[0] + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] self.assertEqual(event.__class__, events.ProcessStateRunningEvent) + self.assertEqual(event.from_state, ProcessStates.STARTING) + self.assertEqual(state_when_event_emitted, ProcessStates.RUNNING) def test_transition_starting_to_running_laststart_in_future(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates future_time = time.time() + 3600 # 1 hour into the future @@ -1556,7 +1579,7 @@ def test_transition_starting_to_running_laststart_in_future(self): # Sleep for (startsecs + 1) time.sleep(test_startsecs + 1) - # This iteration of transition() should actaully trigger the state + # This iteration of transition() should actually trigger the state # transition to RUNNING process.transition() @@ -1568,14 +1591,18 @@ def test_transition_starting_to_running_laststart_in_future(self): self.assertEqual(options.logger.data[0], 'success: process entered RUNNING state, process has ' 'stayed up for > than {} seconds (startsecs)'.format(test_startsecs)) - self.assertEqual(len(L), 1) - event = L[0] + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] self.assertEqual(event.__class__, events.ProcessStateRunningEvent) + self.assertEqual(event.from_state, ProcessStates.STARTING) + self.assertEqual(state_when_event_emitted, ProcessStates.RUNNING) def test_transition_backoff_to_starting_delay_in_future(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates future_time = time.time() + 3600 # 1 hour into the future @@ -1598,18 +1625,23 @@ def test_transition_backoff_to_starting_delay_in_future(self): # Ensure process.delay has rolled backward self.assertTrue(process.delay < future_time) - # This iteration of transition() should actaully trigger the state + # This iteration of transition() should actually trigger the state # transition to STARTING process.transition() self.assertEqual(process.state, ProcessStates.STARTING) - self.assertEqual(len(L), 1) - self.assertEqual(L[0].__class__, events.ProcessStateStartingEvent) + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] + self.assertEqual(event.__class__, events.ProcessStateStartingEvent) + self.assertEqual(event.from_state, ProcessStates.BACKOFF) + self.assertEqual(state_when_event_emitted, ProcessStates.STARTING) def test_transition_backoff_to_fatal(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events_with_states = [] + def subscriber(e): + emitted_events_with_states.append((e, e.process.state)) + events.subscribe(events.ProcessStateEvent, subscriber) from supervisor.states import ProcessStates options = DummyOptions() @@ -1633,14 +1665,16 @@ def test_transition_backoff_to_fatal(self): self.assertEqual(options.logger.data[0], 'gave up: process entered FATAL state, too many start' ' retries too quickly') - self.assertEqual(len(L), 1) - event = L[0] + self.assertEqual(len(emitted_events_with_states), 1) + event, state_when_event_emitted = emitted_events_with_states[0] self.assertEqual(event.__class__, events.ProcessStateFatalEvent) + self.assertEqual(event.from_state, ProcessStates.BACKOFF) + self.assertEqual(state_when_event_emitted, ProcessStates.FATAL) def test_transition_stops_unkillable_notyet(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, emitted_events.append) from supervisor.states import ProcessStates options = DummyOptions() @@ -1651,12 +1685,12 @@ def test_transition_stops_unkillable_notyet(self): process.transition() self.assertEqual(process.state, ProcessStates.STOPPING) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_transition_stops_unkillable(self): from supervisor import events - L = [] - events.subscribe(events.ProcessStateEvent, lambda x: L.append(x)) + emitted_events = [] + events.subscribe(events.ProcessStateEvent, emitted_events.append) from supervisor.states import ProcessStates options = DummyOptions() @@ -1674,7 +1708,7 @@ def test_transition_stops_unkillable(self): self.assertEqual(options.logger.data[0], "killing 'process' (1) with SIGKILL") self.assertEqual(options.kills[1], signal.SIGKILL) - self.assertEqual(L, []) + self.assertEqual(emitted_events, []) def test_change_state_doesnt_notify_if_no_state_change(self): options = DummyOptions() diff --git a/supervisor/tests/test_rpcinterfaces.py b/supervisor/tests/test_rpcinterfaces.py index a6a319871..578ccbc61 100644 --- a/supervisor/tests/test_rpcinterfaces.py +++ b/supervisor/tests/test_rpcinterfaces.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import unittest -import sys import operator import os import time @@ -18,6 +17,7 @@ from supervisor.compat import as_string, PY2 from supervisor.datatypes import Automatic +from supervisor.datatypes import RestartWhenExitUnexpected class TestBase(unittest.TestCase): def setUp(self): @@ -369,6 +369,18 @@ def test_startProcess_file_not_found(self): self._assertRPCError(xmlrpc.Faults.NO_FILE, interface.startProcess, 'foo') + def test_startProcess_bad_command(self): + options = DummyOptions() + pconfig = DummyPConfig(options, 'foo', '/foo/bar', autostart=False) + from supervisor.options import BadCommand + supervisord = PopulatedDummySupervisor(options, 'foo', pconfig) + process = supervisord.process_groups['foo'].processes['foo'] + process.execv_arg_exception = BadCommand + interface = self._makeOne(supervisord) + from supervisor import xmlrpc + self._assertRPCError(xmlrpc.Faults.NOT_EXECUTABLE, + interface.startProcess, 'foo') + def test_startProcess_file_not_executable(self): options = DummyOptions() pconfig = DummyPConfig(options, 'foo', '/foo/bar', autostart=False) @@ -1135,10 +1147,12 @@ def test_getAllConfigInfo(self): supervisord = DummySupervisor(options, 'foo') pconfig1 = DummyPConfig(options, 'process1', __file__, + autorestart=False, stdout_logfile=Automatic, stderr_logfile=Automatic, ) pconfig2 = DummyPConfig(options, 'process2', __file__, + autorestart=RestartWhenExitUnexpected, stdout_logfile=None, stderr_logfile=None, ) @@ -1149,6 +1163,7 @@ def test_getAllConfigInfo(self): interface = self._makeOne(supervisord) configs = interface.getAllConfigInfo() self.assertEqual(configs[0]['autostart'], True) + self.assertEqual(configs[0]['autorestart'], False) self.assertEqual(configs[0]['stopwaitsecs'], 10) self.assertEqual(configs[0]['stdout_events_enabled'], False) self.assertEqual(configs[0]['stderr_events_enabled'], False) @@ -1176,6 +1191,7 @@ def test_getAllConfigInfo(self): assert 'test_rpcinterfaces.py' in configs[0]['command'] self.assertEqual(configs[1]['autostart'], True) + self.assertEqual(configs[1]['autorestart'], "unexpected") self.assertEqual(configs[1]['stopwaitsecs'], 10) self.assertEqual(configs[1]['stdout_events_enabled'], False) self.assertEqual(configs[1]['stderr_events_enabled'], False) @@ -2380,14 +2396,6 @@ def test_it(self): ) - class DummyRPCInterface: def hello(self): return 'Hello!' - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') - diff --git a/supervisor/tests/test_socket_manager.py b/supervisor/tests/test_socket_manager.py index 626d78604..f4fdf8db2 100644 --- a/supervisor/tests/test_socket_manager.py +++ b/supervisor/tests/test_socket_manager.py @@ -1,7 +1,6 @@ """Test suite for supervisor.socket_manager""" import gc -import sys import os import unittest import socket @@ -248,9 +247,3 @@ def gc_collect(): gc.collect() gc.collect() gc.collect() - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_states.py b/supervisor/tests/test_states.py index ba8e58fb7..93b6c49cf 100644 --- a/supervisor/tests/test_states.py +++ b/supervisor/tests/test_states.py @@ -1,6 +1,5 @@ """Test suite for supervisor.states""" -import sys import unittest from supervisor import states @@ -50,10 +49,3 @@ def test_getEventListenerStateDescription_returns_string_when_found(self): def test_getEventListenerStateDescription_returns_None_when_not_found(self): self.assertEqual(states.getEventListenerStateDescription(3.14159), None) - - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_supervisorctl.py b/supervisor/tests/test_supervisorctl.py index 16bdb0717..04c1840c8 100644 --- a/supervisor/tests/test_supervisorctl.py +++ b/supervisor/tests/test_supervisorctl.py @@ -1,4 +1,3 @@ -import sys import unittest from supervisor import xmlrpc from supervisor.compat import StringIO @@ -1287,6 +1286,16 @@ def test_shutdown_help(self): out = plugin.ctl.stdout.getvalue() self.assertTrue("Shut the remote supervisord down" in out) + def test_shutdown_with_arg_shows_error(self): + plugin = self._makeOne() + options = plugin.ctl.options + result = plugin.do_shutdown('bad') + self.assertEqual(result, None) + self.assertEqual(options._server.supervisor._shutdown, False) + val = plugin.ctl.stdout.getvalue() + self.assertTrue(val.startswith('Error: shutdown accepts no arguments'), val) + self.assertEqual(plugin.ctl.exitstatus, LSBInitExitStatuses.GENERIC) + def test_shutdown(self): plugin = self._makeOne() options = plugin.ctl.options @@ -2057,10 +2066,3 @@ def __init__(self, controller=None): def do_help(self, arg): self.helped = True - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') - diff --git a/supervisor/tests/test_supervisord.py b/supervisor/tests/test_supervisord.py index 3d7b4ffad..4099bba6c 100644 --- a/supervisor/tests/test_supervisord.py +++ b/supervisor/tests/test_supervisord.py @@ -834,10 +834,3 @@ def callback(event): self.assertEqual(supervisord.ticks[3600], 3600) self.assertEqual(len(L), 6) self.assertEqual(L[-1].__class__, events.Tick3600Event) - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') - diff --git a/supervisor/tests/test_templating.py b/supervisor/tests/test_templating.py index 29311a796..bf35fb9fa 100644 --- a/supervisor/tests/test_templating.py +++ b/supervisor/tests/test_templating.py @@ -6,7 +6,6 @@ import unittest import re -import sys _SIMPLE_XML = r""" @@ -1785,12 +1784,3 @@ def normalize_xml(s): s = re.sub(r"(?s)\s+<", "<", s) s = re.sub(r"(?s)>\s+", ">", s) return s - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -def main(): - unittest.main(defaultTest='test_suite') - -if __name__ == '__main__': - main() diff --git a/supervisor/tests/test_web.py b/supervisor/tests/test_web.py index 8bae3eddd..af04abb81 100644 --- a/supervisor/tests/test_web.py +++ b/supervisor/tests/test_web.py @@ -1,4 +1,3 @@ -import sys import unittest from supervisor.tests.base import DummySupervisor @@ -177,9 +176,3 @@ def test_render_refresh(self): class DummyContext: pass - -def test_suite(): - return unittest.findTestCases(sys.modules[__name__]) - -if __name__ == '__main__': - unittest.main(defaultTest='test_suite') diff --git a/supervisor/tests/test_xmlrpc.py b/supervisor/tests/test_xmlrpc.py index 3d49ce04b..8cee058ec 100644 --- a/supervisor/tests/test_xmlrpc.py +++ b/supervisor/tests/test_xmlrpc.py @@ -917,4 +917,3 @@ def request(self, *arg, **kw): def close(self): self.closed = True - diff --git a/supervisor/version.txt b/supervisor/version.txt index b75a1c1c0..5f8ceca6f 100644 --- a/supervisor/version.txt +++ b/supervisor/version.txt @@ -1 +1 @@ -4.3.0.dev0 +4.4.0.dev0 diff --git a/supervisor/web.py b/supervisor/web.py index ee1568165..926e8d43f 100644 --- a/supervisor/web.py +++ b/supervisor/web.py @@ -8,6 +8,7 @@ from supervisor.compat import urllib from supervisor.compat import urlparse +from supervisor.compat import as_bytes from supervisor.compat import as_string from supervisor.compat import PY2 from supervisor.compat import unicode @@ -179,7 +180,7 @@ def __call__(self): headers['Pragma'] = 'no-cache' headers['Cache-Control'] = 'no-cache' headers['Expires'] = http_date.build_http_date(0) - response['body'] = as_string(body) + response['body'] = as_bytes(body) return response def render(self): diff --git a/supervisor/xmlrpc.py b/supervisor/xmlrpc.py index 9a1488b86..025ada883 100644 --- a/supervisor/xmlrpc.py +++ b/supervisor/xmlrpc.py @@ -9,7 +9,7 @@ from supervisor.compat import xmlrpclib from supervisor.compat import StringIO -from supervisor.compat import urllib +from supervisor.compat import urlparse from supervisor.compat import as_bytes from supervisor.compat import as_string from supervisor.compat import encodestring @@ -486,13 +486,10 @@ def __init__(self, username=None, password=None, serverurl=None): self.verbose = False self.serverurl = serverurl if serverurl.startswith('http://'): - type, uri = urllib.splittype(serverurl) - host, path = urllib.splithost(uri) - host, port = urllib.splitport(host) + parsed = urlparse.urlparse(serverurl) + host, port = parsed.hostname, parsed.port if port is None: port = 80 - else: - port = int(port) def get_connection(host=host, port=port): return httplib.HTTPConnection(host, port) self._get_connection = get_connection @@ -601,5 +598,3 @@ def gettags(comment): tags.append((tag_lineno, tag, datatype, name, '\n'.join(tag_text))) return tags - - diff --git a/tox.ini b/tox.ini index 74dad21dd..837b419f0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,47 +1,55 @@ [tox] envlist = - cover,cover3,docs,py27,py34,py35,py36,py37,py38 + cover,cover3,docs,py27,py34,py35,py36,py37,py38,py39,py310,py311,py312,py313,py314 [testenv] deps = - attrs < 21.1.0 # see https://github.com/python-attrs/attrs/pull/608 - pytest + attrs < 21.1.0 # see https://github.com/python-attrs/attrs/pull/608 pexpect == 4.7.0 # see https://github.com/Supervisor/supervisor/issues/1327 - mock >= 0.5.0 + pytest passenv = END_TO_END commands = - py.test {posargs} + pytest --capture=no {posargs} -[testenv:py27-configparser] -;see https://github.com/Supervisor/supervisor/issues/1230 +[testenv:py27] basepython = python2.7 deps = {[testenv]deps} - configparser + mock >= 0.5.0 passenv = {[testenv]passenv} commands = {[testenv]commands} +[testenv:py27-configparser] +;see https://github.com/Supervisor/supervisor/issues/1230 +basepython = python2.7 +deps = + {[testenv:py27]deps} + configparser +passenv = {[testenv:py27]passenv} +commands = {[testenv:py27]commands} + [testenv:cover] basepython = python2.7 -commands = - py.test --cov=supervisor --cov-report=term-missing --cov-report=xml {posargs} deps = - {[testenv]deps} + {[testenv:py27]deps} pytest-cov +commands = + pytest --capture=no --cov=supervisor --cov-report=term-missing --cov-report=xml {posargs} [testenv:cover3] -basepython = python3.7 +basepython = python3.8 commands = - py.test --cov=supervisor --cov-report=term-missing --cov-report=xml {posargs} + pytest --capture=no --cov=supervisor --cov-report=term-missing --cov-report=xml {posargs} deps = {[testenv:cover]deps} [testenv:docs] deps = + pygments >= 2.19.1 # Sphinx build fails on 2.19.0 when highlighting ini block Sphinx readme setuptools >= 18.5 -whitelist_externals = make +allowlist_externals = make commands = make -C docs html BUILDDIR={envtmpdir} "SPHINXOPTS=-W -E" python setup.py check -m -r -s