← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-basic-model into lp:launchpad with lp:~cjwatson/launchpad/snap-personmerge-whitelist as a prerequisite.

Commit message:
Add basic model for snap packages.

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-basic-model/+merge/265671

Add basic model for snap packages.

For the sake of the branch not being enormous, this isn't very useful yet, as SnapBuild and SnapFile aren't modelled.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-basic-model 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 14:38:45 +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 14:38:45 +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/configure.zcml'
--- lib/lp/configure.zcml	2013-06-03 07:26:52 +0000
+++ lib/lp/configure.zcml	2015-07-23 14:38:45 +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 14:38:45 +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/security.py'
--- lib/lp/security.py	2015-07-12 23:48:01 +0000
+++ lib/lp/security.py	2015-07-23 14:38:45 +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,7 @@
     ILanguage,
     ILanguageSet,
     )
+from lp.snappy.interfaces.snap import ISnap
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
@@ -3080,3 +3080,39 @@
     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))

=== 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 14:38:45 +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).
+-->
+
+<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" />
+    </facet>
+</configure>

=== 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 14:38:45 +0000
@@ -0,0 +1,42 @@
+<!-- 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>
+
+</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 14:38:45 +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 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 14:38:45 +0000
@@ -0,0 +1,189 @@
+# 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,
+    Int,
+    Reference,
+    Storm,
+    Unicode,
+    )
+from zope.interface import implementer
+
+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.features import getFeatureFlag
+from lp.snappy.interfaces.snap import (
+    DuplicateSnapName,
+    ISnap,
+    ISnapSet,
+    SNAP_FEATURE_FLAG,
+    SnapFeatureDisabled,
+    SnapNotOwner,
+    NoSuchSnap,
+    )
+
+
+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`."""
+        raise NotImplementedError
+
+    @property
+    def builds(self):
+        """See `ISnap`."""
+        return []
+
+    @property
+    def _pending_states(self):
+        """All the build states we consider pending (non-final)."""
+        raise NotImplementedError
+
+    @property
+    def completed_builds(self):
+        """See `ISnap`."""
+        return []
+
+    @property
+    def pending_builds(self):
+        """See `ISnap`."""
+        return []
+
+    def destroySelf(self):
+        """See `ISnap`."""
+        raise NotImplementedError
+
+
+@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 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 14:38:45 +0000
@@ -0,0 +1,173 @@
+# 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.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)
+
+
+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]))

=== 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 14:38:45 +0000
@@ -264,6 +264,7 @@
 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.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.adapters.packagelocation import PackageLocation
 from lp.soyuz.enums import (
@@ -4531,6 +4532,33 @@
             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
+
 
 # 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 14:38:45 +0000
@@ -2,6 +2,7 @@
 lp/testopenid
 lp/testing
 lp/soyuz
+lp/snappy
 lp/services
 lp/scripts
 lp/registry


Follow ups