← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-builds into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-builds into lp:launchpad.

Commit message:
Flesh out Snap.requestBuild, Snap.*builds, and Snap.destroySelf methods.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1476405 in Launchpad itself: "Add support for building snaps"
  https://bugs.launchpad.net/launchpad/+bug/1476405

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-builds/+merge/265697

Flesh out Snap.requestBuild, Snap.*builds, and Snap.destroySelf methods.

Now that both Snap and SnapBuild are modelled, we can link them together.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-builds into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2015-04-21 10:49:24 +0000
+++ lib/lp/app/browser/configure.zcml	2015-07-23 16:23:30 +0000
@@ -841,6 +841,12 @@
       name="fmt"
       />
   <adapter
+      for="lp.snappy.interfaces.snap.ISnap"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.SnapFormatterAPI"
+      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/tales.py'
--- lib/lp/app/browser/tales.py	2015-07-09 12:18:51 +0000
+++ lib/lp/app/browser/tales.py	2015-07-23 16:23:30 +0000
@@ -1858,6 +1858,17 @@
                 'owner': self._context.owner.displayname}
 
 
+class SnapFormatterAPI(CustomizableFormatter):
+    """Adapter providing fmt support for ISnap objects."""
+
+    _link_summary_template = _(
+        'Snap %(name)s for %(owner)s')
+
+    def _link_summary_values(self):
+        return {'name': self._context.name,
+                'owner': self._context.owner.displayname}
+
+
 class SpecificationFormatterAPI(CustomizableFormatter):
     """Adapter providing fmt support for Specification objects"""
 

=== modified file 'lib/lp/buildmaster/enums.py'
--- lib/lp/buildmaster/enums.py	2014-06-20 11:12:40 +0000
+++ lib/lp/buildmaster/enums.py	2015-07-23 16:23:30 +0000
@@ -157,6 +157,12 @@
         Build a live filesystem from an archive.
         """)
 
+    SNAPBUILD = DBItem(6, """
+        Snap package build
+
+        Build a snap package from a recipe.
+        """)
+
 
 class BuildQueueStatus(DBEnumeratedType):
     """Build queue status.

=== modified file 'lib/lp/configure.zcml'
--- lib/lp/configure.zcml	2013-06-03 07:26:52 +0000
+++ lib/lp/configure.zcml	2015-07-23 16:23:30 +0000
@@ -30,6 +30,7 @@
     <include package="lp.code" />
     <include package="lp.coop.answersbugs" />
     <include package="lp.hardwaredb" />
+    <include package="lp.snappy" />
     <include package="lp.soyuz" />
     <include package="lp.translations" />
     <include package="lp.testing" />

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2015-07-21 09:04:01 +0000
+++ lib/lp/registry/browser/person.py	2015-07-23 16:23:30 +0000
@@ -274,6 +274,7 @@
 from lp.services.webapp.publisher import LaunchpadView
 from lp.services.worlddata.interfaces.country import ICountry
 from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.snappy.interfaces.snap import ISnapSet
 from lp.soyuz.browser.archivesubscription import (
     traverse_archive_subscription_for_subscriber,
     )
@@ -619,6 +620,11 @@
 
         return livefs
 
+    @stepthrough('+snap')
+    def traverse_snap(self, name):
+        """Traverse to this person's snap packages."""
+        return getUtility(ISnapSet).getByName(self.context, name)
+
 
 class PersonSetNavigation(Navigation):
 

=== modified file 'lib/lp/registry/personmerge.py'
--- lib/lp/registry/personmerge.py	2015-04-19 12:56:32 +0000
+++ lib/lp/registry/personmerge.py	2015-07-23 16:23:30 +0000
@@ -713,6 +713,9 @@
         ('latestpersonsourcepackagereleasecache', 'maintainer'),
         # Obsolete table.
         ('branchmergequeue', 'owner'),
+        # This needs handling before we deploy the snap code, but can be
+        # ignored for the purpose of deploying the database tables.
+        ('snap', 'owner'),
         ]
 
     references = list(postgresql.listReferences(cur, 'person', 'id'))

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2015-07-12 23:48:01 +0000
+++ lib/lp/security.py	2015-07-23 16:23:30 +0000
@@ -29,7 +29,6 @@
 from lp.answers.interfaces.questionsperson import IQuestionsPerson
 from lp.answers.interfaces.questiontarget import IQuestionTarget
 from lp.app.interfaces.security import IAuthorization
-from lp.app.interfaces.services import IService
 from lp.app.security import (
     AnonymousAuthorization,
     AuthorizationBase,
@@ -192,6 +191,8 @@
     ILanguage,
     ILanguageSet,
     )
+from lp.snappy.interfaces.snap import ISnap
+from lp.snappy.interfaces.snapbuild import ISnapBuild
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
@@ -3080,3 +3081,69 @@
     def __init__(self, obj):
         super(ViewWebhookDeliveryJob, self).__init__(
             obj, obj.webhook, 'launchpad.View')
+
+
+class ViewSnap(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = ISnap
+
+    def __init__(self, obj):
+        super(ViewSnap, self).__init__(obj, obj.owner, 'launchpad.View')
+
+
+class EditSnap(AuthorizationBase):
+    permission = 'launchpad.Edit'
+    usedfor = ISnap
+
+    def checkAuthenticated(self, user):
+        return (
+            user.isOwner(self.obj) or
+            user.in_commercial_admin or user.in_admin)
+
+
+class AdminSnap(AuthorizationBase):
+    """Restrict changing build settings on snap packages.
+
+    The security of the non-virtualised build farm depends on these
+    settings, so they can only be changed by commercial admins, or by "PPA"
+    self admins on snap packages that they can already edit.
+    """
+    permission = 'launchpad.Admin'
+    usedfor = ISnap
+
+    def checkAuthenticated(self, user):
+        if user.in_commercial_admin or user.in_admin:
+            return True
+        return (
+            user.in_ppa_self_admins
+            and EditSnap(self.obj).checkAuthenticated(user))
+
+
+class ViewSnapBuild(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = ISnapBuild
+
+    def iter_objects(self):
+        yield self.obj.snap
+        yield self.obj.archive
+
+
+class EditSnapBuild(AdminByBuilddAdmin):
+    permission = 'launchpad.Edit'
+    usedfor = ISnapBuild
+
+    def checkAuthenticated(self, user):
+        """Check edit access for snap package builds.
+
+        Allow admins, buildd admins, and the owner of the snap package.
+        (Note that the requester of the build is required to be in the team
+        that owns the snap package.)
+        """
+        auth_snap = EditSnap(self.obj.snap)
+        if auth_snap.checkAuthenticated(user):
+            return True
+        return super(EditSnapBuild, self).checkAuthenticated(user)
+
+
+class AdminSnapBuild(AdminByBuilddAdmin):
+    usedfor = ISnapBuild

=== added directory 'lib/lp/snappy'
=== added file 'lib/lp/snappy/__init__.py'
=== added directory 'lib/lp/snappy/browser'
=== added file 'lib/lp/snappy/browser/__init__.py'
=== added file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/configure.zcml	2015-07-23 16:23:30 +0000
@@ -0,0 +1,27 @@
+<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure
+    xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
+    xmlns:i18n="http://namespaces.zope.org/i18n";
+    xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc";
+    i18n_domain="launchpad">
+    <facet facet="overview">
+        <browser:url
+            for="lp.snappy.interfaces.snap.ISnap"
+            path_expression="string:+snap/${name}"
+            attribute_to_parent="owner" />
+        <browser:navigation
+            module="lp.snappy.browser.snap"
+            classes="SnapNavigation" />
+        <browser:url
+            for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+            path_expression="string:+build/${id}"
+            attribute_to_parent="snap" />
+        <browser:navigation
+            module="lp.snappy.browser.snapbuild"
+            classes="SnapBuildNavigation" />
+    </facet>
+</configure>

=== added file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/snap.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,28 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap views."""
+
+__metaclass__ = type
+__all__ = [
+    'SnapNavigation',
+    ]
+
+from lp.services.webapp import (
+    Navigation,
+    stepthrough,
+    )
+from lp.snappy.interfaces.snap import ISnap
+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.soyuz.browser.build import get_build_by_id_str
+
+
+class SnapNavigation(Navigation):
+    usedfor = ISnap
+
+    @stepthrough('+build')
+    def traverse_build(self, name):
+        build = get_build_by_id_str(ISnapBuildSet, name)
+        if build is None or build.snap != self.context:
+            return None
+        return build

=== added file 'lib/lp/snappy/browser/snapbuild.py'
--- lib/lp/snappy/browser/snapbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/snapbuild.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,17 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""SnapBuild views."""
+
+__metaclass__ = type
+__all__ = [
+    'SnapBuildNavigation',
+    ]
+
+from lp.services.librarian.browser import FileNavigationMixin
+from lp.services.webapp import Navigation
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+
+
+class SnapBuildNavigation(Navigation, FileNavigationMixin):
+    usedfor = ISnapBuild

=== added file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/configure.zcml	2015-07-23 16:23:30 +0000
@@ -0,0 +1,73 @@
+<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure
+    xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
+    xmlns:i18n="http://namespaces.zope.org/i18n";
+    xmlns:lp="http://namespaces.canonical.com/lp";
+    xmlns:webservice="http://namespaces.canonical.com/webservice";
+    xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc";
+    i18n_domain="launchpad">
+
+    <include package=".browser" />
+
+    <!-- Snap -->
+    <class class="lp.snappy.model.snap.Snap">
+        <require
+            permission="launchpad.View"
+            interface="lp.snappy.interfaces.snap.ISnapView
+                       lp.snappy.interfaces.snap.ISnapEditableAttributes
+                       lp.snappy.interfaces.snap.ISnapAdminAttributes" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.snappy.interfaces.snap.ISnapEdit"
+            set_schema="lp.snappy.interfaces.snap.ISnapEditableAttributes" />
+        <require
+            permission="launchpad.Admin"
+            set_schema="lp.snappy.interfaces.snap.ISnapAdminAttributes" />
+    </class>
+    <subscriber
+        for="lp.snappy.interfaces.snap.ISnap zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+        handler="lp.snappy.model.snap.snap_modified" />
+
+    <!-- SnapSet -->
+    <securedutility
+        class="lp.snappy.model.snap.SnapSet"
+        provides="lp.snappy.interfaces.snap.ISnapSet">
+        <allow interface="lp.snappy.interfaces.snap.ISnapSet" />
+    </securedutility>
+
+    <!-- SnapBuild -->
+    <class class="lp.snappy.model.snapbuild.SnapBuild">
+        <require
+            permission="launchpad.View"
+            interface="lp.snappy.interfaces.snapbuild.ISnapBuildView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.snappy.interfaces.snapbuild.ISnapBuildEdit" />
+        <require
+            permission="launchpad.Admin"
+            interface="lp.snappy.interfaces.snapbuild.ISnapBuildAdmin" />
+    </class>
+
+    <!-- SnapBuildSet -->
+    <securedutility
+        class="lp.snappy.model.snapbuild.SnapBuildSet"
+        provides="lp.snappy.interfaces.snapbuild.ISnapBuildSet">
+        <allow interface="lp.snappy.interfaces.snapbuild.ISnapBuildSet" />
+    </securedutility>
+    <securedutility
+        class="lp.snappy.model.snapbuild.SnapBuildSet"
+        provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+        name="SNAPBUILD">
+        <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
+    </securedutility>
+
+    <!-- SnapFile -->
+    <class class="lp.snappy.model.snapbuild.SnapFile">
+        <allow interface="lp.snappy.interfaces.snapbuild.ISnapFile" />
+    </class>
+
+</configure>

=== added directory 'lib/lp/snappy/interfaces'
=== added file 'lib/lp/snappy/interfaces/__init__.py'
=== added file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/snap.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,235 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap package interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'CannotDeleteSnap',
+    'DuplicateSnapName',
+    'ISnap',
+    'ISnapSet',
+    'ISnapView',
+    'SNAP_FEATURE_FLAG',
+    'SnapBuildAlreadyPending',
+    'SnapBuildArchiveOwnerMismatch',
+    'SnapFeatureDisabled',
+    'SnapNotOwner',
+    'NoSuchSnap',
+    ]
+
+from lazr.restful.fields import (
+    Reference,
+    ReferenceChoice,
+    )
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Bool,
+    Datetime,
+    Int,
+    Text,
+    TextLine,
+    )
+from zope.security.interfaces import (
+    Forbidden,
+    Unauthorized,
+    )
+
+from lp import _
+from lp.app.errors import NameLookupFailed
+from lp.app.validators.name import name_validator
+from lp.code.interfaces.branch import IBranch
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.role import IHasOwner
+from lp.services.fields import (
+    PersonChoice,
+    PublicPersonChoice,
+    )
+
+
+SNAP_FEATURE_FLAG = u"snap.allow_new"
+
+
+class SnapBuildAlreadyPending(Exception):
+    """A build was requested when an identical build was already pending."""
+
+    def __init__(self):
+        super(SnapBuildAlreadyPending, self).__init__(
+            "An identical build of this snap package is already pending.")
+
+
+class SnapBuildArchiveOwnerMismatch(Forbidden):
+    """Builds against private archives require that owners match.
+
+    The snap package owner must have write permission on the archive, so
+    that a malicious snap package build can't steal any secrets that its
+    owner didn't already have access to.  Furthermore, we want to make sure
+    that future changes to the team owning the snap package don't grant it
+    retrospective access to information about a private archive.  For now,
+    the simplest way to do this is to require that the owners match exactly.
+    """
+
+    def __init__(self):
+        super(SnapBuildArchiveOwnerMismatch, self).__init__(
+            "Snap package builds against private archives are only allowed "
+            "if the snap package owner and the archive owner are equal.")
+
+
+class SnapFeatureDisabled(Unauthorized):
+    """Only certain users can create new snap-related objects."""
+
+    def __init__(self):
+        super(SnapFeatureDisabled, self).__init__(
+            "You do not have permission to create new snaps or new snap "
+            "builds.")
+
+
+class DuplicateSnapName(Exception):
+    """Raised for snap packages with duplicate name/owner."""
+
+    def __init__(self):
+        super(DuplicateSnapName, self).__init__(
+            "There is already a snap package with the same name and owner.")
+
+
+class SnapNotOwner(Unauthorized):
+    """The registrant/requester is not the owner or a member of its team."""
+
+
+class NoSuchSnap(NameLookupFailed):
+    """The requested snap package does not exist."""
+    _message_prefix = "No such snap package with this owner"
+
+
+class CannotDeleteSnap(Exception):
+    """This snap package cannot be deleted."""
+
+
+class ISnapView(Interface):
+    """`ISnap` attributes that require launchpad.View permission."""
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+
+    date_created = Datetime(
+        title=_("Date created"), required=True, readonly=True)
+
+    registrant = PublicPersonChoice(
+        title=_("Registrant"), required=True, readonly=True,
+        vocabulary="ValidPersonOrTeam",
+        description=_("The person who registered this snap package."))
+
+    def requestBuild(requester, archive, distro_arch_series, pocket):
+        """Request that the snap package 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.
+        :return: `ISnapBuild`.
+        """
+
+    builds = Attribute("All builds of this snap package.")
+
+    completed_builds = Attribute("Completed builds of this snap package.")
+
+    pending_builds = Attribute("Pending builds of this snap package.")
+
+
+class ISnapEdit(Interface):
+    """`ISnap` methods that require launchpad.Edit permission."""
+
+    def destroySelf():
+        """Delete this snap package, provided that it has no builds."""
+
+
+class ISnapEditableAttributes(IHasOwner):
+    """`ISnap` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+    date_last_modified = Datetime(
+        title=_("Date last modified"), required=True, readonly=True)
+
+    owner = PersonChoice(
+        title=_("Owner"), required=True, readonly=False,
+        vocabulary="AllUserTeamsParticipationPlusSelf",
+        description=_("The owner of this snap package."))
+
+    distro_series = Reference(
+        IDistroSeries, title=_("Distro Series"), required=True, readonly=False,
+        description=_(
+            "The series for which the snap package should be built."))
+
+    name = TextLine(
+        title=_("Name"), required=True, readonly=False,
+        constraint=name_validator,
+        description=_("The name of the snap package."))
+
+    description = Text(
+        title=_("Description"), required=False, readonly=False,
+        description=_("A description of the snap package."))
+
+    branch = ReferenceChoice(
+        title=_("Bazaar branch"), schema=IBranch, vocabulary="Branch",
+        required=False, readonly=False,
+        description=_(
+            "A Bazaar branch containing a snapcraft.yaml recipe at the top "
+            "level."))
+
+    git_repository = ReferenceChoice(
+        title=_("Git repository"),
+        schema=IGitRepository, vocabulary="GitRepository",
+        required=False, readonly=False,
+        description=_(
+            "A Git repository with a branch containing a snapcraft.yaml "
+            "recipe at the top level."))
+
+    git_path = TextLine(
+        title=_("Git branch path"), required=False, readonly=False,
+        description=_(
+            "The path of the Git branch containing a snapcraft.yaml recipe at "
+            "the top level."))
+
+
+class ISnapAdminAttributes(Interface):
+    """`ISnap` attributes that can be edited by admins.
+
+    These attributes need launchpad.View to see, and launchpad.Admin to change.
+    """
+    require_virtualized = Bool(
+        title=_("Require virtualized builders"), required=True, readonly=False,
+        description=_("Only build this snap package on virtual builders."))
+
+
+class ISnap(
+    ISnapView, ISnapEdit, ISnapEditableAttributes, ISnapAdminAttributes):
+    """A buildable snap package."""
+
+
+class ISnapSet(Interface):
+    """A utility to create and access snap packages."""
+
+    def new(registrant, owner, distro_series, name, description=None,
+            branch=None, git_repository=None, git_path=None,
+            require_virtualized=True, date_created=None):
+        """Create an `ISnap`."""
+
+    def exists(owner, name):
+        """Check to see if a matching snap exists."""
+
+    def getByName(owner, name):
+        """Return the appropriate `ISnap` for the given objects."""
+
+    def getByPerson(owner):
+        """Return all snap packages with the given `owner`."""
+
+    def empty_list():
+        """Return an empty collection of snap packages.
+
+        This only exists to keep lazr.restful happy.
+        """

=== added file 'lib/lp/snappy/interfaces/snapbuild.py'
--- lib/lp/snappy/interfaces/snapbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/snapbuild.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,161 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap package build interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'ISnapBuild',
+    'ISnapBuildSet',
+    'ISnapFile',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Bool,
+    Choice,
+    Int,
+    )
+
+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.snappy.interfaces.snap import ISnap
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+
+
+class ISnapFile(Interface):
+    """A file produced by a snap package build."""
+
+    snapbuild = Attribute("The snap package build producing this file.")
+
+    libraryfile = Reference(
+        ILibraryFileAlias, title=_("The library file alias for this file."),
+        required=True, readonly=True)
+
+
+class ISnapBuildView(IPackageBuild):
+    """`ISnapBuild` attributes that require launchpad.View permission."""
+
+    requester = Reference(
+        IPerson,
+        title=_("The person who requested this build."),
+        required=True, readonly=True)
+
+    snap = Reference(
+        ISnap,
+        title=_("The snap package to build."),
+        required=True, readonly=True)
+
+    archive = Reference(
+        IArchive,
+        title=_("The archive from which to build the snap package."),
+        required=True, readonly=True)
+
+    distro_arch_series = Reference(
+        IDistroArchSeries,
+        title=_("The series and architecture for which to build."),
+        required=True, readonly=True)
+
+    pocket = Choice(
+        title=_("The pocket for which to build."),
+        vocabulary=PackagePublishingPocket, required=True, readonly=True)
+
+    virtualized = Bool(
+        title=_("If True, this build is virtualized."), readonly=True)
+
+    score = Int(
+        title=_("Score of the related build farm job (if any)."),
+        required=False, readonly=True)
+
+    can_be_rescored = Bool(
+        title=_("Can be rescored"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be rescored manually."))
+
+    can_be_cancelled = Bool(
+        title=_("Can be cancelled"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be cancelled."))
+
+    def getFiles():
+        """Retrieve the build's `ISnapFile` records.
+
+        :return: A result set of (`ISnapFile`, `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
+        snap package output file.
+
+        :param filename: The filename to look up.
+        :raises NotFoundError: if no file exists with the given name.
+        :return: The corresponding `ILibraryFileAlias`.
+        """
+
+    def getFileUrls():
+        """URLs for all the files produced by this build.
+
+        :return: A collection of URLs for this build."""
+
+
+class ISnapBuildEdit(Interface):
+    """`ISnapBuild` attributes that require launchpad.Edit."""
+
+    def addFile(lfa):
+        """Add a file to this build.
+
+        :param lfa: An `ILibraryFileAlias`.
+        :return: An `ISnapFile`.
+        """
+
+    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 ISnapBuildAdmin(Interface):
+    """`ISnapBuild` attributes that require launchpad.Admin."""
+
+    def rescore(score):
+        """Change the build's score."""
+
+
+class ISnapBuild(ISnapBuildView, ISnapBuildEdit, ISnapBuildAdmin):
+    """Build information for snap package builds."""
+
+
+class ISnapBuildSet(ISpecificBuildFarmJobSource):
+    """Utility for `ISnapBuild`."""
+
+    def new(requester, snap, archive, distro_arch_series, pocket,
+            date_created=DEFAULT):
+        """Create an `ISnapBuild`."""

=== added directory 'lib/lp/snappy/model'
=== added file 'lib/lp/snappy/model/__init__.py'
=== added file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/snap.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,271 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'Snap',
+    ]
+
+import pytz
+from storm.exceptions import IntegrityError
+from storm.locals import (
+    Bool,
+    DateTime,
+    Desc,
+    Int,
+    Not,
+    Reference,
+    Store,
+    Storm,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.buildmaster.enums import BuildStatus
+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,
+    NullsLast,
+    )
+from lp.services.features import getFeatureFlag
+from lp.services.webapp.interfaces import ILaunchBag
+from lp.snappy.interfaces.snap import (
+    CannotDeleteSnap,
+    DuplicateSnapName,
+    ISnap,
+    ISnapSet,
+    SNAP_FEATURE_FLAG,
+    SnapBuildAlreadyPending,
+    SnapBuildArchiveOwnerMismatch,
+    SnapFeatureDisabled,
+    SnapNotOwner,
+    NoSuchSnap,
+    )
+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.snappy.model.snapbuild import SnapBuild
+from lp.soyuz.interfaces.archive import ArchiveDisabled
+from lp.soyuz.model.archive import (
+    Archive,
+    get_enabled_archive_filter,
+    )
+
+
+def snap_modified(snap, event):
+    """Update the date_last_modified property when a Snap is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on snap packages.
+    """
+    snap.date_last_modified = UTC_NOW
+
+
+@implementer(ISnap, IHasOwner)
+class Snap(Storm):
+    """See `ISnap`."""
+
+    __storm_table__ = 'Snap'
+
+    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)
+
+    description = Unicode(name='description', allow_none=True)
+
+    branch_id = Int(name='branch', allow_none=True)
+    branch = Reference(branch_id, 'Branch.id')
+
+    git_repository_id = Int(name='git_repository', allow_none=True)
+    git_repository = Reference(git_repository_id, 'GitRepository.id')
+
+    git_path = Unicode(name='git_path', allow_none=True)
+
+    require_virtualized = Bool(name='require_virtualized')
+
+    def __init__(self, registrant, owner, distro_series, name,
+                 description=None, branch=None, git_repository=None,
+                 git_path=None, require_virtualized=True,
+                 date_created=DEFAULT):
+        """Construct a `Snap`."""
+        if not getFeatureFlag(SNAP_FEATURE_FLAG):
+            raise SnapFeatureDisabled
+        super(Snap, self).__init__()
+        self.registrant = registrant
+        self.owner = owner
+        self.distro_series = distro_series
+        self.name = name
+        self.description = description
+        self.branch = branch
+        self.git_repository = git_repository
+        self.git_path = git_path
+        self.require_virtualized = require_virtualized
+        self.date_created = date_created
+        self.date_last_modified = date_created
+
+    def requestBuild(self, requester, archive, distro_arch_series, pocket):
+        """See `ISnap`."""
+        if not requester.inTeam(self.owner):
+            raise SnapNotOwner(
+                "%s cannot create snap package builds owned by %s." %
+                (requester.displayname, self.owner.displayname))
+        if not archive.enabled:
+            raise ArchiveDisabled(archive.displayname)
+        if archive.private and self.owner != archive.owner:
+            # See rationale in `SnapBuildArchiveOwnerMismatch` docstring.
+            raise SnapBuildArchiveOwnerMismatch()
+
+        pending = IStore(self).find(
+            SnapBuild,
+            SnapBuild.snap_id == self.id,
+            SnapBuild.archive_id == archive.id,
+            SnapBuild.distro_arch_series_id == distro_arch_series.id,
+            SnapBuild.pocket == pocket,
+            SnapBuild.status == BuildStatus.NEEDSBUILD)
+        if pending.any() is not None:
+            raise SnapBuildAlreadyPending
+
+        build = getUtility(ISnapBuildSet).new(
+            requester, self, archive, distro_arch_series, pocket)
+        build.queueBuild()
+        return build
+
+    def _getBuilds(self, filter_term, order_by):
+        """The actual query to get the builds."""
+        query_args = [
+            SnapBuild.snap == self,
+            SnapBuild.archive_id == Archive.id,
+            Archive._enabled == True,
+            get_enabled_archive_filter(
+                getUtility(ILaunchBag).user, include_public=True,
+                include_subscribed=True)
+            ]
+        if filter_term is not None:
+            query_args.append(filter_term)
+        result = Store.of(self).find(SnapBuild, *query_args)
+        result.order_by(order_by)
+        return result
+
+    @property
+    def builds(self):
+        """See `ISnap`."""
+        order_by = (
+            NullsLast(Desc(Greatest(
+                SnapBuild.date_started,
+                SnapBuild.date_finished))),
+            Desc(SnapBuild.date_created),
+            Desc(SnapBuild.id))
+        return self._getBuilds(None, order_by)
+
+    @property
+    def _pending_states(self):
+        """All the build states we consider pending (non-final)."""
+        return [
+            BuildStatus.NEEDSBUILD,
+            BuildStatus.BUILDING,
+            BuildStatus.UPLOADING,
+            BuildStatus.CANCELLING,
+            ]
+
+    @property
+    def completed_builds(self):
+        """See `ISnap`."""
+        filter_term = (Not(SnapBuild.status.is_in(self._pending_states)))
+        order_by = (
+            NullsLast(Desc(Greatest(
+                SnapBuild.date_started,
+                SnapBuild.date_finished))),
+            Desc(SnapBuild.id))
+        return self._getBuilds(filter_term, order_by)
+
+    @property
+    def pending_builds(self):
+        """See `ISnap`."""
+        filter_term = (SnapBuild.status.is_in(self._pending_states))
+        # 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(SnapBuild.id)
+        return self._getBuilds(filter_term, order_by)
+
+    def destroySelf(self):
+        """See `ISnap`."""
+        if not self.builds.is_empty():
+            raise CannotDeleteSnap("Cannot delete a snap package with builds.")
+        IStore(Snap).remove(self)
+
+
+@implementer(ISnapSet)
+class SnapSet:
+    """See `ISnapSet`."""
+
+    def new(self, registrant, owner, distro_series, name, description=None,
+            branch=None, git_repository=None, git_path=None,
+            require_virtualized=True, date_created=DEFAULT):
+        """See `ISnapSet`."""
+        if not registrant.inTeam(owner):
+            if owner.is_team:
+                raise SnapNotOwner(
+                    "%s is not a member of %s." %
+                    (registrant.displayname, owner.displayname))
+            else:
+                raise SnapNotOwner(
+                    "%s cannot create snap packages owned by %s." %
+                    (registrant.displayname, owner.displayname))
+
+        store = IMasterStore(Snap)
+        snap = Snap(
+            registrant, owner, distro_series, name, description=description,
+            branch=branch, git_repository=git_repository, git_path=git_path,
+            require_virtualized=require_virtualized, date_created=date_created)
+        store.add(snap)
+
+        try:
+            store.flush()
+        except IntegrityError:
+            raise DuplicateSnapName
+
+        return snap
+
+    def _getByName(self, owner, name):
+        return IStore(Snap).find(
+            Snap, Snap.owner == owner, Snap.name == name).one()
+
+    def exists(self, owner, name):
+        """See `ISnapSet`."""
+        return self._getByName(owner, name) is not None
+
+    def getByName(self, owner, name):
+        """See `ISnapSet`."""
+        snap = self._getByName(owner, name)
+        if snap is None:
+            raise NoSuchSnap(name)
+        return snap
+
+    def getByPerson(self, owner):
+        """See `ISnapSet`."""
+        return IStore(Snap).find(Snap, Snap.owner == owner)
+
+    def empty_list(self):
+        """See `ISnapSet`."""
+        return []

=== added file 'lib/lp/snappy/model/snapbuild.py'
--- lib/lp/snappy/model/snapbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/snapbuild.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,357 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'SnapBuild',
+    'SnapFile',
+    ]
+
+from datetime import timedelta
+
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Desc,
+    Int,
+    Reference,
+    Store,
+    Storm,
+    Unicode,
+    )
+from storm.store import EmptyResultSet
+from zope.component import getUtility
+from zope.interface import implementer
+
+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.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.snappy.interfaces.snap import (
+    SNAP_FEATURE_FLAG,
+    SnapFeatureDisabled,
+    )
+from lp.snappy.interfaces.snapbuild import (
+    ISnapBuild,
+    ISnapBuildSet,
+    ISnapFile,
+    )
+from lp.soyuz.interfaces.component import IComponentSet
+from lp.soyuz.model.archive import Archive
+
+
+@implementer(ISnapFile)
+class SnapFile(Storm):
+    """See `ISnap`."""
+
+    __storm_table__ = 'SnapFile'
+
+    id = Int(name='id', primary=True)
+
+    snapbuild_id = Int(name='snapbuild', allow_none=False)
+    snapbuild = Reference(snapbuild_id, 'SnapBuild.id')
+
+    libraryfile_id = Int(name='libraryfile', allow_none=False)
+    libraryfile = Reference(libraryfile_id, 'LibraryFileAlias.id')
+
+    def __init__(self, snapbuild, libraryfile):
+        """Construct a `SnapFile`."""
+        super(SnapFile, self).__init__()
+        self.snapbuild = snapbuild
+        self.libraryfile = libraryfile
+
+
+@implementer(ISnapBuild)
+class SnapBuild(PackageBuildMixin, Storm):
+    """See `ISnapBuild`."""
+
+    __storm_table__ = 'SnapBuild'
+
+    job_type = BuildFarmJobType.SNAPBUILD
+
+    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')
+
+    snap_id = Int(name='snap', allow_none=False)
+    snap = Reference(snap_id, 'Snap.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')
+
+    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, snap, archive,
+                 distro_arch_series, pocket, processor, virtualized,
+                 date_created):
+        """Construct a `SnapBuild`."""
+        if not getFeatureFlag(SNAP_FEATURE_FLAG):
+            raise SnapFeatureDisabled
+        super(SnapBuild, self).__init__()
+        self.build_farm_job = build_farm_job
+        self.requester = requester
+        self.snap = snap
+        self.archive = archive
+        self.distro_arch_series = distro_arch_series
+        self.pocket = pocket
+        self.processor = processor
+        self.virtualized = virtualized
+        self.date_created = date_created
+        self.status = BuildStatus.NEEDSBUILD
+
+    @property
+    def is_private(self):
+        """See `IBuildFarmJob`."""
+        return self.snap.owner.private or self.archive.private
+
+    @property
+    def title(self):
+        das = self.distro_arch_series
+        name = self.snap.name
+        return "%s build of %s snap package in %s %s" % (
+            das.architecturetag, name, das.distroseries.distribution.name,
+            das.distroseries.getSuite(self.pocket))
+
+    @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 2015-07-17: Hardcode to universe for the time being.
+            return getUtility(IComponentSet)["universe"]
+
+    @property
+    def score(self):
+        """See `ISnapBuild`."""
+        if self.buildqueue_record is None:
+            return None
+        else:
+            return self.buildqueue_record.lastscore
+
+    @property
+    def can_be_rescored(self):
+        """See `ISnapBuild`."""
+        return (
+            self.buildqueue_record is not None and
+            self.status is BuildStatus.NEEDSBUILD)
+
+    @property
+    def can_be_cancelled(self):
+        """See `ISnapBuild`."""
+        if not self.buildqueue_record:
+            return False
+
+        cancellable_statuses = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+            ]
+        return self.status in cancellable_statuses
+
+    def rescore(self, score):
+        """See `ISnapBuild`."""
+        assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
+        self.buildqueue_record.manualScore(score)
+
+    def cancel(self):
+        """See `ISnapBuild`."""
+        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(
+            (SnapBuild.date_started, SnapBuild.date_finished),
+            SnapBuild.snap == self.snap_id,
+            SnapBuild.distro_arch_series == self.distro_arch_series_id,
+            SnapBuild.status == BuildStatus.FULLYBUILT)
+        result.order_by(Desc(SnapBuild.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 `ISnapBuild`."""
+        result = Store.of(self).find(
+            (SnapFile, LibraryFileAlias, LibraryFileContent),
+            SnapFile.snapbuild == self.id,
+            LibraryFileAlias.id == SnapFile.libraryfile_id,
+            LibraryFileContent.id == LibraryFileAlias.contentID)
+        return result.order_by([LibraryFileAlias.filename, SnapFile.id])
+
+    def getFileByName(self, filename):
+        """See `ISnapBuild`."""
+        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,
+                SnapFile.snapbuild == self.id,
+                LibraryFileAlias.id == SnapFile.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 `ISnapBuild`."""
+        snapfile = SnapFile(snapbuild=self, libraryfile=lfa)
+        IMasterStore(SnapFile).add(snapfile)
+        return snapfile
+
+    def verifySuccessfulUpload(self):
+        """See `IPackageBuild`."""
+        return not self.getFiles().is_empty()
+
+    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()]
+
+
+@implementer(ISnapBuildSet)
+class SnapBuildSet(SpecificBuildFarmJobSourceMixin):
+
+    def new(self, requester, snap, archive, distro_arch_series, pocket,
+            date_created=DEFAULT):
+        """See `ISnapBuildSet`."""
+        store = IMasterStore(SnapBuild)
+        build_farm_job = getUtility(IBuildFarmJobSource).new(
+            SnapBuild.job_type, BuildStatus.NEEDSBUILD, date_created, None,
+            archive)
+        snapbuild = SnapBuild(
+            build_farm_job, requester, snap, archive, distro_arch_series,
+            pocket, distro_arch_series.processor,
+            not distro_arch_series.processor.supports_nonvirtualized
+            or snap.require_virtualized or archive.require_virtualized,
+            date_created)
+        store.add(snapbuild)
+        return snapbuild
+
+    def getByID(self, build_id):
+        """See `ISpecificBuildFarmJobSource`."""
+        store = IMasterStore(SnapBuild)
+        return store.get(SnapBuild, build_id)
+
+    def getByBuildFarmJob(self, build_farm_job):
+        """See `ISpecificBuildFarmJobSource`."""
+        return Store.of(build_farm_job).find(
+            SnapBuild, build_farm_job_id=build_farm_job.id).one()
+
+    def preloadBuildsData(self, builds):
+        # Circular import.
+        from lp.snappy.model.snap import Snap
+        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(Snap, builds, ["snap_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(
+            SnapBuild, SnapBuild.build_farm_job_id.is_in(
+                bfj.id for bfj in build_farm_jobs))
+        return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)

=== added directory 'lib/lp/snappy/tests'
=== added file 'lib/lp/snappy/tests/__init__.py'
=== added file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_snap.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,351 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test snap packages."""
+
+__metaclass__ = type
+
+from datetime import datetime
+
+from lazr.lifecycle.event import ObjectModifiedEvent
+import pytz
+from storm.locals import Store
+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.snappy.interfaces.snap import (
+    CannotDeleteSnap,
+    ISnap,
+    ISnapSet,
+    SNAP_FEATURE_FLAG,
+    SnapBuildAlreadyPending,
+    SnapFeatureDisabled,
+    )
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+
+
+class TestSnapFeatureFlag(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create new Snaps.
+        person = self.factory.makePerson()
+        self.assertRaises(
+            SnapFeatureDisabled, getUtility(ISnapSet).new,
+            person, person, None, None, True, None)
+
+
+class TestSnap(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnap, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+
+    def test_implements_interfaces(self):
+        # Snap implements ISnap.
+        snap = self.factory.makeSnap()
+        with person_logged_in(snap.owner):
+            self.assertProvides(snap, ISnap)
+
+    def test_initial_date_last_modified(self):
+        # The initial value of date_last_modified is date_created.
+        snap = self.factory.makeSnap(
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+        self.assertEqual(snap.date_created, snap.date_last_modified)
+
+    def test_modifiedevent_sets_date_last_modified(self):
+        # When a Snap receives an object modified event, the last modified
+        # date is set to UTC_NOW.
+        snap = self.factory.makeSnap(
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+        notify(ObjectModifiedEvent(
+            removeSecurityProxy(snap), snap, [ISnap["name"]]))
+        self.assertSqlAttributeEqualsDate(snap, "date_last_modified", UTC_NOW)
+
+    def test_requestBuild(self):
+        # requestBuild creates a new SnapBuild.
+        snap = self.factory.makeSnap()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=snap.distro_series)
+        build = snap.requestBuild(
+            snap.owner, snap.distro_series.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertTrue(ISnapBuild.providedBy(build))
+        self.assertEqual(snap.owner, build.requester)
+        self.assertEqual(snap.distro_series.main_archive, build.archive)
+        self.assertEqual(distroarchseries, build.distro_arch_series)
+        self.assertEqual(PackagePublishingPocket.RELEASE, build.pocket)
+        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(
+            snap.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).
+        snap = self.factory.makeSnap()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=snap.distro_series)
+        build = snap.requestBuild(
+            snap.owner, snap.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.
+        snap = self.factory.makeSnap()
+        archive = self.factory.makeArchive(owner=snap.owner)
+        removeSecurityProxy(archive).relative_build_score = 100
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=snap.distro_series)
+        build = snap.requestBuild(
+            snap.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.
+        snap = self.factory.makeSnap()
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=snap.distro_series)
+        old_build = snap.requestBuild(
+            snap.owner, snap.distro_series.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        self.assertRaises(
+            SnapBuildAlreadyPending, snap.requestBuild,
+            snap.owner, snap.distro_series.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+        # We can build for a different archive.
+        snap.requestBuild(
+            snap.owner, self.factory.makeArchive(owner=snap.owner),
+            distroarchseries, PackagePublishingPocket.RELEASE)
+        # We can build for a different distroarchseries.
+        snap.requestBuild(
+            snap.owner, snap.distro_series.main_archive,
+            self.factory.makeDistroArchSeries(distroseries=snap.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)
+        snap.requestBuild(
+            snap.owner, snap.distro_series.main_archive, distroarchseries,
+            PackagePublishingPocket.RELEASE)
+
+    def test_requestBuild_virtualization(self):
+        # New builds are virtualized if any of the processor, snap or
+        # archive require it.
+        for proc_nonvirt, snap_virt, archive_virt, build_virt in (
+                (True, False, False, False),
+                (True, False, True, True),
+                (True, True, False, True),
+                (True, True, True, True),
+                (False, False, False, True),
+                (False, False, True, True),
+                (False, True, False, True),
+                (False, True, True, True),
+                ):
+            distroarchseries = self.factory.makeDistroArchSeries(
+                processor=self.factory.makeProcessor(
+                    supports_nonvirtualized=proc_nonvirt))
+            snap = self.factory.makeSnap(
+                distroseries=distroarchseries.distroseries,
+                require_virtualized=snap_virt)
+            archive = self.factory.makeArchive(
+                distribution=distroarchseries.distroseries.distribution,
+                owner=snap.owner, virtualized=archive_virt)
+            build = snap.requestBuild(
+                snap.owner, archive, distroarchseries,
+                PackagePublishingPocket.RELEASE)
+            self.assertEqual(build_virt, build.virtualized)
+
+    def test_getBuilds(self):
+        # Test the various getBuilds methods.
+        snap = self.factory.makeSnap()
+        builds = [self.factory.makeSnapBuild(snap=snap) for x in range(3)]
+        # We want the latest builds first.
+        builds.reverse()
+
+        self.assertEqual(builds, list(snap.builds))
+        self.assertEqual([], list(snap.completed_builds))
+        self.assertEqual(builds, list(snap.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, list(snap.builds))
+        self.assertEqual(builds[:1], list(snap.completed_builds))
+        self.assertEqual(builds[1:], list(snap.pending_builds))
+
+    def test_getBuilds_cancelled_never_started_last(self):
+        # A cancelled build that was never even started sorts to the end.
+        snap = self.factory.makeSnap()
+        fullybuilt = self.factory.makeSnapBuild(snap=snap)
+        instacancelled = self.factory.makeSnapBuild(snap=snap)
+        fullybuilt.updateStatus(BuildStatus.BUILDING)
+        fullybuilt.updateStatus(BuildStatus.FULLYBUILT)
+        instacancelled.updateStatus(BuildStatus.CANCELLED)
+        self.assertEqual([fullybuilt, instacancelled], list(snap.builds))
+        self.assertEqual(
+            [fullybuilt, instacancelled], list(snap.completed_builds))
+        self.assertEqual([], list(snap.pending_builds))
+
+    def test_getBuilds_privacy(self):
+        # The various getBuilds methods exclude builds against invisible
+        # archives.
+        snap = self.factory.makeSnap()
+        archive = self.factory.makeArchive(
+            distribution=snap.distro_series.distribution, owner=snap.owner,
+            private=True)
+        with person_logged_in(snap.owner):
+            build = self.factory.makeSnapBuild(snap=snap, archive=archive)
+            self.assertEqual([build], list(snap.builds))
+            self.assertEqual([build], list(snap.pending_builds))
+        self.assertEqual([], list(snap.builds))
+        self.assertEqual([], list(snap.pending_builds))
+
+    def test_delete_without_builds(self):
+        # A snap package with no builds can be deleted.
+        owner = self.factory.makePerson()
+        distroseries = self.factory.makeDistroSeries()
+        snap = self.factory.makeSnap(
+            registrant=owner, owner=owner, distroseries=distroseries,
+            name=u"condemned")
+        self.assertTrue(getUtility(ISnapSet).exists(owner, u"condemned"))
+        with person_logged_in(snap.owner):
+            snap.destroySelf()
+        self.assertFalse(getUtility(ISnapSet).exists(owner, u"condemned"))
+
+    def test_delete_with_builds(self):
+        # A snap package with builds cannot be deleted.
+        owner = self.factory.makePerson()
+        distroseries = self.factory.makeDistroSeries()
+        snap = self.factory.makeSnap(
+            registrant=owner, owner=owner, distroseries=distroseries,
+            name=u"condemned")
+        self.factory.makeSnapBuild(snap=snap)
+        self.assertTrue(getUtility(ISnapSet).exists(owner, u"condemned"))
+        with person_logged_in(snap.owner):
+            self.assertRaises(CannotDeleteSnap, snap.destroySelf)
+        self.assertTrue(getUtility(ISnapSet).exists(owner, u"condemned"))
+
+
+class TestSnapSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapSet, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+
+    def test_class_implements_interfaces(self):
+        # The SnapSet class implements ISnapSet.
+        self.assertProvides(getUtility(ISnapSet), ISnapSet)
+
+    def makeSnapComponents(self, branch=None, git_ref=None):
+        """Return a dict of values that can be used to make a Snap.
+
+        Suggested use: provide as kwargs to ISnapSet.new.
+
+        :param branch: An `IBranch`, or None.
+        :param git_ref: An `IGitRef`, or None.
+        """
+        registrant = self.factory.makePerson()
+        components = dict(
+            registrant=registrant,
+            owner=self.factory.makeTeam(owner=registrant),
+            distro_series=self.factory.makeDistroSeries(),
+            name=self.factory.getUniqueString(u"snap-name"))
+        if branch is None and git_ref is None:
+            branch = self.factory.makeAnyBranch()
+        if branch is not None:
+            components["branch"] = branch
+        else:
+            components["git_repository"] = git_ref.repository
+            components["git_path"] = git_ref.path
+        return components
+
+    def test_creation_bzr(self):
+        # The metadata entries supplied when a Snap is created for a Bazaar
+        # branch are present on the new object.
+        branch = self.factory.makeAnyBranch()
+        components = self.makeSnapComponents(branch=branch)
+        snap = getUtility(ISnapSet).new(**components)
+        transaction.commit()
+        self.assertEqual(components["registrant"], snap.registrant)
+        self.assertEqual(components["owner"], snap.owner)
+        self.assertEqual(components["distro_series"], snap.distro_series)
+        self.assertEqual(components["name"], snap.name)
+        self.assertEqual(branch, snap.branch)
+        self.assertIsNone(snap.git_repository)
+        self.assertIsNone(snap.git_path)
+        self.assertTrue(snap.require_virtualized)
+
+    def test_creation_git(self):
+        # The metadata entries supplied when a Snap is created for a Git
+        # branch are present on the new object.
+        [ref] = self.factory.makeGitRefs()
+        components = self.makeSnapComponents(git_ref=ref)
+        snap = getUtility(ISnapSet).new(**components)
+        transaction.commit()
+        self.assertEqual(components["registrant"], snap.registrant)
+        self.assertEqual(components["owner"], snap.owner)
+        self.assertEqual(components["distro_series"], snap.distro_series)
+        self.assertEqual(components["name"], snap.name)
+        self.assertIsNone(snap.branch)
+        self.assertEqual(ref.repository, snap.git_repository)
+        self.assertEqual(ref.path, snap.git_path)
+        self.assertTrue(snap.require_virtualized)
+
+    def test_exists(self):
+        # ISnapSet.exists checks for matching Snaps.
+        snap = self.factory.makeSnap()
+        self.assertTrue(getUtility(ISnapSet).exists(snap.owner, snap.name))
+        self.assertFalse(
+            getUtility(ISnapSet).exists(self.factory.makePerson(), snap.name))
+        self.assertFalse(getUtility(ISnapSet).exists(snap.owner, u"different"))
+
+    def test_getByPerson(self):
+        # ISnapSet.getByPerson returns all Snaps with the given owner.
+        owners = [self.factory.makePerson() for i in range(2)]
+        snaps = []
+        for owner in owners:
+            for i in range(2):
+                snaps.append(self.factory.makeSnap(
+                    registrant=owner, owner=owner))
+        self.assertContentEqual(
+            snaps[:2], getUtility(ISnapSet).getByPerson(owners[0]))
+        self.assertContentEqual(
+            snaps[2:], getUtility(ISnapSet).getByPerson(owners[1]))

=== added file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py	2015-07-23 16:23:30 +0000
@@ -0,0 +1,226 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test snap package build features."""
+
+__metaclass__ = type
+
+from datetime import timedelta
+
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.errors import NotFoundError
+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.features.testing import FeatureFixture
+from lp.snappy.interfaces.snap import (
+    SNAP_FEATURE_FLAG,
+    SnapFeatureDisabled,
+    )
+from lp.snappy.interfaces.snapbuild import (
+    ISnapBuild,
+    ISnapBuildSet,
+    )
+from lp.soyuz.enums import ArchivePurpose
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class TestSnapBuildFeatureFlag(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create new SnapBuilds.
+        class MockSnap:
+            require_virtualized = False
+
+        self.assertRaises(
+            SnapFeatureDisabled, getUtility(ISnapBuildSet).new,
+            None, MockSnap(), self.factory.makeArchive(),
+            self.factory.makeDistroArchSeries(), None)
+
+
+class TestSnapBuild(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestSnapBuild, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.build = self.factory.makeSnapBuild()
+
+    def test_implements_interfaces(self):
+        # SnapBuild implements IPackageBuild and ISnapBuild.
+        self.assertProvides(self.build, IPackageBuild)
+        self.assertProvides(self.build, ISnapBuild)
+
+    def test_queueBuild(self):
+        # SnapBuild 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):
+        # SnapBuilds 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 SnapBuilds for PPAs always
+        # build in main.
+        build = self.factory.makeSnapBuild(archive=self.factory.makeArchive())
+        self.assertEqual("main", build.current_component.name)
+
+    def test_is_private(self):
+        # A SnapBuild is private iff its Snap 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.makeSnapBuild(
+                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.makeSnapBuild(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 snap package are used for
+        # estimates.
+        self.factory.makeSnapBuild(
+            requester=self.build.requester, snap=self.build.snap,
+            distroarchseries=self.build.distro_arch_series,
+            status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
+        for i in range(3):
+            self.factory.makeSnapBuild(
+                requester=self.build.requester, snap=self.build.snap,
+                distroarchseries=self.build.distro_arch_series,
+                status=BuildStatus.FAILEDTOBUILD,
+                duration=timedelta(seconds=20))
+        self.assertEqual(335, self.build.estimateDuration().seconds)
+
+    def test_build_cookie(self):
+        build = self.factory.makeSnapBuild()
+        self.assertEqual('SNAPBUILD-%d' % build.id, build.build_cookie)
+
+    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.makeSnapFile(snapbuild=self.build)
+        self.assertTrue(self.build.verifySuccessfulUpload())
+
+    def addFakeBuildLog(self, build):
+        build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
+
+    def test_log_url(self):
+        # The log URL for a snap package build will use the archive context.
+        self.addFakeBuildLog(self.build)
+        self.assertEqual(
+            "http://launchpad.dev/~%s/+snap/%s/+build/%d/+files/";
+            "mybuildlog.txt" % (
+                self.build.snap.owner.name, self.build.snap.name,
+                self.build.id),
+            self.build.log_url)
+
+
+class TestSnapBuildSet(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestSnapBuildSet, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+
+    def test_getByBuildFarmJob_works(self):
+        build = self.factory.makeSnapBuild()
+        self.assertEqual(
+            build,
+            getUtility(ISnapBuildSet).getByBuildFarmJob(build.build_farm_job))
+
+    def test_getByBuildFarmJob_returns_None_when_missing(self):
+        bpb = self.factory.makeBinaryPackageBuild()
+        self.assertIsNone(
+            getUtility(ISnapBuildSet).getByBuildFarmJob(bpb.build_farm_job))
+
+    def test_getByBuildFarmJobs_works(self):
+        builds = [self.factory.makeSnapBuild() for i in range(10)]
+        self.assertContentEqual(
+            builds,
+            getUtility(ISnapBuildSet).getByBuildFarmJobs(
+                [build.build_farm_job for build in builds]))
+
+    def test_getByBuildFarmJobs_works_empty(self):
+        self.assertContentEqual(
+            [], getUtility(ISnapBuildSet).getByBuildFarmJobs([]))

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2015-07-07 04:02:36 +0000
+++ lib/lp/testing/factory.py	2015-07-23 16:23:30 +0000
@@ -264,6 +264,9 @@
 from lp.services.webhooks.interfaces import IWebhookSource
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.snappy.interfaces.snap import ISnapSet
+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.snappy.model.snapbuild import SnapFile
 from lp.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.adapters.packagelocation import PackageLocation
 from lp.soyuz.enums import (
@@ -4531,6 +4534,86 @@
             target, self.makePerson(), delivery_url, [], True,
             self.getUniqueUnicode())
 
+    def makeSnap(self, registrant=None, owner=None, distroseries=None,
+                 name=None, branch=None, git_ref=None,
+                 require_virtualized=True, date_created=DEFAULT):
+        """Make a new Snap."""
+        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"snap-name")
+        kwargs = {}
+        if branch is None and git_ref is None:
+            branch = self.makeAnyBranch()
+        if branch is not None:
+            kwargs["branch"] = branch
+        elif git_ref is not None:
+            kwargs["git_repository"] = git_ref.repository
+            kwargs["git_path"] = git_ref.path
+        snap = getUtility(ISnapSet).new(
+            registrant, owner, distroseries, name,
+            require_virtualized=require_virtualized, date_created=date_created,
+            **kwargs)
+        IStore(snap).flush()
+        return snap
+
+    def makeSnapBuild(self, requester=None, registrant=None, snap=None,
+                      archive=None, distroarchseries=None, pocket=None,
+                      date_created=DEFAULT, status=BuildStatus.NEEDSBUILD,
+                      builder=None, duration=None, **kwargs):
+        """Make a new SnapBuild."""
+        if requester is None:
+            requester = self.makePerson()
+        if snap 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
+            snap = self.makeSnap(
+                registrant=registrant, distroseries=distroseries, **kwargs)
+        if archive is None:
+            archive = snap.distro_series.main_archive
+        if distroarchseries is None:
+            distroarchseries = self.makeDistroArchSeries(
+                distroseries=snap.distro_series)
+        if pocket is None:
+            pocket = PackagePublishingPocket.RELEASE
+        snapbuild = getUtility(ISnapBuildSet).new(
+            requester, snap, archive, distroarchseries, pocket,
+            date_created=date_created)
+        if duration is not None:
+            removeSecurityProxy(snapbuild).updateStatus(
+                BuildStatus.BUILDING, builder=builder,
+                date_started=snapbuild.date_created)
+            removeSecurityProxy(snapbuild).updateStatus(
+                status, builder=builder,
+                date_finished=snapbuild.date_started + duration)
+        else:
+            removeSecurityProxy(snapbuild).updateStatus(
+                status, builder=builder)
+        IStore(snapbuild).flush()
+        return snapbuild
+
+    def makeSnapFile(self, snapbuild=None, libraryfile=None):
+        if snapbuild is None:
+            snapbuild = self.makeSnapBuild()
+        if libraryfile is None:
+            libraryfile = self.makeLibraryFileAlias()
+        return ProxyFactory(
+            SnapFile(snapbuild=snapbuild, libraryfile=libraryfile))
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by

=== modified file 'utilities/snakefood/lp-sfood-packages'
--- utilities/snakefood/lp-sfood-packages	2015-01-13 14:07:42 +0000
+++ utilities/snakefood/lp-sfood-packages	2015-07-23 16:23:30 +0000
@@ -2,6 +2,7 @@
 lp/testopenid
 lp/testing
 lp/soyuz
+lp/snappy
 lp/services
 lp/scripts
 lp/registry