← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Before we begin, please accept my apologies for the size of this branch.  I'll try to explain it in layered parts so you can treat it as such.

The first part is the Cobbler XMLRPC API: CobblerSession, the CobblerObject hierarchy, and CobblerCommands.  These wrap Cobbler's XMLRPC API in a bunch of Twisted-enabled but pseudo-synchronous classes.

What do I mean by pseudo-synchronous?  Any method annotated with @inlineCallbacks may return control to the reactor while waiting for a response.  Any such method returns a Deferred.  Any function that wants to call any of these must also use @inlineCallbacks; and because of that it must "yield" any Deferred that it gets back from function calls it makes, and it must use returnValue() rather than the "return" keyword for its return value.  Apart from that, it's a lot like synchronous programming.

CobblerSession is the class that manages basic interaction with Cobbler through XMLRPC.  It logs in, makes pseudo-synchronous calls, and if it finds that its credentials have expired, it logs in again (and retries any failing call).

CobblerObject is the base class for various types of object that Cobbler manages in its internal extensible database: systems, profiles, distros, and whatever else it may have.  They all work very much the same, so this is a little abstraction layer that hides the differences.  CobblerSystem, CobblerProfile, and so on inherit from it (and in some cases, add a few methods of their own).  Internally they all use the CobblerSession to forward requests to Cobbler.  The methods map nearly one-to-one with Cobbler's API, but we swept modification and saving of objects into a single method to simplify the API and keep transactional behaviour manageable.

And CobblerCommands?  That's really just a grab-bag for remaining methods in the Cobbler API, which didn't fit in neatly with the rest.  We may want to refine that later, or maybe not.

The second part of what this branch adds isn't really complete yet: a fake Cobbler implementation, also using a fake Twisted XMLRPC proxy, so that we can write pseudo-synchronous tests without an XMLRPC server.  It works like this: a FakeCobblerSession (it will probably move into fakecobbler.py) is basically a real CobblerSession but with the fake proxy.  The fake proxy acts as an asynchronous XMLRPC server to the session, but routes the calls to plain old synchronous methods on a FakeCobbler object.  Its python API should be identical to the real Cobbler XMLRPC API.

We can extend or instrument FakeCobbler objects to mimick the basic behaviour of a real Cobbler server.  For now it simulates nothing but simple authentication; we can add registration and management of systems and other cobbler objects later.

And that's when we can start proper testing of the CobblerObject hierarchy: register a system, modify it, look it up by name, delete it.  We should also be able to redirect most tests to a real Cobbler and get the same results.

Oh, one more thing: we'll need to get these tests running as part of bin/test.  The ongoing work on nose may help with that.


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/cobbler/+merge/90129
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/cobbler into lp:maas.
=== added directory 'src/provisioningserver'
=== added file 'src/provisioningserver/__init__.py'
=== added file 'src/provisioningserver/cobblerclient.py'
--- src/provisioningserver/cobblerclient.py	1970-01-01 00:00:00 +0000
+++ src/provisioningserver/cobblerclient.py	2012-01-25 15:12:28 +0000
@@ -0,0 +1,429 @@
+# 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,
+    )
+
+"""Wrapper for the Cobbler XMLRPC API, using Twisted.
+
+The API looks synchronous, but under the covers, calls yield to the Twisted
+reactor so that it can service other callbacks.
+"""
+
+__metaclass__ = type
+__all__ = [
+    'CobblerCommands',
+    'CobblerDistro',
+    'CobblerImage',
+    'CobblerProfile',
+    'CobblerSystem',
+    ]
+
+import xmlrpclib
+
+from twisted.internet.defer import (
+    inlineCallbacks,
+    returnValue,
+    )
+from twisted.web.xmlrpc import Proxy
+
+
+def looks_like_auth_expiry(exception):
+    """Does `exception` look like an authentication token expired?"""
+    if not hasattr(exception, 'faultString'):
+        # An auth failure would come as an xmlrpclib.Fault.
+        return False
+    return exception.faultString.startswith("invalid token: ")
+
+
+class CobblerSession:
+    """A session on the Cobbler XMLRPC API.
+
+    The session can be used for many asynchronous requests, all of them
+    sharing a single authentication token.
+    """
+
+    # In an arguments list, this means "insert security token here."
+    token_placeholder = object()
+
+    def __init__(self, url, user, password):
+        self.url = url
+        self.user = user
+        self.password = password
+        self.proxy = self._make_twisted_proxy()
+        self.token = None
+        self.connection_count = 0
+
+    def _make_twisted_proxy(self):
+        """Create a Twisted XMRLPC proxy.
+
+        For internal use only, but overridable for test purposes.
+        """
+# TODO: Is the /RPC2 needed?
+        return Proxy(self.url + '/RPC2')
+
+    def record_state(self):
+        """Return a cookie representing the session's current state.
+
+        The cookie will change whenever the session is reconnected or
+        re-authenticated.  The only valid use of this cookie is to compare
+        it for equality with another one.
+
+        If two calls return different cookies, that means that the session
+        has broken in some way and been re-established between the two calls.
+        """
+        return (self.connection_count, self.token)
+
+    def _login(self):
+        """Log in."""
+        # Doing this synchronously for simplicity.  The authentication token
+        # can be shared, making this a rare operation that multiple
+        # concurrent requests may be waiting on.
+        self.token = xmlrpclib.Server(self.url).login(
+            self.user, self.password)
+
+    def authenticate(self, previous_state=None):
+        """Log in synchronously.
+
+        Call this when starting up, but also when an XMLRPC call result
+        indicates that the authentication token used for a request has
+        expired.
+
+        :param previous_state: The state of the session as recorded by
+            `record_state` before the failed request was issued.  If the
+            session has had to reconnect or re-authenticate since then, the
+            method will assume that a concurrent authentication request has
+            completed and the failed request can be retried without logging
+            in again.
+        """
+        if previous_state is None or self.record_state() == previous_state:
+            # No concurrent login has completed since we last issued a
+            # request that required authentication; try a fresh one.
+            self._login()
+
+    def substitute_token(self, arg):
+        """Return `arg`, or the current auth token for `token_placeholder`."""
+        if arg is self.token_placeholder:
+            return self.token
+        else:
+            return arg
+
+    @inlineCallbacks
+    def _callAndYield(self, method, *args):
+        """Call XMLRPC method, yielding to the reactor in the meantime.
+
+        :param method: Name of XMLRPC method to invoke.
+        :param *args: Arguments for the call.  If any of them is
+            `token_placeholder`, the current security token will be
+            substituted in its place.
+        """
+        args = map(self.substitute_token, args)
+        d = self.proxy.callRemote(method, *args)
+        result = yield d
+        returnValue(result)
+
+    @inlineCallbacks
+    def call(self, method, *args):
+        """Initiate call to XMLRPC `method` by name, through Twisted.
+
+        Initiates XMLRPC call, yields back to the reactor until it's ready
+        with a response, then returns the response.  Use this as if it were
+        a synchronous XMLRPC call; but be aware that it lets the reactor run
+        other code in the meantime.
+
+        :param method: Name of XMLRPC method to call.
+        :param *args: Positional arguments for the XMLRPC call.
+        :return: The result of the call.
+        """
+        original_state = self.record_state()
+        authenticate = (self.token_placeholder in args)
+
+        authentication_expired = False
+        try:
+            result = yield self._callAndYield(method, *args)
+        except xmlrpclib.Fault as e:
+            if authenticate and looks_like_auth_expiry(e):
+                authentication_expired = True
+            else:
+                raise
+
+        if authentication_expired:
+            self.authenticate(original_state)
+            result = yield self._callAndYield(method, *args)
+        returnValue(result)
+
+
+class CobblerObject:
+    """Abstract base class: a type of object in Cobbler's XMLRPC API.
+
+    Cobbler's API exposes several types of object, but they all conform
+    to a very basic standard API.  Implement a type by inheriting from
+    this class.
+
+    :ivar object_type: The identifier for the kind of object represented.
+        Must be set in concrete derived classes.
+    :ivar object_type_plural: Optional plural for the type's identifier.
+        If not given, is derived by suffixing `object_type` with an "s".
+    :ivar known_attributes: Attributes that this object is known to have.
+    """
+
+    # What are objects of this type called in the Cobbler API?
+    object_type = None
+
+    # What's the plural of object_type, if not object_type + "s"?
+    object_type_plural = None
+
+    # What attributes do we expect to support for this type of object?
+    # Only these attributes are allowed.  This is here to force us to
+    # keep an accurate record of which attributes we use for which types
+    # of objects.  We may find that it helps us catch mistakes, or we
+    # may want to let this go once we're comfortable and stable with the
+    # API.
+    known_attributes = []
+
+    def __init__(self, session, handle=None, name=None, values=None):
+        """Reference an object in Cobbler.
+
+        :param session: A `CobblerSession`.
+        :param handle: The object's handle, if known.
+        :param name: Name for this object, if known.
+        :param values: Attribute values for this object, if known.
+        """
+        if values is None:
+            values = {}
+        self.session = session
+        self.values = values
+        # Cache the handle; we need it when modifying or saving objects.
+        self.handle = handle or values.get('handle')
+        # Cache the name; we need it when deleting objects.
+        self.name = name or values.get('name')
+
+    @classmethod
+    def name_method(cls, name_template, plural=False):
+        """Interpolate object_type into a method name template.
+
+        For example, on `CobblerSystem`, "get_%s_handle" would be
+        interpolated into "get_system_handle" and "get_%s" with plural=True
+        becomes "get_systems".
+        """
+        if plural:
+            type_name = (cls.object_type_plural or '%s' % cls.object_type)
+        else:
+            type_name = cls.object_type
+        return name_template % type_name
+
+    @classmethod
+    @inlineCallbacks
+    def retrieve(cls, session, name):
+        """Reference an object from Cobbler's database."""
+        method = cls.name_method('get_%s_handle')
+        handle = yield session.call(method, name, session.token_placeholder)
+        returnValue(cls(session, handle, name=name))
+
+    @classmethod
+    @inlineCallbacks
+    def find(cls, session, **kwargs):
+        """Find objects in the database.
+
+        :param session: The `CobblerSession` to operate in.  No authentication
+            is required.
+        :param **kwargs: Optional search criteria, e.g.
+            hostname="*.maas3.example.com" to limit the search to items with
+            a hostname attribute that ends in ".maas3.example.com".
+        :return: A list of `cls` objects.
+        """
+        if kwargs:
+            method_template = "find_%s"
+            args = (kwargs, )
+        else:
+            method_template = "get_%s"
+            args = ()
+        method = cls.name_method(method_template, plural=True)
+        result = yield session.call(method, *args)
+        returnValue([cls(session, values=item) for item in result])
+
+    @classmethod
+    @inlineCallbacks
+    def new(cls, session):
+        """Create an object in Cobbler."""
+        method = 'new_%s' % cls.object_type
+        handle = yield session.call(method, session.token_placeholder)
+        returnValue(cls(session, handle))
+
+    @inlineCallbacks
+    def delete(self, recurse=True):
+        """Delete this object.  Its name must be known.
+
+        :param recurse: Delete dependent objects recursively?
+        """
+        assert self.name is not None, (
+            "Can't delete %s; don't know its name." % self.object_type)
+        method = self.name_method('remove_%s')
+        yield self.session.call(
+            method, self.name, self.session.token_placeholder, recurse)
+
+    @inlineCallbacks
+    def _modify_attributes(self, attributes):
+        """Attempt to modify the object's attributes."""
+        method = 'modify_%s' % self.object_type
+        for key, value in attributes.items():
+            assert key in self.known_attributes, (
+                "Unknown attribute for %s: %s." % (self.object_type, key))
+            yield self.session.call(
+                method, self.handle, key, value,
+                self.session.token_placeholder)
+            if key == 'name':
+                self.name = value
+
+    @inlineCallbacks
+    def _save_attributes(self):
+        """Save object's current state."""
+        method = 'modify_%s' % self.object_type
+        yield self.session.call(
+            method, self.handle, self.session.token_placeholder)
+
+    @inlineCallbacks
+    def modify(self, **attributes):
+        """Modify this object's attributes, and save.
+
+        :param **attributes: Attribute values to set (as "attribute=value"
+            keyword arguments).
+        """
+        original_state = self.session.record_state()
+        yield self._modify_attributes(self, attributes)
+        if self.session.record_state() != original_state:
+            # Something went wrong and we had to re-authenticate our
+            # session while we were modifying attributes.  We can't be sure
+            # that our changes all got through, so make them all again.
+            yield self._modify_attributes(self, attributes)
+
+        original_state = self.session.record_state()
+        yield self._save_attributes()
+        if self.session.record_state() != original_state:
+            raise RuntimeError(
+                "Cobbler session broke while saving %s." % self.object_type)
+
+
+class CobblerProfile(CobblerObject):
+    """A profile."""
+    object_type = 'profile'
+    known_attributes = [
+        'name',
+        ]
+
+
+class CobblerImage(CobblerObject):
+    """An operating system image."""
+    object_type = "image"
+    known_attributes = [
+        'name',
+        ]
+
+
+class CobblerDistro(CobblerObject):
+    """A distribution."""
+    object_type = 'distro'
+    known_attributes = [
+        # Path to initrd image:
+        'initrd',
+        # Path to kernel:
+        'kernel',
+        # Identifier:
+        'name',
+        ]
+
+
+class CobblerSystem(CobblerObject):
+    """A computer on the network."""
+    object_type = 'system'
+    known_attributes = [
+        # FQDN:
+        'hostname',
+        # Space-separated key=value pairs:
+        'kernel_options'
+        # Space-separated key=value pairs for preseed:
+        'ks_meta',
+        # A special dict; see below.
+        'modify_interface',
+        # Unqualified host name:
+        'name',
+        # Conventionally a distroseries-architecture combo.
+        'profile',
+        ]
+
+    # The modify_interface dict can contain:
+    #  * "macaddress-eth0" etc.
+    #  * "ipaddress-eth0" etc.
+    #  * "dnsname-eth0" etc.
+
+    @staticmethod
+    def get_as_rendered(session, system_name):
+        """Return system information in "blended" form.
+
+        The blended form includes information "as koan (or PXE) (or
+        templating) would evaluate it."
+
+        I have no idea what this means, but it's in the cobbler API.
+        """
+        return session.call('get_system_as_rendered', system_name)
+
+    @staticmethod
+    def get_changed_systems(session, changed_since):
+        """List systems changed since a given time."""
+# TODO: Who accounts for the race window?
+        seconds_since_epoch = int(changed_since.strftime('%s'))
+        return session.call('get_changed_systems', seconds_since_epoch)
+
+    def _callPower(self, operation):
+        """Call API's "power_system" method."""
+        return self.session.call(
+            'power_system', operation, self.session.token_placeholder)
+
+    def powerOn(self):
+        """Turn system on."""
+        return self._callPower('on')
+
+    def powerOff(self):
+        """Turn system on."""
+        return self._callPower('off')
+
+    def reboot(self):
+        """Turn system on."""
+        return self._callPower('reboot')
+
+
+class CobblerCommands:
+    """Other miscellany: grab-bag of API leftovers."""
+
+    def __init__(self, session):
+        self.session = session
+
+    def read_preseed_template(self, path):
+        """Read a preseed template."""
+        return self.session.call(
+            'read_or_write_kickstart_template', path, True, '',
+            self.session.token_placeholder)
+
+    def write_preseed_template(self, path, contents):
+        """Write a preseed template."""
+        return self.session.call(
+            'read_or_write_kickstart_template', path, False, contents,
+            self.session.token_placeholder)
+
+    def read_preseed_snippet(self, path):
+        """Read a preseed snippet."""
+        return self.session.call(
+            'read_or_write_kickstart_snippet', path, True, '',
+            self.session.token_placeholder)
+
+    def write_preseed_snippet(self, path, contents):
+        """Write a preseed snippet."""
+        return self.session.call(
+            'read_or_write_kickstart_snippet', path, False, contents,
+            self.session.token_placeholder)
+
+    def sync_netboot_configs(self):
+        """Update netmasq and tftpd configurations."""
+        return self.session.call('sync', self.session.token_placeholder)

=== added file 'src/provisioningserver/fakecobbler.py'
--- src/provisioningserver/fakecobbler.py	1970-01-01 00:00:00 +0000
+++ src/provisioningserver/fakecobbler.py	2012-01-25 15:12:28 +0000
@@ -0,0 +1,214 @@
+# 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,
+    )
+
+"""Fake, synchronous Cobbler XMLRPC service for testing."""
+
+__metaclass__ = type
+__all__ = [
+    'FakeCobbler',
+    'FakeTwistedProxy',
+    'fake_token',
+    ]
+
+from random import Random
+from xmlrpclib import Fault
+
+from twisted.internet.defer import (
+    inlineCallbacks,
+    returnValue,
+    )
+
+
+randomizer = Random()
+
+sequence_no = randomizer.randint(0, 99999)
+
+
+def get_unique_int():
+    global sequence_no
+    result = sequence_no
+    sequence_no += 1
+    return result
+
+
+def fake_token(user=None):
+    elements = ['token', '%s' % get_unique_int()]
+    if user is not None:
+        elements.append(user)
+    return '-'.join(elements)
+
+
+class FakeTwistedProxy:
+    """Fake Twisted XMLRPC proxy that forwards calls to a `FakeCobbler`."""
+
+    def __init__(self, fake_cobbler=None):
+        if fake_cobbler is None:
+            fake_cobbler = FakeCobbler()
+        self.fake_cobbler = fake_cobbler
+
+    @inlineCallbacks
+    def callRemote(self, method, *args):
+        callee = getattr(self.fake_cobbler, method, None)
+        assert callee is not None, "Unknown Cobbler method: %s" % method
+        result = yield callee(*args)
+        returnValue(result)
+
+
+class FakeCobbler:
+    """Fake implementation of the Cobbler XMLRPC API.
+
+    :param passwords: A dict mapping user names to their passwords.
+
+    :ivar tokens: A dict mapping valid auth tokens to their users.
+    """
+
+    def __init__(self, passwords=None):
+        if passwords is None:
+            self.passwords = {}
+        else:
+            self.passwords = passwords
+
+        self.tokens = {}
+
+    def fake_check_token(self, token):
+        """Not part of the faked API: check token validity."""
+        if token not in self.tokens:
+            raise Fault(1, "invalid token: %s" % token)
+
+    def login(self, user, password):
+        if password != self.passwords.get(user, object()):
+            raise Exception("login failed (%s)" % user)
+        token = fake_token(user)
+        self.tokens[token] = user
+        return token
+
+    def new_distro(self, token):
+        self.fake_check_token(token)
+        pass
+
+    def remove_distro(self, name, token, recurse=True):
+        self.fake_check_token(token)
+        pass
+
+    def get_distro_handle(self, name, token):
+        self.fake_check_token(token)
+        pass
+
+    def find_distros(self, criteria):
+        pass
+
+    def get_distros(self):
+        pass
+
+    def modify_distro(self, handle, key, value, token):
+        self.fake_check_token(token)
+        pass
+
+    def save_distro(self, handle, token):
+        self.fake_check_token(token)
+        pass
+
+    def new_image(self, token):
+        self.fake_check_token(token)
+        pass
+
+    def remove_image(self, name, token, recurse=True):
+        self.fake_check_token(token)
+        pass
+
+    def get_image_handle(self, name, token):
+        self.fake_check_token(token)
+        pass
+
+    def find_images(self, criteria):
+        pass
+
+    def get_images(self):
+        pass
+
+    def modify_image(self, handle, key, value, token):
+        self.fake_check_token(token)
+        pass
+
+    def save_image(self, handle, token):
+        self.fake_check_token(token)
+        pass
+
+    def new_profile(self, token):
+        self.fake_check_token(token)
+        pass
+
+    def remove_profile(self, name, token, recurse=True):
+        self.fake_check_token(token)
+        pass
+
+    def get_profile_handle(self, name, token):
+        self.fake_check_token(token)
+        pass
+
+    def find_profiles(self, criteria):
+        pass
+
+    def get_profiles(self):
+        pass
+
+    def modify_profile(self, handle, key, value, token):
+        self.fake_check_token(token)
+        pass
+
+    def save_profile(self, handle, token):
+        self.fake_check_token(token)
+        pass
+
+    def new_system(self, token):
+        self.fake_check_token(token)
+        pass
+
+    def remove_system(self, name, token, recurse=True):
+        self.fake_check_token(token)
+        pass
+
+    def get_system_handle(self, name, token):
+        self.fake_check_token(token)
+        pass
+
+    def find_systems(self, criteria):
+        pass
+
+    def get_systems(self):
+        pass
+
+    def modify_system(self, handle, key, value, token):
+        self.fake_check_token(token)
+        pass
+
+    def save_system(self, handle, token):
+        self.fake_check_token(token)
+        pass
+
+    def get_system_as_rendered(self, name):
+        pass
+
+    def get_changed_systems(self, seconds_since_epoch):
+        pass
+
+    def power_system(self, operation, token):
+        self.fake_check_token(token)
+        pass
+
+    def read_or_write_kickstart_template(self, path, read, contents, token):
+        self.fake_check_token(token)
+        pass
+
+    def read_or_write_kickstart_snippet(self, path, read, contents, token):
+        self.fake_check_token(token)
+        pass
+
+    def sync(self, token):
+        self.fake_check_token(token)
+        pass

=== added directory 'src/provisioningserver/tests'
=== added file 'src/provisioningserver/tests/__init__.py'
--- src/provisioningserver/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ src/provisioningserver/tests/__init__.py	2012-01-25 15:12:28 +0000
@@ -0,0 +1,23 @@
+# 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,
+    )
+
+import test_fakecobbler
+
+
+__metaclass__ = type
+__all__ = [
+    test_fakecobbler,
+    ]
+
+from os.path import dirname
+
+from django.utils.unittest import defaultTestLoader
+
+
+def suite():
+    return defaultTestLoader.discover(dirname(__file__))

=== added file 'src/provisioningserver/tests/test_cobblersession.py'
--- src/provisioningserver/tests/test_cobblersession.py	1970-01-01 00:00:00 +0000
+++ src/provisioningserver/tests/test_cobblersession.py	2012-01-25 15:12:28 +0000
@@ -0,0 +1,252 @@
+# 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,
+    )
+
+"""Tests for `CobblerSession`."""
+
+__metaclass__ = type
+__all__ = []
+
+from random import Random
+from unittest import TestCase
+from xmlrpclib import Fault
+
+from provisioningserver import cobblerclient
+from provisioningserver.fakecobbler import fake_token
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet.defer import (
+    inlineCallbacks,
+    returnValue,
+    )
+
+
+randomizer = Random()
+
+
+def pick_number():
+    """Pick an arbitrary number."""
+    return randomizer.randint(0, 10 ** 9)
+
+
+class FakeAuthFailure(Fault):
+    """Imitated Cobbler authentication failure."""
+
+    def __init__(self, token):
+        super(FakeAuthFailure, self).__init__(1, "invalid token: %s" % token)
+
+
+def make_auth_failure(broken_token=None):
+    """Mimick a Cobbler authentication failure."""
+    if broken_token is None:
+        broken_token = fake_token()
+    return FakeAuthFailure(broken_token)
+
+
+class InstrumentedSession(cobblerclient.CobblerSession):
+    """Instrumented `CobblerSession` with a fake XMLRPC proxy.
+
+    :ivar fake_proxy: Test double for the XMLRPC proxy.
+    :ivar fake_token: Auth token that login will pretend to receive.
+    """
+
+    def __init__(self, *args, **kwargs):
+        """Create and instrument a session.
+
+        In addition to the arguments for `CobblerSession.__init__`, pass a
+        keyword argument `fake_proxy` to set a test double that the session
+        will use for its proxy; and `fake_token` to provide a login token
+        that the session should pretend it gets from the server on login.
+        """
+        self.fake_proxy = kwargs['fake_proxy']
+        self.fake_token = kwargs['fake_token']
+        del kwargs['fake_proxy']
+        del kwargs['fake_token']
+        super(InstrumentedSession, self).__init__(*args, **kwargs)
+
+    def _make_twisted_proxy(self):
+        return self.fake_proxy
+
+    def _login(self):
+        self.token = self.fake_token
+
+
+class RecordingFakeProxy:
+    """Simple fake Twisted XMLRPC proxy.
+
+    Records XMLRPC calls, and returns predetermined values.
+    """
+    def __init__(self):
+        self.calls = []
+        self.return_values = None
+
+    def set_return_values(self, values):
+        """Set predetermined value to return on following call(s).
+
+        If any return value is an `Exception`, it will be raised instead.
+        """
+        self.return_values = values
+
+    @inlineCallbacks
+    def callRemote(self, method, *args):
+        self.calls.append((method, ) + tuple(args))
+        if self.return_values:
+            value = self.return_values.pop(0)
+        else:
+            value = None
+        if isinstance(value, Exception):
+            raise value
+        else:
+            value = yield value
+            returnValue(value)
+
+
+class TestCobblerSession(TestCase):
+    """Test session management against a fake XMLRPC session."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory()
+
+    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):
+        """Create an `InstrumentedSession` with a `RecordingFakeProxy`."""
+        if session_args is None:
+            session_args = self.make_url_user_password()
+        if token is None:
+            token = fake_token()
+        fake_proxy = RecordingFakeProxy()
+        return InstrumentedSession(
+            *session_args, fake_proxy=fake_proxy, fake_token=token)
+
+    def test_initializes_but_does_not_authenticate_on_creation(self):
+        url, user, password = self.make_url_user_password()
+        session = self.make_recording_session(token=fake_token())
+        self.assertEqual(None, session.token)
+
+    def test_authenticate_authenticates_initially(self):
+        token = fake_token()
+        session = self.make_recording_session(token=token)
+        self.assertEqual(None, session.token)
+        session.authenticate()
+        self.assertEqual(token, session.token)
+
+    def test_state_cookie_stays_constant_during_normal_use(self):
+        session = self.make_recording_session()
+        state = session.record_state()
+        self.assertEqual(state, session.record_state())
+        session.call("some_method")
+        self.assertEqual(state, session.record_state())
+
+    def test_authentication_changes_state_cookie(self):
+        session = self.make_recording_session()
+        old_cookie = session.record_state()
+        session.authenticate()
+        self.assertNotEqual(old_cookie, session.record_state())
+
+    def test_authenticate_backs_off_from_overwriting_concurrent_auth(self):
+        session = self.make_recording_session()
+        # Two requests are made concurrently.
+        cookie_before_request_1 = session.record_state()
+        cookie_before_request_2 = session.record_state()
+        # Request 1 comes back with an authentication failure, and its
+        # callback refreshes the session's auth token.
+        session.authenticate(cookie_before_request_1)
+        token_for_retrying_request_1 = session.token
+        # Request 2 also comes back an authentication failure, and its
+        # callback also asks the session to ensure that it is
+        # authenticated.
+        session.authenticate(cookie_before_request_2)
+        token_for_retrying_request_2 = session.token
+
+        # The double authentication does not confuse the session; both
+        # callbacks get the same auth token for their retries.
+        self.assertEqual(
+            token_for_retrying_request_1, token_for_retrying_request_2)
+        # The token they get is a new token, not the one they started
+        # with.
+        self.assertNotEqual(cookie_before_request_1, session.token)
+        self.assertNotEqual(cookie_before_request_2, session.token)
+
+    def test_substitute_token_substitutes_only_placeholder(self):
+        token = fake_token()
+        session = self.make_recording_session(token=token)
+        session.authenticate()
+        arbitrary_number = pick_number()
+        inputs = [
+            arbitrary_number,
+            cobblerclient.CobblerSession.token_placeholder,
+            None,
+            ]
+        outputs = [
+            arbitrary_number,
+            token,
+            None]
+        self.assertEqual(outputs, map(session.substitute_token, inputs))
+
+    @inlineCallbacks
+    def test_call_calls_xmlrpc(self):
+        session = self.make_recording_session()
+        return_value = pick_number()
+        method = 'method%d' % pick_number()
+        arg = pick_number()
+        session.fake_proxy.set_return_values([return_value])
+        actual_return_value = yield session.call(method, arg)
+        self.assertEqual(return_value, actual_return_value)
+        self.assertEqual([(method, arg)], session.fake_proxy.calls)
+
+    @inlineCallbacks
+    def test_call_reauthenticates_and_retries_on_auth_failure(self):
+        session = self.make_recording_session()
+        fake_proxy = session.fake_proxy
+        fake_proxy.set_return_values([make_auth_failure()])
+        fake_proxy.calls = []
+        yield session.call("failing_method")
+        self.assertEqual(
+            [
+                # Initial call to failing_method: auth failure.
+                ('failing_method', ),
+                # But call() re-authenticates, and retries.
+                ('failing_method', ),
+            ],
+            fake_proxy.calls)
+
+    @inlineCallbacks
+    def test_call_raises_repeated_auth_failure(self):
+        session = self.make_recording_session()
+        session.fake_proxy.set_return_values([
+            # Initial operation fails: not authenticated.
+            make_auth_failure(),
+            # But retry still raises authentication failure.
+            make_auth_failure(),
+            ])
+        try:
+            d = session.call('double_fail')
+            return_value = yield d
+        except Exception:
+            pass
+        else:
+            self.fail("Returned %s instead of raising." % return_value)
+
+    @inlineCallbacks
+    def test_call_raises_general_failure(self):
+        session = self.make_recording_session()
+        session.fake_proxy.set_return_values([
+            Exception("Memory error.  Where did I put it?"),
+            ])
+        try:
+            d = session.call('failing_method')
+            return_value = yield d
+        except Exception:
+            pass
+        else:
+            self.assertTrue(
+                False, "Returned %s instead of raising." % return_value)

=== added file 'src/provisioningserver/tests/test_fakecobbler.py'
--- src/provisioningserver/tests/test_fakecobbler.py	1970-01-01 00:00:00 +0000
+++ src/provisioningserver/tests/test_fakecobbler.py	2012-01-25 15:12:28 +0000
@@ -0,0 +1,90 @@
+# 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,
+    )
+
+"""Tests for the fake Cobbler API."""
+
+__metaclass__ = type
+__all__ = []
+
+from unittest import TestCase
+
+from provisioningserver.cobblerclient import (
+    CobblerSession,
+    CobblerSystem,
+    )
+from provisioningserver.fakecobbler import (
+    FakeCobbler,
+    FakeTwistedProxy,
+    )
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet.defer import inlineCallbacks
+
+
+class FakeCobblerSession(CobblerSession):
+    """A `CobblerSession` instrumented not to use real XMLRPC."""
+
+    def __init__(self, url, user, password, fake_cobbler=None):
+        self.fake_proxy = FakeTwistedProxy(fake_cobbler=fake_cobbler)
+        super(FakeCobblerSession, self).__init__(url, user, password)
+
+    def _make_twisted_proxy(self):
+        return self.fake_proxy
+
+    def _login(self):
+        self.token = self.proxy.fake_cobbler.login(self.user, self.password)
+
+
+def fake_cobbler_session(url=None, user=None, password=None,
+                         fake_cobbler=None):
+    """Fake a CobblerSession."""
+    session = FakeCobblerSession(
+        url, user, password, fake_cobbler=fake_cobbler)
+    session.authenticate()
+    return session
+
+
+class TestFakeCobbler(TestCase):
+    """Test `FakeCobbler`.
+
+    These tests should also pass if run against a real (clean) Cobbler.
+    """
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory()
+
+    def test_login_failure_raises_failure(self):
+        cobbler = FakeCobbler(passwords={'moi': 'potahto'})
+        self.assertRaises(
+            Exception,
+            fake_cobbler_session,
+            user='moi', password='potayto', fake_cobbler=cobbler)
+
+    @inlineCallbacks
+    def test_expired_token_triggers_retry(self):
+        cobbler = FakeCobbler(passwords={'user': 'pw'})
+        session = fake_cobbler_session(
+            user='user', password='pw', fake_cobbler=cobbler)
+        # When an auth token expires, the server just forgets about it.
+        old_token = session.token
+        del cobbler.tokens[session.token]
+
+        # 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)
+
+        # The re-authentication results in a fresh token.
+        self.assertNotEqual(old_token, session.token)
+
+    @inlineCallbacks
+    def test_valid_token_does_not_raise_auth_error(self):
+        cobbler = FakeCobbler(passwords={'user': 'password'})
+        session = fake_cobbler_session(
+            user='user', password='password', fake_cobbler=cobbler)
+        old_token = session.token
+        yield CobblerSystem.new(session)
+        self.assertEqual(old_token, session.token)


Follow ups