← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/realistic-cobbler into lp:maas

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/realistic-cobbler into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/maas/realistic-cobbler/+merge/91242

Some updates to the cobbler client and the fake cobbler, in response to testing with the real cobbler:

 * Twisted fails to encode its service URL, leading to a "Data must not be unicode" error.

 * Twisted fails to encode method names, leading to the same error.  Bastards.  I hid the encoding in _issue_call.

 * By the way, it seems there's no need to append "/RPC2" to the service URL like the Twisted docs seem to suggest.

 * Cobbler object creation requires certain attributes per type, and will fail otherwise.  Not by raising an exception; that would be too easy.  It returns an undocumented False through several layers of call stack, and does not create the object.  Bastards.

 * The CobblerClient now checks for missing attributes, and a new test helper fake_cobbler_object creates an object with any missing required attributes filled out.

 * We had a stateless base class with some test helpers; I extracted its methods to functions and deleted the class.

Jeroen
-- 
https://code.launchpad.net/~jtv/maas/realistic-cobbler/+merge/91242
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/realistic-cobbler into lp:maas.
=== modified file 'src/provisioningserver/cobblerclient.py'
--- src/provisioningserver/cobblerclient.py	2012-01-31 16:09:25 +0000
+++ src/provisioningserver/cobblerclient.py	2012-02-02 11:35:48 +0000
@@ -69,8 +69,12 @@
 
         For internal use only, but overridable for test purposes.
         """
-# TODO: Is the /RPC2 needed?
-        return Proxy(self.url + '/RPC2')
+        # Twisted does not encode the URL, and breaks with "Data must
+        # not be unicode" if it's in Unicode.  We'll have to decode it
+        # here, and hope it doesn't lead to breakage in Twisted.  We'll
+        # figure out what to do about non-ASCII characters in URLs
+        # later.
+        return Proxy(self.url.encode('ascii'))
 
     def record_state(self):
         """Return a cookie representing the session's current state.
@@ -118,7 +122,7 @@
                 self.token = None
 
                 # Now initiate our new authentication.
-                self.token = yield self.proxy.callRemote(
+                self.token = yield self._issue_call(
                     'login', self.user, self.password)
         finally:
             self.authentication_lock.release()
@@ -157,6 +161,10 @@
             substituted in its place.
         :return: `Deferred`.
         """
+        # Twisted XMLRPC does not encode the method name, but breaks if
+        # we give it in Unicode.  Encode it here; thankfully we're
+        # dealing with ASCII only in method names.
+        method = method.encode('ascii')
         args = map(self._substitute_token, args)
         d = self._with_timeout(self.proxy.callRemote(method, *args))
         return d
@@ -226,6 +234,9 @@
     # underscores.  In MaaS, use only underscores.
     known_attributes = []
 
+    # What attributes does Cobbler require for this type of object?
+    required_attributes = []
+
     def __init__(self, session, name=None, values=None):
         """Reference an object in Cobbler.
 
@@ -319,6 +330,12 @@
         :param name: Identifying name for the new object.
         :param attributes: Dict mapping attribute names to values.
         """
+        missing_attributes = (
+            set(cls.required_attributes) - set(attributes.keys()))
+        assert len(missing_attributes) == 0, (
+            "Required attributes for %s missing: %s"
+            % (cls.object_type, missing_attributes))
+
         args = dict(
             (cls._normalize_attribute(key), value)
             for key, value in attributes.items())
@@ -371,6 +388,10 @@
         'virt_path',
         'virt_ram',
         ]
+    required_attributes = [
+        'name',
+        'distro',
+        ]
 
 
 class CobblerImage(CobblerObject):
@@ -416,6 +437,11 @@
         'owners',
         'template-files',
         ]
+    required_attributes = [
+        'initrd',
+        'kernel',
+        'name',
+        ]
 
 
 class CobblerRepo(CobblerObject):
@@ -433,6 +459,10 @@
         'owners',
         'priority',
         ]
+    required_attributes = [
+        'name',
+        'mirror',
+        ]
 
 
 class CobblerSystem(CobblerObject):
@@ -474,6 +504,10 @@
         'virt_path',
         'virt_type',
         ]
+    required_attribute = [
+        'name',
+        'profile',
+        ]
 
     # The modify_interface dict can contain:
     #  * "macaddress-eth0" etc.

=== modified file 'src/provisioningserver/tests/test_cobblersession.py'
--- src/provisioningserver/tests/test_cobblersession.py	2012-02-01 16:03:41 +0000
+++ src/provisioningserver/tests/test_cobblersession.py	2012-02-02 11:35:48 +0000
@@ -124,50 +124,48 @@
         return self.fake_proxy
 
 
-class TestCobblerSessionBase:
-    """Base class helpers for testing `CobblerSession`."""
-
-    def make_url_user_password(self):
-        """Produce arbitrary API URL, username, and password."""
-        return (
-            'http://api.example.com/%d' % pick_number(),
-            'username%d' % pick_number(),
-            'password%d' % pick_number(),
-            )
-
-    def make_recording_session(self, session_args=None, token=None,
-                               fake_proxy=RecordingFakeProxy):
-        """Create a `RecordingSession`."""
-        if session_args is None:
-            session_args = self.make_url_user_password()
-        if token is None:
-            token = fake_token()
-        return RecordingSession(
-            *session_args, fake_token=token, fake_proxy=fake_proxy)
-
-
-class TestCobblerSession(TestCase, TestCobblerSessionBase):
+def make_url_user_password():
+    """Produce arbitrary API URL, username, and password."""
+    return (
+        'http://api.example.com/%d' % pick_number(),
+        'username%d' % pick_number(),
+        'password%d' % pick_number(),
+        )
+
+
+def make_recording_session(session_args=None, token=None,
+                           fake_proxy=RecordingFakeProxy):
+    """Create a `RecordingSession`."""
+    if session_args is None:
+        session_args = make_url_user_password()
+    if token is None:
+        token = fake_token()
+    return RecordingSession(
+        *session_args, fake_token=token, fake_proxy=fake_proxy)
+
+
+class TestCobblerSession(TestCase):
     """Test session management against a fake XMLRPC session."""
 
     run_tests_with = AsynchronousDeferredRunTest
 
     def test_initializes_but_does_not_authenticate_on_creation(self):
-        url, user, password = self.make_url_user_password()
-        session = self.make_recording_session(
+        url, user, password = make_url_user_password()
+        session = make_recording_session(
             token=fake_token(user, 'not-yet-authenticated'))
         self.assertEqual(None, session.token)
 
     @inlineCallbacks
     def test_authenticate_authenticates_initially(self):
         token = fake_token('authenticated')
-        session = self.make_recording_session(token=token)
+        session = make_recording_session(token=token)
         self.assertEqual(None, session.token)
         yield session._authenticate()
         self.assertEqual(token, session.token)
 
     @inlineCallbacks
     def test_state_cookie_stays_constant_during_normal_use(self):
-        session = self.make_recording_session()
+        session = make_recording_session()
         state = session.record_state()
         self.assertEqual(state, session.record_state())
         yield session.call("some_method")
@@ -175,14 +173,14 @@
 
     @inlineCallbacks
     def test_authentication_changes_state_cookie(self):
-        session = self.make_recording_session()
+        session = make_recording_session()
         old_cookie = session.record_state()
         yield session._authenticate()
         self.assertNotEqual(old_cookie, session.record_state())
 
     @inlineCallbacks
     def test_authenticate_backs_off_from_overwriting_concurrent_auth(self):
-        session = self.make_recording_session()
+        session = make_recording_session()
         # Two requests are made concurrently.
         cookie_before_request_1 = session.record_state()
         cookie_before_request_2 = session.record_state()
@@ -207,8 +205,7 @@
 
     @inlineCallbacks
     def test_substitute_token_substitutes_only_placeholder(self):
-        token = fake_token('for-substitution')
-        session = self.make_recording_session(token=token)
+        session = make_recording_session(token=fake_token('for-subst'))
         yield session._authenticate()
         arbitrary_number = pick_number()
         arbitrary_string = 'string-%d' % pick_number()
@@ -220,7 +217,7 @@
             ]
         outputs = [
             arbitrary_number,
-            token,
+            session.token,
             arbitrary_string,
             None,
             ]
@@ -229,7 +226,7 @@
 
     @inlineCallbacks
     def test_call_calls_xmlrpc(self):
-        session = self.make_recording_session()
+        session = make_recording_session()
         return_value = 'returnval-%d' % pick_number()
         method = 'method_%d' % pick_number()
         arg = 'arg-%d' % pick_number()
@@ -242,7 +239,7 @@
     def test_call_reauthenticates_and_retries_on_auth_failure(self):
         # If a call triggers an authentication error, call()
         # re-authenticates and then re-issues the call.
-        session = self.make_recording_session()
+        session = make_recording_session()
         yield session._authenticate()
         successful_return_value = pick_number()
         session.proxy.set_return_values([
@@ -270,7 +267,7 @@
         # is called just once, with the state cookie from before the
         # call.  This ensures that it will always notice a concurrent
         # re-authentication that it needs to back off from.
-        session = self.make_recording_session()
+        session = make_recording_session()
         yield session._authenticate()
         authenticate_cookies = []
 
@@ -286,7 +283,7 @@
 
     @inlineCallbacks
     def test_call_raises_repeated_auth_failure(self):
-        session = self.make_recording_session()
+        session = make_recording_session()
         yield session._authenticate()
         failures = [
             # Initial operation fails: not authenticated.
@@ -302,7 +299,7 @@
 
     @inlineCallbacks
     def test_call_raises_general_failure(self):
-        session = self.make_recording_session()
+        session = make_recording_session()
         yield session._authenticate()
         failure = Exception("Memory error.  Where did I put it?")
         session.proxy.set_return_values([failure])
@@ -315,7 +312,7 @@
         # If there is no auth token, and authentication is required,
         # call() authenticates right away rather than waiting for the
         # first call attempt to fail.
-        session = self.make_recording_session()
+        session = make_recording_session()
         session.token = None
         session.proxy.set_return_values([pick_number()])
         yield session.call(
@@ -326,8 +323,7 @@
             [('authenticate_me_first', session.token)], session.proxy.calls)
 
 
-class TestConnectionTimeouts(TestCase, TestCobblerSessionBase,
-                             fixtures.TestWithFixtures):
+class TestConnectionTimeouts(TestCase, fixtures.TestWithFixtures):
     """Tests for connection timeouts on `CobblerSession`."""
 
     run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted
@@ -336,7 +332,7 @@
         # Winding a clock reactor past the timeout value should cancel
         # the original Deferred.
         clock = Clock()
-        session = self.make_recording_session()
+        session = make_recording_session()
         d = session._with_timeout(defer.Deferred(), 1, clock)
         clock.advance(2)
         return assert_fails_with(d, defer.CancelledError)
@@ -346,7 +342,7 @@
         # (defer.succeed() is pre-fired) Deferred should not trigger a
         # cancellation.
         clock = Clock()
-        session = self.make_recording_session()
+        session = make_recording_session()
         d = session._with_timeout(defer.succeed("frobnicle"), 1, clock)
         clock.advance(2)
 
@@ -360,7 +356,7 @@
         # Winding a clock reactor forwards but not past the timeout
         # should result in no cancellation.
         clock = Clock()
-        session = self.make_recording_session()
+        session = make_recording_session()
         d = session._with_timeout(defer.Deferred(), 5, clock)
         clock.advance(1)
         self.assertFalse(d.called)
@@ -371,7 +367,7 @@
             "provisioningserver.cobblerclient.default_reactor", clock)
         self.useFixture(patch)
 
-        session = self.make_recording_session(fake_proxy=DeadProxy)
+        session = make_recording_session(fake_proxy=DeadProxy)
         d = session._issue_call("login", "foo")
         clock.advance(cobblerclient.DEFAULT_TIMEOUT + 1)
         return assert_fails_with(d, defer.CancelledError)
@@ -389,3 +385,11 @@
         self.assertEqual(
             'x_systems_y',
             cobblerclient.CobblerSystem._name_method('x_%s_y', plural=True))
+
+    def test_new_checks_required_attributes(self):
+        # CobblerObject.new asserts that all required attributes for a
+        # type of object are provided.
+        session = make_recording_session()
+        with ExpectedException(AssertionError):
+            yield cobblerclient.CobblerSystem.new(
+                session, 'incomplete_system', {})

=== modified file 'src/provisioningserver/tests/test_fakecobbler.py'
--- src/provisioningserver/tests/test_fakecobbler.py	2012-02-01 13:02:19 +0000
+++ src/provisioningserver/tests/test_fakecobbler.py	2012-02-02 11:35:48 +0000
@@ -74,6 +74,29 @@
     returnValue(session)
 
 
+def fake_cobbler_object(session, object_class, name=None, attributes=None):
+    """Create a fake Cobbler object.
+
+    :param session: `CobblerSession`.
+    :param object_class: concrete `CobblerObject` class to instantiate.
+    :param name: Option name for the object.
+    :param attributes: Optional dict of attribute values for the object.
+    """
+    if attributes is None:
+        attributes = {}
+    else:
+        attributes = dict(attributes)
+    unique_int = next(unique_ints)
+    if name is None:
+        name = 'name-%s-%d' % (object_class.object_type, unique_int)
+    attributes['name'] = name
+    for attr in object_class.required_attributes:
+        if attr not in attributes:
+            attributes[attr] = '%s-%d' % (attr, unique_int)
+    d = object_class.new(session, name, attributes)
+    return d
+
+
 class TestFakeCobbler(TestCase):
     """Test `FakeCobbler`.
 
@@ -100,7 +123,7 @@
         # Use of the token will now fail with an "invalid token"
         # error.  The Cobbler client notices this, re-authenticates, and
         # re-runs the method.
-        yield CobblerSystem.new(session, 'my-session', {'comment': 'Mine'})
+        yield fake_cobbler_object(session, CobblerSystem)
 
         # The re-authentication results in a fresh token.
         self.assertNotEqual(old_token, session.token)
@@ -109,7 +132,7 @@
     def test_valid_token_does_not_raise_auth_error(self):
         session = yield fake_cobbler_session()
         old_token = session.token
-        yield CobblerSystem.new(session, 'some-session', {'comment': 'Boo'})
+        yield fake_cobbler_object(session, CobblerSystem)
         self.assertEqual(old_token, session.token)
 
 
@@ -141,7 +164,7 @@
     def test_create_object(self):
         session = yield fake_cobbler_session()
         name = self.make_name()
-        obj = yield self.cobbler_class.new(session, name, {})
+        obj = yield fake_cobbler_object(session, self.cobbler_class, name)
         self.assertEqual(name, obj.name)
 
     @inlineCallbacks
@@ -155,7 +178,7 @@
     def test_find_matches_name(self):
         session = yield fake_cobbler_session()
         name = self.make_name()
-        yield self.cobbler_class.new(session, name, {})
+        yield fake_cobbler_object(session, self.cobbler_class, name)
         [by_comment] = yield self.cobbler_class.find(session, name=name)
         self.assertEqual(name, by_comment.name)
 
@@ -163,7 +186,8 @@
     def test_find_matches_attribute(self):
         session = yield fake_cobbler_session()
         name = self.make_name()
-        yield self.cobbler_class.new(session, name, {'comment': 'Hi'})
+        yield fake_cobbler_object(
+            session, self.cobbler_class, name, {'comment': 'Hi'})
         [by_comment] = yield self.cobbler_class.find(session, comment='Hi')
         self.assertEqual(name, by_comment.name)
 
@@ -171,7 +195,7 @@
     def test_find_without_args_finds_everything(self):
         session = yield fake_cobbler_session()
         name = self.make_name()
-        yield self.cobbler_class.new(session, name, {'comment': 'Hi'})
+        yield fake_cobbler_object(session, self.cobbler_class, name)
         found_items = yield self.cobbler_class.find(session)
         self.assertEqual([name], [item.name for item in found_items])
 
@@ -179,24 +203,23 @@
     def test_get_handle_finds_handle(self):
         session = yield fake_cobbler_session()
         name = self.make_name()
-        obj = yield self.cobbler_class.new(session, name, {})
+        obj = yield fake_cobbler_object(session, self.cobbler_class, name)
         handle = yield obj._get_handle()
         self.assertNotEqual(None, handle)
 
     @inlineCallbacks
     def test_get_handle_distinguishes_objects(self):
         session = yield fake_cobbler_session()
-        obj1 = yield self.cobbler_class.new(session, self.make_name(), {})
+        obj1 = yield fake_cobbler_object(session, self.cobbler_class)
         handle1 = yield obj1._get_handle()
-        obj2 = yield self.cobbler_class.new(session, self.make_name(), {})
+        obj2 = yield fake_cobbler_object(session, self.cobbler_class)
         handle2 = yield obj2._get_handle()
         self.assertNotEqual(handle1, handle2)
 
     @inlineCallbacks
     def test_get_handle_is_consistent(self):
         session = yield fake_cobbler_session()
-        name = self.make_name()
-        obj = yield self.cobbler_class.new(session, name, {})
+        obj = yield fake_cobbler_object(session, self.cobbler_class)
         handle1 = yield obj._get_handle()
         handle2 = yield obj._get_handle()
         self.assertEqual(handle1, handle2)
@@ -205,7 +228,7 @@
     def test_delete_removes_object(self):
         session = yield fake_cobbler_session()
         name = self.make_name()
-        obj = yield self.cobbler_class.new(session, name, {'name': name})
+        obj = yield fake_cobbler_object(session, self.cobbler_class, name)
         obj.delete()
         matches = yield self.cobbler_class.find(session, name=name)
         self.assertEqual([], matches)
@@ -295,7 +318,8 @@
         names = [self.make_name() for counter in range(num_systems)]
         systems = []
         for name in names:
-            new_system = yield self.cobbler_class.new(session, name, {})
+            new_system = yield fake_cobbler_object(
+                session, self.cobbler_class, name)
             systems.append(new_system)
         returnValue((names, systems))