← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-request-builds-job into lp:launchpad with lp:~cjwatson/launchpad/snap-parse-architectures as a prerequisite.

Commit message:
Add a job to request builds of a snap for relevant architectures.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1770400 in Launchpad itself: "Support snapcraft architectures keyword"
  https://bugs.launchpad.net/launchpad/+bug/1770400

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

See also https://code.launchpad.net/~cjwatson/launchpad/db-snap-job/+merge/348057.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-request-builds-job into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2018-05-22 06:42:35 +0000
+++ database/schema/security.cfg	2018-06-15 13:00:33 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 #
 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
@@ -287,6 +287,7 @@
 public.snapbuild                        = SELECT, INSERT, UPDATE, DELETE
 public.snapbuildjob                     = SELECT, INSERT, UPDATE, DELETE
 public.snapfile                         = SELECT, INSERT, UPDATE, DELETE
+public.snapjob                          = SELECT, INSERT, UPDATE, DELETE
 public.snappydistroseries               = SELECT, INSERT, UPDATE, DELETE
 public.snappyseries                     = SELECT, INSERT, UPDATE, DELETE
 public.sourcepackageformatselection     = SELECT
@@ -2537,21 +2538,33 @@
 groups=script
 public.account                          = SELECT
 public.archive                          = SELECT
+public.branch                           = SELECT
 public.builder                          = SELECT
+public.buildfarmjob                     = SELECT, INSERT
+public.buildqueue                       = SELECT, INSERT, UPDATE
 public.distribution                     = SELECT
 public.distroarchseries                 = SELECT
 public.distroseries                     = SELECT
 public.emailaddress                     = SELECT
+public.gitref                           = SELECT
+public.gitrepository                    = SELECT
 public.job                              = SELECT, INSERT, UPDATE
 public.libraryfilealias                 = SELECT
 public.libraryfilecontent               = SELECT
 public.person                           = SELECT
 public.personsettings                   = SELECT
+public.pocketchroot                     = SELECT
+public.processor                        = SELECT
+public.product                          = SELECT
 public.snap                             = SELECT, UPDATE
-public.snapbuild                        = SELECT, UPDATE
+public.snaparch                         = SELECT
+public.snapbuild                        = SELECT, INSERT, UPDATE
 public.snapbuildjob                     = SELECT, UPDATE
 public.snapfile                         = SELECT
+public.snapjob                          = SELECT, UPDATE
 public.snappyseries                     = SELECT
+public.sourcepackagename                = SELECT
 public.teammembership                   = SELECT
+public.teamparticipation                = SELECT
 public.webhook                          = SELECT
 public.webhookjob                       = SELECT, INSERT

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2018-06-15 13:00:33 +0000
+++ lib/lp/services/config/schema-lazr.conf	2018-06-15 13:00:33 +0000
@@ -1860,6 +1860,7 @@
     IRemoveArtifactSubscriptionsJobSource,
     ISelfRenewalNotificationJobSource,
     ISevenDayCommercialExpirationJobSource,
+    ISnapRequestBuildsJobSource,
     ISnapStoreUploadJobSource,
     ITeamInvitationNotificationJobSource,
     ITeamJoinNotificationJobSource,
@@ -2002,6 +2003,11 @@
 dbuser: product-job
 crontab_group: MAIN
 
+[ISnapRequestBuildsJobSource]
+module: lp.snappy.interfaces.snapjob
+dbuser: snap-build-job
+crontab_group: MAIN
+
 [ISnapStoreUploadJobSource]
 module: lp.snappy.interfaces.snapbuildjob
 dbuser: snap-build-job

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2017-03-20 00:03:52 +0000
+++ lib/lp/snappy/configure.zcml	2018-06-15 13:00:33 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -127,6 +127,18 @@
     </securedutility>
 
     <!-- Snap-related jobs -->
+    <class class="lp.snappy.model.snapjob.SnapJob">
+        <allow interface="lp.snappy.interfaces.snapjob.ISnapJob" />
+    </class>
+    <securedutility
+        component="lp.snappy.model.snapjob.SnapRequestBuildsJob"
+        provides="lp.snappy.interfaces.snapjob.ISnapRequestBuildsJobSource">
+        <allow interface="lp.snappy.interfaces.snapjob.ISnapRequestBuildsJobSource" />
+    </securedutility>
+    <class class="lp.snappy.model.snapjob.SnapRequestBuildsJob">
+        <allow interface="lp.snappy.interfaces.snapjob.ISnapJob" />
+        <allow interface="lp.snappy.interfaces.snapjob.ISnapRequestBuildsJob" />
+    </class>
     <class class="lp.snappy.model.snapbuildjob.SnapBuildJob">
         <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapBuildJob" />
     </class>

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2018-06-15 13:00:33 +0000
+++ lib/lp/snappy/interfaces/snap.py	2018-06-15 13:00:33 +0000
@@ -316,6 +316,21 @@
         :return: `ISnapBuild`.
         """
 
+    def requestBuildsFromJob(requester, archive, pocket, channels=None,
+                             logger=None):
+        """Synchronous part of `Snap.requestBuilds`.
+
+        Request that the snap package be built for relevant architectures.
+
+        :param requester: The person requesting the builds.
+        :param archive: The IArchive to associate the builds with.
+        :param pocket: The pocket that should be targeted.
+        :param channels: A dictionary mapping snap names to channels to use
+            for these builds.
+        :param logger: An optional logger.
+        :return: A sequence of `ISnapBuild` instances.
+        """
+
     @operation_parameters(
         snap_build_ids=List(
             title=_("A list of snap build ids."),

=== added file 'lib/lp/snappy/interfaces/snapjob.py'
--- lib/lp/snappy/interfaces/snapjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/snapjob.py	2018-06-15 13:00:33 +0000
@@ -0,0 +1,97 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap job interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'ISnapJob',
+    'ISnapRequestBuildsJob',
+    'ISnapRequestBuildsJobSource',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Choice,
+    Dict,
+    List,
+    TextLine,
+    )
+
+from lp import _
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+from lp.snappy.interfaces.snap import ISnap
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+from lp.soyuz.interfaces.archive import IArchive
+
+
+class ISnapJob(Interface):
+    """A job related to a snap package."""
+
+    job = Reference(
+        title=_("The common Job attributes."), schema=IJob,
+        required=True, readonly=True)
+
+    snap = Reference(
+        title=_("The snap package to use for this job."),
+        schema=ISnap, required=True, readonly=True)
+
+    metadata = Attribute(_("A dict of data about the job."))
+
+
+class ISnapRequestBuildsJob(IRunnableJob):
+    """A Job that processes a request for builds of a snap package."""
+
+    requester = Reference(
+        title=_("The person requesting the builds."), schema=IPerson,
+        required=True, readonly=True)
+
+    archive = Reference(
+        title=_("The archive to associate the builds with."), schema=IArchive,
+        required=True, readonly=True)
+
+    pocket = Choice(
+        title=_("The pocket that should be targeted."),
+        vocabulary=PackagePublishingPocket, required=True, readonly=True)
+
+    channels = Dict(
+        title=_("Source snap channels to use for these builds."),
+        description=_(
+            "A dictionary mapping snap names to channels to use for these "
+            "builds.  Currently only 'core' and 'snapcraft' keys are "
+            "supported."),
+        key_type=TextLine(), required=False, readonly=True)
+
+    error_message = TextLine(
+        title=_("Error message resulting from running this job."),
+        required=False, readonly=True)
+
+    builds = List(
+        title=_("The builds created by this request."),
+        value_type=Reference(schema=ISnapBuild), required=True, readonly=True)
+
+
+class ISnapRequestBuildsJobSource(IJobSource):
+
+    def create(snap, requester, archive, pocket, channels):
+        """Request builds of a snap package.
+
+        :param snap: The snap package to build.
+        :param requester: The person requesting the builds.
+        :param archive: The IArchive to associate the builds with.
+        :param pocket: The pocket that should be targeted.
+        :param channels: A dictionary mapping snap names to channels to use
+            for these builds.
+        """

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2018-06-15 13:00:33 +0000
+++ lib/lp/snappy/model/snap.py	2018-06-15 13:00:33 +0000
@@ -6,10 +6,12 @@
     'Snap',
     ]
 
+from collections import OrderedDict
 from datetime import (
     datetime,
     timedelta,
     )
+from operator import attrgetter
 from urlparse import urlsplit
 
 from pymacaroons import Macaroon
@@ -116,6 +118,7 @@
 from lp.services.webapp.interfaces import ILaunchBag
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.model import WebhookTargetMixin
+from lp.snappy.adapters.buildarch import determine_architectures_to_build
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
     BadSnapSource,
@@ -463,21 +466,25 @@
             return False
         return True
 
-    def requestBuild(self, requester, archive, distro_arch_series, pocket,
-                     channels=None):
-        """See `ISnap`."""
+    def _checkRequestBuild(self, requester, archive):
+        """May `requester` request builds of this snap from `archive`?"""
         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 distro_arch_series not in self.getAllowedArchitectures():
-            raise SnapBuildDisallowedArchitecture(distro_arch_series)
         if archive.private and self.owner != archive.owner:
             # See rationale in `SnapBuildArchiveOwnerMismatch` docstring.
             raise SnapBuildArchiveOwnerMismatch()
 
+    def requestBuild(self, requester, archive, distro_arch_series, pocket,
+                     channels=None):
+        """See `ISnap`."""
+        self._checkRequestBuild(requester, archive)
+        if distro_arch_series not in self.getAllowedArchitectures():
+            raise SnapBuildDisallowedArchitecture(distro_arch_series)
+
         pending = IStore(self).find(
             SnapBuild,
             SnapBuild.snap_id == self.id,
@@ -495,8 +502,42 @@
         build.queueBuild()
         return build
 
+    def requestBuildsFromJob(self, requester, archive, pocket, channels=None,
+                             logger=None):
+        """See `ISnap`."""
+        snapcraft_data = removeSecurityProxy(
+            getUtility(ISnapSet).getSnapcraftYaml(self))
+        # Sort by Processor.id for determinism.  This is chosen to be the
+        # same order as in BinaryPackageBuildSet.createForSource, to
+        # minimise confusion.
+        supported_arches = OrderedDict(
+            (das.architecturetag, das) for das in sorted(
+                self.getAllowedArchitectures(),
+                key=attrgetter("processor.id")))
+        architectures_to_build = determine_architectures_to_build(
+            snapcraft_data, supported_arches.keys())
+
+        builds = []
+        for build_instance in architectures_to_build:
+            arch = build_instance.architecture
+            try:
+                build = self.requestBuild(
+                    requester, archive, supported_arches[arch], pocket,
+                    channels)
+                if logger is not None:
+                    logger.debug(
+                        " - %s/%s/%s: Build requested.",
+                        self.owner.name, self.name, arch)
+                builds.append(build)
+            except SnapBuildAlreadyPending as e:
+                if logger is not None:
+                    logger.warning(
+                        " - %s/%s/%s: %s",
+                        self.owner.name, self.name, arch, e)
+        return builds
+
     def requestAutoBuilds(self, allow_failures=False, logger=None):
-        """See `ISnapSet`."""
+        """See `ISnap`."""
         builds = []
         if self.auto_build_archive is None:
             raise CannotRequestAutoBuilds("auto_build_archive")

=== added file 'lib/lp/snappy/model/snapjob.py'
--- lib/lp/snappy/model/snapjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/snapjob.py	2018-06-15 13:00:33 +0000
@@ -0,0 +1,271 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap package jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SnapJob',
+    'SnapJobType',
+    'SnapRequestBuildsJob',
+    ]
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+    DBEnumeratedType,
+    DBItem,
+    )
+from storm.locals import (
+    Int,
+    JSON,
+    Reference,
+    )
+from storm.store import EmptyResultSet
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+    implementer,
+    provider,
+    )
+
+from lp.app.errors import NotFoundError
+from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.config import config
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import (
+    EnumeratedSubclass,
+    Job,
+    )
+from lp.services.job.runner import BaseRunnableJob
+from lp.services.mail.sendmail import format_address_for_person
+from lp.services.propertycache import cachedproperty
+from lp.services.scripts import log
+from lp.snappy.interfaces.snap import (
+    CannotFetchSnapcraftYaml,
+    CannotParseSnapcraftYaml,
+    )
+from lp.snappy.interfaces.snapjob import (
+    ISnapJob,
+    ISnapRequestBuildsJob,
+    ISnapRequestBuildsJobSource,
+    )
+from lp.snappy.model.snapbuild import SnapBuild
+from lp.soyuz.model.archive import Archive
+
+
+class SnapJobType(DBEnumeratedType):
+    """Values that `ISnapJob.job_type` can take."""
+
+    REQUEST_BUILDS = DBItem(0, """
+        Request builds
+
+        This job requests builds of a snap package.
+        """)
+
+
+@implementer(ISnapJob)
+class SnapJob(StormBase):
+    """See `ISnapJob`."""
+
+    __storm_table__ = 'SnapJob'
+
+    job_id = Int(name='job', primary=True, allow_none=False)
+    job = Reference(job_id, 'Job.id')
+
+    snap_id = Int(name='snap', allow_none=False)
+    snap = Reference(snap_id, 'Snap.id')
+
+    job_type = EnumCol(enum=SnapJobType, notNull=True)
+
+    metadata = JSON('json_data', allow_none=False)
+
+    def __init__(self, snap, job_type, metadata, **job_args):
+        """Constructor.
+
+        Extra keyword arguments are used to construct the underlying Job
+        object.
+
+        :param snap: The `ISnap` this job relates to.
+        :param job_type: The `SnapJobType` of this job.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(SnapJob, self).__init__()
+        self.job = Job(**job_args)
+        self.snap = snap
+        self.job_type = job_type
+        self.metadata = metadata
+
+    def makeDerived(self):
+        return SnapJobDerived.makeSubclass(self)
+
+
+@delegate_to(ISnapJob)
+class SnapJobDerived(BaseRunnableJob):
+
+    __metaclass__ = EnumeratedSubclass
+
+    def __init__(self, snap_job):
+        self.context = snap_job
+
+    def __repr__(self):
+        """An informative representation of the job."""
+        return "<%s for ~%s/+snap/%s>" % (
+            self.__class__.__name__, self.snap.owner.name, self.snap.name)
+
+    @classmethod
+    def get(cls, job_id):
+        """Get a job by id.
+
+        :return: The `SnapJob` with the specified id, as the current
+            `SnapJobDerived` subclass.
+        :raises: `NotFoundError` if there is no job with the specified id,
+            or its `job_type` does not match the desired subclass.
+        """
+        snap_job = IStore(SnapJob).get(SnapJob, job_id)
+        if snap_job.job_type != cls.class_job_type:
+            raise NotFoundError(
+                "No object found with id %d and type %s" %
+                (job_id, cls.class_job_type.title))
+        return cls(snap_job)
+
+    @classmethod
+    def iterReady(cls):
+        """See `IJobSource`."""
+        jobs = IMasterStore(SnapJob).find(
+            SnapJob,
+            SnapJob.job_type == cls.class_job_type,
+            SnapJob.job == Job.id,
+            Job.id.is_in(Job.ready_jobs))
+        return (cls(job) for job in jobs)
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        oops_vars = super(SnapJobDerived, self).getOopsVars()
+        oops_vars.extend([
+            ("job_id", self.context.job.id),
+            ("job_type", self.context.job_type.title),
+            ("snap_owner_name", self.context.snap.owner.name),
+            ("snap_name", self.context.snap.name),
+            ])
+        return oops_vars
+
+
+@implementer(ISnapRequestBuildsJob)
+@provider(ISnapRequestBuildsJobSource)
+class SnapRequestBuildsJob(SnapJobDerived):
+    """A Job that processes a request for builds of a snap package."""
+
+    class_job_type = SnapJobType.REQUEST_BUILDS
+
+    user_error_types = (CannotParseSnapcraftYaml, NotFoundError)
+    retry_error_types = (CannotFetchSnapcraftYaml,)
+
+    max_retries = 5
+
+    config = config.ISnapRequestBuildsJobSource
+
+    @classmethod
+    def create(cls, snap, requester, archive, pocket, channels):
+        """See `ISnapRequestBuildsJobSource`."""
+        metadata = {
+            "requester": requester.id,
+            "archive": archive.id,
+            "pocket": pocket.value,
+            "channels": channels,
+            }
+        snap_job = SnapJob(snap, cls.class_job_type, metadata)
+        job = cls(snap_job)
+        job.celeryRunOnCommit()
+        return job
+
+    def getOperationDescription(self):
+        return "requesting builds of %s" % self.snap.name
+
+    def getErrorRecipients(self):
+        if self.requester is None or self.requester.preferredemail is None:
+            return []
+        return [format_address_for_person(self.requester)]
+
+    @cachedproperty
+    def requester(self):
+        """See `ISnapRequestBuildsJob`."""
+        requester_id = self.metadata["requester"]
+        return getUtility(IPersonSet).get(requester_id)
+
+    @cachedproperty
+    def archive(self):
+        """See `ISnapRequestBuildsJob`."""
+        archive_id = self.metadata["archive"]
+        return IStore(Archive).find(Archive, Archive.id == archive_id).one()
+
+    @property
+    def pocket(self):
+        """See `ISnapRequestBuildsJob`."""
+        name = self.metadata["pocket"]
+        return PackagePublishingPocket.items[name]
+
+    @property
+    def channels(self):
+        """See `ISnapRequestBuildsJob`."""
+        return self.metadata["channels"]
+
+    @property
+    def error_message(self):
+        """See `ISnapRequestBuildsJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `ISnapRequestBuildsJob`."""
+        self.metadata["error_message"] = message
+
+    @property
+    def builds(self):
+        """See `ISnapRequestBuildsJob`."""
+        build_ids = self.metadata.get("builds")
+        if build_ids is None:
+            return EmptyResultSet()
+        else:
+            return IStore(SnapBuild).find(
+                SnapBuild, SnapBuild.id.is_in(build_ids))
+
+    @builds.setter
+    def builds(self, builds):
+        """See `ISnapRequestBuildsJob`."""
+        self.metadata["builds"] = [build.id for build in builds]
+
+    def run(self):
+        """See `IRunnableJob`."""
+        requester = self.requester
+        if requester is None:
+            log.info(
+                "Skipping %r because the requester has been deleted." % self)
+            return
+        archive = self.archive
+        if archive is None:
+            log.info(
+                "Skipping %r because the archive has been deleted." % self)
+            return
+        try:
+            self.builds = self.snap.requestBuildsFromJob(
+                requester, archive, self.pocket, channels=self.channels,
+                logger=log)
+            self.error_message = None
+        except self.retry_error_types:
+            raise
+        except Exception as e:
+            self.error_message = str(e)
+            # The normal job infrastructure will abort the transaction, but
+            # we want to commit instead: the only database changes we make
+            # are to this job's metadata and should be preserved.
+            transaction.commit()
+            raise

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2018-06-15 13:00:33 +0000
+++ lib/lp/snappy/tests/test_snap.py	2018-06-15 13:00:33 +0000
@@ -12,6 +12,7 @@
     timedelta,
     )
 import json
+from textwrap import dedent
 from urlparse import urlsplit
 
 from fixtures import MockPatch
@@ -91,6 +92,7 @@
     ISnapBuildSet,
     )
 from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
+from lp.snappy.interfaces.snapjob import ISnapRequestBuildsJobSource
 from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
 from lp.snappy.model.snap import SnapSet
 from lp.snappy.model.snapbuild import SnapFile
@@ -361,6 +363,65 @@
             snap.owner, snap.distro_series.main_archive, distroarchseries,
             PackagePublishingPocket.UPDATES)
 
+    def makeRequestBuildsJob(self, arch_tags):
+        distro = self.factory.makeDistribution()
+        distroseries = self.factory.makeDistroSeries(distribution=distro)
+        processors = [
+            self.factory.makeProcessor(
+                name=arch_tag, supports_virtualized=True)
+            for arch_tag in arch_tags]
+        for processor in processors:
+            das = self.factory.makeDistroArchSeries(
+                distroseries=distroseries, architecturetag=processor.name,
+                processor=processor)
+            das.addOrUpdateChroot(self.factory.makeLibraryFileAlias(
+                filename="fake_chroot.tar.gz", db_only=True))
+        [git_ref] = self.factory.makeGitRefs()
+        snap = self.factory.makeSnap(
+            git_ref=git_ref, distroseries=distroseries, processors=processors)
+        return getUtility(ISnapRequestBuildsJobSource).create(
+            snap, snap.owner.teamowner, distro.main_archive,
+            PackagePublishingPocket.RELEASE, {"snapcraft": "edge"})
+
+    def assertRequestedBuildsMatch(self, builds, job, arch_tags):
+        self.assertThat(builds, MatchesSetwise(
+            *(MatchesStructure(
+                requester=Equals(job.requester),
+                snap=Equals(job.snap),
+                archive=Equals(job.archive),
+                distro_arch_series=Equals(job.snap.distro_series[arch_tag]),
+                pocket=Equals(job.pocket),
+                channels=Equals(job.channels))
+              for arch_tag in arch_tags)))
+
+    def test_requestBuildsFromJob_restricts_explicit_list(self):
+        # requestBuildsFromJob limits builds targeted at an explicit list of
+        # architectures to those allowed for the snap.
+        self.useFixture(GitHostingFixture(blob=dedent("""\
+            architectures:
+              - build-on: sparc
+              - build-on: i386
+              - build-on: avr
+            """)))
+        job = self.makeRequestBuildsJob(["sparc"])
+        with person_logged_in(job.requester):
+            builds = job.snap.requestBuildsFromJob(
+                job.requester, job.archive, job.pocket,
+                removeSecurityProxy(job.channels))
+        self.assertRequestedBuildsMatch(builds, job, ["sparc"])
+
+    def test_requestBuildsFromJob_no_explicit_architectures(self):
+        # If the snap doesn't specify any architectures,
+        # requestBuildsFromJob requests builds for all configured
+        # architectures.
+        self.useFixture(GitHostingFixture(blob="name: foo\n"))
+        job = self.makeRequestBuildsJob(["mips64el", "riscv64"])
+        with person_logged_in(job.requester):
+            builds = job.snap.requestBuildsFromJob(
+                job.requester, job.archive, job.pocket,
+                removeSecurityProxy(job.channels))
+        self.assertRequestedBuildsMatch(builds, job, ["mips64el", "riscv64"])
+
     def test_requestAutoBuilds(self):
         # requestAutoBuilds creates new builds for all configured
         # architectures with appropriate parameters.

=== added file 'lib/lp/snappy/tests/test_snapjob.py'
--- lib/lp/snappy/tests/test_snapjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_snapjob.py	2018-06-15 13:00:33 +0000
@@ -0,0 +1,155 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for snap package jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from textwrap import dedent
+
+from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
+    Equals,
+    Is,
+    MatchesSetwise,
+    MatchesStructure,
+    )
+
+from lp.code.tests.helpers import GitHostingFixture
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.config import config
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
+from lp.services.mail.sendmail import format_address_for_person
+from lp.snappy.interfaces.snap import CannotParseSnapcraftYaml
+from lp.snappy.interfaces.snapjob import (
+    ISnapJob,
+    ISnapRequestBuildsJob,
+    )
+from lp.snappy.model.snapjob import (
+    SnapJob,
+    SnapJobType,
+    SnapRequestBuildsJob,
+    )
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestSnapJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_provides_interface(self):
+        # `SnapJob` objects provide `ISnapJob`.
+        snap = self.factory.makeSnap()
+        self.assertProvides(
+            SnapJob(snap, SnapJobType.REQUEST_BUILDS, {}), ISnapJob)
+
+
+class TestSnapRequestBuildsJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_provides_interface(self):
+        # `SnapRequestBuildsJob` objects provide `ISnapRequestBuildsJob`."""
+        snap = self.factory.makeSnap()
+        archive = self.factory.makeArchive()
+        job = SnapRequestBuildsJob.create(
+            snap, snap.registrant, archive, PackagePublishingPocket.RELEASE,
+            None)
+        self.assertProvides(job, ISnapRequestBuildsJob)
+
+    def test___repr__(self):
+        # `SnapRequestBuildsJob` objects have an informative __repr__.
+        snap = self.factory.makeSnap()
+        archive = self.factory.makeArchive()
+        job = SnapRequestBuildsJob.create(
+            snap, snap.registrant, archive, PackagePublishingPocket.RELEASE,
+            None)
+        self.assertEqual(
+            "<SnapRequestBuildsJob for ~%s/+snap/%s>" % (
+                snap.owner.name, snap.name),
+            repr(job))
+
+    def makeSeriesAndProcessors(self, arch_tags):
+        distro = self.factory.makeDistribution()
+        distroseries = self.factory.makeDistroSeries(distribution=distro)
+        processors = [
+            self.factory.makeProcessor(
+                name=arch_tag, supports_virtualized=True)
+            for arch_tag in arch_tags]
+        for processor in processors:
+            das = self.factory.makeDistroArchSeries(
+                distroseries=distroseries, architecturetag=processor.name,
+                processor=processor)
+            das.addOrUpdateChroot(self.factory.makeLibraryFileAlias(
+                filename="fake_chroot.tar.gz", db_only=True))
+        return distroseries, processors
+
+    def test_run(self):
+        # The job requests builds and records the result.
+        distroseries, processors = self.makeSeriesAndProcessors(
+            ["avr2001", "sparc64", "x32"])
+        [git_ref] = self.factory.makeGitRefs()
+        snap = self.factory.makeSnap(
+            git_ref=git_ref, distroseries=distroseries, processors=processors)
+        job = SnapRequestBuildsJob.create(
+            snap, snap.registrant, distroseries.main_archive,
+            PackagePublishingPocket.RELEASE, {"core": "stable"})
+        snapcraft_yaml = dedent("""\
+            architectures:
+              - build-on: avr2001
+              - build-on: x32
+            """)
+        self.useFixture(GitHostingFixture(blob=snapcraft_yaml))
+        with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
+            JobRunner([job]).runAll()
+        self.assertEmailQueueLength(0)
+        self.assertThat(job, MatchesStructure(
+            job=MatchesStructure.byEquality(status=JobStatus.COMPLETED),
+            error_message=Is(None),
+            builds=AfterPreprocessing(set, MatchesSetwise(*[
+                MatchesStructure.byEquality(
+                    requester=snap.registrant,
+                    snap=snap,
+                    archive=distroseries.main_archive,
+                    distro_arch_series=distroseries[arch],
+                    pocket=PackagePublishingPocket.RELEASE,
+                    channels={"core": "stable"})
+                for arch in ("avr2001", "x32")]))))
+
+    def test_run_failed(self):
+        # A failed run sets the job status to FAILED and records the error
+        # message.
+        # The job requests builds and records the result.
+        distroseries, processors = self.makeSeriesAndProcessors(
+            ["avr2001", "sparc64", "x32"])
+        [git_ref] = self.factory.makeGitRefs()
+        snap = self.factory.makeSnap(
+            git_ref=git_ref, distroseries=distroseries, processors=processors)
+        job = SnapRequestBuildsJob.create(
+            snap, snap.registrant, distroseries.main_archive,
+            PackagePublishingPocket.RELEASE, {"core": "stable"})
+        self.useFixture(GitHostingFixture()).getBlob.failure = (
+            CannotParseSnapcraftYaml("Nonsense on stilts"))
+        with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
+            JobRunner([job]).runAll()
+        [notification] = self.assertEmailQueueLength(1)
+        self.assertThat(dict(notification), ContainsDict({
+            "From": Equals(config.canonical.noreply_from_address),
+            "To": Equals(format_address_for_person(snap.registrant)),
+            "Subject": Equals(
+                "Launchpad error while requesting builds of %s" % snap.name),
+            }))
+        self.assertEqual(
+            "Launchpad encountered an error during the following operation: "
+            "requesting builds of %s.  Nonsense on stilts" % snap.name,
+            notification.get_payload(decode=True))
+        self.assertThat(job, MatchesStructure(
+            job=MatchesStructure.byEquality(status=JobStatus.FAILED),
+            error_message=Equals("Nonsense on stilts"),
+            builds=AfterPreprocessing(set, MatchesSetwise())))


Follow ups