launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06773
[Merge] lp:~flacoste/maas/avahi_server into lp:maas
Francis J. Lacoste has proposed merging lp:~flacoste/maas/avahi_server into lp:maas.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~flacoste/maas/avahi_server/+merge/98283
This publishes the maas_name config over Avahi.
The setup_maas_avahi_service is hooked in urls.py, as this seems to be the "best" place for code that needs to be executed once when the app server start.
The tests for the ZeroconfService which handles the logic of publishing a service name over Avahi aren't the greatest. They do test the full stack. I've found a way to get a local dbus-session for testing purpose, but it wouldn't have the Avahi service on top of it, (it runs in the system bus only and configuring an isolated avahi service was too much of a pain.) Also, I resort to avahi-browser to watch the registration, as using the avahi/dbus bindings would require setting up a glib_mainloop from within the tests. More pain. So the price is 2s of time.sleep() in the tests.
That's also why I resorted to mocking for the MAAS avahi integration.
For integration testing, you can verify that avahi-browser -k -p _maas._tcp contains your maas name after starting the server.
--
https://code.launchpad.net/~flacoste/maas/avahi_server/+merge/98283
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~flacoste/maas/avahi_server into lp:maas.
=== modified file 'HACKING.txt'
--- HACKING.txt 2012-03-19 17:51:02 +0000
+++ HACKING.txt 2012-03-19 20:57:24 +0000
@@ -39,7 +39,11 @@
python-django-south python-twisted python-txamqp python-amqplib \
python-formencode python-oauth python-oops python-oops-datedir-repo \
python-twisted python-oops-wsgi python-oops-twisted \
+<<<<<<< TREE
python-psycopg2 python-yaml python-convoy python-django-south
+=======
+ python-psycopg2 python-yaml python-convoy python-avahi python-dbus
+>>>>>>> MERGE-SOURCE
Additionally, you need to install the following python libraries
for development convenience::
=== modified file 'buildout.cfg'
--- buildout.cfg 2012-03-15 17:30:53 +0000
+++ buildout.cfg 2012-03-19 20:57:24 +0000
@@ -21,7 +21,9 @@
# mainly those which contains C extensions like lxml
include-site-packages = true
allowed-eggs-from-site-packages =
+ avahi
convoy
+ dbus
Django
South
amqplib
@@ -63,6 +65,9 @@
[maas]
recipe = zc.recipe.egg
+# avahi and dbus should be listed as egg
+# but they don't have links on PyPI and that makes buildout really
+# unhappy. It refuses to see them, even if they are in site-packages :-(
eggs =
${common:test-eggs}
convoy
=== modified file 'setup.py'
--- setup.py 2012-03-15 13:58:32 +0000
+++ setup.py 2012-03-19 20:57:24 +0000
@@ -61,8 +61,10 @@
'setuptools',
'Django == 1.3.1',
'psycopg2',
+ 'avahi',
'amqplib',
'convoy',
+ 'dbus',
'django-piston',
'FormEncode',
'oauth',
=== added file 'src/maasserver/maasavahi.py'
--- src/maasserver/maasavahi.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/maasavahi.py 2012-03-19 20:57:24 +0000
@@ -0,0 +1,53 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""..."""
+
+from __future__ import (
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = []
+
+from maasserver.models import Config
+from maasserver.zeroconfservice import ZeroconfService
+
+
+class MAASAvahiService:
+ """Publishes the MAAS server existence over Avahi.
+
+ The server is exported as '%s MAAS Server' using the _maas._tcp service
+ type.
+ """
+
+ def __init__(self):
+ self.service = None
+
+ def maas_name_changed(self, sender, instance, created, **kwargs):
+ """Signal callback called when the MAAS name changed."""
+ self.publish()
+
+ def publish(self):
+ """Publish the maas_name.
+
+ It will remove any previously published name first.
+ """
+ if self.service is not None:
+ self.service.unpublish()
+
+ site_name = "%s MAAS Server" % Config.objects.get_config('maas_name')
+ self.service = ZeroconfService(
+ name=site_name, port=80, stype="_maas._tcp")
+ self.service.publish()
+
+
+def setup_maas_avahi_service():
+ """Register the MAASAvahiService() with the config changed signal."""
+ service = MAASAvahiService()
+
+ # Publish it first.
+ service.publish()
+ Config.objects.config_changed_connect(
+ 'maas_name', service.maas_name_changed)
=== added file 'src/maasserver/tests/test_maasavahi.py'
--- src/maasserver/tests/test_maasavahi.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_maasavahi.py 2012-03-19 20:57:24 +0000
@@ -0,0 +1,120 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the Avahi export of MAAS."""
+
+from __future__ import (
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = []
+
+from collections import defaultdict
+
+from maastesting.testcase import TestCase
+
+import maasserver.maasavahi
+from maasserver.maasavahi import (
+ MAASAvahiService,
+ setup_maas_avahi_service,
+ )
+from maasserver.models import (
+ Config,
+ config_manager,
+ )
+
+
+class MockZeroconfServiceFactory:
+ """Factory used to track usage of the zeroconfservice module.
+
+ An instance is meant to be patched as
+ maasserver.maasavahi.ZeroconfService. It will register instances
+ created, as well as the parameters and methods called on each instance.
+ """
+
+ def __init__(self):
+ self.instances = []
+
+ def __call__(self, *args, **kwargs):
+ mock = MockZeroconfService(*args, **kwargs)
+ self.instances.append(mock)
+ return mock
+
+
+class MockZeroconfService:
+
+ def __init__(self, name, port, stype):
+ self.name = name
+ self.port = port
+ self.stype = stype
+ self.calls = []
+
+ def publish(self):
+ self.calls.append('publish')
+
+ def unpublish(self):
+ self.calls.append('unpublish')
+
+
+class TestMAASAvahiService(TestCase):
+
+ def setup_mock_avahi(self):
+ # Unregister other signals from Config, otherwise
+ # the one registered in urls.py, will interfere with these tests
+ self.patch(
+ config_manager, '_config_changed_connections', defaultdict(set))
+
+ mock_avahi = MockZeroconfServiceFactory()
+ self.patch(
+ maasserver.maasavahi, 'ZeroconfService', mock_avahi)
+ return mock_avahi
+
+ def test_publish_exports_name_over_avahi(self):
+ mock_avahi = self.setup_mock_avahi()
+ service = MAASAvahiService()
+ Config.objects.set_config('maas_name', 'My Test')
+ service.publish()
+ # One ZeroconfService should have been created
+ self.assertEquals(1, len(mock_avahi.instances))
+ zeroconf = mock_avahi.instances[0]
+ self.assertEquals('My Test MAAS Server', zeroconf.name)
+ self.assertEquals(80, zeroconf.port)
+ self.assertEquals('_maas._tcp', zeroconf.stype)
+
+ # And published.
+ self.assertEquals(['publish'], zeroconf.calls)
+
+ def test_publish_twice_unpublishes_first(self):
+ mock_avahi = self.setup_mock_avahi()
+ service = MAASAvahiService()
+ Config.objects.set_config('maas_name', 'My Test')
+ service.publish()
+ service.publish()
+
+ # Two ZeroconfService should have been created. The first
+ # should have been published, and unpublished,
+ # while the second one should have one publish call.
+ self.assertEquals(2, len(mock_avahi.instances))
+ self.assertEquals(
+ ['publish', 'unpublish'], mock_avahi.instances[0].calls)
+ self.assertEquals(
+ ['publish'], mock_avahi.instances[1].calls)
+
+ def test_setup_maas_avahi_service(self):
+ mock_avahi = self.setup_mock_avahi()
+ Config.objects.set_config('maas_name', 'First Name')
+ setup_maas_avahi_service()
+
+ # Name should have been published.
+ self.assertEquals(1, len(mock_avahi.instances))
+ self.assertEquals(
+ 'First Name MAAS Server', mock_avahi.instances[0].name)
+
+ Config.objects.set_config('maas_name', 'Second Name')
+
+ # A new publication should have occured.
+ self.assertEquals(2, len(mock_avahi.instances))
+ self.assertEquals(
+ 'Second Name MAAS Server', mock_avahi.instances[1].name)
=== added file 'src/maasserver/tests/test_zeroconfservice.py'
--- src/maasserver/tests/test_zeroconfservice.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_zeroconfservice.py 2012-03-19 20:57:24 +0000
@@ -0,0 +1,70 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the ZeroconfService class"""
+
+from __future__ import (
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = []
+
+import random
+import subprocess
+import time
+
+from maastesting.testcase import TestCase
+from maasserver.zeroconfservice import ZeroconfService
+
+
+# These tests will actually inject data in the system Avahi system.
+# Would be nice to isolate it from the system Avahi service, but I didn't
+# feel like writing a private DBus session with a mock Avahi service on it.
+class TestZeroconfService(TestCase):
+
+ STYPE = '_maas_zeroconftest._tcp'
+
+ count = 0
+
+ def avahi_browse(self, service_type, timeout=1):
+ """Return the list of published Avahi service through avahi-browse."""
+ # Doing this from pure python would be a pain, as it would involve
+ # running a glib mainloop. And stopping one is hard. Much easier to
+ # kill an external process. This slows test, and could be fragile,
+ # but it's the best I've come with.
+ browser = subprocess.Popen(
+ ['avahi-browse', '-k', '-p', service_type],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ time.sleep(timeout)
+ browser.terminate()
+ names = []
+ for record in browser.stdout.readlines():
+ fields = record.split(';')
+ names.append(fields[3])
+ return names
+
+ @classmethod
+ def getUniqueServiceNameAndPort(self):
+ # getUniqueString() generates an invalid service name
+ name = 'My-Test-Service-%d' % self.count
+ self.count += 1
+ port = random.randint(30000, 40000)
+ return name, port
+
+ def test_publish(self):
+ name, port = self.getUniqueServiceNameAndPort()
+ service = ZeroconfService(name, port, self.STYPE)
+ service.publish()
+ self.addCleanup(service.group.Reset)
+ services = self.avahi_browse(self.STYPE)
+ self.assertIn(name, services)
+
+ def test_unpublish(self):
+ name, port = self.getUniqueServiceNameAndPort()
+ service = ZeroconfService(name, port, self.STYPE)
+ service.publish()
+ service.unpublish()
+ services = self.avahi_browse(self.STYPE)
+ self.assertNotIn(name, services)
=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py 2012-03-19 12:23:46 +0000
+++ src/maasserver/urls.py 2012-03-19 20:57:24 +0000
@@ -24,6 +24,7 @@
direct_to_template,
redirect_to,
)
+from maasserver.maasavahi import setup_maas_avahi_service
from maasserver.models import Node
from maasserver.views import (
AccountsAdd,
@@ -127,3 +128,7 @@
urlpatterns += patterns('',
(r'^api/1\.0/', include('maasserver.urls_api'))
)
+
+# Code to run once when the server initialized,
+# as suggested in http://stackoverflow.com/questions/6791911/execute-code-when-django-starts-once-only
+setup_maas_avahi_service()
=== added file 'src/maasserver/zeroconfservice.py'
--- src/maasserver/zeroconfservice.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/zeroconfservice.py 2012-03-19 20:57:24 +0000
@@ -0,0 +1,61 @@
+# Copyright 2008 (c) Pierre Duquesne <stackp@xxxxxxxxx>
+# Copyright 2012 Canonical Ltd.
+# This software is licensed under the GNU Affero General Public
+# License version 3 (see the file LICENSE).
+
+import avahi
+import dbus
+
+__all__ = [
+ "ZeroconfService",
+ ]
+
+
+class ZeroconfService:
+ """A simple class to publish a network service with zeroconf using avahi.
+ """
+
+ def __init__(self, name, port, stype="_http._tcp",
+ domain="", host="", text=""):
+ """Create an object that can publish a service over Avahi.
+
+ :param name: The name of the service to be published.
+ :param port: The port number where it's published.
+ :param stype: The service type string.
+ """
+ self.name = name
+ self.stype = stype
+ self.domain = domain
+ self.host = host
+ self.port = port
+ self.text = text
+
+ def publish(self):
+ """Publish the service through Avahi."""
+ bus = dbus.SystemBus()
+ server = dbus.Interface(
+ bus.get_object(avahi.DBUS_NAME, avahi.DBUS_PATH_SERVER),
+ avahi.DBUS_INTERFACE_SERVER)
+
+ group = dbus.Interface(
+ bus.get_object(avahi.DBUS_NAME, server.EntryGroupNew()),
+ avahi.DBUS_INTERFACE_ENTRY_GROUP)
+
+ group.AddService(
+ avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0),
+ self.name, self.stype, self.domain, self.host,
+ dbus.UInt16(self.port), self.text)
+
+ group.Commit()
+ self.group = group
+
+ def unpublish(self):
+ """Unpublish the service through Avahi."""
+ self.group.Reset()
+
+
+if __name__ == "__main__":
+ service = ZeroconfService(name="TestService", port=3000)
+ service.publish()
+ raw_input("Press any key to unpublish the service ")
+ service.unpublish()
=== modified file 'versions.cfg'
--- versions.cfg 2012-03-14 17:01:10 +0000
+++ versions.cfg 2012-03-19 20:57:24 +0000
@@ -15,7 +15,9 @@
# Versions in Precise
amqplib = 1.0.0
+avahi = 0.6.30
convoy = 0.2.2
+dbus = 1.0.0
django-piston = 0.2.3
FormEncode = 1.2.4
oauth = 1.0.1