← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add browser code for live filesystems.

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-browser/+merge/219504

== Summary ==

Here's the browser code for live filesystems, following up on:

  https://code.launchpad.net/~cjwatson/launchpad/livefs/+merge/217261

== Implementation details ==

While I wrote a Person:+new-livefs page mainly because it was convenient to have one for my local testing (and I'm open to moving that somewhere else, although I'm not sure how the webservice approach with a "livefses" top-level collection could straightforwardly be translated into the web UI), I didn't link it anywhere.  This was partly YAGNI, and partly because I didn't see anywhere it could obviously go on Person:+index.  Suggestions welcome.
-- 
https://code.launchpad.net/~cjwatson/launchpad/livefs-browser/+merge/219504
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/livefs-browser 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-05-14 11:42:03 +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,13 @@
     )
 from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.livefs import (
+    ILiveFSView,
+    )
+from lp.soyuz.interfaces.livefsbuild import (
+    ILiveFSBuild,
+    ILiveFSFile,
+    )
 from lp.soyuz.interfaces.packageset import (
     IPackageset,
     IPackagesetSet,
@@ -576,6 +583,15 @@
 # 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
+ILiveFSView['completed_builds'].value_type.schema = ILiveFSBuild
+ILiveFSView['pending_builds'].value_type.schema = ILiveFSBuild
+
 # IPackageset
 patch_collection_return_type(
     IPackageset, 'setsIncluded', IPackageset)

=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2013-04-17 11:07:52 +0000
+++ lib/lp/app/browser/configure.zcml	2014-05-14 11:42:03 +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).
 -->
 
@@ -564,6 +564,12 @@
       factory="lp.app.browser.tales.BuildImageDisplayAPI"
       name="image"
       />
+  <adapter
+      for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.BuildImageDisplayAPI"
+      name="image"
+      />
 
   <adapter
       for="lp.soyuz.interfaces.archive.IArchive"
@@ -815,6 +821,12 @@
       name="fmt"
       />
   <adapter
+      for="lp.soyuz.interfaces.livefs.ILiveFS"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.LiveFSFormatterAPI"
+      name="fmt"
+      />
+  <adapter
       for="lp.blueprints.interfaces.specification.ISpecification"
       provides="zope.traversing.interfaces.IPathAdapter"
       factory="lp.app.browser.tales.SpecificationFormatterAPI"

=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py	2014-02-26 05:35:04 +0000
+++ lib/lp/app/browser/launchpad.py	2014-05-14 11:42:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 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 code for the launchpad application."""
@@ -111,7 +111,6 @@
 from lp.registry.interfaces.role import IPersonRoles
 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
 from lp.services.config import config
-from lp.services.features import getFeatureFlag
 from lp.services.helpers import intOrZero
 from lp.services.identity.interfaces.account import AccountStatus
 from lp.services.propertycache import cachedproperty
@@ -144,6 +143,7 @@
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
+from lp.soyuz.interfaces.livefs import ILiveFSSet
 from lp.soyuz.interfaces.packageset import IPackagesetSet
 from lp.soyuz.interfaces.processor import IProcessorSet
 from lp.testopenid.interfaces.server import ITestOpenIDApplication
@@ -689,6 +689,7 @@
         'karmaaction': IKarmaActionSet,
         '+imports': ITranslationImportQueue,
         '+languages': ILanguageSet,
+        'livefses': ILiveFSSet,
         '+nameblacklist': INameBlacklistSet,
         'package-sets': IPackagesetSet,
         'people': IPersonSet,

=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2013-06-22 08:37:21 +0000
+++ lib/lp/app/browser/tales.py	2014-05-14 11:42:03 +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 of the lp: htmlform: fmt: namespaces in TALES."""
@@ -1830,6 +1830,18 @@
                 'owner': self._context.owner.displayname}
 
 
+class LiveFSFormatterAPI(CustomizableFormatter):
+    """Adapter providing fmt support for ILiveFS objects."""
+
+    _link_summary_template = _(
+        'Live filesystem %(distroseries)s %(name)s for %(owner)s')
+
+    def _link_summary_values(self):
+        return {'distroseries': self._context.distro_series.name,
+                'name': self._context.name,
+                'owner': self._context.owner.displayname}
+
+
 class SpecificationFormatterAPI(CustomizableFormatter):
     """Adapter providing fmt support for Specification objects"""
 

=== modified file 'lib/lp/app/browser/tests/test_webservice.py'
--- lib/lp/app/browser/tests/test_webservice.py	2012-01-01 02:58:52 +0000
+++ lib/lp/app/browser/tests/test_webservice.py	2014-05-14 11:42:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for webservice features across Launchpad."""
@@ -115,6 +115,12 @@
     object_type = 'languages'
 
 
+class TestMissingLiveFSes(BaseMissingObjectWebService, TestCaseWithFactory):
+    """Test NotFound for webservice livefses requests."""
+
+    object_type = 'livefses'
+
+
 class TestMissingPackagesets(
     BaseMissingObjectWebService, TestCaseWithFactory):
     """Test NotFound for webservice packagesets requests."""

=== added file 'lib/lp/archiveuploader/livefsupload.py'
--- lib/lp/archiveuploader/livefsupload.py	1970-01-01 00:00:00 +0000
+++ lib/lp/archiveuploader/livefsupload.py	2014-05-14 11:42:03 +0000
@@ -0,0 +1,58 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Process a live filesystem upload."""
+
+__metaclass__ = type
+
+import os
+
+from zope.component import getUtility
+
+from lp.buildmaster.enums import BuildStatus
+from lp.services.helpers import filenameToContentType
+from lp.services.librarian.interfaces import ILibraryFileAliasSet
+
+
+class LiveFSUpload:
+    """A live filesystem upload.
+
+    Unlike package uploads, these have no .changes files.  We simply attach
+    all the files in the upload directory to the appropriate `ILiveFSBuild`.
+    """
+
+    def __init__(self, upload_path, logger):
+        """Create a `LiveFSUpload`.
+
+        :param upload_path: A directory containing files to upload.
+        :param logger: The logger to be used.
+        """
+        self.upload_path = upload_path
+        self.logger = logger
+
+        self.librarian = getUtility(ILibraryFileAliasSet)
+
+    def process(self, build):
+        """Process this upload, loading it into the database."""
+        self.logger.debug("Beginning processing.")
+
+        for dirpath, _, filenames in os.walk(self.upload_path):
+            if dirpath == self.upload_path:
+                # All relevant files will be in a subdirectory; this is a
+                # simple way to avoid uploading any .distro file that may
+                # exist.
+                continue
+            for livefs_file in sorted(filenames):
+                livefs_path = os.path.join(dirpath, livefs_file)
+                libraryfile = self.librarian.create(
+                    livefs_file, os.stat(livefs_path).st_size,
+                    open(livefs_path, "rb"),
+                    filenameToContentType(livefs_path),
+                    restricted=build.archive.private)
+                build.addFile(libraryfile)
+
+        # The master verifies the status to confirm successful upload.
+        self.logger.debug("Updating %s" % build.title)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+
+        self.logger.debug("Finished upload.")

=== added file 'lib/lp/archiveuploader/tests/test_livefsupload.py'
--- lib/lp/archiveuploader/tests/test_livefsupload.py	1970-01-01 00:00:00 +0000
+++ lib/lp/archiveuploader/tests/test_livefsupload.py	2014-05-14 11:42:03 +0000
@@ -0,0 +1,67 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test uploads of LiveFSBuilds."""
+
+__metaclass__ = type
+
+import os
+
+from storm.store import Store
+from zope.component import getUtility
+
+from lp.archiveuploader.tests.test_uploadprocessor import (
+    TestUploadProcessorBase,
+    )
+from lp.archiveuploader.uploadprocessor import (
+    UploadHandler,
+    UploadStatusEnum,
+    )
+from lp.buildmaster.enums import BuildStatus
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.features.testing import FeatureFixture
+from lp.services.osutils import write_file
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
+
+
+class TestLiveFSBuildUploads(TestUploadProcessorBase):
+    """End-to-end tests of LiveFS build uploads."""
+
+    def setUp(self):
+        super(TestLiveFSBuildUploads, self).setUp()
+
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.setupBreezy()
+
+        self.switchToAdmin()
+        self.livefs = self.factory.makeLiveFS()
+        self.build = getUtility(ILiveFSBuildSet).new(
+            requester=self.livefs.owner, livefs=self.livefs,
+            archive=self.factory.makeArchive(
+                distribution=self.ubuntu, owner=self.livefs.owner),
+            distro_arch_series=self.breezy["i386"],
+            pocket=PackagePublishingPocket.RELEASE)
+        self.build.updateStatus(BuildStatus.UPLOADING)
+        Store.of(self.build).flush()
+        self.switchToUploader()
+        self.options.context = "buildd"
+
+        self.uploadprocessor = self.getUploadProcessor(
+            self.layer.txn, builds=True)
+
+    def test_sets_build_and_state(self):
+        # The upload processor uploads files and sets the correct status.
+        self.assertFalse(self.build.verifySuccessfulUpload())
+        upload_dir = os.path.join(
+            self.incoming_folder, "test", str(self.build.id), "ubuntu")
+        write_file(os.path.join(upload_dir, "ubuntu.squashfs"), "squashfs")
+        write_file(os.path.join(upload_dir, "ubuntu.manifest"), "manifest")
+        handler = UploadHandler.forProcessor(
+            self.uploadprocessor, self.incoming_folder, "test", self.build)
+        result = handler.processLiveFS(self.log)
+        self.assertEqual(
+            UploadStatusEnum.ACCEPTED, result,
+            "LiveFS upload failed\nGot: %s" % self.log.getLogBuffer())
+        self.assertEqual(BuildStatus.FULLYBUILT, self.build.status)
+        self.assertTrue(self.build.verifySuccessfulUpload())

=== modified file 'lib/lp/archiveuploader/uploadprocessor.py'
--- lib/lp/archiveuploader/uploadprocessor.py	2013-08-01 14:09:45 +0000
+++ lib/lp/archiveuploader/uploadprocessor.py	2014-05-14 11:42:03 +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).
 
 """Code for 'processing' 'uploads'. Also see nascentupload.py.
@@ -57,6 +57,7 @@
 from zope.component import getUtility
 
 from lp.app.errors import NotFoundError
+from lp.archiveuploader.livefsupload import LiveFSUpload
 from lp.archiveuploader.nascentupload import (
     EarlyReturnUploadError,
     NascentUpload,
@@ -82,6 +83,7 @@
     IArchiveSet,
     NoSuchPPA,
     )
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
 
 
 __all__ = [
@@ -641,6 +643,28 @@
                 "Unable to find %s with id %d. Skipping." %
                 (job_type, job_id))
 
+    def processLiveFS(self, logger=None):
+        """Process a live filesystem upload."""
+        assert ILiveFSBuild.providedBy(self.build)
+        if logger is None:
+            logger = self.processor.log
+        try:
+            logger.info("Processing LiveFS upload %s" % self.upload_path)
+            LiveFSUpload(self.upload_path, logger).process(self.build)
+
+            if self.processor.dry_run:
+                logger.info("Dry run, aborting transaction.")
+                self.processor.ztm.abort()
+            else:
+                logger.info(
+                    "Committing the transaction and any mails associated "
+                    "with this upload.")
+                self.processor.ztm.commit()
+            return UploadStatusEnum.ACCEPTED
+        except:
+            self.processor.ztm.abort()
+            raise
+
     def process(self):
         """Process an upload that is the result of a build.
 
@@ -660,8 +684,11 @@
             # because we want the standard cleanup to occur.
             recipe_deleted = (ISourcePackageRecipeBuild.providedBy(self.build)
                 and self.build.recipe is None)
+            is_livefs = ILiveFSBuild.providedBy(self.build)
             if recipe_deleted:
                 result = UploadStatusEnum.FAILED
+            elif is_livefs:
+                result = self.processLiveFS(logger)
             else:
                 self.processor.log.debug("Build %s found" % self.build.id)
                 [changes_file] = self.locateChangesFiles()

=== modified file 'lib/lp/buildmaster/browser/builder.py'
--- lib/lp/buildmaster/browser/builder.py	2014-05-06 09:11:14 +0000
+++ lib/lp/buildmaster/browser/builder.py	2014-05-14 11:42:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 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 builders."""
@@ -64,6 +64,7 @@
     get_build_by_id_str,
     )
 from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 
 
 class BuilderSetNavigation(GetitemNavigation):
@@ -84,6 +85,13 @@
             return None
         return self.redirectSubTree(canonical_url(build))
 
+    @stepthrough('+livefsbuild')
+    def traverse_livefsbuild(self, name):
+        build = get_build_by_id_str(ILiveFSBuildSet, name)
+        if build is None:
+            return None
+        return self.redirectSubTree(canonical_url(build))
+
 
 class BuilderSetBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IBuilderSet`."""

=== 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-05-14 11:42:03 +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/buildmaster/interfaces/buildfarmjobbehaviour.py'
--- lib/lp/buildmaster/interfaces/buildfarmjobbehaviour.py	2014-02-03 14:43:08 +0000
+++ lib/lp/buildmaster/interfaces/buildfarmjobbehaviour.py	2014-05-14 11:42:03 +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).
 
 """Interface for build farm job behaviours."""
@@ -41,6 +41,9 @@
     def getBuildCookie():
         """Return a string which uniquely identifies the job."""
 
+    def verifySuccessfulBuild():
+        """Check that we are allowed to collect this successful build."""
+
     def handleStatus(bq, status, slave_status):
         """Update the build from a WAITING slave result.
 

=== modified file 'lib/lp/buildmaster/model/buildfarmjobbehaviour.py'
--- lib/lp/buildmaster/model/buildfarmjobbehaviour.py	2014-05-10 18:40:36 +0000
+++ lib/lp/buildmaster/model/buildfarmjobbehaviour.py	2014-05-14 11:42:03 +0000
@@ -129,6 +129,17 @@
         self.build.setLog(lfa_id)
         transaction.commit()
 
+    def verifySuccessfulBuild(self):
+        """See `IBuildFarmJobBehaviour`."""
+        build = self.build
+
+        # Explode before collecting a binary that is denied in this
+        # distroseries/pocket/archive
+        assert build.archive.canModifySuite(
+            build.distro_series, build.pocket), (
+                "%s (%s) can not be built for pocket %s in %s: illegal status"
+                % (build.title, build.id, build.pocket.name, build.archive))
+
     # The list of build status values for which email notifications are
     # allowed to be sent. It is up to each callback as to whether it will
     # consider sending a notification but it won't do so if the status is not
@@ -178,12 +189,7 @@
                 self.build.buildqueue_record.destroySelf()
                 return
 
-        # Explode before collect a binary that is denied in this
-        # distroseries/pocket/archive
-        assert build.archive.canModifySuite(
-            build.distro_series, build.pocket), (
-                "%s (%s) can not be built for pocket %s in %s: illegal status"
-                % (build.title, build.id, build.pocket.name, build.archive))
+        self.verifySuccessfulBuild()
 
         # Ensure we have the correct build root as:
         # <BUILDMASTER_ROOT>/incoming/<UPLOAD_LEAF>/<TARGET_PATH>/[FILES]

=== modified file 'lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py'
--- lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py	2014-01-30 15:04:06 +0000
+++ lib/lp/buildmaster/tests/test_buildfarmjobbehaviour.py	2014-05-14 11:42:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 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).
 
 """Unit tests for BuildFarmJobBehaviourBase."""
@@ -114,6 +114,40 @@
             (self.build.job_type.name, self.build.id), (job_type, job_id))
 
 
+class TestVerifySuccessfulBuildMixin:
+    """Tests for `IBuildFarmJobBehaviour`'s verifySuccessfulBuild method."""
+
+    layer = LaunchpadZopelessLayer
+
+    def makeBuild(self):
+        """Allow classes to override the build with which the test runs."""
+        raise NotImplementedError
+
+    def makeUnmodifiableBuild(self):
+        """Allow classes to override the build with which the test runs."""
+        raise NotImplementedError
+
+    def setUp(self):
+        super(TestVerifySuccessfulBuildMixin, self).setUp()
+        self.factory = LaunchpadObjectFactory()
+
+    def test_verifySuccessfulBuild_allows_modifiable_suite(self):
+        # verifySuccessfulBuild allows uploading to a suite that the archive
+        # says is modifiable.
+        build = self.makeBuild()
+        behaviour = IBuildFarmJobBehaviour(
+            build.buildqueue_record.specific_build)
+        behaviour.verifySuccessfulBuild()
+
+    def test_verifySuccessfulBuild_denies_unmodifiable_suite(self):
+        # verifySuccessfulBuild refuses to upload to a suite that the
+        # archive says is unmodifiable.
+        build = self.makeUnmodifiableBuild()
+        behaviour = IBuildFarmJobBehaviour(
+            build.buildqueue_record.specific_build)
+        self.assertRaises(AssertionError, behaviour.verifySuccessfulBuild)
+
+
 class TestHandleStatusMixin:
     """Tests for `IPackageBuild`s handleStatus method.
 

=== modified file 'lib/lp/code/model/tests/test_recipebuilder.py'
--- lib/lp/code/model/tests/test_recipebuilder.py	2014-01-30 15:04:06 +0000
+++ lib/lp/code/model/tests/test_recipebuilder.py	2014-05-14 11:42:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 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).
 
 """Test RecipeBuildBehaviour."""
@@ -35,15 +35,18 @@
 from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
     TestGetUploadMethodsMixin,
     TestHandleStatusMixin,
+    TestVerifySuccessfulBuildMixin,
     )
 from lp.code.model.recipebuilder import RecipeBuildBehaviour
 from lp.code.model.sourcepackagerecipebuild import SourcePackageRecipeBuild
 from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.log.logger import BufferLogger
 from lp.soyuz.adapters.archivedependencies import (
     get_sources_list_for_building,
     )
+from lp.soyuz.enums import ArchivePurpose
 from lp.soyuz.interfaces.processor import IProcessorSet
 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
 from lp.testing import (
@@ -399,12 +402,26 @@
         build.queueBuild()
         return build
 
+    def makeUnmodifiableBuild(self):
+        archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
+        build = self.factory.makeSourcePackageRecipeBuild(
+            archive=archive, status=BuildStatus.BUILDING)
+        build.distro_series.status = SeriesStatus.CURRENT
+        build.queueBuild()
+        return build
+
 
 class TestGetUploadMethodsForSPRecipeBuild(
     MakeSPRecipeBuildMixin, TestGetUploadMethodsMixin, TestCaseWithFactory):
     """IPackageBuild.getUpload-related methods work with SPRecipe builds."""
 
 
+class TestVerifySuccessfulBuildForSPRBuild(
+    MakeSPRecipeBuildMixin, TestVerifySuccessfulBuildMixin,
+    TestCaseWithFactory):
+    """IBuildFarmJobBehaviour.verifySuccessfulBuild works."""
+
+
 class TestHandleStatusForSPRBuild(
     MakeSPRecipeBuildMixin, TestHandleStatusMixin, TrialTestCase):
     """IPackageBuild.handleStatus works with SPRecipe builds."""

=== 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-05-14 11:42:03 +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,29 @@
         """Traverse to this person's merge queues."""
         return self.context.getMergeQueue(name)
 
+    @stepthrough('+livefs')
+    def traverse_livefs(self, distribution_name):
+        """Traverse to this person's live filesystem images."""
+        if len(self.request.stepstogo) < 2:
+            return None
+
+        distroseries_name = self.request.stepstogo.consume()
+        livefs_name = self.request.stepstogo.consume()
+        livefs = getUtility(ILiveFSSet).interpret(
+            self.context.name, distribution_name, distroseries_name,
+            livefs_name)
+
+        if livefs is None:
+            raise NotFoundError
+
+        if livefs.distro_series.distribution.name != distribution_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-05-14 11:42:03 +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."""
@@ -1029,7 +1029,7 @@
 
     @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-05-14 11:42:03 +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."""

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2014-03-17 21:50:33 +0000
+++ lib/lp/security.py	2014-05-14 11:42:03 +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 iter_objects(self):
+        yield self.obj.livefs
+        yield self.obj.archive
+
+
+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/services/helpers.py'
--- lib/lp/services/helpers.py	2011-12-23 23:44:59 +0000
+++ lib/lp/services/helpers.py	2014-05-14 11:42:03 +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).
 
 """Various functions and classes that are useful across different parts of
@@ -232,6 +232,10 @@
              ".txt": "text/plain",
              # For the build master logs
              ".txt.gz": "text/plain",
+             # For live filesystem builds
+             ".manifest": "text/plain",
+             ".manifest-remove": "text/plain",
+             ".size": "text/plain",
              }
     for ending in ftmap:
         if fname.endswith(ending):

=== 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-05-14 11:42:03 +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,13 +119,16 @@
     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:
+        ancestry = None
+    else:
+        ancestry = primary_archive.getPublishedSources(
+            name=sourcepackagename,
+            distroseries=distroseries, exact_match=True).first()
 
-    try:
-        return ancestries[0].component.name
-    except IndexError:
+    if ancestry is not None:
+        return ancestry.component.name
+    else:
         return 'universe'
 
 

=== modified file 'lib/lp/soyuz/browser/archive.py'
--- lib/lp/soyuz/browser/archive.py	2014-05-06 09:11:14 +0000
+++ lib/lp/soyuz/browser/archive.py	2014-05-14 11:42:03 +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).
 
 """Browser views for archive."""
@@ -151,6 +151,7 @@
     )
 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
 from lp.soyuz.interfaces.component import IComponentSet
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJobSource
 from lp.soyuz.interfaces.packagecopyrequest import IPackageCopyRequestSet
 from lp.soyuz.interfaces.packageset import IPackagesetSet
@@ -249,6 +250,13 @@
             return None
         return build
 
+    @stepthrough('+livefsbuild')
+    def traverse_livefsbuild(self, name):
+        build = get_build_by_id_str(ILiveFSBuildSet, name)
+        if build is None or build.archive != self.context:
+            return None
+        return build
+
     @stepthrough('+sourcepub')
     def traverse_sourcepub(self, name):
         return self._traverse_publication(name, source=True)

=== modified file 'lib/lp/soyuz/browser/build.py'
--- lib/lp/soyuz/browser/build.py	2014-05-06 09:11:14 +0000
+++ lib/lp/soyuz/browser/build.py	2014-05-14 11:42:03 +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."""

=== 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-05-14 11:42:03 +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,104 @@
             template="../templates/packagerelationship-list.pt"
          />
     </browser:pages>
+    <browser:url
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        path_expression="string:+livefs/${distro_series/distribution/name}/${distro_series/name}/${name}"
+        attribute_to_parent="owner"
+        />
+    <browser:defaultView
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        name="+index"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        class="lp.soyuz.browser.livefs.LiveFSView"
+        permission="launchpad.View"
+        name="+index"
+        template="../templates/livefs-index.pt"
+        />
+    <browser:menus
+        module="lp.soyuz.browser.livefs"
+        classes="LiveFSNavigationMenu"
+        />
+    <browser:navigation
+        module="lp.soyuz.browser.livefs"
+        classes="LiveFSNavigation"
+        />
+    <browser:page
+        for="lp.registry.interfaces.person.IPerson"
+        class="lp.soyuz.browser.livefs.LiveFSAddView"
+        permission="launchpad.Edit"
+        name="+new-livefs"
+        template="../templates/livefs-new.pt"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        class="lp.soyuz.browser.livefs.LiveFSEditView"
+        permission="launchpad.Edit"
+        name="+edit"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        class="lp.soyuz.browser.livefs.LiveFSHierarchy"
+        permission="zope.Public"
+        name="+hierarchy"
+        template="../../app/templates/launchpad-hierarchy.pt"
+        />
+    <adapter
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        factory="lp.services.webapp.breadcrumb.NameBreadcrumb"
+        permission="zope.Public"
+        />
+    <browser:url
+        for="lp.soyuz.interfaces.livefs.ILiveFSSet"
+        path_expression="string:livefses"
+        parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot"
+        />
+    <browser:url
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        path_expression="string:+livefsbuild/${id}"
+        attribute_to_parent="livefs"
+        />
+    <browser:menus
+        module="lp.soyuz.browser.livefsbuild"
+        classes="LiveFSBuildContextMenu"
+        />
+    <browser:navigation
+        module="lp.soyuz.browser.livefsbuild"
+        classes="LiveFSBuildNavigation"
+        />
+    <browser:defaultView
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        name="+index"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        class="lp.soyuz.browser.livefsbuild.LiveFSBuildView"
+        permission="launchpad.View"
+        name="+index"
+        template="../templates/livefsbuild-index.pt"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        class="lp.soyuz.browser.livefsbuild.LiveFSBuildCancelView"
+        permission="launchpad.Edit"
+        name="+cancel"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        class="lp.soyuz.browser.livefsbuild.LiveFSBuildRescoreView"
+        permission="launchpad.Admin"
+        name="+rescore"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <adapter
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
+        permission="zope.Public"
+        />
     </facet>
 </configure>

=== added file 'lib/lp/soyuz/browser/livefs.py'
--- lib/lp/soyuz/browser/livefs.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/browser/livefs.py	2014-05-14 11:42:03 +0000
@@ -0,0 +1,340 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""LiveFS views."""
+
+__metaclass__ = type
+__all__ = [
+    'LiveFSAddView',
+    'LiveFSEditView',
+    'LiveFSNavigation',
+    'LiveFSNavigationMenu',
+    'LiveFSView',
+    ]
+
+import json
+
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
+from lazr.restful import ResourceJSONEncoder
+from lazr.restful.interface import (
+    copy_field,
+    use_template,
+    )
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import (
+    implements,
+    Interface,
+    providedBy,
+    )
+from zope.schema import (
+    Choice,
+    Text,
+    )
+
+from lp.app.browser.launchpad import Hierarchy
+from lp.app.browser.launchpadform import (
+    action,
+    custom_widget,
+    LaunchpadEditFormView,
+    LaunchpadFormView,
+    )
+from lp.app.browser.lazrjs import (
+    InlinePersonEditPickerWidget,
+    TextLineEditorWidget,
+    )
+from lp.app.browser.tales import format_link
+from lp.app.widgets.itemswidgets import LaunchpadRadioWidget
+from lp.code.vocabularies.sourcepackagerecipe import BuildableDistroSeries
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.features import getFeatureFlag
+from lp.services.webapp import (
+    canonical_url,
+    enabled_with_permission,
+    LaunchpadView,
+    Link,
+    Navigation,
+    NavigationMenu,
+    stepthrough,
+    )
+from lp.services.webapp.authorization import check_permission
+from lp.services.webapp.breadcrumb import Breadcrumb
+from lp.soyuz.browser.build import get_build_by_id_str
+from lp.soyuz.interfaces.livefs import (
+    ILiveFS,
+    ILiveFSSet,
+    LIVEFS_FEATURE_FLAG,
+    LiveFSFeatureDisabled,
+    )
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
+
+
+class LiveFSNavigation(Navigation):
+    usedfor = ILiveFS
+
+    @stepthrough('+livefsbuild')
+    def traverse_livefsbuild(self, name):
+        build = get_build_by_id_str(ILiveFSBuildSet, name)
+        if build is None or build.livefs != self.context:
+            return None
+        return build
+
+
+class ILiveFSesForPerson(Interface):
+    """A marker interface for live filesystem sets."""
+
+
+class LiveFSesForPersonBreadcrumb(Breadcrumb):
+    """A Breadcrumb to handle the "Live filesystems" link."""
+
+    rootsite = None
+    text = 'Live filesystems'
+
+    implements(ILiveFSesForPerson)
+
+    @property
+    def url(self):
+        return canonical_url(self.context, view_name="+livefs")
+
+
+class LiveFSHierarchy(Hierarchy):
+    """Hierarchy for live filesystems."""
+
+    @property
+    def objects(self):
+        """See `Hierarchy`."""
+        traversed = list(self.request.traversed_objects)
+        # Pop the root object.
+        yield traversed.pop(0)
+        # Pop until we find the live filesystem.
+        livefs = traversed.pop(0)
+        while not ILiveFS.providedBy(livefs):
+            yield livefs
+            livefs = traversed.pop(0)
+        # Pop in the "Live filesystems" link.
+        yield LiveFSesForPersonBreadcrumb(livefs.owner)
+        yield livefs
+        for item in traversed:
+            yield item
+
+
+class LiveFSNavigationMenu(NavigationMenu):
+    """Navigation menu for live filesystems."""
+
+    usedfor = ILiveFS
+
+    facet = 'overview'
+
+    links = ('edit',)
+
+    @enabled_with_permission('launchpad.Edit')
+    def edit(self):
+        return Link('+edit', 'Edit live filesystem', icon='edit')
+
+
+class LiveFSView(LaunchpadView):
+    """Default view of a LiveFS."""
+
+    @property
+    def page_title(self):
+        return "%(name)s's %(livefs_name) live filesystem" % {
+            'name': self.context.owner.displayname,
+            'livefs_name': self.context.name,
+            }
+
+    label = page_title
+
+    @property
+    def builds(self):
+        return builds_for_livefs(self.context)
+
+    @property
+    def person_picker(self):
+        field = copy_field(
+            ILiveFS['owner'],
+            vocabularyName='UserTeamsParticipationPlusSelfSimpleDisplay')
+        return InlinePersonEditPickerWidget(
+            self.context, field, format_link(self.context.owner),
+            header='Change owner', step_title='Select a new owner')
+
+    @property
+    def name_widget(self):
+        name = ILiveFS['name']
+        title = "Edit the live filesystem name"
+        return TextLineEditorWidget(
+            self.context, name, title, 'h1', max_width='95%', truncate_lines=1)
+
+    @property
+    def sorted_metadata_items(self):
+        return sorted(self.context.metadata.items())
+
+
+def builds_for_livefs(livefs):
+    """A list of interesting builds.
+
+    All pending builds are shown, as well as 1-10 recent builds.  Recent
+    builds are ordered by date finished (if completed) or date_started (if
+    date finished is not set due to an error building or other circumstance
+    which resulted in the build not being completed).  This allows started
+    but unfinished builds to show up in the view but be discarded as more
+    recent builds become available.
+
+    Builds that the user does not have permission to see are excluded.
+    """
+    builds = [
+        build for build in livefs.pending_builds
+        if check_permission('launchpad.View', build)]
+    for build in livefs.completed_builds:
+        if not check_permission('launchpad.View', build):
+            continue
+        builds.append(build)
+        if len(builds) >= 10:
+            break
+    return builds
+
+
+class ILiveFSEditSchema(Interface):
+    """Schema for adding or editing a live filesystem."""
+
+    use_template(ILiveFS, include=[
+        'owner',
+        'name',
+        ])
+    distro_series = Choice(
+        vocabulary='BuildableDistroSeries', title=u'Distribution series')
+    metadata = Text(
+        title=u'Live filesystem build metadata',
+        description=(
+            u'A JSON dictionary of data about the image.  Entries here will '
+             'be passed to the builder slave.'))
+
+
+class LiveFSMetadataValidatorMixin:
+    """Class to validate that live filesystem properties are valid."""
+
+    def validate(self, data):
+        if data['metadata']:
+            try:
+                json.loads(data['metadata'])
+            except Exception as e:
+                self.setFieldError('metadata', str(e))
+
+
+class LiveFSAddView(LiveFSMetadataValidatorMixin, LaunchpadFormView):
+    """View for creating live filesystems."""
+
+    title = label = 'Create a new live filesystem'
+
+    schema = ILiveFSEditSchema
+    custom_widget('distro_series', LaunchpadRadioWidget)
+
+    def initialize(self):
+        """See `LaunchpadView`."""
+        if not getFeatureFlag(LIVEFS_FEATURE_FLAG):
+            raise LiveFSFeatureDisabled
+        super(LiveFSAddView, self).initialize()
+
+    @property
+    def initial_values(self):
+        series = [
+            term.value for term in BuildableDistroSeries()
+            if term.value.status in (
+                SeriesStatus.CURRENT, SeriesStatus.DEVELOPMENT)][0]
+        return {
+            'owner': self.user,
+            'distro_series': series,
+            'metadata': '{}',
+            }
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action('Create live filesystem', name='create')
+    def request_action(self, action, data):
+        livefs = getUtility(ILiveFSSet).new(
+            self.user, data['owner'], data['distro_series'], data['name'],
+            json.loads(data['metadata']))
+        self.next_url = canonical_url(livefs)
+
+    def validate(self, data):
+        super(LiveFSAddView, self).validate(data)
+        owner = data.get('owner', None)
+        distro_series = data['distro_series']
+        name = data.get('name', None)
+        if owner and name:
+            if getUtility(ILiveFSSet).exists(owner, distro_series, name):
+                self.setFieldError(
+                    'name',
+                    'There is already a live filesystem for %s owned by %s '
+                    'with this name.' % (
+                        distro_series.displayname, owner.displayname))
+
+
+class LiveFSEditView(LiveFSMetadataValidatorMixin, LaunchpadEditFormView):
+    """View for editing live filesystems."""
+
+    @property
+    def title(self):
+        return 'Edit %s live filesystem' % self.context.name
+
+    label = title
+
+    schema = ILiveFSEditSchema
+    custom_widget('distro_series', LaunchpadRadioWidget)
+
+    @property
+    def initial_values(self):
+        return {
+            'distro_series': self.context.distro_series,
+            'metadata': json.dumps(
+                self.context.metadata, ensure_ascii=False,
+                cls=ResourceJSONEncoder),
+            }
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action('Update live filesystem', name='update')
+    def request_action(self, action, data):
+        changed = False
+        livefs_before_modification = Snapshot(
+            self.context, providing=providedBy(self.context))
+
+        metadata = json.loads(data.pop('metadata'))
+        if self.context.metadata != metadata:
+            self.context.metadata = metadata
+            changed = True
+
+        if self.updateContextFromData(data, notify_modified=False):
+            changed = True
+
+        if changed:
+            field_names = [
+                form_field.__name__ for form_field in self.form_fields]
+            notify(ObjectModifiedEvent(
+                self.context, livefs_before_modification, field_names))
+
+        self.next_url = canonical_url(self.context)
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {ILiveFSEditSchema: self.context}
+
+    def validate(self, data):
+        super(LiveFSEditView, self).validate(data)
+        owner = data.get('owner', None)
+        distro_series = data['distro_series']
+        name = data.get('name', None)
+        if owner and name:
+            livefs = getUtility(ILiveFSSet).getByName(
+                owner, distro_series, name)
+            if livefs is not None and livefs != self.context:
+                self.setFieldError(
+                    'name',
+                    'There is already a live filesystem for %s owned by %s '
+                    'with this name.' % (
+                        distro_series.displayname, owner.displayname))

=== added file 'lib/lp/soyuz/browser/livefsbuild.py'
--- lib/lp/soyuz/browser/livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/browser/livefsbuild.py	2014-05-14 11:42:03 +0000
@@ -0,0 +1,167 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""LiveFSBuild views."""
+
+__metaclass__ = type
+__all__ = [
+    'LiveFSBuildContextMenu',
+    'LiveFSBuildNavigation',
+    'LiveFSBuildView',
+    ]
+
+from zope.interface import Interface
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadFormView,
+    )
+from lp.buildmaster.enums import BuildQueueStatus
+from lp.services.librarian.browser import (
+    FileNavigationMixin,
+    ProxiedLibraryFileAlias,
+    )
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp import (
+    canonical_url,
+    ContextMenu,
+    enabled_with_permission,
+    LaunchpadView,
+    Link,
+    Navigation,
+    )
+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
+
+
+class LiveFSBuildNavigation(Navigation, FileNavigationMixin):
+    usedfor = ILiveFSBuild
+
+
+class LiveFSBuildContextMenu(ContextMenu):
+    """Context menu for live filesystem builds."""
+
+    usedfor = ILiveFSBuild
+
+    facet = 'overview'
+
+    links = ('cancel', 'rescore')
+
+    @enabled_with_permission('launchpad.Edit')
+    def cancel(self):
+        return Link(
+            '+cancel', 'Cancel build', icon='remove',
+            enabled=self.context.can_be_cancelled)
+
+    @enabled_with_permission('launchpad.Admin')
+    def rescore(self):
+        return Link(
+            '+rescore', 'Rescore build', icon='edit',
+            enabled=self.context.can_be_rescored)
+
+
+class LiveFSBuildView(LaunchpadView):
+    """Default view of a LiveFSBuild."""
+
+    @property
+    def label(self):
+        return self.context.title
+
+    page_title = label
+
+    @cachedproperty
+    def eta(self):
+        """The datetime when the build job is estimated to complete.
+
+        This is the BuildQueue.estimated_duration plus the
+        Job.date_started or BuildQueue.getEstimatedJobStartTime.
+        """
+        if self.context.buildqueue_record is None:
+            return None
+        queue_record = self.context.buildqueue_record
+        if queue_record.status == BuildQueueStatus.WAITING:
+            start_time = queue_record.getEstimatedJobStartTime()
+        else:
+            start_time = queue_record.date_started
+        if start_time is None:
+            return None
+        duration = queue_record.estimated_duration
+        return start_time + duration
+
+    @cachedproperty
+    def estimate(self):
+        """If true, the date value is an estimate."""
+        if self.context.date_finished is not None:
+            return False
+        return self.eta is not None
+
+    @cachedproperty
+    def date(self):
+        """The date when the build completed or is estimated to complete."""
+        if self.estimate:
+            return self.eta
+        return self.context.date_finished
+
+    @cachedproperty
+    def files(self):
+        """Return `LibraryFileAlias`es for files produced by this build."""
+        if not self.context.was_built:
+            return None
+
+        return [
+            ProxiedLibraryFileAlias(alias, self.context)
+            for _, alias, _ in self.context.getFiles() if not alias.deleted]
+
+    @cachedproperty
+    def has_files(self):
+        return bool(self.files)
+
+
+class LiveFSBuildCancelView(LaunchpadFormView):
+    """View for cancelling a live filesystem build."""
+
+    class schema(Interface):
+        """Schema for cancelling a build."""
+
+    page_title = label = 'Cancel build'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action('Cancel build', name='cancel')
+    def request_action(self, action, data):
+        """Cancel the build."""
+        self.context.cancel()
+
+
+class LiveFSBuildRescoreView(LaunchpadFormView):
+    """View for rescoring a live filesystem build."""
+
+    schema = IBuildRescoreForm
+
+    page_title = label = 'Rescore build'
+
+    def __call__(self):
+        if self.context.can_be_rescored:
+            return super(LiveFSBuildRescoreView, self).__call__()
+        self.request.response.addWarningNotification(
+            "Cannot rescore this build because it is not queued.")
+        self.request.response.redirect(canonical_url(self.context))
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action('Rescore build', name='rescore')
+    def request_action(self, action, data):
+        """Rescore the build."""
+        score = data.get('priority')
+        self.context.rescore(score)
+        self.request.response.addNotification('Build rescored to %s.' % score)
+
+    @property
+    def initial_values(self):
+        return {'score': str(self.context.buildqueue_record.lastscore)}

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,376 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test live filesystem views."""
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+import json
+
+from fixtures import FakeLogger
+import pytz
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.database.constants import UTC_NOW
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp import canonical_url
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.soyuz.browser.livefs import (
+    LiveFSEditView,
+    LiveFSView,
+    )
+from lp.soyuz.interfaces.livefs import (
+    LIVEFS_FEATURE_FLAG,
+    LiveFSFeatureDisabled,
+    )
+from lp.soyuz.interfaces.processor import IProcessorSet
+from lp.testing import (
+    BrowserTestCase,
+    person_logged_in,
+    TestCaseWithFactory,
+    time_counter,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.matchers import (
+    MatchesPickerText,
+    MatchesTagText,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    get_feedback_messages,
+    )
+from lp.testing.publication import test_traverse
+from lp.testing.views import create_initialized_view
+
+
+class TestLiveFSNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSNavigation, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        distribution = self.factory.makeDistribution(
+            name="distro", owner=owner)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable")
+        livefs = self.factory.makeLiveFS(
+            registrant=owner, owner=owner, distroseries=distroseries,
+            name=u"livefs")
+        self.assertEqual(
+            "http://launchpad.dev/~person/+livefs/distro/unstable/livefs";,
+            canonical_url(livefs))
+
+    def test_livefs(self):
+        livefs = self.factory.makeLiveFS()
+        obj, _, _ = test_traverse(
+            "http://launchpad.dev/~%s/+livefs/%s/%s/%s"; % (
+                livefs.owner.name, livefs.distro_series.distribution.name,
+                livefs.distro_series.name, livefs.name))
+        self.assertEqual(livefs, obj)
+
+
+class TestLiveFSViewsFeatureFlag(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create new LiveFSes.
+        person = self.factory.makePerson()
+        self.assertRaises(
+            LiveFSFeatureDisabled, create_initialized_view,
+            person, "+new-livefs")
+
+
+class TestLiveFSAddView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSAddView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_initial_distroseries(self):
+        # The initial distroseries is the newest that is current or in
+        # development.
+        archive = self.factory.makeArchive(owner=self.person)
+        self.factory.makeDistroSeries(
+            distribution=archive.distribution, version="14.04",
+            status=SeriesStatus.DEVELOPMENT)
+        development = self.factory.makeDistroSeries(
+            distribution=archive.distribution, version="14.10",
+            status=SeriesStatus.DEVELOPMENT)
+        self.factory.makeDistroSeries(
+            distribution=archive.distribution, version="15.04",
+            status=SeriesStatus.EXPERIMENTAL)
+        with person_logged_in(self.person):
+            view = create_initialized_view(self.person, "+new-livefs")
+        self.assertEqual(development, view.initial_values["distro_series"])
+
+    def test_create_new_livefs_not_logged_in(self):
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, self.person,
+            view_name="+new-livefs", no_login=True)
+
+    def test_create_new_livefs(self):
+        archive = self.factory.makeArchive()
+        distroseries = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
+        browser = self.getViewBrowser(
+            self.person, view_name="+new-livefs", user=self.person)
+        browser.getControl("Name").value = "ubuntu-core"
+        browser.getControl("Live filesystem build metadata").value = (
+            '{"product": "ubuntu-core", "image_format": "plain"}')
+        browser.getControl("Create live filesystem").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("ubuntu-core\nEdit", extract_text(content.h1))
+        self.assertThat(
+            "Test Person", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Distribution series:\n%s\nEdit live filesystem" %
+            distroseries.fullseriesname,
+            MatchesTagText(content, "distro_series"))
+        self.assertThat(
+            "Metadata:\nimage_format\nplain\nproduct\nubuntu-core",
+            MatchesTagText(content, "metadata"))
+
+    def test_create_new_livefs_users_teams_as_owner_options(self):
+        # Teams that the user is in are options for the live filesystem owner.
+        self.factory.makeTeam(
+            name="test-team", displayname="Test Team", members=[self.person])
+        browser = self.getViewBrowser(
+            self.person, view_name="+new-livefs", user=self.person)
+        options = browser.getControl("Owner").displayOptions
+        self.assertEqual(
+            ["Test Person (test-person)", "Test Team (test-team)"],
+            sorted(str(option) for option in options))
+
+    def test_create_new_livefs_invalid_metadata(self):
+        # The metadata field must contain valid JSON.
+        browser = self.getViewBrowser(
+            self.person, view_name="+new-livefs", user=self.person)
+        browser.getControl("Name").value = "ubuntu-core"
+        browser.getControl("Live filesystem build metadata").value = "{"
+        browser.getControl("Create live filesystem").click()
+        json_error = str(self.assertRaises(ValueError, json.loads, "{"))
+        self.assertEqual(
+            json_error, get_feedback_messages(browser.contents)[1])
+
+
+class TestLiveFSEditView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSEditView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_edit_livefs(self):
+        archive = self.factory.makeArchive()
+        old_series = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.CURRENT)
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person, distroseries=old_series)
+        self.factory.makeTeam(
+            name="new-team", displayname="New Team", members=[self.person])
+        new_series = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
+
+        browser = self.getViewBrowser(livefs, user=self.person)
+        browser.getLink("Edit live filesystem").click()
+        browser.getControl("Owner").value = ["new-team"]
+        browser.getControl("Name").value = "new-name"
+        browser.getControl(name="field.distro_series").value = [
+            str(new_series.id)]
+        browser.getControl("Live filesystem build metadata").value = (
+            '{"product": "new-name"}')
+        browser.getControl("Update live filesystem").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("new-name\nEdit", extract_text(content.h1))
+        self.assertThat("New Team", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Distribution series:\n%s\nEdit live filesystem" %
+            new_series.fullseriesname,
+            MatchesTagText(content, "distro_series"))
+        self.assertThat(
+            "Metadata:\nproduct\nnew-name",
+            MatchesTagText(content, "metadata"))
+
+    def test_edit_livefs_sets_date_last_modified(self):
+        # Editing a live filesystem sets the date_last_modified property.
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, date_created=date_created)
+        with person_logged_in(self.person):
+            view = LiveFSEditView(livefs, LaunchpadTestRequest())
+            view.initialize()
+            view.request_action.success({
+                "owner": livefs.owner,
+                "name": u"changed",
+                "distro_series": livefs.distro_series,
+                "metadata": "{}",
+                })
+        self.assertSqlAttributeEqualsDate(
+            livefs, "date_last_modified", UTC_NOW)
+
+    def test_edit_livefs_already_exists(self):
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            displayname="Grumpy")
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries, name=u"one")
+        self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries, name=u"two")
+        browser = self.getViewBrowser(livefs, user=self.person)
+        browser.getLink("Edit live filesystem").click()
+        browser.getControl("Name").value = "two"
+        browser.getControl("Update live filesystem").click()
+        self.assertEqual(
+            "There is already a live filesystem for Grumpy owned by "
+            "Test Person with this name.",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+
+class TestLiveFSView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        self.distroseries = self.factory.makeDistroSeries(
+            distribution=self.ubuntu, name="shiny", displayname="Shiny")
+        processor = getUtility(IProcessorSet).getByName("386")
+        self.distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries, architecturetag="i386",
+            processor=processor)
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def makeLiveFS(self):
+        return self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person,
+            distroseries=self.distroseries, name=u"livefs-name",
+            metadata={"project": "ubuntu-test"})
+
+    def makeBuild(self, livefs=None, archive=None, date_created=None,
+                  **kwargs):
+        if livefs is None:
+            livefs = self.makeLiveFS()
+        if archive is None:
+            archive = self.ubuntu.main_archive
+        if date_created is None:
+            date_created = datetime.now(pytz.UTC) - timedelta(hours=1)
+        return self.factory.makeLiveFSBuild(
+            requester=self.person, livefs=livefs, archive=archive,
+            distroarchseries=self.distroarchseries, date_created=date_created,
+            **kwargs)
+
+    def test_index(self):
+        build = self.makeBuild(
+            status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Test Person Live filesystems livefs-name
+            .*
+            Live filesystem information
+            Owner: Test Person
+            Distribution series: Ubuntu Shiny
+            Metadata: project ubuntu-test
+            Latest builds
+            Status When complete Architecture Archive
+            Successfully built 30 minutes ago i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.livefs))
+
+    def test_index_success_with_buildlog(self):
+        # The build log is shown if it is there.
+        build = self.makeBuild(
+            status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
+        build.setLog(self.factory.makeLibraryFileAlias())
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture Archive
+            Successfully built 30 minutes ago buildlog \(.*\) i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.livefs))
+
+    def test_index_hides_builds_into_private_archive(self):
+        # The index page hides builds into archives the user can't view.
+        archive = self.factory.makeArchive(private=True)
+        with person_logged_in(archive.owner):
+            livefs = self.makeBuild(archive=archive).livefs
+        self.assertIn(
+            "This live filesystem has not been built yet.",
+            self.getMainText(livefs))
+
+    def test_index_no_builds(self):
+        # A message is shown when there are no builds.
+        livefs = self.factory.makeLiveFS()
+        self.assertIn(
+            "This live filesystem has not been built yet.",
+            self.getMainText(livefs))
+
+    def test_index_pending(self):
+        # A pending build is listed as such.
+        build = self.makeBuild()
+        build.queueBuild()
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture Archive
+            Needs building in .* \(estimated\) i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.livefs))
+
+    def setStatus(self, build, status):
+        build.updateStatus(
+            BuildStatus.BUILDING, date_started=build.date_created)
+        build.updateStatus(
+            status, date_finished=build.date_started + timedelta(minutes=30))
+
+    def test_builds(self):
+        # LiveFSView.builds produces reasonable results.
+        livefs = self.makeLiveFS()
+        # Create oldest builds first so that they sort properly by id.
+        date_gen = time_counter(
+            datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
+        builds = [
+            self.makeBuild(livefs=livefs, date_created=next(date_gen))
+            for i in range(11)]
+        view = LiveFSView(livefs, None)
+        self.assertEqual(list(reversed(builds)), view.builds)
+        self.setStatus(builds[10], BuildStatus.FULLYBUILT)
+        self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
+        # When there are >= 9 pending builds, only the most recent of any
+        # completed builds is returned.
+        self.assertEqual(
+            list(reversed(builds[:9])) + [builds[10]], view.builds)
+        for build in builds[:9]:
+            self.setStatus(build, BuildStatus.FULLYBUILT)
+        self.assertEqual(list(reversed(builds[1:])), view.builds)

=== added file 'lib/lp/soyuz/browser/tests/test_livefsbuild.py'
--- lib/lp/soyuz/browser/tests/test_livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/browser/tests/test_livefsbuild.py	2014-05-14 11:42:03 +0000
@@ -0,0 +1,261 @@
+# 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 views."""
+
+__metaclass__ = type
+
+from fixtures import FakeLogger
+from mechanize import LinkNotFoundError
+from storm.locals import Store
+from testtools.matchers import StartsWith
+import transaction
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp import canonical_url
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
+from lp.testing import (
+    admin_logged_in,
+    ANONYMOUS,
+    BrowserTestCase,
+    login,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    setupBrowser,
+    setupBrowserForUser,
+    )
+from lp.testing.views import create_initialized_view
+
+
+class TestCanonicalUrlForLiveFSBuild(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestCanonicalUrlForLiveFSBuild, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        distribution = self.factory.makeDistribution(
+            name="distro", owner=owner)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable")
+        livefs = self.factory.makeLiveFS(
+            registrant=owner, owner=owner, distroseries=distroseries,
+            name=u"livefs")
+        build = self.factory.makeLiveFSBuild(requester=owner, livefs=livefs)
+        self.assertThat(
+            canonical_url(build),
+            StartsWith(
+                "http://launchpad.dev/~person/+livefs/distro/unstable/livefs/";
+                "+livefsbuild/"))
+
+
+class TestLiveFSBuildView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_files(self):
+        # LiveFSBuildView.files returns all the associated files.
+        build = self.factory.makeLiveFSBuild(status=BuildStatus.FULLYBUILT)
+        livefsfile = self.factory.makeLiveFSFile(livefsbuild=build)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual(
+            [livefsfile.libraryfile.filename],
+            [lfa.filename for lfa in build_view.files])
+        # Deleted files won't be included.
+        self.assertFalse(livefsfile.libraryfile.deleted)
+        removeSecurityProxy(livefsfile.libraryfile).content = None
+        self.assertTrue(livefsfile.libraryfile.deleted)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual([], build_view.files)
+
+    def test_eta(self):
+        # LiveFSBuildView.eta returns a non-None value when it should, or
+        # None when there's no start time.
+        build = self.factory.makeLiveFSBuild()
+        build.queueBuild()
+        self.assertIsNone(create_initialized_view(build, "+index").eta)
+        self.factory.makeBuilder(processors=[build.processor])
+        self.assertIsNotNone(create_initialized_view(build, "+index").eta)
+
+    def test_estimate(self):
+        # LiveFSBuildView.estimate returns True until the job is completed.
+        build = self.factory.makeLiveFSBuild()
+        build.queueBuild()
+        self.factory.makeBuilder(processors=[build.processor])
+        build.updateStatus(BuildStatus.BUILDING)
+        self.assertTrue(create_initialized_view(build, "+index").estimate)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertFalse(create_initialized_view(build, "+index").estimate)
+
+
+class TestLiveFSBuildOperations(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildOperations, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.build = self.factory.makeLiveFSBuild()
+        self.build_url = canonical_url(self.build)
+        self.requester = self.build.requester
+        self.buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+
+    def test_cancel_build(self):
+        # The requester of a build can cancel it.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        browser.getLink("Cancel build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Cancel build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+
+    def test_cancel_build_random_user(self):
+        # An unrelated non-admin user cannot cancel a build.
+        self.build.queueBuild()
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+cancel",
+            user=user)
+
+    def test_cancel_build_wrong_state(self):
+        # If the build isn't queued, you can't cancel it.
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+
+    def test_rescore_build(self):
+        # A buildd admin can rescore a build.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        browser.getLink("Rescore build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Priority").value = "1024"
+        browser.getControl("Rescore build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(1024, self.build.buildqueue_record.lastscore)
+
+    def test_rescore_build_invalid_score(self):
+        # Build scores can only take numbers.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        browser.getLink("Rescore build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Priority").value = "tentwentyfour"
+        browser.getControl("Rescore build").click()
+        self.assertEqual(
+            "Invalid integer data",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+    def test_rescore_build_not_admin(self):
+        # A non-admin user cannot cancel a build.
+        self.build.queueBuild()
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+rescore",
+            user=user)
+
+    def test_rescore_build_wrong_state(self):
+        # If the build isn't NEEDSBUILD, you can't rescore it.
+        self.build.queueBuild()
+        with person_logged_in(self.requester):
+            self.build.cancel()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+
+    def test_rescore_build_wrong_state_stale_link(self):
+        # An attempt to rescore a non-queued build from a stale link shows a
+        # sensible error message.
+        self.build.queueBuild()
+        with person_logged_in(self.requester):
+            self.build.cancel()
+        browser = self.getViewBrowser(
+            self.build, "+rescore", user=self.buildd_admin)
+        self.assertEqual(self.build_url, browser.url)
+        self.assertIn(
+            "Cannot rescore this build because it is not queued.",
+            browser.contents)
+
+    def test_builder_history(self):
+        Store.of(self.build).flush()
+        self.build.updateStatus(
+            BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder())
+        title = self.build.title
+        browser = self.getViewBrowser(self.build.builder, "+history")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            "Build history.*%s" % title,
+            extract_text(find_main_content(browser.contents)))
+        self.assertEqual(self.build_url, browser.getLink(title).url)
+
+    def makeBuildingLiveFS(self, archive=None):
+        builder = self.factory.makeBuilder()
+        build = self.factory.makeLiveFSBuild(archive=archive)
+        build.updateStatus(BuildStatus.BUILDING, builder=builder)
+        build.queueBuild()
+        build.buildqueue_record.builder = builder
+        build.buildqueue_record.logtail = "tail of the log"
+        return build
+
+    def makeNonRedirectingBrowser(self, url, user=None):
+        browser = setupBrowserForUser(user) if user else setupBrowser()
+        browser.mech_browser.set_handle_equiv(False)
+        browser.open(url)
+        return browser
+
+    def test_builder_index_public(self):
+        build = self.makeBuildingLiveFS()
+        builder_url = canonical_url(build.builder)
+        logout()
+        browser = self.makeNonRedirectingBrowser(builder_url)
+        self.assertIn("tail of the log", browser.contents)
+
+    def test_builder_index_private(self):
+        archive = self.factory.makeArchive(private=True)
+        with admin_logged_in():
+            build = self.makeBuildingLiveFS(archive=archive)
+            builder_url = canonical_url(build.builder)
+        user = self.factory.makePerson()
+        logout()
+
+        # An unrelated user can't see the logtail of a private build.
+        browser = self.makeNonRedirectingBrowser(builder_url, user=user)
+        self.assertNotIn("tail of the log", browser.contents)
+
+        # But someone who can see the archive can.
+        browser = self.makeNonRedirectingBrowser(
+            builder_url, user=archive.owner)
+        self.assertIn("tail of the log", browser.contents)

=== 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-05-14 11:42:03 +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,68 @@
       <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"
+            set_attributes="date_last_modified"/>
+    </class>
+    <subscriber
+        for=".interfaces.livefs.ILiveFS zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+        handler=".model.livefs.livefs_modified"/>
+
+    <!-- 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"/>
+
+    <!-- LiveFSFile -->
+    <class class=".model.livefsbuild.LiveFSFile">
+        <allow
+            interface=".interfaces.livefsbuild.ILiveFSFile"/>
+    </class>
+
     <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-05-14 11:42:03 +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

=== modified file 'lib/lp/soyuz/interfaces/binarypackagebuild.py'
--- lib/lp/soyuz/interfaces/binarypackagebuild.py	2013-11-26 01:36:10 +0000
+++ lib/lp/soyuz/interfaces/binarypackagebuild.py	2014-05-14 11:42:03 +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).
 
 """BinaryPackageBuild interfaces."""
@@ -233,8 +233,8 @@
     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.
+        Check the can_be_cancelled property prior to calling this method 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

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,246 @@
+# 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__ = [
+    'DuplicateLiveFSName',
+    'ILiveFS',
+    'ILiveFSEditableAttributes',
+    'ILiveFSSet',
+    'ILiveFSView',
+    'LIVEFS_FEATURE_FLAG',
+    'LiveFSBuildAlreadyPending',
+    'LiveFSFeatureDisabled',
+    'LiveFSNotOwner',
+    ]
+
+import httplib
+
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+    call_with,
+    collection_default_content,
+    error_status,
+    export_as_webservice_collection,
+    export_as_webservice_entry,
+    export_factory_operation,
+    export_read_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    operation_returns_entry,
+    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.validators.name import name_validator
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.person import IPerson
+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.")
+
+
+@error_status(httplib.BAD_REQUEST)
+class DuplicateLiveFSName(Exception):
+    """Raised for live filesystems with duplicate name/owner/distroseries."""
+
+    def __init__(self):
+        super(DuplicateLiveFSName, self).__init__(
+            "There is already a live filesystem with the same name, owner, "
+            "and distroseries.")
+
+
+@error_status(httplib.UNAUTHORIZED)
+class LiveFSNotOwner(Unauthorized):
+    """The registrant/requester is not the owner or a member of its team."""
+
+
+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),
+        distro_arch_series=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 dict of data about the image."),
+            key_type=TextLine(), required=False))
+    # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+    @export_factory_operation(Interface, [])
+    @operation_for_version("devel")
+    def requestBuild(requester, archive, distro_arch_series, 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 distro_arch_series: 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)))
+
+    completed_builds = exported(doNotSnapshot(CollectionField(
+        title=_("Completed builds of this live filesystem."),
+        description=_(
+            "Completed builds of this live filesystem, sorted in descending "
+            "order of finishing."),
+        # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+        value_type=Reference(schema=Interface), readonly=True)))
+
+    pending_builds = exported(doNotSnapshot(CollectionField(
+        title=_("Pending builds of this live filesystem."),
+        description=_(
+            "Pending builds of this live filesystem, sorted in descending "
+            "order of creation."),
+        # Really ILiveFSBuild, patched in _schema_circular_imports.py.
+        value_type=Reference(schema=Interface), readonly=True)))
+
+
+class ILiveFSEditableAttributes(IHasOwner):
+    """`ILiveFS` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+    date_last_modified = exported(Datetime(
+        title=_("Date last modified"), required=True, readonly=True))
+
+    owner = exported(PersonChoice(
+        title=_("Owner"), required=True, readonly=False,
+        vocabulary="AllUserTeamsParticipationPlusSelf",
+        description=_("The owner of this live filesystem image.")))
+
+    distro_series = 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."""
+
+    # XXX cjwatson 2014-05-06 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(
+        singular_name="livefs", plural_name="livefses", as_of="beta")
+
+
+class ILiveFSSet(Interface):
+    """A utility to create and access live filesystems."""
+
+    export_as_webservice_collection(ILiveFS)
+
+    @call_with(registrant=REQUEST_USER)
+    @export_factory_operation(
+        ILiveFS, ["owner", "distro_series", "name", "metadata"])
+    @operation_for_version("devel")
+    def new(registrant, owner, distro_series, name, metadata,
+            date_created=None):
+        """Create an `ILiveFS`."""
+
+    def exists(owner, distro_series, name):
+        """Check to see if a matching live filesystem exists."""
+
+    @operation_parameters(
+        owner=Reference(IPerson, title=_("Owner"), required=True),
+        distro_series=Reference(
+            IDistroSeries, title=_("Distroseries"), required=True),
+        name=TextLine(title=_("Live filesystem name"), required=True))
+    @operation_returns_entry(ILiveFS)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getByName(owner, distro_series, name):
+        """Return the appropriate `ILiveFS` for the given objects."""
+
+    def interpret(owner_name, distribution_name, distro_series_name, name):
+        """Like `getByName`, but takes names of objects."""
+
+    @collection_default_content()
+    def getAll():
+        """Return all of the live filesystems in Launchpad.
+
+        :return: A (potentially empty) sequence of `ILiveFS` instances.
+        """

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,199 @@
+# 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_read_operation,
+    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))
+
+    distro_arch_series = 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_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`, `ILibraryFileAlias`,
+            `ILibraryFileContent`).
+        """
+
+    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`.
+        """
+
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getFileUrls():
+        """URLs for all the files produced by this build.
+
+        :return: A collection of URLs for this build."""
+
+
+class ILiveFSBuildEdit(Interface):
+    """`ILiveFSBuild` attributes that require launchpad.Edit."""
+
+    def addFile(lfa):
+        """Add a file to this build.
+
+        :param lfa: An `ILibraryFileAlias`.
+        :return: An `ILiveFSFile`.
+        """
+
+    @export_write_operation()
+    @operation_for_version("devel")
+    def cancel():
+        """Cancel the build if it is either pending or in progress.
+
+        Check the can_be_cancelled property prior to calling this method 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."""
+
+    # XXX cjwatson 2014-05-06 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(singular_name="livefs_build", as_of="beta")
+
+
+class ILiveFSBuildSet(ISpecificBuildFarmJobSource):
+    """Utility for `ILiveFSBuild`."""
+
+    def new(requester, livefs, archive, distro_arch_series, 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-05-14 11:42:03 +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.
@@ -29,6 +29,9 @@
     'IBinaryPackagePublishingHistory',
     'IBinaryPackageReleaseDownloadCount',
     'IDistroArchSeries',
+    'ILiveFS',
+    'ILiveFSBuild',
+    'ILiveFSSet',
     'IPackageUpload',
     'IPackageset',
     'IPackagesetSet',
@@ -86,6 +89,11 @@
     )
 from lp.soyuz.interfaces.buildrecords import IncompatibleArguments
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.livefs import (
+    ILiveFS,
+    ILiveFSSet,
+    )
+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-05-14 11:42:03 +0000
@@ -0,0 +1,89 @@
+# 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",
+            "livefsbuild-notification.txt", 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-status")
+        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.distro_series,
+            "architecturetag": build.distro_arch_series.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-05-14 11:42:03 +0000
@@ -0,0 +1,249 @@
+# 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.exceptions import IntegrityError
+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,
+    UTC_NOW,
+    )
+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 (
+    DuplicateLiveFSName,
+    ILiveFS,
+    ILiveFSSet,
+    LIVEFS_FEATURE_FLAG,
+    LiveFSBuildAlreadyPending,
+    LiveFSFeatureDisabled,
+    LiveFSNotOwner,
+    )
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
+from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.livefsbuild import LiveFSBuild
+
+
+def livefs_modified(livefs, event):
+    """Update the date_last_modified property when a LiveFS is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on live filesystems.
+    """
+    livefs.date_last_modified = UTC_NOW
+
+
+class LiveFS(Storm):
+    """See `ILiveFS`."""
+
+    __storm_table__ = 'LiveFS'
+
+    implements(ILiveFS, IHasOwner)
+
+    id = Int(primary=True)
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+    date_last_modified = DateTime(
+        name='date_last_modified', 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')
+
+    distro_series_id = Int(name='distro_series', allow_none=False)
+    distro_series = Reference(distro_series_id, 'DistroSeries.id')
+
+    name = Unicode(name='name', allow_none=False)
+
+    metadata = JSON('json_data')
+
+    def __init__(self, registrant, owner, distro_series, 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.distro_series = distro_series
+        self.name = name
+        self.metadata = metadata
+        self.date_created = date_created
+        self.date_last_modified = date_created
+
+    def requestBuild(self, requester, archive, distro_arch_series, pocket,
+                     unique_key=None, metadata_override=None):
+        """See `ILiveFS`."""
+        if not requester.inTeam(self.owner):
+            raise LiveFSNotOwner(
+                "%s cannot create live filesystem builds owned by %s." %
+                (requester.displayname, self.owner.displayname))
+
+        pending = IStore(self).find(
+            LiveFSBuild,
+            LiveFSBuild.livefs_id == self.id,
+            LiveFSBuild.archive_id == archive.id,
+            LiveFSBuild.distro_arch_series_id == distro_arch_series.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, distro_arch_series, 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 completed_builds(self):
+        """See `ILiveFS`."""
+        filter_term = (LiveFSBuild.status != BuildStatus.NEEDSBUILD)
+        order_by = (
+            Desc(Greatest(
+                LiveFSBuild.date_started,
+                LiveFSBuild.date_finished)),
+            Desc(LiveFSBuild.id))
+        return self._getBuilds(filter_term, order_by)
+
+    @property
+    def pending_builds(self):
+        """See `ILiveFS`."""
+        filter_term = (LiveFSBuild.status == BuildStatus.NEEDSBUILD)
+        # We want to order by date_created but this is the same as ordering
+        # by id (since id increases monotonically) and is less expensive.
+        order_by = Desc(LiveFSBuild.id)
+        return self._getBuilds(filter_term, order_by)
+
+
+class LiveFSSet:
+    """See `ILiveFSSet`."""
+
+    implements(ILiveFSSet)
+
+    def new(self, registrant, owner, distro_series, name, metadata,
+            date_created=DEFAULT):
+        """See `ILiveFSSet`."""
+        if not registrant.inTeam(owner):
+            if owner.is_team:
+                raise LiveFSNotOwner(
+                    "%s is not a member of %s." %
+                    (registrant.displayname, owner.displayname))
+            else:
+                raise LiveFSNotOwner(
+                    "%s cannot create live filesystems owned by %s." %
+                    (registrant.displayname, owner.displayname))
+
+        store = IMasterStore(LiveFS)
+        livefs = LiveFS(
+            registrant, owner, distro_series, name, metadata, date_created)
+        store.add(livefs)
+
+        try:
+            store.flush()
+        except IntegrityError:
+            raise DuplicateLiveFSName
+
+        return livefs
+
+    def exists(self, owner, distro_series, name):
+        """See `ILiveFSSet`."""
+        return self.getByName(owner, distro_series, name) is not None
+
+    def getByName(self, owner, distro_series, name):
+        """See `ILiveFSSet`."""
+        store = IStore(LiveFS)
+        return store.find(
+            LiveFS,
+            LiveFS.owner == owner,
+            LiveFS.distro_series == distro_series,
+            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 interpret(self, owner_name, distribution_name, distro_series_name,
+                  name):
+        """See `ILiveFSSet`."""
+        owner = self._findOrRaise(
+            NoSuchPerson, owner_name, getUtility(IPersonSet).getByName)
+        distribution = self._findOrRaise(
+            NoSuchDistribution, distribution_name,
+            getUtility(IDistributionSet).getByName)
+        distro_series = self._findOrRaise(
+            NoSuchDistroSeries, distro_series_name,
+            getUtility(IDistroSeriesSet).queryByName, distribution)
+        return self.getByName(owner, distro_series, name)
+
+    def getAll(self):
+        """See `ILiveFSSet`."""
+        store = IStore(LiveFS)
+        return store.find(LiveFS).order_by("name")

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,389 @@
+# 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,
+    Desc,
+    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,
+    LibraryFileContent,
+    )
+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')
+
+    def __init__(self, livefsbuild, libraryfile):
+        """Construct a `LiveFSFile`."""
+        super(LiveFSFile, self).__init__()
+        self.livefsbuild = livefsbuild
+        self.libraryfile = libraryfile
+
+
+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')
+
+    distro_arch_series_id = Int(name='distro_arch_series', allow_none=False)
+    distro_arch_series = Reference(
+        distro_arch_series_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,
+                 distro_arch_series, 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.distro_arch_series = distro_arch_series
+        self.pocket = pocket
+        self.processor = processor
+        self.virtualized = virtualized
+        self.unique_key = unique_key
+        self.metadata_override = metadata_override
+        self.date_created = date_created
+        self.status = BuildStatus.NEEDSBUILD
+
+    @property
+    def is_private(self):
+        """See `IBuildFarmJob`."""
+        return self.livefs.owner.private or self.archive.private
+
+    @property
+    def is_virtualized(self):
+        """See `ILiveFSBuild`."""
+        return self.archive.require_virtualized
+
+    @property
+    def title(self):
+        das = self.distro_arch_series
+        name = self.livefs.name
+        if self.unique_key is not None:
+            name += " (%s)" % self.unique_key
+        return "%s build of %s live filesystem 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.distro_arch_series.distroseries.distribution
+
+    @property
+    def distro_series(self):
+        """See `IPackageBuild`."""
+        return self.distro_arch_series.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_rescored(self):
+        """See `ILiveFSBuild`."""
+        return (
+            self.buildqueue_record is not None and
+            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 our successful builds."""
+        store = IStore(self)
+        result = store.find(
+            (LiveFSBuild.date_started, LiveFSBuild.date_finished),
+            LiveFSBuild.livefs == self.livefs_id,
+            LiveFSBuild.distro_arch_series == self.distro_arch_series_id,
+            LiveFSBuild.status == BuildStatus.FULLYBUILT)
+        result.order_by(Desc(LiveFSBuild.date_finished))
+        durations = [row[1] - row[0] for row in result[:9]]
+        if len(durations) == 0:
+            return None
+        durations.sort()
+        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, LibraryFileAlias, LibraryFileContent),
+            LiveFSFile.livefsbuild == self.id,
+            LibraryFileAlias.id == LiveFSFile.libraryfile_id,
+            LibraryFileContent.id == LibraryFileAlias.contentID)
+        return result.order_by(
+            [LibraryFileAlias.filename, LiveFSFile.id]).config()
+
+    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(
+                LibraryFileAlias,
+                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 addFile(self, lfa):
+        """See `ILiveFSBuild`."""
+        livefsfile = LiveFSFile(livefsbuild=self, libraryfile=lfa)
+        IMasterStore(LiveFSFile).add(livefsfile)
+        return livefsfile
+
+    def verifySuccessfulUpload(self):
+        """See `IPackageBuild`."""
+        return not self.getFiles().is_empty()
+
+    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 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)
+
+    def getFileUrls(self):
+        return [self.lfaUrl(lfa) for _, lfa, _ in self.getFiles()]
+
+
+class LiveFSBuildSet(SpecificBuildFarmJobSourceMixin):
+    implements(ILiveFSBuildSet)
+
+    def new(self, requester, livefs, archive, distro_arch_series, 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, distro_arch_series,
+            pocket, distro_arch_series.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.get(LiveFSBuild, build_id)
+
+    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-05-14 11:42:03 +0000
@@ -0,0 +1,147 @@
+# 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 zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.interfaces.builder import CannotBuild
+from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
+    IBuildFarmJobBehaviour,
+    )
+from lp.buildmaster.model.buildfarmjobbehaviour import (
+    BuildFarmJobBehaviourBase,
+    )
+from lp.registry.interfaces.series import SeriesStatus
+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.distro_arch_series
+        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.distro_arch_series.getChroot()
+        if chroot is None:
+            raise CannotBuild(
+                "Missing chroot for %s" % build.distro_arch_series.displayname)
+
+    def _extraBuildArgs(self):
+        """
+        Return the extra arguments required by the slave for the given build.
+        """
+        build = self.build
+        # Non-trivial metadata values may have been security-wrapped, which
+        # is pointless here and just gets in the way of xmlrpclib
+        # serialisation.
+        args = dict(removeSecurityProxy(build.livefs.metadata))
+        if build.metadata_override is not None:
+            args.update(removeSecurityProxy(build.metadata_override))
+        args["suite"] = build.distro_series.getSuite(build.pocket)
+        args["arch_tag"] = build.distro_arch_series.architecturetag
+        args["datestamp"] = build.version
+        args["archives"] = get_sources_list_for_building(
+            build, build.distro_arch_series, 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.
+        distro_arch_series = self.build.distro_arch_series
+        chroot = distro_arch_series.getChroot()
+        if chroot is None:
+            raise CannotBuild(
+                "Unable to find a chroot for %s" %
+                distro_arch_series.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)
+
+    def verifySuccessfulBuild(self):
+        """See `IBuildFarmJobBehaviour`."""
+        # The implementation in BuildFarmJobBehaviourBase checks whether the
+        # target suite is modifiable in the target archive.  However, a
+        # `LiveFSBuild`'s archive is a source rather than a target, so that
+        # check does not make sense.  We do, however, refuse to build for
+        # obsolete series.
+        assert self.build.distro_series.status != SeriesStatus.OBSOLETE

=== added file 'lib/lp/soyuz/templates/livefs-index.pt'
--- lib/lp/soyuz/templates/livefs-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/livefs-index.pt	2014-05-14 11:42:03 +0000
@@ -0,0 +1,109 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_side"
+  i18n:domain="launchpad"
+>
+
+<body>
+  <metal:registering fill-slot="registering">
+    Created by
+      <tal:registrant replace="structure context/registrant/fmt:link"/>
+    on
+      <tal:created-on replace="structure context/date_created/fmt:date"/>
+    and last modified on
+      <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
+  </metal:registering>
+
+  <metal:side fill-slot="side">
+    <div tal:replace="structure context/@@+global-actions"/>
+  </metal:side>
+
+  <metal:heading fill-slot="heading">
+    <h1 tal:replace="structure view/name_widget"/>
+  </metal:heading>
+
+  <div metal:fill-slot="main">
+    <h2>Live filesystem information</h2>
+    <div class="two-column-list">
+      <dl id="owner">
+        <dt>Owner:</dt>
+        <dd tal:content="structure view/person_picker"/>
+      </dl>
+      <dl id="distro_series">
+        <dt>Distribution series:</dt>
+        <dd tal:define="distro_series context/distro_series">
+          <a tal:attributes="href distro_series/fmt:url"
+             tal:content="distro_series/fullseriesname"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+      <dl id="metadata">
+        <dt>Metadata:</dt>
+        <dd>
+          <table class="listing compressed">
+            <tbody>
+              <tr tal:repeat="pair view/sorted_metadata_items">
+                <td tal:repeat="value pair" tal:content="value"/>
+              </tr>
+            </tbody>
+          </table>
+        </dd>
+      </dl>
+    </div>
+
+    <h2>Latest builds</h2>
+    <table id="latest-builds-listing" class="listing"
+           style="margin-bottom: 1em;">
+      <thead>
+        <tr>
+          <th>Status</th>
+          <th>When complete</th>
+          <th>Architecture</th>
+          <th>Archive</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tal:livefs-builds repeat="build view/builds">
+          <tal:build-view define="buildview nocall:build/@@+index">
+            <tr tal:attributes="id string:build-${build/id}">
+              <td>
+                <span tal:replace="structure build/image:icon"/>
+                <a tal:content="build/status/title"
+                   tal:attributes="href build/fmt:url"/>
+              </td>
+              <td>
+                <tal:date replace="buildview/date/fmt:displaydate"/>
+                <tal:estimate condition="buildview/estimate">
+                  (estimated)
+                </tal:estimate>
+
+                <tal:build-log define="file build/log" tal:condition="file">
+                  <a class="sprite download"
+                     tal:attributes="href build/log_url">buildlog</a>
+                  (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+                </tal:build-log>
+              </td>
+              <td>
+                <a class="sprite distribution"
+                   tal:define="archseries build/distro_arch_series"
+                   tal:attributes="href archseries/fmt:url"
+                   tal:content="archseries/architecturetag"/>
+              </td>
+              <td>
+                <tal:archive replace="structure build/archive/fmt:link"/>
+              </td>
+            </tr>
+          </tal:build-view>
+        </tal:livefs-builds>
+      </tbody>
+    </table>
+    <p tal:condition="not: view/builds">
+      This live filesystem has not been built yet.
+    </p>
+  </div>
+
+</body>
+</html>

=== added file 'lib/lp/soyuz/templates/livefs-new.pt'
--- lib/lp/soyuz/templates/livefs-new.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/livefs-new.pt	2014-05-14 11:42:03 +0000
@@ -0,0 +1,45 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_side"
+  i18n:domain="launchpad"
+>
+
+<body>
+  <div metal:fill-slot="main">
+    <div>
+      <p>
+        A live filesystem image is a copy of an operating system that can
+        start from a removable medium such as a DVD or a USB drive, without
+        needing to be installed to a hard disk. It is typically used as part
+        of installation media released by distributions.
+      </p>
+      <p>
+        Launchpad can build a limited variety of live filesystem images
+        using <tt>live-build</tt> and <tt>livecd-rootfs</tt>.
+      </p>
+    </div>
+
+    <div metal:use-macro="context/@@launchpad_form/form">
+      <metal:formbody fill-slot="widgets">
+        <table class="form">
+          <tal:widget define="widget nocall:view/widgets/name">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/owner">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/distro_series">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/metadata">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+        </table>
+      </metal:formbody>
+    </div>
+  </div>
+</body>
+</html>

=== added file 'lib/lp/soyuz/templates/livefsbuild-index.pt'
--- lib/lp/soyuz/templates/livefsbuild-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/livefsbuild-index.pt	2014-05-14 11:42:03 +0000
@@ -0,0 +1,205 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+
+  <body>
+
+    <tal:registering metal:fill-slot="registering">
+        created
+        <span tal:content="context/date_created/fmt:displaydate"
+              tal:attributes="title context/date_created/fmt:datetime"/>
+    </tal:registering>
+
+    <div metal:fill-slot="main">
+
+      <div class="yui-g">
+
+        <div id="status" class="yui-u first">
+          <div class="portlet">
+            <div metal:use-macro="template/macros/status"/>
+          </div>
+        </div>
+
+        <div id="details" class="yui-u">
+          <div class="portlet">
+            <div metal:use-macro="template/macros/details"/>
+          </div>
+        </div>
+
+      </div> <!-- yui-g  -->
+
+      <div id="files" class="portlet" tal:condition="view/has_files">
+        <div metal:use-macro="template/macros/files"/>
+      </div>
+
+      <div id="buildlog" class="portlet"
+           tal:condition="context/status/enumvalue:BUILDING">
+        <div metal:use-macro="template/macros/buildlog"/>
+      </div>
+
+   </div> <!-- main -->
+
+
+<metal:macros fill-slot="bogus">
+
+  <metal:macro define-macro="details">
+    <tal:comment replace="nothing">
+      Details section.
+    </tal:comment>
+    <h2>Build details</h2>
+    <div class="two-column-list">
+      <dl>
+        <dt>Live filesystem:</dt>
+          <dd>
+            <tal:livefs replace="structure context/livefs/fmt:link"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Archive:</dt>
+          <dd>
+            <span tal:replace="structure context/archive/fmt:link"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Series:</dt>
+          <dd><a class="sprite distribution"
+                 tal:define="series context/distro_series"
+                 tal:attributes="href series/fmt:url"
+                 tal:content="series/displayname"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Architecture:</dt>
+          <dd><a class="sprite distribution"
+                 tal:define="archseries context/distro_arch_series"
+                 tal:attributes="href archseries/fmt:url"
+                 tal:content="archseries/architecturetag"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Pocket:</dt>
+          <dd><span tal:replace="context/pocket/title"/></dd>
+      </dl>
+    </div>
+  </metal:macro>
+
+  <metal:macro define-macro="status">
+    <tal:comment replace="nothing">
+      Status section.
+    </tal:comment>
+    <h2>Build status</h2>
+    <p>
+      <span tal:replace="structure context/image:icon" />
+      <span tal:attributes="
+            class string:buildstatus${context/status/name};"
+            tal:content="context/status/title"/>
+      <tal:building condition="context/status/enumvalue:BUILDING">
+        on <a tal:content="context/buildqueue_record/builder/title"
+              tal:attributes="href context/buildqueue_record/builder/fmt:url"/>
+      </tal:building>
+      <tal:built condition="context/builder">
+        on <a tal:content="context/builder/title"
+              tal:attributes="href context/builder/fmt:url"/>
+      </tal:built>
+      <tal:cancel define="link context/menu:context/cancel"
+                  condition="link/enabled"
+                  replace="structure link/fmt:link" />
+    </p>
+
+    <ul>
+      <li tal:condition="context/dependencies">
+        Missing build dependencies: <em tal:content="context/dependencies"/>
+     </li>
+      <tal:reallypending condition="context/buildqueue_record">
+      <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING">
+        <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime">
+          Start <tal:eta replace="eta/fmt:approximatedate"/>
+          (<span tal:replace="context/buildqueue_record/lastscore"/>)
+          <a href="https://help.launchpad.net/Packaging/BuildScores";
+             target="_blank">What's this?</a>
+        </li>
+      </tal:pending>
+      </tal:reallypending>
+      <tal:started condition="context/date_started">
+        <li tal:condition="context/date_started">
+          Started <span
+           tal:define="start context/date_started"
+           tal:attributes="title start/fmt:datetime"
+           tal:content="start/fmt:displaydate"/>
+        </li>
+      </tal:started>
+      <tal:finish condition="not: context/date_finished">
+        <li tal:define="eta view/eta" tal:condition="view/eta">
+          Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
+        </li>
+      </tal:finish>
+
+      <li tal:condition="context/date_finished">
+        Finished <span
+          tal:attributes="title context/date_finished/fmt:datetime"
+          tal:content="context/date_finished/fmt:displaydate"/>
+        <tal:duration condition="context/duration">
+          (took <span tal:replace="context/duration/fmt:exactduration"/>)
+        </tal:duration>
+      </li>
+      <li tal:define="file context/log"
+          tal:condition="file">
+        <a class="sprite download"
+           tal:attributes="href context/log_url">buildlog</a>
+        (<span tal:replace="file/content/filesize/fmt:bytes" />)
+      </li>
+      <li tal:define="file context/upload_log"
+          tal:condition="file">
+        <a class="sprite download"
+           tal:attributes="href context/upload_log_url">uploadlog</a>
+        (<span tal:replace="file/content/filesize/fmt:bytes" />)
+      </li>
+    </ul>
+
+    <div
+      style="margin-top: 1.5em"
+      tal:define="link context/menu:context/rescore"
+      tal:condition="link/enabled"
+      >
+      <a tal:replace="structure link/fmt:link"/>
+    </div>
+  </metal:macro>
+
+  <metal:macro define-macro="files">
+    <tal:comment replace="nothing">
+      Files section.
+    </tal:comment>
+    <h2>Built files</h2>
+    <p>Files resulting from this build:</p>
+    <ul>
+      <li tal:repeat="file view/files">
+        <a class="sprite download"
+           tal:content="file/filename"
+           tal:attributes="href file/http_url"/>
+        (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+      </li>
+    </ul>
+  </metal:macro>
+
+  <metal:macro define-macro="buildlog">
+    <tal:comment replace="nothing">
+      Buildlog section.
+    </tal:comment>
+    <h2>Buildlog</h2>
+    <div id="buildlog-tail" class="logtail"
+         tal:define="logtail context/buildqueue_record/logtail"
+         tal:content="structure logtail/fmt:text-to-html"/>
+    <p class="lesser" tal:condition="view/user">
+      Updated on <span tal:replace="structure view/user/fmt:local-time"/>
+    </p>
+  </metal:macro>
+
+</metal:macros>
+
+  </body>
+</html>

=== modified file 'lib/lp/soyuz/tests/test_binarypackagebuildbehaviour.py'
--- lib/lp/soyuz/tests/test_binarypackagebuildbehaviour.py	2014-02-03 14:43:08 +0000
+++ lib/lp/soyuz/tests/test_binarypackagebuildbehaviour.py	2014-05-14 11:42:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 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).
 
 """Tests for BinaryPackageBuildBehaviour."""
@@ -39,6 +39,7 @@
 from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
     TestGetUploadMethodsMixin,
     TestHandleStatusMixin,
+    TestVerifySuccessfulBuildMixin,
     )
 from lp.buildmaster.tests.test_manager import MockBuilderFactory
 from lp.registry.interfaces.pocket import (
@@ -537,6 +538,13 @@
         build.queueBuild()
         return build
 
+    def makeUnmodifiableBuild(self):
+        build = self.factory.makeBinaryPackageBuild()
+        build.distro_series.status = SeriesStatus.CURRENT
+        build.updateStatus(BuildStatus.BUILDING)
+        build.queueBuild()
+        return build
+
 
 class TestGetUploadMethodsForBinaryPackageBuild(
     MakeBinaryPackageBuildMixin, TestGetUploadMethodsMixin,
@@ -544,6 +552,12 @@
     """IPackageBuild.getUpload-related methods work with binary builds."""
 
 
+class TestVerifySuccessfulBuildForBinaryPackageBuild(
+    MakeBinaryPackageBuildMixin, TestVerifySuccessfulBuildMixin,
+    TestCaseWithFactory):
+    """IBuildFarmJobBehaviour.verifySuccessfulBuild works."""
+
+
 class TestHandleStatusForBinaryPackageBuild(
     MakeBinaryPackageBuildMixin, TestHandleStatusMixin, TrialTestCase):
     """IPackageBuild.handleStatus works with binary builds."""

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,498 @@
+# 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 (
+    datetime,
+    timedelta,
+    )
+
+from lazr.lifecycle.event import ObjectModifiedEvent
+import pytz
+from storm.locals import Store
+from testtools.matchers import Equals
+import transaction
+from zope.component import getUtility
+from zope.event import notify
+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.database.constants import UTC_NOW
+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.
+        person = self.factory.makePerson()
+        self.assertRaises(
+            LiveFSFeatureDisabled, getUtility(ILiveFSSet).new,
+            person, person, 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_snapshots(self):
+        self.assertThat(
+            self.factory.makeLiveFS(),
+            DoesNotSnapshot(
+                ["builds", "completed_builds", "pending_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),
+            distro_series=self.factory.makeDistroSeries(),
+            name=self.factory.getUniqueString(u"livefs-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["distro_series"], livefs.distro_series)
+        self.assertEqual(components["name"], livefs.name)
+        self.assertEqual(components["metadata"], livefs.metadata)
+
+    def test_initial_date_last_modified(self):
+        # The initial value of date_last_modified is date_created.
+        livefs = self.factory.makeLiveFS(
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+        self.assertEqual(livefs.date_created, livefs.date_last_modified)
+
+    def test_modifiedevent_sets_date_last_modified(self):
+        # When a LiveFS receives an object modified event, the last modified
+        # date is set to UTC_NOW.
+        livefs = self.factory.makeLiveFS(
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+        notify(ObjectModifiedEvent(
+            removeSecurityProxy(livefs), livefs, [ILiveFS["name"]]))
+        self.assertSqlAttributeEqualsDate(
+            livefs, "date_last_modified", UTC_NOW)
+
+    def test_exists(self):
+        # ILiveFSSet.exists checks for matching LiveFSes.
+        livefs = self.factory.makeLiveFS()
+        self.assertTrue(
+            getUtility(ILiveFSSet).exists(
+                livefs.owner, livefs.distro_series, livefs.name))
+        self.assertFalse(
+            getUtility(ILiveFSSet).exists(
+                self.factory.makePerson(), livefs.distro_series, livefs.name))
+        self.assertFalse(
+            getUtility(ILiveFSSet).exists(
+                livefs.owner, self.factory.makeDistroSeries(), livefs.name))
+        self.assertFalse(
+            getUtility(ILiveFSSet).exists(
+                livefs.owner, livefs.distro_series, u"different"))
+
+    def test_requestBuild(self):
+        # requestBuild creates a new LiveFSBuild.
+        livefs = self.factory.makeLiveFS()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=livefs.distro_series)
+        build = livefs.requestBuild(
+            livefs.owner, livefs.distro_series.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertTrue(ILiveFSBuild.providedBy(build))
+        self.assertEqual(livefs.owner, build.requester)
+        self.assertEqual(livefs.distro_series.main_archive, build.archive)
+        self.assertEqual(distroarchseries, build.distro_arch_series)
+        self.assertEqual(PackagePublishingPocket.RELEASE, build.pocket)
+        self.assertIsNone(build.unique_key)
+        self.assertIsNone(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.distro_series.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.distro_series)
+        build = livefs.requestBuild(
+            livefs.owner, livefs.distro_series.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.distro_series)
+        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.distro_series)
+        old_build = livefs.requestBuild(
+            livefs.owner, livefs.distro_series.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertRaises(
+            LiveFSBuildAlreadyPending, livefs.requestBuild,
+            livefs.owner, livefs.distro_series.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.distro_series.main_archive,
+            self.factory.makeDistroArchSeries(
+                distroseries=livefs.distro_series),
+            PackagePublishingPocket.RELEASE)
+        # Changing the status of the old build allows a new build.
+        old_build.updateStatus(BuildStatus.BUILDING)
+        old_build.updateStatus(BuildStatus.FULLYBUILT)
+        livefs.requestBuild(
+            livefs.owner, livefs.distro_series.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.assertEqual([], list(livefs.completed_builds))
+        self.assertEqual(builds, list(livefs.pending_builds))
+
+        # Change the status of one of the builds and retest.
+        builds[0].updateStatus(BuildStatus.BUILDING)
+        builds[0].updateStatus(BuildStatus.FULLYBUILT)
+        self.assertEqual(builds[1:] + builds[:1], list(livefs.builds))
+        self.assertEqual(builds[:1], list(livefs.completed_builds))
+        self.assertEqual(builds[1:], list(livefs.pending_builds))
+
+
+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(displayname="Test Person")
+        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, owner=None, distroseries=None, metadata=None,
+                   webservice=None):
+        if owner is None:
+            owner = self.person
+        if distroseries is None:
+            distroseries = self.factory.makeDistroSeries(registrant=owner)
+        if metadata is None:
+            metadata = {"project": "flavour"}
+        if webservice is None:
+            webservice = self.webservice
+        transaction.commit()
+        distroseries_url = api_url(distroseries)
+        owner_url = api_url(owner)
+        logout()
+        response = webservice.named_post(
+            "/livefses", "new", owner=owner_url,
+            distro_series=distroseries_url, name="flavour-desktop",
+            metadata=metadata)
+        self.assertEqual(201, response.status)
+        livefs = 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_new(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["distro_series_link"])
+            self.assertEqual({"project": "flavour"}, livefs["metadata"])
+
+    def test_duplicate(self):
+        # An attempt to create a duplicate LiveFS fails.
+        team = self.factory.makeTeam(owner=self.person)
+        _, distroseries_url = self.makeLiveFS(owner=team)
+        with person_logged_in(self.person):
+            owner_url = api_url(team)
+        response = self.webservice.named_post(
+            "/livefses", "new", owner=owner_url,
+            distro_series=distroseries_url, name="flavour-desktop",
+            metadata={})
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "There is already a live filesystem with the same name, owner, "
+            "and distroseries.", response.body)
+
+    def test_not_owner(self):
+        # If the registrant is not the owner or a member of the owner team,
+        # LiveFS creation fails.
+        other_person = self.factory.makePerson(displayname="Other Person")
+        other_team = self.factory.makeTeam(
+            owner=other_person, displayname="Other Team")
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        transaction.commit()
+        other_person_url = api_url(other_person)
+        other_team_url = api_url(other_team)
+        distroseries_url = api_url(distroseries)
+        logout()
+        response = self.webservice.named_post(
+            "/livefses", "new", owner=other_person_url,
+            distro_series=distroseries_url, name="dummy", metadata={})
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            "Test Person cannot create live filesystems owned by Other "
+            "Person.", response.body)
+        response = self.webservice.named_post(
+            "/livefses", "new", owner=other_team_url,
+            distro_series=distroseries_url, name="dummy", metadata={})
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            "Test Person is not a member of Other Team.", response.body)
+
+    def test_requestBuild(self):
+        # Build requests can be performed and end up in livefs.builds and
+        # livefs.pending_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, _ = self.makeLiveFS(distroseries=distroseries)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+        build = self.webservice.get(response.getHeader("Location")).jsonBody()
+        self.assertEqual(
+            [build["self_link"]], self.getCollectionLinks(livefs, "builds"))
+        self.assertEqual(
+            [], self.getCollectionLinks(livefs, "completed_builds"))
+        self.assertEqual(
+            [build["self_link"]],
+            self.getCollectionLinks(livefs, "pending_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, _ = self.makeLiveFS(distroseries=distroseries)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=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_requestBuild_not_owner(self):
+        # If the requester is not the owner or a member of the owner team,
+        # build requests are rejected.
+        other_team = self.factory.makeTeam(displayname="Other Team")
+        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)
+        other_webservice = webservice_for_person(
+            other_team.teamowner, permission=OAuthPermission.WRITE_PUBLIC)
+        other_webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+        livefs, _ = self.makeLiveFS(
+            owner=other_team, distroseries=distroseries,
+            webservice=other_webservice)
+        response = self.webservice.named_post(
+            livefs["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            "Test Person cannot create live filesystem builds owned by Other "
+            "Team.", response.body)
+
+    def test_getBuilds(self):
+        # The builds, completed_builds, and pending_builds properties 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, _ = self.makeLiveFS(distroseries=distroseries)
+        builds = []
+        for archive_url in archive_urls:
+            response = self.webservice.named_post(
+                livefs["self_link"], "requestBuild", archive=archive_url,
+                distro_arch_series=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"))
+        self.assertEqual(
+            [], self.getCollectionLinks(livefs, "completed_builds"))
+        self.assertEqual(
+            builds, self.getCollectionLinks(livefs, "pending_builds"))
+        livefs = self.webservice.get(livefs["self_link"]).jsonBody()
+
+        with person_logged_in(self.person):
+            db_livefs = getUtility(ILiveFSSet).getByName(
+                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()
+        # Builds that have not yet been started are listed first (since DESC
+        # defaults to NULLS FIRST).
+        self.assertEqual(
+            builds[1:] + builds[:1], self.getCollectionLinks(livefs, "builds"))
+        self.assertEqual(
+            builds[:1], self.getCollectionLinks(livefs, "completed_builds"))
+        self.assertEqual(
+            builds[1:], self.getCollectionLinks(livefs, "pending_builds"))
+
+        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[2], builds[3], builds[1], builds[0]],
+            self.getCollectionLinks(livefs, "builds"))
+        self.assertEqual(
+            [builds[1], builds[0]],
+            self.getCollectionLinks(livefs, "completed_builds"))
+        self.assertEqual(
+            builds[2:], self.getCollectionLinks(livefs, "pending_builds"))
+
+    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(21)))

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,494 @@
+# 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,
+    )
+from urllib2 import (
+    HTTPError,
+    urlopen,
+    )
+
+import pytz
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import Browser
+from zope.testbrowser.testing import PublisherMechanizeBrowser
+
+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.registry.enums import PersonVisibility
+from lp.services.config import config
+from lp.services.features.testing import FeatureFixture
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
+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.soyuz.interfaces.processor import IProcessorSet
+from lp.testing import (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    LaunchpadFunctionalLayer,
+    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)
+
+
+expected_body = """\
+ * Live Filesystem: livefs-1
+ * Version: 20140425-103800
+ * Archive: distro primary archive
+ * Distroseries: distro unstable
+ * Architecture: i386
+ * Pocket: RELEASE
+ * State: Failed to build
+ * Duration: 10 minutes
+ * Build Log: %s
+ * Upload Log: %s
+ * Builder: http://launchpad.dev/builders/bob
+"""
+
+
+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 LiveFS and archive are.
+        self.assertFalse(self.build.is_private)
+        private_team = self.factory.makeTeam(
+            visibility=PersonVisibility.PRIVATE)
+        with person_logged_in(private_team.teamowner):
+            build = self.factory.makeLiveFSBuild(
+                requester=private_team.teamowner, owner=private_team)
+            self.assertTrue(build.is_private)
+        private_archive = self.factory.makeArchive(private=True)
+        with person_logged_in(private_archive.owner):
+            build = self.factory.makeLiveFSBuild(archive=private_archive)
+            self.assertTrue(build.is_private)
+
+    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 successful builds of the same live filesystem are used
+        # for estimates.
+        self.factory.makeLiveFSBuild(
+            requester=self.build.requester, livefs=self.build.livefs,
+            distroarchseries=self.build.distro_arch_series,
+            status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
+        for i in range(3):
+            self.factory.makeLiveFSBuild(
+                requester=self.build.requester, livefs=self.build.livefs,
+                distroarchseries=self.build.distro_arch_series,
+                status=BuildStatus.FAILEDTOBUILD,
+                duration=timedelta(seconds=20))
+        self.assertEqual(335, self.build.estimateDuration().seconds)
+
+    def test_getFileByName_logs(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))
+
+    def test_getFileByName_uploaded_files(self):
+        # getFileByName returns uploaded files when requested by name.
+        filenames = ("ubuntu.squashfs", "ubuntu.manifest")
+        lfas = []
+        for filename in filenames:
+            lfa = self.factory.makeLibraryFileAlias(filename=filename)
+            lfas.append(lfa)
+            self.build.addFile(lfa)
+        self.assertContentEqual(
+            lfas, [row[1] for row in self.build.getFiles()])
+        for filename, lfa in zip(filenames, lfas):
+            self.assertEqual(lfa, self.build.getFileByName(filename))
+        self.assertRaises(NotFoundError, self.build.getFileByName, "missing")
+
+    def test_verifySuccessfulUpload(self):
+        self.assertFalse(self.build.verifySuccessfulUpload())
+        self.factory.makeLiveFSFile(livefsbuild=self.build)
+        self.assertTrue(self.build.verifySuccessfulUpload())
+
+    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, status=BuildStatus.FULLYBUILT)
+        build.notify()
+        self.assertEqual(0, len(pop_notifications()))
+
+    def test_notify_packagefail(self):
+        # notify sends mail when a LiveFSBuild fails.
+        person = self.factory.makePerson(name="person")
+        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(
+            name=u"livefs-1", requester=person, owner=person,
+            distroarchseries=distroarchseries,
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC),
+            status=BuildStatus.FAILEDTOBUILD,
+            builder=self.factory.makeBuilder(name="bob"),
+            duration=timedelta(minutes=10))
+        build.setLog(self.factory.makeLibraryFileAlias())
+        build.notify()
+        [notification] = pop_notifications()
+        self.assertEqual(
+            config.canonical.noreply_from_address, notification["From"])
+        self.assertEqual(
+            "Person <%s>" % person.preferredemail.email, notification["To"])
+        subject = notification["Subject"].replace("\n ", " ")
+        self.assertEqual(
+            "[LiveFS build #%d] i386 build of livefs-1 live filesystem in "
+            "distro unstable RELEASE" % build.id, subject)
+        self.assertEqual(
+            "Requester", notification["X-Launchpad-Message-Rationale"])
+        self.assertEqual(
+            "livefs-build-status",
+            notification["X-Launchpad-Notification-Type"])
+        self.assertEqual(
+            "FAILEDTOBUILD", notification["X-Launchpad-Build-State"])
+        body, footer = notification.get_payload(decode=True).split("\n-- \n")
+        self.assertEqual(expected_body % (build.log_url, ""), body)
+        self.assertEqual(
+            "http://launchpad.dev/~person/+livefs/distro/unstable/livefs-1/";
+            "+livefsbuild/%d\n"
+            "You are the requester of the build.\n" % build.id, footer)
+
+    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/+livefs/%s/%s/%s/+livefsbuild/%d/+files/";
+            "mybuildlog.txt" % (
+                self.build.livefs.owner.name, self.build.distribution.name,
+                self.build.distro_series.name, self.build.livefs.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 NonRedirectingMechanizeBrowser(PublisherMechanizeBrowser):
+    """A `mechanize.Browser` that does not handle redirects."""
+
+    default_features = [
+        feature for feature in PublisherMechanizeBrowser.default_features
+        if feature != "_redirect"]
+
+
+class TestLiveFSBuildWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    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_PRIVATE)
+        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.distro_arch_series),
+                build["distro_arch_series_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.assertFalse(build["can_be_rescored"])
+            self.assertFalse(build["can_be_cancelled"])
+
+    def test_public(self):
+        # A LiveFSBuild with a public LiveFS and archive is itself public.
+        db_build = self.factory.makeLiveFSBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(200, unpriv_webservice.get(build_url).status)
+
+    def test_private_livefs(self):
+        # A LiveFSBuild with a private LiveFS is private.
+        db_team = self.factory.makeTeam(
+            owner=self.person, visibility=PersonVisibility.PRIVATE)
+        with person_logged_in(self.person):
+            db_build = self.factory.makeLiveFSBuild(
+                requester=self.person, owner=db_team)
+            build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        # 404 since we aren't allowed to know that the private team exists.
+        self.assertEqual(404, unpriv_webservice.get(build_url).status)
+
+    def test_private_archive(self):
+        # A LiveFSBuild with a private archive is private.
+        db_archive = self.factory.makeArchive(owner=self.person, private=True)
+        with person_logged_in(self.person):
+            db_build = self.factory.makeLiveFSBuild(archive=db_archive)
+            build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(401, unpriv_webservice.get(build_url).status)
+
+    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)
+        unpriv_webservice.default_api_version = "devel"
+        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)
+        buildd_admin_webservice.default_api_version = "devel"
+        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"])
+
+    def makeNonRedirectingBrowser(self, person):
+        # The test browser can only work with the appserver, not the
+        # librarian, so follow one layer of redirection through the
+        # appserver and then ask the librarian for the real file.
+        browser = Browser(mech_browser=NonRedirectingMechanizeBrowser())
+        browser.handleErrors = False
+        with person_logged_in(person):
+            browser.addHeader(
+                "Authorization", "Basic %s:test" % person.preferredemail.email)
+        return browser
+
+    def assertCanOpenRedirectedUrl(self, browser, url):
+        redirection = self.assertRaises(HTTPError, browser.open, url)
+        self.assertEqual(303, redirection.code)
+        urlopen(redirection.hdrs["Location"]).close()
+
+    def test_logs(self):
+        # API clients can fetch the build and upload logs.
+        db_build = self.factory.makeLiveFSBuild(requester=self.person)
+        db_build.setLog(self.factory.makeLibraryFileAlias("buildlog.txt.gz"))
+        db_build.storeUploadLog("uploaded")
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        browser = self.makeNonRedirectingBrowser(self.person)
+        self.assertIsNotNone(build["build_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["build_log_url"])
+        self.assertIsNotNone(build["upload_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["upload_log_url"])
+
+    def test_getFileUrls(self):
+        # API clients can fetch files attached to builds.
+        db_build = self.factory.makeLiveFSBuild(requester=self.person)
+        db_files = [
+            self.factory.makeLiveFSFile(livefsbuild=db_build)
+            for i in range(2)]
+        build_url = api_url(db_build)
+        file_urls = [
+            ProxiedLibraryFileAlias(file.libraryfile, db_build).http_url
+            for file in db_files]
+        logout()
+        response = self.webservice.named_get(build_url, "getFileUrls")
+        self.assertEqual(200, response.status)
+        self.assertContentEqual(file_urls, response.jsonBody())
+        browser = self.makeNonRedirectingBrowser(self.person)
+        for file_url in file_urls:
+            self.assertCanOpenRedirectedUrl(browser, file_url)

=== 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-05-14 11:42:03 +0000
@@ -0,0 +1,240 @@
+# 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 fixtures
+import pytz
+from testtools import run_test_with
+from testtools.deferredruntest import (
+    assert_fails_with,
+    AsynchronousDeferredRunTest,
+    )
+import transaction
+from twisted.internet import defer
+from twisted.trial.unittest import TestCase as TrialTestCase
+from zope.component import getUtility
+from zope.security.proxy import (
+    Proxy,
+    removeSecurityProxy,
+    )
+
+from lp.buildmaster.enums import BuildStatus
+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.buildmaster.tests.test_buildfarmjobbehaviour import (
+    TestGetUploadMethodsMixin,
+    TestHandleStatusMixin,
+    TestVerifySuccessfulBuildMixin,
+    )
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.series import SeriesStatus
+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 live filesystem 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 live filesystem 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.distro_arch_series.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.distro_arch_series.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.distro_arch_series, None)
+        self.assertEqual({
+            "archive_private": False,
+            "archives": expected_archives,
+            "arch_tag": "i386",
+            "datestamp": "20140425-103800",
+            "project": "distro",
+            "subproject": "special",
+            "suite": "unstable",
+            }, job._extraBuildArgs())
+
+    def test_extraBuildArgs_no_security_proxy(self):
+        # _extraBuildArgs returns an object without security wrapping, even
+        # if values in the metadata are (say) lists and hence get proxied by
+        # Zope.
+        job = self.makeJob(metadata={"lb_args": ["--option=value"]})
+        args = job._extraBuildArgs()
+        self.assertEqual(["--option=value"], args["lb_args"])
+        self.assertIsNot(Proxy, type(args["lb_args"]))
+
+    @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.distro_arch_series.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)
+
+
+class MakeLiveFSBuildMixin:
+    """Provide the common makeBuild method returning a queued build."""
+
+    def makeBuild(self):
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        build = self.factory.makeLiveFSBuild(status=BuildStatus.BUILDING)
+        build.queueBuild()
+        return build
+
+    def makeUnmodifiableBuild(self):
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        build = self.factory.makeLiveFSBuild(status=BuildStatus.BUILDING)
+        build.distro_series.status = SeriesStatus.OBSOLETE
+        build.queueBuild()
+        return build
+
+
+class TestGetUploadMethodsForLiveFSBuild(
+    MakeLiveFSBuildMixin, TestGetUploadMethodsMixin, TestCaseWithFactory):
+    """IPackageBuild.getUpload-related methods work with LiveFS builds."""
+
+
+class TestVerifySuccessfulBuildForLiveFSBuild(
+    MakeLiveFSBuildMixin, TestVerifySuccessfulBuildMixin, TestCaseWithFactory):
+    """IBuildFarmJobBehaviour.verifySuccessfulBuild works."""
+
+
+class TestHandleStatusForLiveFSBuild(
+    MakeLiveFSBuildMixin, TestHandleStatusMixin, TrialTestCase,
+    fixtures.TestWithFixtures):
+    """IPackageBuild.handleStatus works with LiveFS builds."""

=== 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-05-14 11:42:03 +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
@@ -297,6 +299,7 @@
     BinaryPackageFile,
     SourcePackageReleaseFile,
     )
+from lp.soyuz.model.livefsbuild import LiveFSFile
 from lp.soyuz.model.packagediff import PackageDiff
 from lp.testing import (
     admin_logged_in,
@@ -4316,6 +4319,80 @@
         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.makeTeam(registrant)
+        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, registrant=None, livefs=None,
+                        archive=None, distroarchseries=None, pocket=None,
+                        unique_key=None, metadata_override=None,
+                        date_created=DEFAULT, status=BuildStatus.NEEDSBUILD,
+                        builder=None, duration=None, **kwargs):
+        """Make a new LiveFSBuild."""
+        if requester is None:
+            requester = self.makePerson()
+        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
+            if registrant is None:
+                registrant = requester
+            livefs = self.makeLiveFS(
+                registrant=registrant, distroseries=distroseries, **kwargs)
+        if archive is None:
+            archive = livefs.distro_series.main_archive
+        if distroarchseries is None:
+            distroarchseries = self.makeDistroArchSeries(
+                distroseries=livefs.distro_series)
+        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, builder=builder,
+                date_started=livefsbuild.date_created)
+            removeSecurityProxy(livefsbuild).updateStatus(
+                status, builder=builder,
+                date_finished=livefsbuild.date_started + duration)
+        else:
+            removeSecurityProxy(livefsbuild).updateStatus(
+                status, builder=builder)
+        IStore(livefsbuild).flush()
+        return livefsbuild
+
+    def makeLiveFSFile(self, livefsbuild=None, libraryfile=None):
+        if livefsbuild is None:
+            livefsbuild = self.makeLiveFSBuild()
+        if libraryfile is None:
+            libraryfile = self.makeLibraryFileAlias()
+        return ProxyFactory(
+            LiveFSFile(livefsbuild=livefsbuild, libraryfile=libraryfile))
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by