Permalink
Browse files

Initial commit

  • Loading branch information...
0 parents commit d38e2265e5d6dd34b877634eb44fe31c87f02015 @nickstenning nickstenning committed Aug 3, 2012
Showing with 641 additions and 0 deletions.
  1. +5 −0 .gitignore
  2. +11 −0 .travis.yml
  3. +7 −0 LICENSE
  4. +100 −0 README.rst
  5. +43 −0 setup.py
  6. 0 test/__init__.py
  7. +9 −0 test/helpers.py
  8. +157 −0 test/test_herder.py
  9. +13 −0 tox.ini
  10. +1 −0 unicornherder/__init__.py
  11. +58 −0 unicornherder/command.py
  12. +219 −0 unicornherder/herder.py
  13. +18 −0 unicornherder/timeout.py
@@ -0,0 +1,5 @@
+*.pyc
+/.coverage
+/.tox
+/*.egg-info
+
@@ -0,0 +1,11 @@
+# Validate this file using http://lint.travis-ci.org/
+language: python
+python:
+ - 2.6
+ - 2.7
+ - pypy
+ - 3.2
+install:
+ - pip install . --use-mirrors
+ - pip install nose mock --use-mirrors
+script: nosetests
@@ -0,0 +1,7 @@
+Copyright (c) 2012 Government Digital Service
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,100 @@
+Unicorn Herder
+==============
+
+.. image:: https://secure.travis-ci.org/alphagov/unicornherder.png
+ :target: http://travis-ci.org/alphagov/unicornherder
+
+`Unicorn <http://unicorn.bogomips.org/>`_ and `Gunicorn
+<http://gunicorn.org/>`_ are awesome tools for people writing web services in
+Ruby and Python. One of the more nifty features of both programs is their
+ability to reload application code on-the-fly, by spawning a new master
+process (or "arbiter", in Gunicorn's language) in response to an operating
+system signal (SIGUSR2). Unfortunately, this reloading process is incompatible
+with process-tracking supervisors such as `Upstart
+<http://upstart.ubuntu.com/>`_, because the old master process dies as part of
+the reload.
+
+Unicorn Herder is a utility designed to assist in the use of Upstart and
+similar supervisors with Unicorn. It does this by polling the pidfile written
+by the Unicorn master process, and automating the sequence of signals that
+must be sent to the master to do a "hot-reload". If Unicorn quits, so will the
+Unicorn Herder, meaning that if you supervise the herder (which does not
+daemonize), you are effectively supervising the Unicorn process.
+
+Installation
+------------
+
+Unicorn Herder is available from the `Python Package Index
+<http://pypi.python.org/>`_, and can be installed with `pip
+<http://pipinstaller.org/>`_::
+
+ $ pip install unicornherder
+
+Usage
+-----
+
+With gunicorn::
+
+ $ unicornherder -- -w 4 myapp:app
+
+With unicorn (using `Bundler <http://gembundler.com>`_)::
+
+ $ bundle exec unicornherder -u unicorn
+
+Signals
+-------
+
+Unicorn Herder forwards the following signals to the unicorn master process::
+
+ INT QUIT TERM TTIN TTOU USR1 USR2
+
+Notably, Unicorn Herder does *not* forward ``SIGWINCH``, because it is not
+intended to be daemonized.
+
+Unicorn Herder *also* intercepts ``SIGHUP``, because this is the signal sent by
+Upstart when you call ``initctl reload``, and uses it to trigger a hot-reload of
+its Unicorn instance. This process will take two minutes, in order to give the
+new workers time to start up.
+
+**NB**: There will be a period during hot-reload when requests are served by
+both old and new workers. This might have serious implications if you are
+running data migrations between deploying versions of your application. Please
+bear this in mind when deciding if you should use Unicorn Herder's
+hot-reloading feature.
+
+Upstart config
+--------------
+
+An example upstart config (compatible with Upstart v1.4 and later) for use
+with Unicorn Herder is given below::
+
+ description "Unicorn Herder"
+
+ start on runlevel [2345]
+ stop on runlevel [!2345]
+
+ respawn
+ respawn limit 5 20
+
+ env PORT=4567
+
+ setuid www
+ setgid www
+
+ chdir /var/apps/myapp
+
+ exec bundle exec unicornherder -u unicorn -- --port $PORT
+
+ # Or, for a gunicorn installation with a virtualenv
+ # at /var/venv/myapp...
+
+ #script
+ # . /var/venv/myapp/bin/activate
+ # exec unicornherder -- -w 4 -b "127.0.0.1:$PORT" myapp:app
+ #end script
+
+License
+-------
+
+Unicorn Herder is released under the MIT license, a copy of which can be found
+in ``LICENSE``.
@@ -0,0 +1,43 @@
+import os
+import sys
+from setuptools import setup, find_packages
+from unicornherder import __version__
+
+install_requires = [
+ 'psutil==0.5.1',
+]
+
+if sys.version_info < (2, 7):
+ install_requires.append('argparse')
+
+HERE = os.path.dirname(__file__)
+try:
+ long_description = open(os.path.join(HERE, 'README.rst')).read()
+except:
+ long_description = None
+
+setup(
+ name='unicornherder',
+ version=__version__,
+ packages=find_packages(),
+
+ # metadata for upload to PyPI
+ author='Nick Stenning',
+ author_email='nick@whiteink.com',
+ maintainer='Government Digital Service',
+ url='https://github.com/alphagov/unicornherder',
+
+ description='Unicorn Herder: manage daemonized (g)unicorns',
+ long_description=long_description,
+
+ license='MIT',
+
+ keywords='sysadmin process supervision unicorn gunicorn upstart',
+ install_requires=install_requires,
+
+ entry_points={
+ 'console_scripts': [
+ 'unicornherder = unicornherder.command:main'
+ ]
+ }
+)
No changes.
@@ -0,0 +1,9 @@
+import contextlib
+
+from nose.tools import *
+from mock import *
+
+@contextlib.contextmanager
+def fake_timeout_fail(*args, **kwargs):
+ from unicornherder.timeout import TimeoutError
+ raise TimeoutError()
@@ -0,0 +1,157 @@
+import signal
+import sys
+from .helpers import *
+from unicornherder.herder import Herder, HerderError
+
+if sys.version_info > (3, 0):
+ builtin_mod = 'builtins'
+else:
+ builtin_mod = '__builtin__'
+
+
+class TestHerder(object):
+
+ def test_init_defaults(self):
+ h = Herder()
+ assert_equal(h.unicorn, 'gunicorn')
+ assert_equal(h.pidfile, 'gunicorn.pid')
+ assert_equal(h.args, '')
+
+ def test_init_unicorn(self):
+ h = Herder(unicorn='unicorn')
+ assert_equal(h.unicorn, 'unicorn')
+
+ def test_init_gunicorn(self):
+ h = Herder(unicorn='gunicorn')
+ assert_equal(h.unicorn, 'gunicorn')
+
+ def test_init_unicornbad(self):
+ assert_raises(HerderError, Herder, unicorn='unicornbad')
+
+ @patch('unicornherder.herder.subprocess.Popen')
+ def test_spawn_returns_true(self, popen_mock):
+ h = Herder()
+ h._boot_loop = lambda: True
+ assert_true(h.spawn())
+
+ @patch('unicornherder.herder.subprocess.Popen')
+ def test_spawn_gunicorn(self, popen_mock):
+ h = Herder(unicorn='gunicorn')
+ h._boot_loop = lambda: True
+ h.spawn()
+ assert_equal(popen_mock.call_count, 1)
+ popen_mock.assert_called_once_with(['gunicorn', '-D', '-p', 'gunicorn.pid'])
+
+ @patch('unicornherder.herder.subprocess.Popen')
+ def test_spawn_unicorn(self, popen_mock):
+ h = Herder(unicorn='unicorn')
+ h._boot_loop = lambda: True
+ h.spawn()
+ assert_equal(popen_mock.call_count, 1)
+ popen_mock.assert_called_once_with(['unicorn', '-D', '-P', 'unicorn.pid'])
+
+ @patch('unicornherder.herder.subprocess.Popen')
+ @patch('unicornherder.herder.timeout')
+ def test_spawn_unicorn_timeout(self, timeout_mock, popen_mock):
+ popen_mock.return_value.pid = -1
+ timeout_mock.side_effect = fake_timeout_fail
+ h = Herder()
+ h._boot_loop = lambda: True
+ popen_mock.return_value.poll.return_value = None
+ ret = h.spawn()
+ assert_false(ret)
+ popen_mock.return_value.terminate.assert_called_once_with()
+
+ @patch('unicornherder.herder.psutil.Process')
+ @patch('%s.open' % builtin_mod)
+ def test_loop_valid_pid(self, open_mock, process_mock):
+ open_mock.return_value.read.return_value = '123\n'
+ h = Herder()
+ ret = h._loop_inner()
+ assert_equal(ret, 0)
+ process_mock.assert_called_once_with(123)
+
+ @patch('%s.open' % builtin_mod)
+ def test_loop_invalid_pid(self, open_mock):
+ open_mock.return_value.read.return_value = 'foobar'
+ h = Herder()
+ ret = h._loop_inner()
+ assert_equal(ret, 1)
+
+ @patch('%s.open' % builtin_mod)
+ def test_loop_nonexistent_pidfile(self, open_mock):
+ def _fail():
+ raise IOError()
+ open_mock.return_value.read.side_effect = _fail
+ h = Herder()
+ ret = h._loop_inner()
+ assert_equal(ret, 1)
+
+ @patch('unicornherder.herder.time.sleep')
+ @patch('unicornherder.herder.psutil.Process')
+ @patch('%s.open' % builtin_mod)
+ def test_loop_detects_pidchange(self, open_mock, process_mock, sleep_mock):
+ proc1 = MagicMock()
+ proc2 = MagicMock()
+ proc1.pid = 123
+ proc2.pid = 456
+
+ h = Herder()
+
+ open_mock.return_value.read.return_value = '123\n'
+ process_mock.return_value = proc1
+ ret = h._loop_inner()
+ assert_equal(ret, 0)
+
+ open_mock.return_value.read.return_value = '456\n'
+ process_mock.return_value = proc2
+ ret = h._loop_inner()
+ assert_equal(ret, 0)
+
+ expected_calls = []
+ assert_equal(proc1.mock_calls, expected_calls)
+
+ @patch('unicornherder.herder.time.sleep')
+ @patch('unicornherder.herder.psutil.Process')
+ @patch('%s.open' % builtin_mod)
+ def test_loop_reload_pidchange_signals(self, open_mock, process_mock,
+ sleep_mock):
+ proc1 = MagicMock()
+ proc2 = MagicMock()
+ proc1.pid = 123
+ proc2.pid = 456
+
+ h = Herder()
+
+ open_mock.return_value.read.return_value = '123\n'
+ process_mock.return_value = proc1
+ ret = h._loop_inner()
+ assert_equal(ret, 0)
+
+ # Simulate SIGHUP
+ h._handle_HUP(signal.SIGHUP, None)
+
+ open_mock.return_value.read.return_value = '456\n'
+ process_mock.return_value = proc2
+ ret = h._loop_inner()
+ assert_equal(ret, 0)
+
+ expected_calls = [call.send_signal(signal.SIGUSR2),
+ call.send_signal(signal.SIGWINCH),
+ call.send_signal(signal.SIGQUIT)]
+ assert_equal(proc1.mock_calls, expected_calls)
+
+ def test_forward_signal(self):
+ h = Herder()
+ h.master = MagicMock()
+
+ h._handle_signal('INT')(signal.SIGINT, None)
+ h.master.send_signal.assert_called_once_with(signal.SIGINT)
+
+ def test_forward_signal_nomaster(self):
+ h = Herder()
+ h._handle_signal('INT')(signal.SIGINT, None)
+
+ def test_handle_hup_nomaster(self):
+ h = Herder()
+ h._handle_HUP(signal.SIGHUP, None)
13 tox.ini
@@ -0,0 +1,13 @@
+# Tox (http://tox.testrun.org/) is a tool for running tests
+# in multiple virtualenvs. This configuration file will run the
+# test suite on all supported python versions. To use it, "pip install tox"
+# and then run "tox" from this directory.
+
+[tox]
+envlist = py26, py27, py32, pypy
+
+[testenv]
+deps =
+ nose
+ mock
+commands = nosetests
@@ -0,0 +1 @@
+__version__ = "0.0.1"
Oops, something went wrong.

0 comments on commit d38e226

Please sign in to comment.