← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/delete-pxe-file-bug-978425 into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/delete-pxe-file-bug-978425 into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #978425 in MAAS: "Deleting a node leaves the PXE file"
  https://bugs.launchpad.net/maas/+bug/978425

For more details, see:
https://code.launchpad.net/~allenap/maas/delete-pxe-file-bug-978425/+merge/102122

Cobbler tries to keep the on-disk part of its database in sync with the in-memory part, but sometimes gets it wrong. For example, deleting a network interface from a node does not cause the related netboot files to be removed from the filesystem. A full `sync` call is necessary.

This branch fixes that specific problem, and adds a regression test for it, but also adds sync calls to the end of all PAPI calls that mutate Cobbler, so we can be reasonably sure that the on-disk stuff is all in order.
-- 
https://code.launchpad.net/~allenap/maas/delete-pxe-file-bug-978425/+merge/102122
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/maas/delete-pxe-file-bug-978425 into lp:maas.
=== modified file 'src/provisioningserver/api.py'
--- src/provisioningserver/api.py	2012-04-05 17:42:01 +0000
+++ src/provisioningserver/api.py	2012-04-16 14:53:14 +0000
@@ -170,6 +170,11 @@
         super(ProvisioningAPI, self).__init__()
         self.session = session
 
+    def sync(self):
+        """Request Cobbler to sync and return when it's finished."""
+        return self.session.call(
+            "sync", self.session.token_placeholder)
+
     @inlineCallbacks
     def add_distro(self, name, initrd, kernel):
         assert isinstance(name, basestring)
@@ -180,6 +185,7 @@
                 "initrd": initrd,
                 "kernel": kernel,
                 })
+        yield self.sync()
         returnValue(distro.name)
 
     @inlineCallbacks
@@ -188,6 +194,7 @@
         assert isinstance(distro, basestring)
         profile = yield CobblerProfile.new(
             self.session, name, {"distro": distro})
+        yield self.sync()
         returnValue(profile.name)
 
     @inlineCallbacks
@@ -204,17 +211,20 @@
             "power_type": power_type,
             }
         system = yield CobblerSystem.new(self.session, name, attributes)
+        yield self.sync()
         returnValue(system.name)
 
     @inlineCallbacks
     def modify_distros(self, deltas):
         for name, delta in deltas.items():
             yield CobblerDistro(self.session, name).modify(delta)
+        yield self.sync()
 
     @inlineCallbacks
     def modify_profiles(self, deltas):
         for name, delta in deltas.items():
             yield CobblerProfile(self.session, name).modify(delta)
+        yield self.sync()
 
     @inlineCallbacks
     def modify_nodes(self, deltas):
@@ -231,6 +241,7 @@
                 for interface_modification in interface_modifications:
                     yield system.modify(interface_modification)
             yield system.modify(delta)
+        yield self.sync()
 
     @inlineCallbacks
     def get_objects_by_name(self, object_type, names):
@@ -278,6 +289,7 @@
         assert all(isinstance(name, basestring) for name in names)
         for name in names:
             yield object_type(self.session, name).delete()
+        yield self.sync()
 
     @deferred
     def delete_distros_by_name(self, names):

=== modified file 'src/provisioningserver/testing/realcobbler.py'
--- src/provisioningserver/testing/realcobbler.py	2012-03-16 10:19:30 +0000
+++ src/provisioningserver/testing/realcobbler.py	2012-04-16 14:53:14 +0000
@@ -16,6 +16,7 @@
 from os import environ
 from textwrap import dedent
 from urlparse import urlparse
+from unittest import skipIf
 
 from provisioningserver.cobblerclient import CobblerSession
 
@@ -33,11 +34,17 @@
 
     env_var = 'PSERV_TEST_COBBLER_URL'
 
-    help_text = dedent("""
+    help_text_available = dedent("""\
         Set %s to the URL for a Cobbler instance to test against,
+        e.g. http://username:password@xxxxxxxxxxx/cobbler_api.
+        WARNING: this will modify your Cobbler database.
+        """ % env_var)
+
+    help_text_local = dedent("""\
+        Set %s to the URL for a *local* Cobbler instance to test against,
         e.g. http://username:password@localhost/cobbler_api.
         WARNING: this will modify your Cobbler database.
-        """.lstrip('\n') % env_var)
+        """ % env_var)
 
     def __init__(self):
         self.url = environ.get(self.env_var)
@@ -46,23 +53,52 @@
             self.username = urlparts.username or 'cobbler'
             self.password = urlparts.password or ''
 
+    @property
     def is_available(self):
-        """Is a real Cobbler available for tests?
-
-        Use this to disable real-Cobbler tests if no real Cobbler is
-        available: annotate them with
-
-        @testtools.skipIf(
-            not real_cobbler.is_available(), RealCobbler.help_text)
-        """
+        """Is a real Cobbler available for tests?"""
         return self.url is not None
 
+    @property
+    def skip_unless_available(self):
+        """Decorator to disable tests if no real Cobbler is available.
+
+        Annotate tests like so::
+
+          @real_cobbler.skip_unless_available
+          def test_something_that_requires_a_real_cobbler(self):
+              ...
+
+        """
+        return skipIf(not self.is_available, self.help_text_available)
+
+    @property
+    def is_local(self):
+        """Is a real Cobbler installed locally available for tests?"""
+        if self.is_available:
+            hostname = urlparse(self.url).hostname
+            return hostname == "localhost" or hostname.startswith("127.")
+        else:
+            return False
+
+    @property
+    def skip_unless_local(self):
+        """Decorator to disable tests if no real *local* Cobbler is available.
+
+        Annotate tests like so::
+
+          @real_cobbler.skip_unless_local
+          def test_something_that_requires_a_real_local_cobbler(self):
+              ...
+
+        """
+        return skipIf(not self.is_local, self.help_text_local)
+
     def get_session(self):
         """Obtain a session on the real Cobbler.
 
         Returns None if no real Cobbler is available.
         """
-        if self.is_available():
+        if self.is_available:
             return CobblerSession(self.url, self.username, self.password)
         else:
             return None

=== modified file 'src/provisioningserver/tests/test_api.py'
--- src/provisioningserver/tests/test_api.py	2012-04-05 18:39:06 +0000
+++ src/provisioningserver/tests/test_api.py	2012-04-16 14:53:14 +0000
@@ -19,7 +19,8 @@
     abstractmethod,
     )
 from base64 import b64decode
-from unittest import skipIf
+from contextlib import contextmanager
+from functools import partial
 
 from maasserver.testing.enum import map_enum
 from maastesting.factory import factory
@@ -40,6 +41,11 @@
 from provisioningserver.testing.realcobbler import RealCobbler
 from testtools import TestCase
 from testtools.deferredruntest import AsynchronousDeferredRunTest
+from testtools.matchers import (
+    FileExists,
+    Not,
+    )
+from testtools.monkey import patch
 from twisted.internet.defer import inlineCallbacks
 from zope.interface.verify import verifyObject
 
@@ -607,6 +613,84 @@
         self.assertEqual(
             preseed_data, b64decode(attrs['ks_meta']['MAAS_PRESEED']))
 
+    @contextmanager
+    def expected_sync(self, papi, times=1):
+        """Context where # calls to `papi.sync` must match `times`."""
+        sync_calls = []
+        orig_sync = papi.sync
+        fake_sync = lambda: orig_sync().addCallback(sync_calls.append)
+        unpatch = patch(papi, "sync", fake_sync)
+        try:
+            yield
+        finally:
+            unpatch()
+        self.assertEqual(times, len(sync_calls))
+
+    @inlineCallbacks
+    def test_add_distro_syncs(self):
+        # add_distro ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield self.add_distro(papi)
+
+    @inlineCallbacks
+    def test_add_profile_syncs(self):
+        # add_profile ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        distro_name = yield self.add_distro(papi)
+        with self.expected_sync(papi):
+            yield self.add_profile(papi, distro_name=distro_name)
+
+    @inlineCallbacks
+    def test_add_node_syncs(self):
+        # add_node ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        profile_name = yield self.add_profile(papi)
+        with self.expected_sync(papi):
+            yield self.add_node(papi, profile_name=profile_name)
+
+    @inlineCallbacks
+    def test_modify_distros_syncs(self):
+        # modify_distros ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield papi.modify_distros({})
+
+    @inlineCallbacks
+    def test_modify_profiles_syncs(self):
+        # modify_profiles ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield papi.modify_profiles({})
+
+    @inlineCallbacks
+    def test_modify_nodes_syncs(self):
+        # modify_nodes ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield papi.modify_nodes({})
+
+    @inlineCallbacks
+    def test_delete_distros_by_name_syncs(self):
+        # delete_distros_by_name ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield papi.delete_distros_by_name([])
+
+    @inlineCallbacks
+    def test_delete_profiles_by_name_syncs(self):
+        # delete_profiles_by_name ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield papi.delete_profiles_by_name([])
+
+    @inlineCallbacks
+    def test_delete_nodes_by_name_syncs(self):
+        # delete_nodes_by_name ensures that Cobbler syncs.
+        papi = self.get_provisioning_api()
+        with self.expected_sync(papi):
+            yield papi.delete_nodes_by_name([])
+
 
 class TestFakeProvisioningAPI(ProvisioningAPITests, TestCase):
     """Test :class:`FakeAsynchronousProvisioningAPI`.
@@ -631,6 +715,22 @@
         """Return a real ProvisioningAPI, but using a fake Cobbler session."""
         return ProvisioningAPI(make_fake_cobbler_session())
 
+    def test_sync(self):
+        """`ProvisioningAPI.sync` issues an authenticated ``sync`` call.
+
+        It is not exported - i.e. it is not part of :class:`IProvisioningAPI`
+        - but is used heavily by other methods in `IProvisioningAPI`.
+        """
+        papi = self.get_provisioning_api()
+        calls = []
+        self.patch(
+            papi.session, "call",
+            lambda *args: calls.append(args))
+        papi.sync()
+        self.assertEqual(
+            [("sync", papi.session.token_placeholder)],
+            calls)
+
 
 class TestProvisioningAPIWithRealCobbler(ProvisioningAPITests,
                                          ProvisioningAPITestsWithCobbler,
@@ -640,12 +740,34 @@
     The URL for the Cobbler instance must be provided in the
     `PSERV_TEST_COBBLER_URL` environment variable.
 
-    Includes by inheritance all the tests in :class:`ProvisioningAPITests`.
+    Includes by inheritance all the tests in :class:`ProvisioningAPITests` and
+    :class:`ProvisioningAPITestsWithCobbler`.
     """
 
     real_cobbler = RealCobbler()
 
-    @skipIf(not real_cobbler.is_available(), RealCobbler.help_text)
+    @real_cobbler.skip_unless_available
     def get_provisioning_api(self):
         """Return a connected :class:`ProvisioningAPI`."""
         return ProvisioningAPI(self.real_cobbler.get_session())
+
+    @real_cobbler.skip_unless_local
+    @inlineCallbacks
+    def test_sync_after_modify(self):
+        # When MAAS modifies the MAC addresses of a node it triggers a sync of
+        # Cobbler. This is to ensure that netboot files are up-to-date, or
+        # removed as appropriate.
+        papi = self.get_provisioning_api()
+        node_name = yield self.add_node(papi)
+        mac_address = factory.getRandomMACAddress()
+        yield papi.modify_nodes(
+            {node_name: {"mac_addresses": [mac_address]}})
+        # The PXE file corresponding to the node's MAC address is present.
+        pxe_filename = "/var/lib/tftpboot/pxelinux.cfg/01-%s" % (
+            mac_address.replace(":", "-"),)
+        self.assertThat(pxe_filename, FileExists())
+        # Remove the MAC address again.
+        yield papi.modify_nodes(
+            {node_name: {"mac_addresses": []}})
+        # The PXE file has been removed too.
+        self.assertThat(pxe_filename, Not(FileExists()))

=== modified file 'src/provisioningserver/tests/test_cobblercatcher.py'
--- src/provisioningserver/tests/test_cobblercatcher.py	2012-03-20 02:45:17 +0000
+++ src/provisioningserver/tests/test_cobblercatcher.py	2012-04-16 14:53:14 +0000
@@ -180,7 +180,7 @@
 
     real_cobbler = RealCobbler()
 
-    @skipIf(not real_cobbler.is_available(), RealCobbler.help_text)
+    @real_cobbler.skip_unless_available
     @deferred
     def get_cobbler_session(self):
         return self.real_cobbler.get_session()