← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/missing-rabbit into lp:maas

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/missing-rabbit into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/maas/missing-rabbit/+merge/98781

MAAS currently oopses when it can't connect to RabbitMQ.  That's bad.

This branch makes the test suite pass when rabbit is not present:
 * Introduces a new NoRabbit exception for “no Rabbit found running.”
 * Disables longpoll when no rabbit is found.
 * Logs warning, but does not break, when model changes can't be published on rabbit.
 * Otherwise, still breaks as before (and as expected) when rabbit runs amok.

Any tests that actually require rabbit fire up their own instance, so the test suite passes unchanged.  Plus of course I added some new tests for the new behaviours.

In the rabbit tests, I made the use of Rabbit explicit, and demoted the test-case-with-rabbit base class from a TestCase (it didn't contain any tests itself and clearly wasn't meant to) to a helper mix-in.

Note that RabbitSession.connection looks like a property, but it makes network connections so it can take oodles of time and fail in any number of ways.  This is very bad style, but I left it in place because Doris is feeling a little cold this morning.  It also means that I need to test failures with ExpectedException instead of a simple self.assertRaises: I can't pass session.connection to assertRaises without evaluating it.


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/missing-rabbit/+merge/98781
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/missing-rabbit into lp:maas.
=== modified file 'src/maasserver/exceptions.py'
--- src/maasserver/exceptions.py	2012-03-19 06:04:57 +0000
+++ src/maasserver/exceptions.py	2012-03-22 05:31:18 +0000
@@ -34,6 +34,10 @@
     """System profile does not exist."""
 
 
+class NoRabbit(MAASException):
+    """Could not reach RabbitMQ."""
+
+
 class MAASAPIException(Exception):
     """Base class for MAAS' API exceptions.
 

=== modified file 'src/maasserver/messages.py'
--- src/maasserver/messages.py	2012-03-15 13:58:32 +0000
+++ src/maasserver/messages.py	2012-03-22 05:31:18 +0000
@@ -20,6 +20,7 @@
     ABCMeta,
     abstractmethod,
     )
+from logging import getLogger
 
 from django.conf import settings
 from django.core.serializers.json import DjangoJSONEncoder
@@ -27,6 +28,7 @@
     post_delete,
     post_save,
     )
+from maasserver.exceptions import NoRabbit
 from maasserver.models import Node
 from maasserver.rabbit import RabbitMessaging
 
@@ -62,16 +64,26 @@
     def create_msg(self, event_name, instance):
         """Format a message from the given event_name and instance."""
 
+    def publish_message(self, message):
+        """Attempt to publish `message` on the producer.
+
+        If RabbitMQ is not available, log an error message but return
+        normally.
+        """
+        try:
+            self.producer.publish(message)
+        except NoRabbit as e:
+            getLogger('maasserver').warn("Could not reach RabbitMQ: %s", e)
+
     def update_obj(self, sender, instance, created, **kwargs):
         event_name = (
             MESSENGER_EVENT.CREATED if created
             else MESSENGER_EVENT.UPDATED)
-        message = self.create_msg(event_name, instance)
-        self.producer.publish(message)
+        self.publish_message(self.create_msg(event_name, instance))
 
     def delete_obj(self, sender, instance, **kwargs):
         message = self.create_msg(MESSENGER_EVENT.DELETED, instance)
-        self.producer.publish(message)
+        self.publish_message(message)
 
     def register(self):
         post_save.connect(

=== modified file 'src/maasserver/rabbit.py'
--- src/maasserver/rabbit.py	2012-03-13 11:43:09 +0000
+++ src/maasserver/rabbit.py	2012-03-22 05:31:18 +0000
@@ -17,20 +17,29 @@
     ]
 
 
+from errno import ECONNREFUSED
+import socket
 import threading
 
 from amqplib import client_0_8 as amqp
 from django.conf import settings
+from maasserver.exceptions import NoRabbit
 
 
 def connect():
     """Connect to AMQP."""
-    return amqp.Connection(
-        host=settings.RABBITMQ_HOST,
-        userid=settings.RABBITMQ_USERID,
-        password=settings.RABBITMQ_PASSWORD,
-        virtual_host=settings.RABBITMQ_VIRTUAL_HOST,
-        insist=False)
+    try:
+        return amqp.Connection(
+            host=settings.RABBITMQ_HOST,
+            userid=settings.RABBITMQ_USERID,
+            password=settings.RABBITMQ_PASSWORD,
+            virtual_host=settings.RABBITMQ_VIRTUAL_HOST,
+            insist=False)
+    except socket.error as e:
+        if e.errno == ECONNREFUSED:
+            raise NoRabbit(e.message)
+        else:
+            raise
 
 
 class RabbitSession(threading.local):

=== modified file 'src/maasserver/tests/test_messages.py'
--- src/maasserver/tests/test_messages.py	2012-03-15 13:58:32 +0000
+++ src/maasserver/tests/test_messages.py	2012-03-22 05:31:18 +0000
@@ -12,7 +12,9 @@
 __all__ = []
 
 import json
+import socket
 
+from maasserver.exceptions import NoRabbit
 from maasserver.messages import (
     MAASMessenger,
     MESSENGER_EVENT,
@@ -97,6 +99,41 @@
         self.assertEqual(
             [[MESSENGER_EVENT.DELETED, obj]], producer.messages)
 
+    def test_publish_message_publishes_message(self):
+        event = factory.getRandomString()
+        instance = {factory.getRandomString(): factory.getRandomString()}
+        messenger = TestMessenger(MessagesTestModel, FakeProducer())
+        messenger.publish_message(messenger.create_msg(event, instance))
+        self.assertEqual([[event, instance]], messenger.producer.messages)
+
+    def test_publish_message_swallows_missing_rabbit(self):
+        event = factory.getRandomString()
+        instance = {factory.getRandomString(): factory.getRandomString()}
+
+        def fail_for_lack_of_rabbit(*args, **kwargs):
+            raise NoRabbit("I'm pretending not to have a RabbitMQ.")
+
+        messenger = TestMessenger(MessagesTestModel, FakeProducer())
+        messenger.producer.publish = fail_for_lack_of_rabbit
+
+        messenger.publish_message(messenger.create_msg(event, instance))
+        self.assertEqual([], messenger.producer.messages)
+
+    def test_publish_message_propagates_exceptions(self):
+        event = factory.getRandomString()
+        instance = {factory.getRandomString(): factory.getRandomString()}
+
+        def fail_despite_having_a_rabbit(*args, **kwargs):
+            raise socket.error("I have a rabbit but I fail anyway.")
+
+        messenger = TestMessenger(MessagesTestModel, FakeProducer())
+        messenger.producer.publish = fail_despite_having_a_rabbit
+
+        self.assertRaises(
+            socket.error,
+            messenger.publish_message, messenger.create_msg(event, instance))
+        self.assertEqual([], messenger.producer.messages)
+
 
 class MAASMessengerTest(TestModelTestCase):
 

=== modified file 'src/maasserver/tests/test_rabbit.py'
--- src/maasserver/tests/test_rabbit.py	2012-03-15 14:32:25 +0000
+++ src/maasserver/tests/test_rabbit.py	2012-03-22 05:31:18 +0000
@@ -12,10 +12,13 @@
 __all__ = []
 
 
+import socket
 import time
 
 from amqplib import client_0_8 as amqp
+from django.conf import settings
 from fixtures import MonkeyPatch
+from maasserver.exceptions import NoRabbit
 from maasserver.rabbit import (
     RabbitBase,
     RabbitExchange,
@@ -26,12 +29,12 @@
 from maasserver.testing.factory import factory
 from maastesting.testcase import TestCase
 from rabbitfixture.server import RabbitServer
-
-
-class RabbitTestCase(TestCase):
-
-    def setUp(self):
-        super(RabbitTestCase, self).setUp()
+from testtools.testcase import ExpectedException
+
+
+class RabbitTestBase:
+
+    def use_rabbit(self):
         self.rabbit_server = self.useFixture(RabbitServer())
         self.rabbit_env = self.rabbit_server.runner.environment
         patch = MonkeyPatch(
@@ -43,9 +46,10 @@
         return self.rabbit_env.rabbitctl(str(command))[0]
 
 
-class TestRabbitSession(RabbitTestCase):
+class TestRabbitSession(TestCase, RabbitTestBase):
 
-    def test_session_connection(self):
+    def test_connection_gets_connection(self):
+        self.use_rabbit()
         session = RabbitSession()
         # Referencing the connection property causes a connection to be
         # created.
@@ -54,15 +58,35 @@
         # The same connection is returned every time.
         self.assertIs(connection, session.connection)
 
-    def test_session_disconnect(self):
+    def test_connection_raises_NoRabbit_if_cannot_connect(self):
+        # Attempt to connect to a RabbitMQ on the local "discard"
+        # service.  The connection will be refused.
+        self.patch(settings, 'RABBITMQ_HOST', 'localhost:9')
+        session = RabbitSession()
+        with ExpectedException(NoRabbit):
+            session.connection
+
+    def test_connection_propagates_exceptions(self):
+
+        def fail(*args, **kwargs):
+            raise socket.error("Connection not refused, but failed anyway.")
+
+        self.patch(amqp, 'Connection', fail)
+        session = RabbitSession()
+        with ExpectedException(socket.error):
+            session.connection
+
+    def test_disconnect(self):
+        self.use_rabbit()
         session = RabbitSession()
         session.disconnect()
         self.assertIsNone(session._connection)
 
 
-class TestRabbitMessaging(RabbitTestCase):
+class TestRabbitMessaging(TestCase, RabbitTestBase):
 
     def test_messaging_getExchange(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         messaging = RabbitMessaging(exchange_name)
         exchange = messaging.getExchange()
@@ -71,6 +95,7 @@
         self.assertEqual(exchange_name, exchange.exchange_name)
 
     def test_messaging_getQueue(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         messaging = RabbitMessaging(exchange_name)
         queue = messaging.getQueue()
@@ -79,19 +104,22 @@
         self.assertEqual(exchange_name, queue.exchange_name)
 
 
-class TestRabbitBase(RabbitTestCase):
+class TestRabbitBase(TestCase, RabbitTestBase):
 
     def test_rabbitbase_contains_session(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         rabbitbase = RabbitBase(RabbitSession(), exchange_name)
         self.assertIsInstance(rabbitbase._session, RabbitSession)
 
     def test_base_has_exchange_name(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         rabbitbase = RabbitBase(RabbitSession(), exchange_name)
         self.assertEqual(exchange_name, rabbitbase.exchange_name)
 
     def test_base_channel(self):
+        self.use_rabbit()
         rabbitbase = RabbitBase(RabbitSession(), factory.getRandomString())
         # Referencing the channel property causes an open channel to be
         # created.
@@ -102,6 +130,7 @@
         self.assertIs(channel, rabbitbase.channel)
 
     def test_base_channel_creates_exchange(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         rabbitbase = RabbitBase(RabbitSession(), exchange_name)
         rabbitbase.channel
@@ -110,7 +139,7 @@
             self.get_command_output('list_exchanges'))
 
 
-class TestRabbitExchange(RabbitTestCase):
+class TestRabbitExchange(TestCase, RabbitTestBase):
 
     def basic_get(self, channel, queue_name, timeout):
         endtime = time.time() + timeout
@@ -124,6 +153,7 @@
                 return message
 
     def test_exchange_publish(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         message_content = factory.getRandomString()
         exchange = RabbitExchange(RabbitSession(), exchange_name)
@@ -136,9 +166,10 @@
         self.assertEqual(message_content, message.body)
 
 
-class TestRabbitQueue(RabbitTestCase):
+class TestRabbitQueue(TestCase, RabbitTestBase):
 
     def test_rabbit_queue_binds_queue(self):
+        self.use_rabbit()
         exchange_name = factory.getRandomString()
         message_content = factory.getRandomString()
         queue = RabbitQueue(RabbitSession(), exchange_name)

=== modified file 'src/maasserver/tests/test_views.py'
--- src/maasserver/tests/test_views.py	2012-03-22 04:29:00 +0000
+++ src/maasserver/tests/test_views.py	2012-03-22 05:31:18 +0000
@@ -21,8 +21,11 @@
 from django.contrib.auth.models import User
 from django.core.urlresolvers import reverse
 from lxml.html import fromstring
-from maasserver import views
-from maasserver.messages import get_messaging
+from maasserver import (
+    messages,
+    views,
+    )
+from maasserver.exceptions import NoRabbit
 from maasserver.models import (
     Config,
     NODE_AFTER_COMMISSIONING_ACTION,
@@ -248,12 +251,23 @@
 
     def test_get_longpoll_context_empty_if_rabbitmq_publish_is_none(self):
         self.patch(settings, 'RABBITMQ_PUBLISH', None)
-        self.patch(views, 'messaging', get_messaging())
+        self.patch(views, 'messaging', messages.get_messaging())
+        self.assertEqual({}, get_longpoll_context())
+
+    def test_get_longpoll_context_returns_empty_if_rabbit_not_running(self):
+
+        class FakeMessaging:
+            """Fake :class:`RabbitMessaging`: fail with `NoRabbit`."""
+
+            def getQueue(self, *args, **kwargs):
+                raise NoRabbit("Pretending not to have a rabbit.")
+
+        self.patch(messages, 'messaging', FakeMessaging())
         self.assertEqual({}, get_longpoll_context())
 
     def test_get_longpoll_context_empty_if_longpoll_url_is_None(self):
         self.patch(settings, 'LONGPOLL_PATH', None)
-        self.patch(views, 'messaging', get_messaging())
+        self.patch(views, 'messaging', messages.get_messaging())
         self.assertEqual({}, get_longpoll_context())
 
     @uses_rabbit_fixture
@@ -261,7 +275,7 @@
         longpoll = factory.getRandomString()
         self.patch(settings, 'LONGPOLL_PATH', longpoll)
         self.patch(settings, 'RABBITMQ_PUBLISH', True)
-        self.patch(views, 'messaging', get_messaging())
+        self.patch(views, 'messaging', messages.get_messaging())
         context = get_longpoll_context()
         self.assertItemsEqual(
             ['LONGPOLL_PATH', 'longpoll_queue'], list(context))

=== modified file 'src/maasserver/views.py'
--- src/maasserver/views.py	2012-03-22 04:29:00 +0000
+++ src/maasserver/views.py	2012-03-22 05:31:18 +0000
@@ -16,6 +16,7 @@
     "NodeView",
     ]
 
+from logging import getLogger
 import mimetypes
 import os
 import urllib2
@@ -52,7 +53,10 @@
     ListView,
     UpdateView,
     )
-from maasserver.exceptions import CannotDeleteUserException
+from maasserver.exceptions import (
+    CannotDeleteUserException,
+    NoRabbit,
+    )
 from maasserver.forms import (
     AddArchiveForm,
     CommissioningForm,
@@ -96,10 +100,15 @@
 
 def get_longpoll_context():
     if messaging is not None and django_settings.LONGPOLL_PATH is not None:
-        return {
-            'longpoll_queue': messaging.getQueue().name,
-            'LONGPOLL_PATH': django_settings.LONGPOLL_PATH,
-            }
+        try:
+            return {
+                'longpoll_queue': messaging.getQueue().name,
+                'LONGPOLL_PATH': django_settings.LONGPOLL_PATH,
+                }
+        except NoRabbit as e:
+            getLogger('maasserver').warn(
+                "Could not connect to RabbitMQ: %s", e)
+            return {}
     else:
         return {}
 


Follow ups