← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/livefs into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/livefs into lp:launchpad.

Commit message:
Implement live filesystem building.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1247461 in Launchpad itself: "Move live filesystem building into Launchpad"
  https://bugs.launchpad.net/launchpad/+bug/1247461

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/livefs/+merge/217261

WIP for live filesystem building.  Not actually ready for review yet, but this will produce a diff.
-- 
https://code.launchpad.net/~cjwatson/launchpad/livefs/+merge/217261
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/livefs into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2014-04-23 14:24:15 +0000
+++ lib/lp/_schema_circular_imports.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Update the interface schema values due to circular imports.
@@ -196,6 +196,14 @@
     )
 from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.livefs import (
+    ILiveFS,
+    ILiveFSView,
+    )
+from lp.soyuz.interfaces.livefsbuild import (
+    ILiveFSBuild,
+    ILiveFSFile,
+    )
 from lp.soyuz.interfaces.packageset import (
     IPackageset,
     IPackagesetSet,
@@ -350,6 +358,10 @@
                           Reference(schema=IDistroSeries))
 patch_plain_parameter_type(IPerson, 'createRecipe', 'daily_build_archive',
                            IArchive)
+patch_entry_return_type(IPerson, 'createLiveFS', ILiveFS)
+patch_plain_parameter_type(IPerson, 'createLiveFS', 'owner', IPerson)
+patch_plain_parameter_type(IPerson, 'createLiveFS', 'distroseries',
+                           IDistroSeries)
 patch_plain_parameter_type(IPerson, 'getArchiveSubscriptionURL', 'archive',
                            IArchive)
 patch_collection_return_type(
@@ -576,6 +588,14 @@
 # IDistroArchSeries
 patch_reference_property(IDistroArchSeries, 'main_archive', IArchive)
 
+# ILiveFSFile
+patch_reference_property(ILiveFSFile, 'livefsbuild', ILiveFSBuild)
+
+# ILiveFSView
+patch_entry_return_type(ILiveFSView, 'requestBuild', ILiveFSBuild)
+ILiveFSView['builds'].value_type.schema = ILiveFSBuild
+patch_reference_property(ILiveFSView, 'last_completed_build', ILiveFSBuild)
+
 # IPackageset
 patch_collection_return_type(
     IPackageset, 'setsIncluded', IPackageset)

=== modified file 'lib/lp/buildmaster/enums.py'
--- lib/lp/buildmaster/enums.py	2013-10-31 07:30:54 +0000
+++ lib/lp/buildmaster/enums.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Common build interfaces."""
@@ -149,6 +149,12 @@
         Generate translation templates from a bazaar branch.
         """)
 
+    LIVEFSBUILD = DBItem(5, """
+        Live filesystem build
+
+        Build a live filesystem from an archive.
+        """)
+
 
 class BuildQueueStatus(DBEnumeratedType):
     """Build queue status.

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2014-03-21 03:34:57 +0000
+++ lib/lp/registry/browser/person.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Person-related view classes."""
@@ -264,6 +264,7 @@
     )
 from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
 from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet
+from lp.soyuz.interfaces.livefs import ILiveFSSet
 from lp.soyuz.interfaces.publishing import ISourcePackagePublishingHistory
 from lp.soyuz.interfaces.sourcepackagerelease import ISourcePackageRelease
 
@@ -494,6 +495,26 @@
         """Traverse to this person's merge queues."""
         return self.context.getMergeQueue(name)
 
+    @stepto('+livefs')
+    def traverse_livefs(self):
+        """Traverse to this person's live filesystem images."""
+        def get_segments(pillar_name):
+            base = [self.context.name, pillar_name]
+            return itertools.chain(iter(base), iter(self.request.stepstogo))
+
+        pillar_name = self.request.stepstogo.next()
+        livefs = getUtility(ILiveFSSet).traverse(get_segments(pillar_name))
+        if livefs is None:
+            raise NotFoundError
+
+        if livefs.distroseries.distribution.name != pillar_name:
+            # This live filesystem was accessed through one of its
+            # distribution's aliases, so we must redirect to its canonical
+            # URL.
+            return self.redirectSubTree(canonical_url(livefs))
+
+        return livefs
+
 
 class PersonSetNavigation(Navigation):
 

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2014-03-11 10:37:23 +0000
+++ lib/lp/registry/interfaces/person.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Person interfaces."""
@@ -84,6 +84,7 @@
     Bool,
     Choice,
     Datetime,
+    Dict,
     Int,
     List,
     Object,
@@ -1024,12 +1025,32 @@
     def getRecipe(name):
         """Return the person's recipe with the given name."""
 
+    @call_with(registrant=REQUEST_USER)
+    @operation_parameters(
+        owner=Reference(
+            Interface,
+            title=_("The person who registered this live filesystem image.")),
+        distroseries=Reference(
+            Interface, title=_("The owner of this live filesystem image.")),
+        name=TextLine(
+            title=_("The series for which the image should be built.")),
+        metadata=Dict(
+            title=_(
+                "A dict of data about the image.  Entries here will be passed "
+                "to the builder slave."),
+            key_type=TextLine()),
+        )
+    @export_factory_operation(Interface, [])
+    @operation_for_version("devel")
+    def createLiveFS(registrant, owner, distroseries, name, metadata):
+        """Create a `LiveFS` owned by this person."""
+
     def getMergeQueue(name):
         """Return the person's merge queue with the given name."""
 
     @call_with(requester=REQUEST_USER)
     @export_read_operation()
-    @operation_returns_collection_of(Interface) # Really IArchiveSubscriber
+    @operation_returns_collection_of(Interface)  # Really IArchiveSubscriber
     @operation_for_version('devel')
     def getArchiveSubscriptions(requester):
         """Return (private) archives subscription for this person."""

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2014-03-11 11:34:08 +0000
+++ lib/lp/registry/model/person.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Implementation classes for a Person."""
@@ -311,6 +311,7 @@
     )
 from lp.soyuz.interfaces.archive import IArchiveSet
 from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
+from lp.soyuz.interfaces.livefs import ILiveFSSet
 from lp.soyuz.model.archive import (
     Archive,
     validate_ppa,
@@ -2951,6 +2952,13 @@
             SourcePackageRecipe, SourcePackageRecipe.owner == self,
             SourcePackageRecipe.name == name).one()
 
+    def createLiveFS(self, registrant, owner, distroseries, name, metadata):
+        """See `IPerson`."""
+        livefs = getUtility(ILiveFSSet).new(
+            registrant, owner, distroseries, name, metadata)
+        Store.of(livefs).flush()
+        return livefs
+
     def getMergeQueue(self, name):
         from lp.code.model.branchmergequeue import BranchMergeQueue
         return Store.of(self).find(

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2014-03-17 21:50:33 +0000
+++ lib/lp/security.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Security policies for using content objects."""
@@ -196,6 +196,8 @@
     IBinaryPackageReleaseDownloadCount,
     )
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.livefs import ILiveFS
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
 from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJob
 from lp.soyuz.interfaces.packageset import (
     IPackageset,
@@ -2872,3 +2874,46 @@
             sourcepackagename=self.obj.sourcepackagename,
             component=None, strict_component=False)
         return reason is None
+
+
+class ViewLiveFS(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = ILiveFS
+
+    def __init__(self, obj):
+        super(ViewLiveFS, self).__init__(obj, obj.owner, 'launchpad.View')
+
+
+class EditLiveFS(EditByOwnersOrAdmins):
+    usedfor = ILiveFS
+
+
+class ViewLiveFSBuild(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = ILiveFSBuild
+
+    def __init__(self, obj):
+        super(ViewLiveFSBuild, self).__init__(
+            obj, obj.livefs, 'launchpad.View')
+
+
+class EditLiveFSBuild(AdminByBuilddAdmin):
+    permission = 'launchpad.Edit'
+    usedfor = ILiveFSBuild
+
+    def checkAuthenticated(self, user):
+        """Check edit access for live filesystem builds.
+
+        Allow admins, buildd admins, the owner of the live filesystem, and
+        the requester of the live filesystem build.
+        """
+        if user.inTeam(self.obj.requester):
+            return True
+        auth_livefs = EditLiveFS(self.obj.livefs)
+        if auth_livefs.checkAuthenticated(user):
+            return True
+        return super(EditLiveFSBuild, self).checkAuthenticated(user)
+
+
+class AdminLiveFSBuild(AdminByBuilddAdmin):
+    usedfor = ILiveFSBuild

=== modified file 'lib/lp/soyuz/adapters/archivedependencies.py'
--- lib/lp/soyuz/adapters/archivedependencies.py	2013-05-02 00:40:14 +0000
+++ lib/lp/soyuz/adapters/archivedependencies.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Archive dependencies helper function.
@@ -119,9 +119,12 @@
     If no ancestry could be found, default to 'universe'.
     """
     primary_archive = archive.distribution.main_archive
-    ancestries = primary_archive.getPublishedSources(
-        name=sourcepackagename,
-        distroseries=distroseries, exact_match=True)
+    if sourcepackagename is None:
+        ancestries = []
+    else:
+        ancestries = primary_archive.getPublishedSources(
+            name=sourcepackagename,
+            distroseries=distroseries, exact_match=True)
 
     try:
         return ancestries[0].component.name

=== modified file 'lib/lp/soyuz/browser/build.py'
--- lib/lp/soyuz/browser/build.py	2014-02-26 03:05:44 +0000
+++ lib/lp/soyuz/browser/build.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for builds."""
@@ -84,6 +84,7 @@
     IBinaryPackageBuildSet,
     IBuildRescoreForm,
     )
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 
 
 class BuildUrl:
@@ -149,6 +150,17 @@
         except NotFoundError:
             return None
 
+    @stepthrough('+livefsbuild')
+    def traverse_livefsbuild(self, name):
+        try:
+            build_id = int(name)
+        except ValueError:
+            return None
+        try:
+            return getUtility(ILiveFSBuildSet).getByID(build_id)
+        except NotFoundError:
+            return None
+
 
 class BuildContextMenu(ContextMenu):
     """Overview menu for build records """

=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml	2014-04-24 07:30:36 +0000
+++ lib/lp/soyuz/browser/configure.zcml	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -728,5 +728,15 @@
             template="../templates/packagerelationship-list.pt"
          />
     </browser:pages>
+    <browser:url
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        path_expression="string:+livefs/${distroseries/distribution/name}/${distroseries/name}/${name}"
+        attribute_to_parent="owner"
+        />
+    <browser:url
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        path_expression="string:+livefsbuild/${id}"
+        attribute_to_parent="archive"
+        />
     </facet>
 </configure>

=== added file 'lib/lp/soyuz/browser/tests/test_livefs.py'
--- lib/lp/soyuz/browser/tests/test_livefs.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/browser/tests/test_livefs.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,29 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test live filesystem navigation."""
+
+__metaclass__ = type
+
+from lp.services.features.testing import FeatureFixture
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.publication import test_traverse
+
+
+class TestLiveFSNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSNavigation, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_livefs(self):
+        livefs = self.factory.makeLiveFS()
+        obj, _, _ = test_traverse(
+            "http://api.launchpad.dev/devel/~%s/+livefs/%s/%s/%s"; % (
+                livefs.owner.name, livefs.distroseries.distribution.name,
+                livefs.distroseries.name, livefs.name))
+        self.assertEqual(livefs, obj)

=== modified file 'lib/lp/soyuz/configure.zcml'
--- lib/lp/soyuz/configure.zcml	2014-02-18 11:40:52 +0000
+++ lib/lp/soyuz/configure.zcml	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -975,6 +975,58 @@
       <allow
       interface=".interfaces.packagetranslationsuploadjob.IPackageTranslationsUploadJob" />
     </class>
+
+    <!-- LiveFS -->
+    <class class=".model.livefs.LiveFS">
+        <require
+            permission="launchpad.View"
+            interface=".interfaces.livefs.ILiveFSView
+                       .interfaces.livefs.ILiveFSEditableAttributes"/>
+        <require
+            permission="launchpad.Edit"
+            set_schema=".interfaces.livefs.ILiveFSEditableAttributes"/>
+    </class>
+
+    <!-- LiveFSSet -->
+    <securedutility
+        class=".model.livefs.LiveFSSet"
+        provides=".interfaces.livefs.ILiveFSSet">
+        <allow interface=".interfaces.livefs.ILiveFSSet"/>
+    </securedutility>
+
+    <!-- LiveFSBuild -->
+    <class class=".model.livefsbuild.LiveFSBuild">
+        <require
+            permission="launchpad.View"
+            interface=".interfaces.livefsbuild.ILiveFSBuildView"/>
+        <require
+            permission="launchpad.Edit"
+            interface=".interfaces.livefsbuild.ILiveFSBuildEdit"/>
+        <require
+            permission="launchpad.Admin"
+            interface=".interfaces.livefsbuild.ILiveFSBuildAdmin"/>
+    </class>
+
+    <!-- LiveFSBuildSet -->
+    <securedutility
+        class=".model.livefsbuild.LiveFSBuildSet"
+        provides=".interfaces.livefsbuild.ILiveFSBuildSet">
+        <allow interface=".interfaces.livefsbuild.ILiveFSBuildSet"/>
+    </securedutility>
+    <securedutility
+        class=".model.livefsbuild.LiveFSBuildSet"
+        provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+        name="LIVEFSBUILD">
+        <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"/>
+    </securedutility>
+
+    <!-- LiveFSBuildBehaviour -->
+    <adapter
+        for=".interfaces.livefsbuild.ILiveFSBuild"
+        provides="lp.buildmaster.interfaces.buildfarmjobbehaviour.IBuildFarmJobBehaviour"
+        factory=".model.livefsbuildbehaviour.LiveFSBuildBehaviour"
+        permission="zope.Public"/>
+
     <webservice:register module="lp.soyuz.interfaces.webservice" />
 
 </configure>

=== added file 'lib/lp/soyuz/emailtemplates/livefsbuild-notification.txt'
--- lib/lp/soyuz/emailtemplates/livefsbuild-notification.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/emailtemplates/livefsbuild-notification.txt	2014-04-25 15:26:13 +0000
@@ -0,0 +1,11 @@
+ * Live Filesystem: %(livefs_name)s
+ * Version: %(version)s
+ * Archive: %(archive_tag)s
+ * Distroseries: %(distroseries)s
+ * Architecture: %(architecturetag)s
+ * Pocket: %(pocket)s
+ * State: %(build_state)s
+ * Duration: %(build_duration)s
+ * Build Log: %(log_url)s
+ * Upload Log: %(upload_log_url)s
+ * Builder: %(builder_url)s

=== added file 'lib/lp/soyuz/interfaces/livefs.py'
--- lib/lp/soyuz/interfaces/livefs.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/interfaces/livefs.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,230 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Live filesystem interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'ILiveFS',
+    'ILiveFSEditableAttributes',
+    'ILiveFSSet',
+    'ILiveFSView',
+    'InvalidLiveFSNamespace',
+    'LIVEFS_FEATURE_FLAG',
+    'LiveFSBuildAlreadyPending',
+    'LiveFSFeatureDisabled',
+    'NoSuchLiveFS',
+    ]
+
+import httplib
+
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+    call_with,
+    error_status,
+    export_as_webservice_entry,
+    export_factory_operation,
+    export_write_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    REQUEST_USER,
+    )
+from lazr.restful.fields import (
+    CollectionField,
+    Reference,
+    )
+from zope.interface import Interface
+from zope.schema import (
+    Choice,
+    Datetime,
+    Dict,
+    Int,
+    TextLine,
+    )
+from zope.security.interfaces import Unauthorized
+
+from lp import _
+from lp.app.errors import NameLookupFailed
+from lp.app.validators.name import name_validator
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.role import IHasOwner
+from lp.services.fields import (
+    PersonChoice,
+    PublicPersonChoice,
+    )
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+
+
+LIVEFS_FEATURE_FLAG = u"soyuz.livefs.allow_new"
+
+
+@error_status(httplib.BAD_REQUEST)
+class LiveFSBuildAlreadyPending(Exception):
+    """A build was requested when an identical build was already pending."""
+
+    def __init__(self):
+        super(LiveFSBuildAlreadyPending, self).__init__(
+            "An identical build of this live filesystem image is already "
+            "pending.")
+
+
+@error_status(httplib.UNAUTHORIZED)
+class LiveFSFeatureDisabled(Unauthorized):
+    """Only certain users can create new LiveFS-related objects."""
+
+    def __init__(self):
+        super(LiveFSFeatureDisabled, self).__init__(
+            "You do not have permission to create new live filesystems or "
+            "new live filesystem builds.")
+
+
+class InvalidLiveFSNamespace(Exception):
+    """Raised when someone tries to lookup a namespace with a bad name.
+
+    By 'bad', we mean that the name is unparsable.  It might be too short,
+    too long, or malformed in some other way.
+    """
+
+    def __init__(self, name):
+        self.name = name
+        super(InvalidLiveFSNamespace, self).__init__(
+            "Cannot understand namespace name: '%s'" % name)
+
+
+class NoSuchLiveFS(NameLookupFailed):
+    """Raised when we try to load a live filesystem that does not exist."""
+
+    _message_prefix = "No such live filesystem"
+
+
+class ILiveFSView(Interface):
+    """`ILiveFS` attributes that require launchpad.View permission."""
+
+    id = exported(Int(title=_("ID"), required=True, readonly=True))
+
+    date_created = exported(Datetime(
+        title=_("Date created"), required=True, readonly=True))
+
+    registrant = exported(PublicPersonChoice(
+        title=_("Registrant"), required=True, readonly=True,
+        vocabulary="ValidPersonOrTeam",
+        description=_(
+            "The person who registered this live filesystem image.")))
+
+    @call_with(requester=REQUEST_USER)
+    @operation_parameters(
+        archive=Reference(schema=IArchive),
+        distroarchseries=Reference(schema=IDistroArchSeries),
+        pocket=Choice(vocabulary=PackagePublishingPocket),
+        unique_key=TextLine(
+            title=_("A unique key for this build, if required."),
+            required=False),
+        metadata_override=Dict(
+            title=_("A JSON string with a dict of data about the image."),
+            key_type=TextLine(), required=False))
+    # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+    @export_factory_operation(Interface, [])
+    @export_write_operation()
+    @operation_for_version("devel")
+    def requestBuild(requester, archive, distroarchseries, pocket,
+                     unique_key=None, metadata_override=None):
+        """Request that the live filesystem be built.
+
+        :param requester: The person requesting the build.
+        :param archive: The IArchive to associate the build with.
+        :param distroarchseries: The architecture to build for.
+        :param pocket: The pocket that should be targeted.
+        :param unique_key: An optional unique key for this build; if set,
+            this identifies a class of builds for this live filesystem.
+        :param metadata_override: An optional JSON string with a dict of
+            data about the image; this will be merged into the metadata dict
+            for the live filesystem.
+        :return: `ILiveFSBuild`.
+        """
+
+    builds = exported(doNotSnapshot(CollectionField(
+        title=_("All builds of this live filesystem."),
+        description=_(
+            "All builds of this live filesystem, sorted in descending order "
+            "of finishing (or starting if not completed successfully)."),
+        # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+        value_type=Reference(schema=Interface), readonly=True)))
+
+    last_completed_build = exported(Reference(
+        # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+        Interface,
+        title=_("The most recent completed build of this live filesystem."),
+        readonly=True))
+
+
+class ILiveFSEditableAttributes(IHasOwner):
+    """`ILiveFS` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+    owner = exported(PersonChoice(
+        title=_("Owner"), required=True, readonly=False,
+        vocabulary="AllUserTeamsParticipationPlusSelf",
+        description=_("The owner of this live filesystem image.")))
+
+    distroseries = exported(Reference(
+        IDistroSeries, title=_("Distro Series"), required=True, readonly=False,
+        description=_("The series for which the image should be built.")))
+
+    name = exported(TextLine(
+        title=_("Name"), required=True, readonly=False,
+        constraint=name_validator,
+        description=_("The name of the live filesystem image.")))
+
+    metadata = exported(Dict(
+        title=_(
+            "A dict of data about the image.  Entries here will be passed to "
+            "the builder slave."),
+        key_type=TextLine(), required=True, readonly=False))
+
+
+class ILiveFS(ILiveFSView, ILiveFSEditableAttributes):
+    """A buildable live filesystem image."""
+
+    export_as_webservice_entry(
+        singular_name="livefs", plural_name="livefses", as_of="devel")
+
+
+class ILiveFSSet(Interface):
+    """A utility to create and access live filesystems."""
+
+    def new(registrant, owner, distroseries, name, metadata,
+            date_created=None):
+        """Create an `ILiveFS`."""
+
+    def exists(owner, distroseries, name):
+        """Check to see if a matching live filesystem exists."""
+
+    def get(owner, distroseries, name):
+        """Return the appropriate `ILiveFS` for the given objects."""
+
+    def traverse(segments):
+        """Look up the `ILiveFS` at the path given by 'segments'.
+
+        The iterable 'segments' will be consumed until a live filesystem is
+        found.  As soon as a live filesystem is found, it will be returned
+        and the consumption of segments will stop.  Thus, there will often
+        be unconsumed segments that can be used for further traversal.
+
+        :param segments: An iterable of names of Launchpad components.
+            The first segment is the username, *not* preceded by a '~'.
+        :raise InvalidNamespace: if there are not enough segments to define
+            a live filesystem.
+        :raise NoSuchPerson: if the person referred to cannot be found.
+        :raise NoSuchDistribution: if the distribution referred to cannot be
+            found.
+        :raise NoSuchDistroSeries: if the distroseries referred to cannot be
+            found.
+        :raise NoSuchLiveFS: if the live filesystem referred to cannot be
+            found.
+        :return: `ILiveFS`.
+        """

=== added file 'lib/lp/soyuz/interfaces/livefsbuild.py'
--- lib/lp/soyuz/interfaces/livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/interfaces/livefsbuild.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,185 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Live filesystem build interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'ILiveFSBuild',
+    'ILiveFSBuildSet',
+    'ILiveFSFile',
+    ]
+
+from lazr.restful.declarations import (
+    export_as_webservice_entry,
+    export_write_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    )
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Bool,
+    Choice,
+    Dict,
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.database.constants import DEFAULT
+from lp.services.librarian.interfaces import ILibraryFileAlias
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.livefs import ILiveFS
+
+
+class ILiveFSFile(Interface):
+    """A file produced by a live filesystem build."""
+
+    livefsbuild = Reference(
+        # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+        Interface,
+        title=_("The live filesystem build producing this file."),
+        required=True, readonly=True)
+    libraryfile = Reference(
+        ILibraryFileAlias, title=_("The library file alias for this file."),
+        required=True, readonly=True)
+
+
+class ILiveFSBuildView(IPackageBuild):
+    """`ILiveFSBuild` attributes that require launchpad.View permission."""
+
+    requester = exported(Reference(
+        IPerson,
+        title=_("The person who requested this build."),
+        required=True, readonly=True))
+
+    livefs = exported(Reference(
+        ILiveFS,
+        title=_("The live filesystem to build."),
+        required=True, readonly=True))
+
+    archive = exported(Reference(
+        IArchive,
+        title=_("The archive from which to build the live filesystem."),
+        required=True, readonly=True))
+
+    distroarchseries = exported(Reference(
+        IDistroArchSeries,
+        title=_("The series and architecture for which to build."),
+        required=True, readonly=True))
+
+    pocket = exported(Choice(
+        title=_("The pocket for which to build."),
+        vocabulary=PackagePublishingPocket, required=True, readonly=True))
+
+    unique_key = exported(TextLine(
+        title=_(
+            "An optional unique key; if set, this identifies a class of "
+            "builds for this live filesystem."),
+        required=False, readonly=True))
+
+    metadata_override = exported(Dict(
+        title=_(
+            "A dict of data about the image; this will be merged into the "
+            "metadata dict for the live filesystem."),
+        key_type=TextLine(), required=False, readonly=True))
+
+    is_virtualized = Bool(
+        title=_("If True, this build is virtualized."), readonly=True)
+
+    version = exported(TextLine(
+        title=_("A timestamp-based version identifying this build."),
+        required=True, readonly=True))
+
+    score = exported(Int(
+        title=_("Score of the related build farm job (if any)."),
+        required=False, readonly=True))
+
+    can_be_rescored = exported(Bool(
+        title=_("Can be rescored"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be rescored manually.")))
+
+    can_be_retried = exported(Bool(
+        title=_("Can be retried"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be retried.")))
+
+    can_be_cancelled = exported(Bool(
+        title=_("Can be cancelled"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be cancelled.")))
+
+    def getFiles():
+        """Retrieve the build's `ILiveFSFile` records.
+
+        :return: A result set of `ILiveFSFile` records.
+        """
+
+    def getFileByName(filename):
+        """Return the corresponding `ILibraryFileAlias` in this context.
+
+        The following file types (and extension) can be looked up:
+
+         * Build log: '.txt.gz'
+         * Upload log: '_log.txt'
+
+        Any filename not matching one of these extensions is looked up as a
+        live filesystem output file.
+
+        :param filename: The filename to look up.
+        :raises NotFoundError: if no file exists with the given name.
+        :return: The corresponding `ILibraryFileAlias`.
+        """
+
+
+class ILiveFSBuildEdit(Interface):
+    """`ILiveFSBuild` attributes that require launchpad.Edit."""
+
+    @export_write_operation()
+    @operation_for_version("devel")
+    def cancel():
+        """Cancel the build if it is either pending or in progress.
+
+        Call the can_be_cancelled() method prior to this one to find out if
+        cancelling the build is possible.
+
+        If the build is in progress, it is marked as CANCELLING until the
+        buildd manager terminates the build and marks it CANCELLED.  If the
+        build is not in progress, it is marked CANCELLED immediately and is
+        removed from the build queue.
+
+        If the build is not in a cancellable state, this method is a no-op.
+        """
+
+
+class ILiveFSBuildAdmin(Interface):
+    """`ILiveFSBuild` attributes that require launchpad.Admin."""
+
+    @operation_parameters(score=Int(title=_("Score"), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def rescore(score):
+        """Change the build's score."""
+
+
+class ILiveFSBuild(ILiveFSBuildView, ILiveFSBuildEdit, ILiveFSBuildAdmin):
+    """Build information for live filesystem builds."""
+
+    export_as_webservice_entry(singular_name="livefs_build", as_of="devel")
+
+
+class ILiveFSBuildSet(ISpecificBuildFarmJobSource):
+    """Utility for `ILiveFSBuild`."""
+
+    def new(requester, livefs, archive, distroarchseries, pocket,
+            unique_key=None, metadata_override=None, date_created=DEFAULT):
+        """Create an `ILiveFSBuild`."""

=== modified file 'lib/lp/soyuz/interfaces/webservice.py'
--- lib/lp/soyuz/interfaces/webservice.py	2013-09-13 07:09:55 +0000
+++ lib/lp/soyuz/interfaces/webservice.py	2014-04-25 15:26:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """All the interfaces that are exposed through the webservice.
@@ -86,6 +86,8 @@
     )
 from lp.soyuz.interfaces.buildrecords import IncompatibleArguments
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.livefs import ILiveFS
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
 from lp.soyuz.interfaces.packageset import (
     DuplicatePackagesetName,
     IPackageset,

=== added file 'lib/lp/soyuz/mail/livefsbuild.py'
--- lib/lp/soyuz/mail/livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/mail/livefsbuild.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,88 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'LiveFSBuildMailer',
+    ]
+
+from lp.app.browser.tales import DurationFormatterAPI
+from lp.archivepublisher.utils import get_ppa_reference
+from lp.services.config import config
+from lp.services.mail.basemailer import (
+    BaseMailer,
+    RecipientReason,
+    )
+from lp.services.webapp import canonical_url
+
+
+class LiveFSBuildMailer(BaseMailer):
+
+    app = 'soyuz'
+
+    @classmethod
+    def forStatus(cls, build):
+        """Create a mailer for notifying about live filesystem build status.
+
+        :param build: The relevant build.
+        """
+        requester = build.requester
+        recipients = {requester: RecipientReason.forBuildRequester(requester)}
+        return cls(
+            "[LiveFS build #%(build_id)d] %(build_title)s", recipients,
+            config.canonical.noreply_from_address, build)
+
+    def __init__(self, subject, template_name, recipients, from_address,
+                 build):
+        super(LiveFSBuildMailer, self).__init__(
+            subject, template_name, recipients, from_address,
+            notification_type="livefs-build")
+        self.build = build
+
+    def _getHeaders(self, email):
+        """See `BaseMailer`."""
+        headers = super(LiveFSBuildMailer, self)._getHeaders(email)
+        headers["X-Launchpad-Build-State"] = self.build.status.name
+        return headers
+
+    def _getTemplateParams(self, email, recipient):
+        """See `BaseMailer`."""
+        build = self.build
+        params = super(LiveFSBuildMailer, self)._getTemplateParams(
+            email, recipient)
+        params.update({
+            "build_id": build.id,
+            "build_title": build.title,
+            "livefs_name": build.livefs.name,
+            "version": build.version,
+            "distroseries": build.livefs.distroseries,
+            "architecturetag": build.distroarchseries.architecturetag,
+            "pocket": build.pocket.name,
+            "build_state": build.status.title,
+            "build_duration": "",
+            "log_url": "",
+            "upload_log_url": "",
+            "builder_url": "",
+            "build_url": canonical_url(self.build),
+            })
+        if build.archive.is_ppa:
+            archive_tag = "%s PPA" % get_ppa_reference(build.archive)
+        else:
+            archive_tag = "%s primary archive" % (
+                build.archive.distribution.name)
+        params["archive_tag"] = archive_tag
+        if build.duration is not None:
+            duration_formatter = DurationFormatterAPI(build.duration)
+            params["build_duration"] = duration_formatter.approximateduration()
+        if build.log is not None:
+            params["log_url"] = build.log_url
+        if build.upload_log is not None:
+            params["upload_log_url"] = build.upload_log_url
+        if build.builder is not None:
+            params["builder_url"] = canonical_url(build.builder)
+        return params
+
+    def _getFooter(self, params):
+        """See `BaseMailer`."""
+        return ("%(build_url)s\n"
+                "%(reason)s\n" % params)

=== added file 'lib/lp/soyuz/model/livefs.py'
--- lib/lp/soyuz/model/livefs.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/model/livefs.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,217 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'LiveFS',
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Desc,
+    Int,
+    JSON,
+    Reference,
+    Store,
+    Storm,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import implements
+
+from lp.buildmaster.enums import BuildStatus
+from lp.registry.errors import NoSuchDistroSeries
+from lp.registry.interfaces.distribution import (
+    IDistributionSet,
+    NoSuchDistribution,
+    )
+from lp.registry.interfaces.distroseries import IDistroSeriesSet
+from lp.registry.interfaces.person import (
+    IPersonSet,
+    NoSuchPerson,
+    )
+from lp.registry.interfaces.role import IHasOwner
+from lp.services.database.constants import DEFAULT
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.services.database.stormexpr import Greatest
+from lp.services.features import getFeatureFlag
+from lp.soyuz.interfaces.livefs import (
+    ILiveFS,
+    ILiveFSSet,
+    InvalidLiveFSNamespace,
+    LIVEFS_FEATURE_FLAG,
+    LiveFSBuildAlreadyPending,
+    LiveFSFeatureDisabled,
+    NoSuchLiveFS,
+    )
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
+from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.livefsbuild import LiveFSBuild
+
+
+class LiveFS(Storm):
+    """See `ILiveFS`."""
+
+    __storm_table__ = 'LiveFS'
+
+    def __str__(self):
+        return '%s %s' % (self.distroseries, self.name)
+
+    implements(ILiveFS, IHasOwner)
+
+    id = Int(primary=True)
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+
+    registrant_id = Int(name='registrant', allow_none=False)
+    registrant = Reference(registrant_id, 'Person.id')
+
+    owner_id = Int(name='owner', allow_none=False)
+    owner = Reference(owner_id, 'Person.id')
+
+    distroseries_id = Int(name='distroseries', allow_none=False)
+    distroseries = Reference(distroseries_id, 'DistroSeries.id')
+
+    name = Unicode(name='name', allow_none=False)
+
+    metadata = JSON('json_data')
+
+    def __init__(self, registrant, owner, distroseries, name, metadata,
+                 date_created):
+        """Construct a `LiveFS`."""
+        if not getFeatureFlag(LIVEFS_FEATURE_FLAG):
+            raise LiveFSFeatureDisabled
+        super(LiveFS, self).__init__()
+        self.registrant = registrant
+        self.owner = owner
+        self.distroseries = distroseries
+        self.name = name
+        self.metadata = metadata
+        self.date_created = date_created
+
+    def requestBuild(self, requester, archive, distroarchseries, pocket,
+                     unique_key=None, metadata_override=None):
+        """See `ILiveFS`."""
+        pending = IStore(self).find(
+            LiveFSBuild,
+            LiveFSBuild.livefs_id == self.id,
+            LiveFSBuild.archive_id == archive.id,
+            LiveFSBuild.distroarchseries_id == distroarchseries.id,
+            LiveFSBuild.pocket == pocket,
+            LiveFSBuild.unique_key == unique_key,
+            LiveFSBuild.status == BuildStatus.NEEDSBUILD)
+        if pending.any() is not None:
+            raise LiveFSBuildAlreadyPending
+
+        build = getUtility(ILiveFSBuildSet).new(
+            requester, self, archive, distroarchseries, pocket,
+            unique_key=unique_key, metadata_override=metadata_override)
+        build.queueBuild()
+        return build
+
+    def _getBuilds(self, filter_term, order_by):
+        """The actual query to get the builds."""
+        query_args = [
+            LiveFSBuild.livefs == self,
+            LiveFSBuild.archive_id == Archive.id,
+            Archive._enabled == True,
+            ]
+        if filter_term is not None:
+            query_args.append(filter_term)
+        result = Store.of(self).find(LiveFSBuild, *query_args)
+        result.order_by(order_by)
+        return result
+
+    @property
+    def builds(self):
+        """See `ILiveFS`."""
+        order_by = (
+            Desc(Greatest(
+                LiveFSBuild.date_started,
+                LiveFSBuild.date_finished)),
+            Desc(LiveFSBuild.date_created),
+            Desc(LiveFSBuild.id))
+        return self._getBuilds(None, order_by)
+
+    @property
+    def last_completed_build(self):
+        """See `ILiveFS`."""
+        filter_term = (LiveFSBuild.status != BuildStatus.NEEDSBUILD)
+        return self._getBuilds(
+            filter_term, Desc(LiveFSBuild.date_finished)).first()
+
+
+class LiveFSSet:
+    """See `ILiveFSSet`."""
+
+    implements(ILiveFSSet)
+
+    def new(self, registrant, owner, distroseries, name, metadata,
+            date_created=DEFAULT):
+        """See `ILiveFSSet`."""
+        store = IMasterStore(LiveFS)
+        livefs = LiveFS(
+            registrant, owner, distroseries, name, metadata, date_created)
+        store.add(livefs)
+        return livefs
+
+    def exists(self, owner, distroseries, name):
+        """See `ILiveFSSet`."""
+        livefs = self.get(owner, distroseries, name)
+        if livefs:
+            return True
+        else:
+            return False
+
+    def get(self, owner, distroseries, name):
+        """See `ILiveFSSet`."""
+        store = IMasterStore(LiveFS)
+        return store.find(
+            LiveFS,
+            LiveFS.owner == owner,
+            LiveFS.distroseries == distroseries,
+            LiveFS.name == name).one()
+
+    def _findOrRaise(self, error, name, finder, *args):
+        if name is None:
+            return None
+        args = list(args)
+        args.append(name)
+        result = finder(*args)
+        if result is None:
+            raise error(name)
+        return result
+
+    def traverse(self, segments):
+        """See `ILiveFSSet`."""
+        traversed_segments = []
+
+        def get_next_segment():
+            try:
+                result = segments.next()
+            except StopIteration:
+                raise InvalidLiveFSNamespace("/".join(traversed_segments))
+            if result is None:
+                raise AssertionError("None segment passed to traverse()")
+            traversed_segments.append(result)
+            return result
+
+        person_name = get_next_segment()
+        person = self._findOrRaise(
+            NoSuchPerson, person_name, getUtility(IPersonSet).getByName)
+        distribution_name = get_next_segment()
+        distribution = self._findOrRaise(
+            NoSuchDistribution, distribution_name,
+            getUtility(IDistributionSet).getByName)
+        distroseries_name = get_next_segment()
+        distroseries = self._findOrRaise(
+            NoSuchDistroSeries, distroseries_name,
+            getUtility(IDistroSeriesSet).queryByName, distribution)
+        livefs_name = get_next_segment()
+        return self._findOrRaise(
+            NoSuchLiveFS, livefs_name, self.get, person, distroseries)

=== added file 'lib/lp/soyuz/model/livefsbuild.py'
--- lib/lp/soyuz/model/livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/model/livefsbuild.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,375 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'LiveFSBuild',
+    'LiveFSFile',
+    ]
+
+from datetime import timedelta
+
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Int,
+    JSON,
+    Reference,
+    Store,
+    Storm,
+    Unicode,
+    )
+from storm.store import EmptyResultSet
+from zope.component import getUtility
+from zope.interface import implements
+
+from lp.app.errors import NotFoundError
+from lp.buildmaster.enums import (
+    BuildFarmJobType,
+    BuildStatus,
+    )
+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
+from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
+from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.model.person import Person
+from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.constants import DEFAULT
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.services.features import getFeatureFlag
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
+from lp.services.librarian.model import LibraryFileAlias
+from lp.soyuz.interfaces.component import IComponentSet
+from lp.soyuz.interfaces.livefs import (
+    LIVEFS_FEATURE_FLAG,
+    LiveFSFeatureDisabled,
+    )
+from lp.soyuz.interfaces.livefsbuild import (
+    ILiveFSBuild,
+    ILiveFSBuildSet,
+    ILiveFSFile,
+    )
+from lp.soyuz.mail.livefsbuild import LiveFSBuildMailer
+from lp.soyuz.model.archive import Archive
+
+
+class LiveFSFile(Storm):
+    """See `ILiveFS`."""
+
+    implements(ILiveFSFile)
+
+    __storm_table__ = 'LiveFSFile'
+
+    id = Int(name='id', primary=True)
+
+    livefsbuild_id = Int(name='livefsbuild', allow_none=False)
+    livefsbuild = Reference(livefsbuild_id, 'LiveFSBuild.id')
+
+    libraryfile_id = Int(name='libraryfile', allow_none=False)
+    libraryfile = Reference(libraryfile_id, 'LibraryFileAlias.id')
+
+
+class LiveFSBuild(PackageBuildMixin, Storm):
+    """See `ILiveFSBuild`."""
+
+    implements(ILiveFSBuild)
+
+    __storm_table__ = 'LiveFSBuild'
+
+    job_type = BuildFarmJobType.LIVEFSBUILD
+
+    id = Int(name='id', primary=True)
+
+    build_farm_job_id = Int(name='build_farm_job', allow_none=False)
+    build_farm_job = Reference(build_farm_job_id, 'BuildFarmJob.id')
+
+    requester_id = Int(name='requester', allow_none=False)
+    requester = Reference(requester_id, 'Person.id')
+
+    livefs_id = Int(name='livefs', allow_none=False)
+    livefs = Reference(livefs_id, 'LiveFS.id')
+
+    archive_id = Int(name='archive', allow_none=False)
+    archive = Reference(archive_id, 'Archive.id')
+
+    distroarchseries_id = Int(name='distroarchseries', allow_none=False)
+    distroarchseries = Reference(distroarchseries_id, 'DistroArchSeries.id')
+
+    pocket = DBEnum(enum=PackagePublishingPocket, allow_none=False)
+
+    processor_id = Int(name='processor', allow_none=False)
+    processor = Reference(processor_id, 'Processor.id')
+    virtualized = Bool(name='virtualized')
+
+    unique_key = Unicode(name='unique_key')
+
+    metadata_override = JSON('json_data_override')
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+    date_started = DateTime(name='date_started', tzinfo=pytz.UTC)
+    date_finished = DateTime(name='date_finished', tzinfo=pytz.UTC)
+    date_first_dispatched = DateTime(
+        name='date_first_dispatched', tzinfo=pytz.UTC)
+
+    builder_id = Int(name='builder')
+    builder = Reference(builder_id, 'Builder.id')
+
+    status = DBEnum(name='status', enum=BuildStatus, allow_none=False)
+
+    log_id = Int(name='log')
+    log = Reference(log_id, 'LibraryFileAlias.id')
+
+    upload_log_id = Int(name='upload_log')
+    upload_log = Reference(upload_log_id, 'LibraryFileAlias.id')
+
+    dependencies = Unicode(name='dependencies')
+
+    failure_count = Int(name='failure_count', allow_none=False)
+
+    def __init__(self, build_farm_job, requester, livefs, archive,
+                 distroarchseries, pocket, processor, virtualized, unique_key,
+                 metadata_override, date_created):
+        """Construct a `LiveFSBuild`."""
+        if not getFeatureFlag(LIVEFS_FEATURE_FLAG):
+            raise LiveFSFeatureDisabled
+        super(LiveFSBuild, self).__init__()
+        self.build_farm_job = build_farm_job
+        self.requester = requester
+        self.livefs = livefs
+        self.archive = archive
+        self.distroarchseries = distroarchseries
+        self.pocket = pocket
+        self.processor = processor
+        self.virtualized = virtualized
+        self.unique_key = unique_key
+        if metadata_override is None:
+            metadata_override = {}
+        self.metadata_override = metadata_override
+        self.date_created = date_created
+        self.status = BuildStatus.NEEDSBUILD
+
+    @property
+    def is_virtualized(self):
+        """See `ILiveFSBuild`."""
+        return self.archive.require_virtualized
+
+    @property
+    def title(self):
+        das = self.distroarchseries
+        name = self.livefs.name
+        if self.unique_key is not None:
+            name += " (%s)" % self.unique_key
+        return "%s build of %s in %s %s %s" % (
+            das.architecturetag, name, das.distroseries.distribution.name,
+            das.distroseries.name, self.pocket.name)
+
+    @property
+    def distribution(self):
+        """See `IPackageBuild`."""
+        return self.distroarchseries.distroseries.distribution
+
+    @property
+    def distro_series(self):
+        """See `IPackageBuild`."""
+        return self.distroarchseries.distroseries
+
+    @property
+    def current_component(self):
+        component = self.archive.default_component
+        if component is not None:
+            return component
+        else:
+            # XXX cjwatson 2014-04-22: Hardcode to universe for the time being.
+            return getUtility(IComponentSet)["universe"]
+
+    @property
+    def version(self):
+        """See `ILiveFSBuild`."""
+        return self.date_created.strftime("%Y%m%d-%H%M%S")
+
+    @property
+    def score(self):
+        """See `ILiveFSBuild`."""
+        if self.buildqueue_record is None:
+            return None
+        else:
+            return self.buildqueue_record.lastscore
+
+    @property
+    def can_be_retried(self):
+        """See `ILiveFSBuild`."""
+        # We provide this property for API convenience, but live filesystem
+        # builds cannot be retried.  Request another build using
+        # LiveFS.requestBuild instead.
+        return False
+
+    @property
+    def can_be_rescored(self):
+        """See `ILiveFSBuild`."""
+        return self.status is BuildStatus.NEEDSBUILD
+
+    @property
+    def can_be_cancelled(self):
+        """See `ILiveFSBuild`."""
+        if not self.buildqueue_record:
+            return False
+
+        cancellable_statuses = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+            ]
+        return self.status in cancellable_statuses
+
+    def rescore(self, score):
+        """See `ILiveFSBuild`."""
+        assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
+        self.buildqueue_record.manualScore(score)
+
+    def cancel(self):
+        """See `ILiveFSBuild`."""
+        if not self.can_be_cancelled:
+            return
+        # BuildQueue.cancel() will decide whether to go straight to
+        # CANCELLED, or go through CANCELLING to let buildd-manager clean up
+        # the slave.
+        self.buildqueue_record.cancel()
+
+    def calculateScore(self):
+        return 2505 + self.archive.relative_build_score
+
+    def getMedianBuildDuration(self):
+        """Return the median duration of builds of this live filesystem."""
+        store = IStore(self)
+        result = store.find(
+            LiveFSBuild,
+            LiveFSBuild.livefs == self.livefs_id,
+            LiveFSBuild.distroarchseries == self.distroarchseries_id,
+            LiveFSBuild.date_finished != None)
+        durations = [
+            build.date_finished - build.date_started for build in result]
+        if len(durations) == 0:
+            return None
+        durations.sort(reverse=True)
+        return durations[len(durations) / 2]
+
+    def estimateDuration(self):
+        """See `IBuildFarmJob`."""
+        median = self.getMedianBuildDuration()
+        if median is not None:
+            return median
+        return timedelta(minutes=30)
+
+    def getFiles(self):
+        """See `ILiveFSBuild`."""
+        store = Store.of(self)
+        result = store.find(
+            LiveFSFile,
+            LiveFSFile.livefsbuild == self.id,
+            LibraryFileAlias.id == LiveFSFile.libraryfile_id)
+        return result.order_by(
+            [LibraryFileAlias.filename, LiveFSFile.id]).config(distinct=True)
+
+    def getFileByName(self, filename):
+        """See `ILiveFSBuild`."""
+        if filename.endswith(".txt.gz"):
+            file_object = self.log
+        elif filename.endswith("_log.txt"):
+            file_object = self.upload_log
+        else:
+            file_object = Store.of(self).find(
+                LiveFSFile,
+                LiveFSFile.livefsbuild == self.id,
+                LibraryFileAlias.id == LiveFSFile.libraryfile_id,
+                LibraryFileAlias.filename == filename).one()
+
+        if file_object is not None and file_object.filename == filename:
+            return file_object
+
+        raise NotFoundError(filename)
+
+    def verifySuccessfulUpload(self):
+        """See `IPackageBuild`."""
+        return bool(self.getFiles())
+
+    def notify(self, extra_info=None):
+        """See `IPackageBuild`."""
+        if not config.builddmaster.send_build_notification:
+            return
+        if self.status == BuildStatus.FULLYBUILT:
+            return
+        mailer = LiveFSBuildMailer.forStatus(self)
+        mailer.sendAll()
+
+    def getUploader(self, changes):
+        """See `IPackageBuild`."""
+        return self.requester
+
+    def lfaUrl(self, lfa):
+        """Return the URL for a LibraryFileAlias in this context."""
+        if lfa is None:
+            return None
+        return ProxiedLibraryFileAlias(lfa, self).http_url
+
+    @property
+    def log_url(self):
+        """See `IBuildFarmJob`."""
+        return self.lfaUrl(self.log)
+
+    @property
+    def upload_log_url(self):
+        """See `IPackageBuild`."""
+        return self.lfaUrl(self.upload_log)
+
+
+class LiveFSBuildSet(SpecificBuildFarmJobSourceMixin):
+    implements(ILiveFSBuildSet)
+
+    def new(self, requester, livefs, archive, distroarchseries, pocket,
+            unique_key=None, metadata_override=None, date_created=DEFAULT):
+        """See `ILiveFSBuildSet`."""
+        store = IMasterStore(LiveFSBuild)
+        build_farm_job = getUtility(IBuildFarmJobSource).new(
+            LiveFSBuild.job_type, BuildStatus.NEEDSBUILD, date_created, None,
+            archive)
+        livefsbuild = LiveFSBuild(
+            build_farm_job, requester, livefs, archive, distroarchseries,
+            pocket, distroarchseries.processor, archive.require_virtualized,
+            unique_key, metadata_override, date_created)
+        store.add(livefsbuild)
+        return livefsbuild
+
+    def getByID(self, build_id):
+        """See `ISpecificBuildFarmJobSource`."""
+        store = IMasterStore(LiveFSBuild)
+        return store.find(LiveFSBuild, LiveFSBuild.id == build_id).one()
+
+    def getByBuildFarmJob(self, build_farm_job):
+        """See `ISpecificBuildFarmJobSource`."""
+        return Store.of(build_farm_job).find(
+            LiveFSBuild, build_farm_job_id=build_farm_job.id).one()
+
+    def preloadBuildsData(self, builds):
+        # Circular import.
+        from lp.soyuz.model.livefs import LiveFS
+        load_related(Person, builds, ["requester_id"])
+        load_related(LibraryFileAlias, builds, ["log_id"])
+        archives = load_related(Archive, builds, ["archive_id"])
+        load_related(Person, archives, ["ownerID"])
+        load_related(LiveFS, builds, ["livefs_id"])
+
+    def getByBuildFarmJobs(self, build_farm_jobs):
+        """See `ISpecificBuildFarmJobSource`."""
+        if len(build_farm_jobs) == 0:
+            return EmptyResultSet()
+        rows = Store.of(build_farm_jobs[0]).find(
+            LiveFSBuild, LiveFSBuild.build_farm_job_id.is_in(
+                bfj.id for bfj in build_farm_jobs))
+        return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)

=== added file 'lib/lp/soyuz/model/livefsbuildbehaviour.py'
--- lib/lp/soyuz/model/livefsbuildbehaviour.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/model/livefsbuildbehaviour.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,133 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""An `IBuildFarmJobBehaviour` for `LiveFSBuild`.
+
+Dispatches live filesystem build jobs to build-farm slaves.
+"""
+
+__metaclass__ = type
+__all__ = [
+    'LiveFSBuildBehaviour',
+    ]
+
+from twisted.internet import defer
+from zope.component import adapts
+from zope.interface import implements
+
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+    IBuildFarmJobBehaviour,
+    )
+from lp.buildmaster.model.buildfarmjobbehaviour import (
+    BuildFarmJobBehaviourBase,
+    )
+from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
+
+
+class LiveFSBuildBehaviour(BuildFarmJobBehaviourBase):
+    """Dispatches `LiveFSBuild` jobs to slaves."""
+
+    adapts(ILiveFSBuild)
+    implements(IBuildFarmJobBehaviour)
+
+    # Identify the type of job to the slave.
+    build_type = 'livefs'
+
+    @property
+    def displayname(self):
+        ret = self.build.title
+        if self._builder is not None:
+            ret += " (on %s)" % self._builder.url
+        return ret
+
+    def logStartBuild(self, logger):
+        """See `IBuildFarmJobBehaviour`."""
+        logger.info("startBuild(%s)", self.displayname)
+
+    def getLogFileName(self):
+        das = self.build.distroarchseries
+        archname = das.architecturetag
+        if self.build.unique_key:
+            archname += '_%s' % self.build.unique_key
+
+        # Examples:
+        #   buildlog_ubuntu_trusty_i386_ubuntu-desktop_FULLYBUILT.txt
+        return 'buildlog_%s_%s_%s_%s_%s.txt' % (
+            das.distroseries.distribution.name, das.distroseries.name,
+            archname, self.build.livefs.name, self.build.status.name)
+
+    def verifyBuildRequest(self, logger):
+        """Assert some pre-build checks.
+
+        The build request is checked:
+         * Virtualized builds can't build on a non-virtual builder
+         * Ensure that we have a chroot
+        """
+        build = self.build
+        if build.is_virtualized and not self._builder.virtualized:
+            raise AssertionError(
+                "Attempt to build virtual item on a non-virtual builder.")
+
+        chroot = build.distroarchseries.getChroot()
+        if chroot is None:
+            raise CannotBuild(
+                "Missing chroot for %s" % build.distroarchseries.displayname)
+
+    def _extraBuildArgs(self):
+        """
+        Return the extra arguments required by the slave for the given build.
+        """
+        build = self.build
+        args = dict(build.livefs.metadata)
+        args.update(build.metadata_override)
+        args["suite"] = build.distroarchseries.distroseries.getSuite(
+            build.pocket)
+        args["arch_tag"] = build.distroarchseries.architecturetag
+        args["datestamp"] = build.version
+        args["archives"] = get_sources_list_for_building(
+            build, build.distroarchseries, None)
+        args["archive_private"] = build.archive.private
+        return args
+
+    @defer.inlineCallbacks
+    def dispatchBuildToSlave(self, build_queue_id, logger):
+        """See `IBuildFarmJobBehaviour`."""
+
+        # Start the build on the slave builder.  First we send the chroot.
+        distroarchseries = self.build.distroarchseries
+        chroot = distroarchseries.getChroot()
+        if chroot is None:
+            raise CannotBuild(
+                "Unable to find a chroot for %s" %
+                distroarchseries.displayname)
+        logger.info(
+            "Sending chroot file for live filesystem build to %s" %
+            self._builder.name)
+        yield self._slave.cacheFile(logger, chroot)
+
+        # Generate a string which can be used to cross-check when obtaining
+        # results so we know we are referring to the right database object
+        # in subsequent runs.
+        buildid = "%s-%s" % (self.build.id, build_queue_id)
+        logger.info("Initiating build %s on %s" % (buildid, self._builder.url))
+
+        cookie = self.getBuildCookie()
+        args = self._extraBuildArgs()
+        status, info = yield self._slave.build(
+            cookie, "livefs", chroot.content.sha1, {}, args)
+
+        message = """%s (%s):
+        ***** RESULT *****
+        %s
+        %s: %s
+        ******************
+        """ % (
+            self._builder.name,
+            self._builder.url,
+            args,
+            status,
+            info,
+            )
+        logger.info(message)

=== added file 'lib/lp/soyuz/tests/test_livefs.py'
--- lib/lp/soyuz/tests/test_livefs.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/tests/test_livefs.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,384 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test live filesystems."""
+
+__metaclass__ = type
+
+from datetime import timedelta
+
+from storm.locals import Store
+from testtools.matchers import Equals
+import transaction
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.enums import (
+    BuildQueueStatus,
+    BuildStatus,
+    )
+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
+from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.soyuz.interfaces.livefs import (
+    ILiveFS,
+    ILiveFSSet,
+    ILiveFSView,
+    LIVEFS_FEATURE_FLAG,
+    LiveFSBuildAlreadyPending,
+    LiveFSFeatureDisabled,
+    )
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
+from lp.testing import (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
+    person_logged_in,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+from lp.testing.matchers import (
+    DoesNotSnapshot,
+    HasQueryCount,
+    )
+from lp.testing.pages import webservice_for_person
+
+
+class TestLiveFSFeatureFlag(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create new LiveFSes.
+        self.assertRaises(
+            LiveFSFeatureDisabled, getUtility(ILiveFSSet).new,
+            None, None, None, None, None)
+
+
+class TestLiveFS(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFS, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_implements_interfaces(self):
+        # LiveFS implements ILiveFS.
+        livefs = self.factory.makeLiveFS()
+        self.assertProvides(livefs, ILiveFS)
+
+    def test_class_implements_interfaces(self):
+        # The LiveFS class implements ILiveFSSet.
+        self.assertProvides(getUtility(ILiveFSSet), ILiveFSSet)
+
+    def test_avoids_problematic_snapshot(self):
+        self.assertThat(
+            self.factory.makeLiveFS(),
+            DoesNotSnapshot(["builds"], ILiveFSView))
+
+    def makeLiveFSComponents(self, metadata={}):
+        """Return a dict of values that can be used to make a LiveFS.
+
+        Suggested use: provide as kwargs to ILiveFSSet.new.
+
+        :param metadata: A dict to set as LiveFS.metadata.
+        """
+        registrant = self.factory.makePerson()
+        return dict(
+            registrant=registrant,
+            owner=self.factory.makeTeam(owner=registrant),
+            distroseries=self.factory.makeDistroSeries(),
+            name=self.factory.getUniqueString(u"recipe-name"),
+            metadata=metadata)
+
+    def test_creation(self):
+        # The metadata entries supplied when a LiveFS is created are present
+        # on the new object.
+        components = self.makeLiveFSComponents(metadata={"project": "foo"})
+        livefs = getUtility(ILiveFSSet).new(**components)
+        transaction.commit()
+        self.assertEqual(components["registrant"], livefs.registrant)
+        self.assertEqual(components["owner"], livefs.owner)
+        self.assertEqual(components["distroseries"], livefs.distroseries)
+        self.assertEqual(components["name"], livefs.name)
+        self.assertEqual(components["metadata"], livefs.metadata)
+
+    def test_exists(self):
+        # ILiveFSSet.exists checks for matching LiveFSes.
+        livefs = self.factory.makeLiveFS()
+        self.assertTrue(
+            getUtility(ILiveFSSet).exists(
+                livefs.owner, livefs.distroseries, livefs.name))
+        self.assertFalse(
+            getUtility(ILiveFSSet).exists(
+                self.factory.makePerson(), livefs.distroseries, livefs.name))
+        self.assertFalse(
+            getUtility(ILiveFSSet).exists(
+                livefs.owner, self.factory.makeDistroSeries(), livefs.name))
+        self.assertFalse(
+            getUtility(ILiveFSSet).exists(
+                livefs.owner, livefs.distroseries, u"different"))
+
+    def test_requestBuild(self):
+        # requestBuild creates a new LiveFSBuild.
+        livefs = self.factory.makeLiveFS()
+        requester = self.factory.makePerson()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=livefs.distroseries)
+        build = livefs.requestBuild(
+            requester, livefs.distroseries.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertTrue(ILiveFSBuild.providedBy(build))
+        self.assertEqual(requester, build.requester)
+        self.assertEqual(livefs.distroseries.main_archive, build.archive)
+        self.assertEqual(distroarchseries, build.distroarchseries)
+        self.assertEqual(PackagePublishingPocket.RELEASE, build.pocket)
+        self.assertIsNone(build.unique_key)
+        self.assertEqual({}, build.metadata_override)
+        self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
+        store = Store.of(build)
+        store.flush()
+        build_queue = store.find(
+            BuildQueue,
+            BuildQueue._build_farm_job_id ==
+                removeSecurityProxy(build).build_farm_job_id).one()
+        self.assertProvides(build_queue, IBuildQueue)
+        self.assertEqual(
+            livefs.distroseries.main_archive.require_virtualized,
+            build_queue.virtualized)
+        self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
+
+    def test_requestBuild_score(self):
+        # Build requests have a relatively low queue score (2505).
+        livefs = self.factory.makeLiveFS()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=livefs.distroseries)
+        build = livefs.requestBuild(
+            livefs.owner, livefs.distroseries.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        queue_record = build.buildqueue_record
+        queue_record.score()
+        self.assertEqual(2505, queue_record.lastscore)
+
+    def test_requestBuild_relative_build_score(self):
+        # Offsets for archives are respected.
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner)
+        removeSecurityProxy(archive).relative_build_score = 100
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=livefs.distroseries)
+        build = livefs.requestBuild(
+            livefs.owner, archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        queue_record = build.buildqueue_record
+        queue_record.score()
+        self.assertEqual(2605, queue_record.lastscore)
+
+    def test_requestBuild_rejects_repeats(self):
+        # requestBuild refuses if there is already a pending build.
+        livefs = self.factory.makeLiveFS()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=livefs.distroseries)
+        old_build = livefs.requestBuild(
+            livefs.owner, livefs.distroseries.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertRaises(
+            LiveFSBuildAlreadyPending, livefs.requestBuild,
+            livefs.owner, livefs.distroseries.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        # We can build for a different archive.
+        livefs.requestBuild(
+            livefs.owner, self.factory.makeArchive(owner=livefs.owner),
+            distroarchseries, PackagePublishingPocket.RELEASE)
+        # We can build for a different distroarchseries.
+        livefs.requestBuild(
+            livefs.owner, livefs.distroseries.main_archive,
+            self.factory.makeDistroArchSeries(
+                distroseries=livefs.distroseries),
+            PackagePublishingPocket.RELEASE)
+        # Changing the status of the old build allows a new build.
+        old_build.updateStatus(BuildStatus.FULLYBUILT)
+        livefs.requestBuild(
+            livefs.owner, livefs.distroseries.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+
+    def test_getBuilds(self):
+        # Test the various getBuilds methods.
+        livefs = self.factory.makeLiveFS()
+        builds = [
+            self.factory.makeLiveFSBuild(livefs=livefs) for x in range(3)]
+        # We want the latest builds first.
+        builds.reverse()
+
+        self.assertEqual(builds, list(livefs.builds))
+        self.assertIsNone(livefs.last_completed_build)
+
+        # Change the status of one of the builds and retest.
+        builds[0].updateStatus(BuildStatus.FULLYBUILT)
+        self.assertEqual(builds, list(livefs.builds))
+        self.assertEqual(builds[0], livefs.last_completed_build)
+
+
+class TestLiveFSWebservice(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSWebservice, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.person = self.factory.makePerson()
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PUBLIC)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def makeLiveFS(self, registrant=None, owner=None, distroseries=None,
+                   metadata=None):
+        if registrant is None:
+            registrant = self.person
+        if owner is None:
+            owner = registrant
+        if metadata is None:
+            metadata = {"project": "flavour"}
+        if distroseries is None:
+            distroseries = self.factory.makeDistroSeries(registrant=registrant)
+        transaction.commit()
+        distroseries_url = api_url(distroseries)
+        registrant_url = api_url(registrant)
+        owner_url = api_url(owner)
+        logout()
+        response = self.webservice.named_post(
+            registrant_url, "createLiveFS", owner=owner_url,
+            distroseries=distroseries_url, name="flavour-desktop",
+            metadata=metadata)
+        self.assertEqual(201, response.status)
+        livefs = self.webservice.get(response.getHeader("Location")).jsonBody()
+        return livefs, distroseries_url
+
+    def getCollectionLinks(self, entry, member):
+        """Return a list of self_link attributes of entries in a collection."""
+        collection = self.webservice.get(
+            entry["%s_collection_link" % member]).jsonBody()
+        return [entry["self_link"] for entry in collection["entries"]]
+
+    def test_createLiveFS(self):
+        # Ensure LiveFS creation works.
+        team = self.factory.makeTeam(owner=self.person)
+        livefs, distroseries_url = self.makeLiveFS(owner=team)
+        with person_logged_in(self.person):
+            self.assertEqual(
+                self.getURL(self.person), livefs["registrant_link"])
+            self.assertEqual(self.getURL(team), livefs["owner_link"])
+            self.assertEqual("flavour-desktop", livefs["name"])
+            self.assertEqual(
+                self.webservice.getAbsoluteUrl(distroseries_url),
+                livefs["distroseries_link"])
+            self.assertEqual({"project": "flavour"}, livefs["metadata"])
+
+    def test_requestBuild(self):
+        # Build requests can be performed and end up in livefs.builds.
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive_url = api_url(distroseries.main_archive)
+        livefs, distroseries_url = self.makeLiveFS(distroseries=distroseries)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distroarchseries=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+        build = self.webservice.get(response.getHeader("Location")).jsonBody()
+        builds_collection = self.webservice.get(
+            livefs["builds_collection_link"]).jsonBody()
+        self.assertEqual(
+            [build["self_link"]], self.getCollectionLinks(livefs, "builds"))
+
+    def test_requestBuild_rejects_repeats(self):
+        # Build requests are rejected if already pending.
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive_url = api_url(distroseries.main_archive)
+        livefs, ws_distroseries = self.makeLiveFS(distroseries=distroseries)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distroarchseries=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distroarchseries=distroarchseries_url, pocket="Release")
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "An identical build of this live filesystem image is already "
+            "pending.", response.body)
+
+    def test_getBuilds(self):
+        # builds and last_completed_build are as expected.
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archives = [
+            self.factory.makeArchive(
+                distribution=distroseries.distribution, owner=self.person)
+            for x in range(4)]
+        archive_urls = [api_url(archive) for archive in archives]
+        livefs, distroseries_url = self.makeLiveFS(distroseries=distroseries)
+        builds = []
+        for archive_url in archive_urls:
+            response = self.webservice.named_post(
+                livefs["self_link"], "requestBuild", archive=archive_url,
+                distroarchseries = distroarchseries_url, pocket="Proposed")
+            self.assertEqual(201, response.status)
+            build = self.webservice.get(
+                response.getHeader("Location")).jsonBody()
+            builds.insert(0, build["self_link"])
+        self.assertEqual(builds, self.getCollectionLinks(livefs, "builds"))
+        livefs = self.webservice.get(livefs["self_link"]).jsonBody()
+        self.assertIsNone(livefs["last_completed_build_link"])
+
+        with person_logged_in(self.person):
+            db_livefs = getUtility(ILiveFSSet).get(
+                self.person, distroseries, livefs["name"])
+            db_builds = list(db_livefs.builds)
+            db_builds[0].updateStatus(
+                BuildStatus.BUILDING, date_started=db_livefs.date_created)
+            db_builds[0].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_livefs.date_created + timedelta(minutes=10))
+        livefs = self.webservice.get(livefs["self_link"]).jsonBody()
+        self.assertEqual(builds[0], livefs["last_completed_build_link"])
+
+        with person_logged_in(self.person):
+            db_builds[1].updateStatus(
+                BuildStatus.BUILDING, date_started=db_livefs.date_created)
+            db_builds[1].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_livefs.date_created + timedelta(minutes=20))
+        livefs = self.webservice.get(livefs["self_link"]).jsonBody()
+        self.assertEqual(builds[1], livefs["last_completed_build_link"])
+
+    def test_query_count(self):
+        # LiveFS has a reasonable query count.
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person)
+        url = api_url(livefs)
+        logout()
+        store = Store.of(livefs)
+        store.flush()
+        store.invalidate()
+        with StormStatementRecorder() as recorder:
+            self.webservice.get(url)
+        self.assertThat(recorder, HasQueryCount(Equals(22)))

=== added file 'lib/lp/soyuz/tests/test_livefsbuild.py'
--- lib/lp/soyuz/tests/test_livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/tests/test_livefsbuild.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,299 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test live filesystem build features."""
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+
+import pytz
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.services.database.interfaces import IStore
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.soyuz.enums import ArchivePurpose
+from lp.soyuz.interfaces.livefs import (
+    LIVEFS_FEATURE_FLAG,
+    LiveFSFeatureDisabled,
+    )
+from lp.soyuz.interfaces.livefsbuild import (
+    ILiveFSBuild,
+    ILiveFSBuildSet,
+    )
+from lp.testing import (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+from lp.testing.mail_helpers import pop_notifications
+from lp.testing.pages import webservice_for_person
+
+
+class TestLiveFSBuildFeatureFlag(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create new LiveFSBuilds.
+        self.assertRaises(
+            LiveFSFeatureDisabled, getUtility(ILiveFSBuildSet).new,
+            None, None, self.factory.makeArchive(),
+            self.factory.makeDistroArchSeries(), None, None, None)
+
+
+class TestLiveFSBuild(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestLiveFSBuild, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.build = self.factory.makeLiveFSBuild()
+
+    def test_implements_interfaces(self):
+        # LiveFSBuild implements IPackageBuild and ILiveFSBuild.
+        self.assertProvides(self.build, IPackageBuild)
+        self.assertProvides(self.build, ILiveFSBuild)
+
+    def test_queueBuild(self):
+        # LiveFSBuild can create the queue entry for itself.
+        bq = self.build.queueBuild()
+        self.assertProvides(bq, IBuildQueue)
+        self.assertEqual(
+            self.build.build_farm_job, removeSecurityProxy(bq)._build_farm_job)
+        self.assertEqual(self.build, bq.specific_build)
+        self.assertEqual(self.build.virtualized, bq.virtualized)
+        self.assertIsNotNone(bq.processor)
+        self.assertEqual(bq, self.build.buildqueue_record)
+
+    def test_current_component_primary(self):
+        # LiveFSBuilds for primary archives always build in universe for the
+        # time being.
+        self.assertEqual(ArchivePurpose.PRIMARY, self.build.archive.purpose)
+        self.assertEqual("universe", self.build.current_component.name)
+
+    def test_current_component_ppa(self):
+        # PPAs only have indices for main, so LiveFSBuilds for PPAs always
+        # build in main.
+        build = self.factory.makeLiveFSBuild(
+            archive=self.factory.makeArchive())
+        self.assertEqual("main", build.current_component.name)
+
+    def test_is_private(self):
+        # A LiveFSBuild is private iff its owner is.
+        self.assertFalse(self.build.is_private)
+        # TODO archive too?  need to override PackageBuild.is_private in
+        # LiveFSBuild?
+
+    def test_can_be_cancelled(self):
+        # For all states that can be cancelled, can_be_cancelled returns True.
+        ok_cases = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+            ]
+        for status in BuildStatus:
+            if status in ok_cases:
+                self.assertTrue(self.build.can_be_cancelled)
+            else:
+                self.assertFalse(self.build.can_be_cancelled)
+
+    def test_cancel_not_in_progress(self):
+        # The cancel() method for a pending build leaves it in the CANCELLED
+        # state.
+        self.build.queueBuild()
+        self.build.cancel()
+        self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+        self.assertIsNone(self.build.buildqueue_record)
+
+    def test_cancel_in_progress(self):
+        # The cancel() method for a building build leaves it in the
+        # CANCELLING state.
+        bq = self.build.queueBuild()
+        bq.markAsBuilding(self.factory.makeBuilder())
+        self.build.cancel()
+        self.assertEqual(BuildStatus.CANCELLING, self.build.status)
+        self.assertEqual(bq, self.build.buildqueue_record)
+
+    def test_estimateDuration(self):
+        # Without previous builds, the default time estimate is 30m.
+        self.assertEqual(1800, self.build.estimateDuration().seconds)
+
+    def test_estimateDuration_with_history(self):
+        # Previous builds of the same live filesystem are used for estimates.
+        self.factory.makeLiveFSBuild(
+            requester=self.build.requester, livefs=self.build.livefs,
+            distroarchseries=self.build.distroarchseries,
+            status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
+        self.assertEqual(335, self.build.estimateDuration().seconds)
+
+    def test_getFileByName(self):
+        # getFileByName returns the logs when requested by name.
+        self.build.setLog(
+            self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz"))
+        self.assertEqual(
+            self.build.log, self.build.getFileByName("buildlog.txt.gz"))
+        self.assertRaises(NotFoundError, self.build.getFileByName, "foo")
+        self.build.storeUploadLog("uploaded")
+        self.assertEqual(
+            self.build.upload_log,
+            self.build.getFileByName(self.build.upload_log.filename))
+
+    # TODO getFileByName on other files
+
+    def test_notify_fullybuilt(self):
+        # notify does not send mail when a LiveFSBuild completes normally.
+        person = self.factory.makePerson(name="person")
+        build = self.factory.makeLiveFSBuild(requester=person)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        IStore(build).flush()
+        build.notify()
+        self.assertEqual(0, len(pop_notifications()))
+
+    # TODO real notification
+
+    def addFakeBuildLog(self, build):
+        build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
+
+    def test_log_url(self):
+        # The log URL for a live filesystem build will use the archive context.
+        self.addFakeBuildLog(self.build)
+        self.assertEqual(
+            "http://launchpad.dev/%s/+archive/primary/+livefsbuild/%d/+files/";
+            "mybuildlog.txt" % (self.build.distribution.name, self.build.id),
+            self.build.log_url)
+
+
+class TestLiveFSBuildSet(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildSet, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_getByBuildFarmJob_works(self):
+        build = self.factory.makeLiveFSBuild()
+        self.assertEqual(
+            build,
+            getUtility(ILiveFSBuildSet).getByBuildFarmJob(
+                build.build_farm_job))
+
+    def test_getByBuildFarmJob_returns_None_when_missing(self):
+        bpb = self.factory.makeBinaryPackageBuild()
+        self.assertIsNone(
+            getUtility(ILiveFSBuildSet).getByBuildFarmJob(bpb.build_farm_job))
+
+    def test_getByBuildFarmJobs_works(self):
+        builds = [self.factory.makeLiveFSBuild() for i in range(10)]
+        self.assertContentEqual(
+            builds,
+            getUtility(ILiveFSBuildSet).getByBuildFarmJobs(
+                [build.build_farm_job for build in builds]))
+
+    def test_getByBuildFarmJobs_works_empty(self):
+        self.assertContentEqual(
+            [], getUtility(ILiveFSBuildSet).getByBuildFarmJobs([]))
+
+
+class TestLiveFSBuildWebservice(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildWebservice, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.person = self.factory.makePerson()
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PUBLIC)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def test_properties(self):
+        # The basic properties of a LiveFSBuild are sensible.
+        db_build = self.factory.makeLiveFSBuild(
+            requester=self.person, unique_key=u"foo",
+            metadata_override={"image_format": "plain"},
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        with person_logged_in(self.person):
+            self.assertEqual(self.getURL(self.person), build["requester_link"])
+            self.assertEqual(
+                self.getURL(db_build.livefs), build["livefs_link"])
+            self.assertEqual(
+                self.getURL(db_build.archive), build["archive_link"])
+            self.assertEqual(
+                self.getURL(db_build.distroarchseries),
+                build["distroarchseries_link"])
+            self.assertEqual("Release", build["pocket"])
+            self.assertEqual("foo", build["unique_key"])
+            self.assertEqual(
+                {"image_format": "plain"}, build["metadata_override"])
+            self.assertEqual("20140425-103800", build["version"])
+            self.assertIsNone(build["score"])
+            self.assertTrue(build["can_be_rescored"])
+            self.assertFalse(build["can_be_retried"])
+            self.assertFalse(build["can_be_cancelled"])
+
+    def test_cancel(self):
+        # The owner of a build can cancel it.
+        db_build = self.factory.makeLiveFSBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertTrue(build["can_be_cancelled"])
+        response = unpriv_webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(401, response.status)
+        response = self.webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertFalse(build["can_be_cancelled"])
+        with person_logged_in(self.person):
+            self.assertEqual(BuildStatus.CANCELLED, db_build.status)
+
+    def test_rescore(self):
+        # Buildd administrators can rescore builds.
+        db_build = self.factory.makeLiveFSBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+        buildd_admin_webservice = webservice_for_person(
+            buildd_admin, permission=OAuthPermission.WRITE_PUBLIC)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(2505, build["score"])
+        self.assertTrue(build["can_be_rescored"])
+        response = self.webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(401, response.status)
+        response = buildd_admin_webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(5000, build["score"])

=== added file 'lib/lp/soyuz/tests/test_livefsbuildbehaviour.py'
--- lib/lp/soyuz/tests/test_livefsbuildbehaviour.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/tests/test_livefsbuildbehaviour.py	2014-04-25 15:26:13 +0000
@@ -0,0 +1,185 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test live filesystem build behaviour."""
+
+__metaclass__ = type
+
+from datetime import datetime
+from textwrap import dedent
+
+import pytz
+from testtools import run_test_with
+from testtools.deferredruntest import (
+    assert_fails_with,
+    AsynchronousDeferredRunTest,
+    )
+import transaction
+from twisted.internet import defer
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+    IBuildFarmJobBehaviour,
+    )
+from lp.buildmaster.tests.mock_slaves import (
+    MockBuilder,
+    OkSlave,
+    )
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.features.testing import FeatureFixture
+from lp.services.log.logger import BufferLogger
+from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
+from lp.soyuz.interfaces.processor import IProcessorSet
+from lp.soyuz.model.livefsbuildbehaviour import LiveFSBuildBehaviour
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class TestLiveFSBuildBehaviour(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildBehaviour, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def makeJob(self, **kwargs):
+        """Create a sample `ILiveFSBuildBehaviour`."""
+        distribution = self.factory.makeDistribution(name="distro")
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable")
+        processor = getUtility(IProcessorSet).getByName("386")
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag="i386",
+            processor=processor)
+        build = self.factory.makeLiveFSBuild(
+            distroarchseries=distroarchseries,
+            pocket=PackagePublishingPocket.RELEASE, name=u"livefs", **kwargs)
+        return IBuildFarmJobBehaviour(build)
+
+    def test_provides_interface(self):
+        # LiveFSBuildBehaviour provides IBuildFarmJobBehaviour.
+        job = LiveFSBuildBehaviour(None)
+        self.assertProvides(job, IBuildFarmJobBehaviour)
+
+    def test_adapts_ILiveFSBuild(self):
+        # IBuildFarmJobBehaviour adapts an ILiveFSBuild.
+        build = self.factory.makeLiveFSBuild()
+        job = IBuildFarmJobBehaviour(build)
+        self.assertProvides(job, IBuildFarmJobBehaviour)
+
+    def test_displayname(self):
+        # displayname contains a reasonable description of the job.
+        job = self.makeJob()
+        self.assertEqual(
+            "i386 build of livefs in distro unstable RELEASE", job.displayname)
+
+    def test_logStartBuild(self):
+        # logStartBuild will properly report the image that's being built.
+        job = self.makeJob()
+        logger = BufferLogger()
+        job.logStartBuild(logger)
+        self.assertEqual(
+            "INFO startBuild(i386 build of livefs in distro unstable "
+            "RELEASE)\n", logger.getLogBuffer())
+
+    def test_verifyBuildRequest_valid(self):
+        # verifyBuildRequest doesn't raise any exceptions when called with a
+        # valid builder set.
+        job = self.makeJob()
+        lfa = self.factory.makeLibraryFileAlias()
+        transaction.commit()
+        job.build.distroarchseries.addOrUpdateChroot(lfa)
+        builder = MockBuilder()
+        job.setBuilder(builder, OkSlave())
+        logger = BufferLogger()
+        job.verifyBuildRequest(logger)
+        self.assertEqual("", logger.getLogBuffer())
+
+    def test_verifyBuildRequest_virtual_mismatch(self):
+        # verifyBuildRequest raises on an attempt to build a virtualized
+        # build on a non-virtual builder.
+        job = self.makeJob()
+        lfa = self.factory.makeLibraryFileAlias()
+        transaction.commit()
+        job.build.distroarchseries.addOrUpdateChroot(lfa)
+        builder = MockBuilder(virtualized=False)
+        job.setBuilder(builder, OkSlave())
+        logger = BufferLogger()
+        e = self.assertRaises(AssertionError, job.verifyBuildRequest, logger)
+        self.assertEqual(
+            "Attempt to build virtual item on a non-virtual builder.", str(e))
+
+    def test_verifyBuildRequest_no_chroot(self):
+        # verifyBuildRequest raises when the DAS has no chroot.
+        job = self.makeJob()
+        builder = MockBuilder()
+        job.setBuilder(builder, OkSlave())
+        logger = BufferLogger()
+        e = self.assertRaises(CannotBuild, job.verifyBuildRequest, logger)
+        self.assertIn("Missing chroot", str(e))
+
+    def test_getBuildCookie(self):
+        # A build cookie is made up of the job type and record id.  The
+        # uploadprocessor relies on this format.
+        job = self.makeJob()
+        cookie = removeSecurityProxy(job).getBuildCookie()
+        self.assertEqual("LIVEFSBUILD-%s" % job.build.id, cookie)
+
+    def test_extraBuildArgs(self):
+        # _extraBuildArgs returns a reasonable set of additional arguments.
+        job = self.makeJob(
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC),
+            metadata={"project": "distro", "subproject": "special"})
+        expected_archives = get_sources_list_for_building(
+            job.build, job.build.distroarchseries, None)
+        self.assertEqual({
+            "archive_private": False,
+            "archives": expected_archives,
+            "arch_tag": "i386",
+            "datestamp": "20140425-103800",
+            "project": "distro",
+            "subproject": "special",
+            "suite": "unstable",
+            }, job._extraBuildArgs())
+
+    @run_test_with(AsynchronousDeferredRunTest)
+    @defer.inlineCallbacks
+    def test_dispatchBuildToSlave(self):
+        # dispatchBuildToSlave makes the proper calls to the slave.
+        job = self.makeJob()
+        lfa = self.factory.makeLibraryFileAlias()
+        transaction.commit()
+        job.build.distroarchseries.addOrUpdateChroot(lfa)
+        slave = OkSlave()
+        builder = MockBuilder("bob")
+        builder.processor = getUtility(IProcessorSet).getByName("386")
+        job.setBuilder(builder, slave)
+        logger = BufferLogger()
+        yield job.dispatchBuildToSlave("someid", logger)
+        self.assertStartsWith(
+            logger.getLogBuffer(),
+            dedent("""\
+                INFO Sending chroot file for live filesystem build to bob
+                INFO Initiating build 1-someid on http://fake:0000
+                """))
+        self.assertEqual(
+            ["ensurepresent", "build"], [call[0] for call in slave.call_log])
+        build_args = slave.call_log[1][1:]
+        self.assertEqual(job.getBuildCookie(), build_args[0])
+        self.assertEqual("livefs", build_args[1])
+        self.assertEqual([], build_args[3])
+        self.assertEqual(job._extraBuildArgs(), build_args[4])
+
+    @run_test_with(AsynchronousDeferredRunTest)
+    def test_dispatchBuildToSlave_no_chroot(self):
+        # dispatchBuildToSlave fails when the DAS has no chroot.
+        job = self.makeJob()
+        builder = MockBuilder()
+        builder.processor = getUtility(IProcessorSet).getByName("386")
+        job.setBuilder(builder, OkSlave())
+        d = job.dispatchBuildToSlave("someid", BufferLogger())
+        return assert_fails_with(d, CannotBuild)

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2014-04-24 02:16:27 +0000
+++ lib/lp/testing/factory.py	2014-04-25 15:26:13 +0000
@@ -2,7 +2,7 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Testing infrastructure for the Launchpad application.
@@ -283,6 +283,8 @@
     IComponent,
     IComponentSet,
     )
+from lp.soyuz.interfaces.livefs import ILiveFSSet
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJobSource
 from lp.soyuz.interfaces.packageset import IPackagesetSet
 from lp.soyuz.interfaces.processor import IProcessorSet
@@ -4316,6 +4318,66 @@
         product.redeemSubscriptionVoucher(
             self.getUniqueString(), person, person, months)
 
+    def makeLiveFS(self, registrant=None, owner=None, distroseries=None,
+                   name=None, metadata=None, date_created=DEFAULT):
+        """Make a new LiveFS."""
+        if registrant is None:
+            registrant = self.makePerson()
+        if owner is None:
+            owner = self.makePerson()
+        if distroseries is None:
+            distroseries = self.makeDistroSeries()
+        if name is None:
+            name = self.getUniqueString(u"livefs-name")
+        if metadata is None:
+            metadata = {}
+        livefs = getUtility(ILiveFSSet).new(
+            registrant, owner, distroseries, name, metadata,
+            date_created=date_created)
+        IStore(livefs).flush()
+        return livefs
+
+    def makeLiveFSBuild(self, requester=None, livefs=None, archive=None,
+                        distroarchseries=None, pocket=None, unique_key=None,
+                        metadata_override=None, date_created=DEFAULT,
+                        status=BuildStatus.NEEDSBUILD, duration=None,
+                        **kwargs):
+        """Make a new LiveFSBuild."""
+        if livefs is None:
+            if "distroseries" in kwargs:
+                distroseries = kwargs["distroseries"]
+                del kwargs["distroseries"]
+            elif distroarchseries is not None:
+                distroseries = distroarchseries.distroseries
+            elif archive is not None:
+                distroseries = self.makeDistroSeries(
+                    distribution=archive.distribution)
+            else:
+                distroseries = None
+            livefs = self.makeLiveFS(distroseries=distroseries, **kwargs)
+        if requester is None:
+            requester = self.makePerson()
+        if archive is None:
+            archive = livefs.distroseries.main_archive
+        if distroarchseries is None:
+            distroarchseries = self.makeDistroArchSeries(
+                distroseries=livefs.distroseries)
+        if pocket is None:
+            pocket = PackagePublishingPocket.RELEASE
+        livefsbuild = getUtility(ILiveFSBuildSet).new(
+            requester, livefs, archive, distroarchseries, pocket,
+            unique_key=unique_key, metadata_override=metadata_override,
+            date_created=date_created)
+        if duration is not None:
+            removeSecurityProxy(livefsbuild).updateStatus(
+                BuildStatus.BUILDING, date_started=livefsbuild.date_created)
+            removeSecurityProxy(livefsbuild).updateStatus(
+                status, date_finished=livefsbuild.date_started + duration)
+        else:
+            removeSecurityProxy(livefsbuild).updateStatus(status)
+        IStore(livefsbuild).flush()
+        return livefsbuild
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by