launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #22633
[Merge] lp:~cjwatson/launchpad/snap-request-builds into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-request-builds into lp:launchpad with lp:~cjwatson/launchpad/snap-request-builds-job as a prerequisite.
Commit message:
Add Snap.requestBuilds and associated webservice workflow.
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/+merge/348088
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-request-builds into lp:launchpad.
=== modified file 'lib/lp/security.py'
--- lib/lp/security.py 2018-02-20 11:17:28 +0000
+++ lib/lp/security.py 2018-06-15 16:59: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).
"""Security policies for using content objects."""
@@ -192,7 +192,10 @@
ILanguage,
ILanguageSet,
)
-from lp.snappy.interfaces.snap import ISnap
+from lp.snappy.interfaces.snap import (
+ ISnap,
+ ISnapBuildRequest,
+ )
from lp.snappy.interfaces.snapbuild import ISnapBuild
from lp.snappy.interfaces.snappyseries import (
ISnappySeries,
@@ -3222,6 +3225,15 @@
and EditSnap(self.obj).checkAuthenticated(user))
+class ViewSnapBuildRequest(DelegatedAuthorization):
+ permission = 'launchpad.View'
+ usedfor = ISnapBuildRequest
+
+ def __init__(self, obj):
+ super(ViewSnapBuildRequest, self).__init__(
+ obj, obj.snap, 'launchpad.View')
+
+
class ViewSnapBuild(DelegatedAuthorization):
permission = 'launchpad.View'
usedfor = ISnapBuild
=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml 2016-05-19 02:02:39 +0000
+++ lib/lp/snappy/browser/configure.zcml 2018-06-15 16:59:33 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2016 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).
-->
@@ -88,6 +88,10 @@
path_expression="string:+snaps"
parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
<browser:url
+ for="lp.snappy.interfaces.snap.ISnapBuildRequest"
+ path_expression="string:+build-request/${id}"
+ attribute_to_parent="snap" />
+ <browser:url
for="lp.snappy.interfaces.snapbuild.ISnapBuild"
path_expression="string:+build/${id}"
attribute_to_parent="snap" />
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/browser/snap.py 2018-06-15 16:59:33 +0000
@@ -104,6 +104,14 @@
class SnapNavigation(WebhookTargetNavigationMixin, Navigation):
usedfor = ISnap
+ @stepthrough('+build-request')
+ def traverse_build_request(self, name):
+ try:
+ job_id = int(name)
+ except ValueError:
+ return None
+ return self.context.getBuildRequest(job_id)
+
@stepthrough('+build')
def traverse_build(self, name):
build = get_build_by_id_str(ISnapBuildSet, name)
=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/configure.zcml 2018-06-15 16:59:33 +0000
@@ -42,6 +42,13 @@
<allow interface="lp.snappy.interfaces.snap.ISnapSet" />
</securedutility>
+ <!-- SnapBuildRequest -->
+ <class class="lp.snappy.model.snap.SnapBuildRequest">
+ <require
+ permission="launchpad.View"
+ interface="lp.snappy.interfaces.snap.ISnapBuildRequest" />
+ </class>
+
<!-- SnapBuild -->
<class class="lp.snappy.model.snapbuild.SnapBuild">
<require
=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/interfaces/snap.py 2018-06-15 16:59:33 +0000
@@ -15,6 +15,7 @@
'CannotRequestAutoBuilds',
'DuplicateSnapName',
'ISnap',
+ 'ISnapBuildRequest',
'ISnapEdit',
'ISnapSet',
'ISnapView',
@@ -27,6 +28,7 @@
'SnapBuildAlreadyPending',
'SnapBuildArchiveOwnerMismatch',
'SnapBuildDisallowedArchitecture',
+ 'SnapBuildRequestStatus',
'SnapNotOwner',
'SnapPrivacyMismatch',
'SnapPrivateFeatureDisabled',
@@ -34,6 +36,10 @@
import httplib
+from lazr.enum import (
+ EnumeratedType,
+ Item,
+ )
from lazr.lifecycle.snapshot import doNotSnapshot
from lazr.restful.declarations import (
call_with,
@@ -247,6 +253,57 @@
"""Launchpad cannot parse this snap package's snapcraft.yaml."""
+class SnapBuildRequestStatus(EnumeratedType):
+ """The status of a request to build a snap package."""
+
+ PENDING = Item("""
+ Pending
+
+ This snap build request is pending.
+ """)
+
+ FAILED = Item("""
+ Failed
+
+ This snap build request failed.
+ """)
+
+ COMPLETED = Item("""
+ Completed
+
+ This snap build request completed successfully.
+ """)
+
+
+class ISnapBuildRequest(Interface):
+ """A request to build a snap package."""
+
+ # XXX cjwatson 2018-06-14 bug=760849: "beta" is a lie to get WADL
+ # generation working. Individual attributes must set their version to
+ # "devel".
+ export_as_webservice_entry(as_of="beta")
+
+ id = Int(title=_("ID"), required=True, readonly=True)
+
+ snap = exported(Reference(
+ # Really ISnap, patched in lp.snappy.interfaces.webservice.
+ Interface,
+ title=_("Snap package"), required=True, readonly=True))
+
+ status = exported(Choice(
+ title=_("Status"), vocabulary=SnapBuildRequestStatus,
+ required=True, readonly=True))
+
+ error_message = exported(TextLine(
+ title=_("Error message"), required=True, readonly=True))
+
+ builds = exported(CollectionField(
+ title=_("Builds produced by this request"),
+ # Really ISnapBuild, patched in lp.snappy.interfaces.webservice.
+ value_type=Reference(schema=Interface),
+ required=True, readonly=True))
+
+
class ISnapView(Interface):
"""`ISnap` attributes that require launchpad.View permission."""
@@ -316,6 +373,34 @@
:return: `ISnapBuild`.
"""
+ @call_with(requester=REQUEST_USER)
+ @operation_parameters(
+ archive=Reference(schema=IArchive),
+ pocket=Choice(vocabulary=PackagePublishingPocket),
+ channels=Dict(
+ title=_("Source snap channels to use for this build."),
+ description=_(
+ "A dictionary mapping snap names to channels to use for this "
+ "build. Currently only 'core' and 'snapcraft' keys are "
+ "supported."),
+ key_type=TextLine(), required=False))
+ @export_factory_operation(ISnapBuildRequest, [])
+ @operation_for_version("devel")
+ def requestBuilds(requester, archive, pocket, channels=None):
+ """Request that the snap package be built for relevant architectures.
+
+ This is an asynchronous operation, and returns a job ID which can be
+ passed to `snap.getRequestedBuilds`; once the operation has
+ finished, that method will return the resulting builds.
+
+ :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.
+ :return: An `ISnapBuildRequest`.
+ """
+
def requestBuildsFromJob(requester, archive, pocket, channels=None,
logger=None):
"""Synchronous part of `Snap.requestBuilds`.
@@ -331,6 +416,13 @@
:return: A sequence of `ISnapBuild` instances.
"""
+ def getBuildRequest(job_id):
+ """Get an asynchronous build request by ID.
+
+ :param job_id: The ID of the build request.
+ :return: `ISnapBuildRequest`.
+ """
+
@operation_parameters(
snap_build_ids=List(
title=_("A list of snap build ids."),
=== modified file 'lib/lp/snappy/interfaces/snapjob.py'
--- lib/lp/snappy/interfaces/snapjob.py 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/interfaces/snapjob.py 2018-06-15 16:59:33 +0000
@@ -95,3 +95,11 @@
:param channels: A dictionary mapping snap names to channels to use
for these builds.
"""
+
+ def getBySnapAndID(snap, job_id):
+ """Get a job by snap and job ID.
+
+ :return: The `SnapRequestBuildsJob` with the specified snap and ID.
+ :raises: `NotFoundError` if there is no job with the specified snap
+ and ID, or its `job_type` is not `SnapJobType.REQUEST_BUILDS`.
+ """
=== modified file 'lib/lp/snappy/interfaces/webservice.py'
--- lib/lp/snappy/interfaces/webservice.py 2016-07-18 17:16:30 +0000
+++ lib/lp/snappy/interfaces/webservice.py 2018-06-15 16:59:33 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 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).
"""All the interfaces that are exposed through the webservice.
@@ -12,6 +12,7 @@
__all__ = [
'ISnap',
'ISnapBuild',
+ 'ISnapBuildRequest',
'ISnappySeries',
'ISnappySeriesSet',
'ISnapSet',
@@ -25,6 +26,7 @@
)
from lp.snappy.interfaces.snap import (
ISnap,
+ ISnapBuildRequest,
ISnapEdit,
ISnapSet,
ISnapView,
@@ -42,6 +44,10 @@
# ISnapFile
patch_reference_property(ISnapFile, 'snapbuild', ISnapBuild)
+# ISnapBuildRequest
+patch_reference_property(ISnapBuildRequest, 'snap', ISnap)
+patch_collection_property(ISnapBuildRequest, 'builds', ISnapBuild)
+
# ISnapView
patch_entry_return_type(ISnapView, 'requestBuild', ISnapBuild)
patch_collection_property(ISnapView, 'builds', ISnapBuild)
=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/model/snap.py 2018-06-15 16:59:33 +0000
@@ -110,6 +110,7 @@
NullsLast,
)
from lp.services.features import getFeatureFlag
+from lp.services.job.interfaces.job import JobStatus
from lp.services.librarian.model import (
LibraryFileAlias,
LibraryFileContent,
@@ -129,6 +130,7 @@
CannotRequestAutoBuilds,
DuplicateSnapName,
ISnap,
+ ISnapBuildRequest,
ISnapSet,
NoSourceForSnap,
NoSuchSnap,
@@ -137,11 +139,13 @@
SnapBuildAlreadyPending,
SnapBuildArchiveOwnerMismatch,
SnapBuildDisallowedArchitecture,
+ SnapBuildRequestStatus,
SnapNotOwner,
SnapPrivacyMismatch,
SnapPrivateFeatureDisabled,
)
from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.snappy.interfaces.snapjob import ISnapRequestBuildsJobSource
from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
from lp.snappy.model.snapbuild import SnapBuild
@@ -162,6 +166,43 @@
removeSecurityProxy(snap).date_last_modified = UTC_NOW
+@implementer(ISnapBuildRequest)
+class SnapBuildRequest:
+ """See `ISnapBuildRequest`.
+
+ This is not directly backed by a database table; instead, it is a
+ webservice-friendly view of an asynchronous build request.
+ """
+
+ def __init__(self, snap, id):
+ self._job = getUtility(ISnapRequestBuildsJobSource).getBySnapAndID(
+ snap, id)
+ self.snap = snap
+ self.id = id
+
+ @property
+ def status(self):
+ """See `ISnapBuildRequest`."""
+ status_map = {
+ JobStatus.WAITING: SnapBuildRequestStatus.PENDING,
+ JobStatus.RUNNING: SnapBuildRequestStatus.PENDING,
+ JobStatus.COMPLETED: SnapBuildRequestStatus.COMPLETED,
+ JobStatus.FAILED: SnapBuildRequestStatus.FAILED,
+ JobStatus.SUSPENDED: SnapBuildRequestStatus.PENDING,
+ }
+ return status_map[self._job.job.status]
+
+ @property
+ def error_message(self):
+ """See `ISnapBuildRequest`."""
+ return self._job.error_message
+
+ @property
+ def builds(self):
+ """See `ISnapBuildRequest`."""
+ return self._job.builds
+
+
@implementer(ISnap, IHasOwner)
class Snap(Storm, WebhookTargetMixin):
"""See `ISnap`."""
@@ -502,6 +543,13 @@
build.queueBuild()
return build
+ def requestBuilds(self, requester, archive, pocket, channels=None):
+ """See `ISnap`."""
+ self._checkRequestBuild(requester, archive)
+ job = getUtility(ISnapRequestBuildsJobSource).create(
+ self, requester, archive, pocket, channels)
+ return self.getBuildRequest(job.job_id)
+
def requestBuildsFromJob(self, requester, archive, pocket, channels=None,
logger=None):
"""See `ISnap`."""
@@ -572,6 +620,10 @@
self.owner.name, self.name, arch.architecturetag, e)
return builds
+ def getBuildRequest(self, job_id):
+ """See `ISnap`."""
+ return SnapBuildRequest(self, job_id)
+
def _getBuilds(self, filter_term, order_by):
"""The actual query to get the builds."""
query_args = [
=== modified file 'lib/lp/snappy/model/snapjob.py'
--- lib/lp/snappy/model/snapjob.py 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/model/snapjob.py 2018-06-15 16:59:33 +0000
@@ -187,6 +187,20 @@
job.celeryRunOnCommit()
return job
+ @classmethod
+ def getBySnapAndID(cls, snap, job_id):
+ """See `ISnapRequestBuildsJobSource`."""
+ snap_job = IStore(SnapJob).find(
+ SnapJob,
+ SnapJob.job_id == job_id,
+ SnapJob.snap == snap,
+ SnapJob.job_type == cls.class_job_type).one()
+ if snap_job is None:
+ raise NotFoundError(
+ "No REQUEST_BUILDS job with ID %d found for %r" %
+ (job_id, snap))
+ return cls(snap_job)
+
def getOperationDescription(self):
return "requesting builds of %s" % self.snap.name
=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py 2018-06-15 16:59:32 +0000
+++ lib/lp/snappy/tests/test_snap.py 2018-06-15 16:59:33 +0000
@@ -23,6 +23,7 @@
from storm.exceptions import LostObjectError
from storm.locals import Store
from testtools.matchers import (
+ AfterPreprocessing,
ContainsDict,
Equals,
Is,
@@ -69,6 +70,8 @@
FeatureFixture,
MemoryFeatureFixture,
)
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
from lp.services.log.logger import BufferLogger
from lp.services.propertycache import clear_property_cache
from lp.services.webapp.interfaces import OAuthPermission
@@ -84,6 +87,7 @@
SNAP_TESTING_FLAGS,
SnapBuildAlreadyPending,
SnapBuildDisallowedArchitecture,
+ SnapBuildRequestStatus,
SnapPrivacyMismatch,
SnapPrivateFeatureDisabled,
)
@@ -363,6 +367,30 @@
snap.owner, snap.distro_series.main_archive, distroarchseries,
PackagePublishingPocket.UPDATES)
+ def test_requestBuilds(self):
+ # requestBuilds schedules a job and returns a corresponding
+ # SnapBuildRequest.
+ snap = self.factory.makeSnap()
+ with person_logged_in(snap.owner.teamowner):
+ request = snap.requestBuilds(
+ snap.owner.teamowner, snap.distro_series.main_archive,
+ PackagePublishingPocket.UPDATES,
+ channels={"snapcraft": "edge"})
+ self.assertThat(request, MatchesStructure(
+ snap=Equals(snap),
+ status=Equals(SnapBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ builds=AfterPreprocessing(set, MatchesSetwise())))
+ [job] = getUtility(ISnapRequestBuildsJobSource).iterReady()
+ self.assertThat(job, MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ snap=Equals(snap),
+ requester=Equals(snap.owner.teamowner),
+ archive=Equals(snap.distro_series.main_archive),
+ pocket=Equals(PackagePublishingPocket.UPDATES),
+ channels=Equals({"snapcraft": "edge"})))
+
def makeRequestBuildsJob(self, arch_tags):
distro = self.factory.makeDistribution()
distroseries = self.factory.makeDistroSeries(distribution=distro)
@@ -2408,6 +2436,128 @@
"if the snap package owner and the archive owner are equal.",
response.body)
+ def test_requestBuilds(self):
+ # Requests for builds for all relevant architectures can be
+ # performed over the webservice, and the returned entry indicates
+ # the status of the asynchronous job.
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ processors = [
+ self.factory.makeProcessor(supports_virtualized=True)
+ for _ in range(3)]
+ for processor in processors:
+ self.makeBuildableDistroArchSeries(
+ distroseries=distroseries, architecturetag=processor.name,
+ processor=processor, owner=self.person)
+ archive_url = api_url(distroseries.main_archive)
+ [git_ref] = self.factory.makeGitRefs()
+ snap = self.makeSnap(
+ git_ref=git_ref, distroseries=distroseries, processors=processors)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuilds", archive=archive_url,
+ pocket="Updates", channels={"snapcraft": "edge"})
+ self.assertEqual(201, response.status)
+ build_request_url = response.getHeader("Location")
+ build_request = self.webservice.get(build_request_url).jsonBody()
+ self.assertThat(build_request, ContainsDict({
+ "snap_link": Equals(snap["self_link"]),
+ "status": Equals("Pending"),
+ "error_message": Is(None),
+ "builds_collection_link": Equals(build_request_url + "/builds"),
+ }))
+ self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+ with person_logged_in(self.person):
+ snapcraft_yaml = "architectures:\n"
+ for processor in processors:
+ snapcraft_yaml += " - build-on: %s\n" % processor.name
+ self.useFixture(GitHostingFixture(blob=snapcraft_yaml))
+ [job] = getUtility(ISnapRequestBuildsJobSource).iterReady()
+ with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
+ JobRunner([job]).runAll()
+ build_request = self.webservice.get(
+ build_request["self_link"]).jsonBody()
+ self.assertThat(build_request, ContainsDict({
+ "snap_link": Equals(snap["self_link"]),
+ "status": Equals("Completed"),
+ "error_message": Is(None),
+ "builds_collection_link": Equals(build_request_url + "/builds"),
+ }))
+ builds = self.webservice.get(
+ build_request["builds_collection_link"]).jsonBody()["entries"]
+ with person_logged_in(self.person):
+ self.assertThat(builds, MatchesSetwise(*(
+ ContainsDict({
+ "snap_link": Equals(snap["self_link"]),
+ "archive_link": Equals(
+ self.getURL(distroseries.main_archive)),
+ "arch_tag": Equals(processor.name),
+ "pocket": Equals("Updates"),
+ "channels": Equals({"snapcraft": "edge"}),
+ })
+ for processor in processors)))
+
+ def test_requestBuilds_failure(self):
+ # If the asynchronous build request job fails, this is reflected in
+ # the build request entry.
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ processor = self.factory.makeProcessor(supports_virtualized=True)
+ self.makeBuildableDistroArchSeries(
+ distroseries=distroseries, architecturetag=processor.name,
+ processor=processor, owner=self.person)
+ archive_url = api_url(distroseries.main_archive)
+ [git_ref] = self.factory.makeGitRefs()
+ snap = self.makeSnap(
+ git_ref=git_ref, distroseries=distroseries,
+ processors=[processor])
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuilds", archive=archive_url,
+ pocket="Updates")
+ self.assertEqual(201, response.status)
+ build_request_url = response.getHeader("Location")
+ build_request = self.webservice.get(build_request_url).jsonBody()
+ self.assertThat(build_request, ContainsDict({
+ "snap_link": Equals(snap["self_link"]),
+ "status": Equals("Pending"),
+ "error_message": Is(None),
+ "builds_collection_link": Equals(build_request_url + "/builds"),
+ }))
+ self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+ with person_logged_in(self.person):
+ self.useFixture(GitHostingFixture()).getBlob.failure = Exception(
+ "Something went wrong")
+ [job] = getUtility(ISnapRequestBuildsJobSource).iterReady()
+ with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
+ JobRunner([job]).runAll()
+ build_request = self.webservice.get(
+ build_request["self_link"]).jsonBody()
+ self.assertThat(build_request, ContainsDict({
+ "snap_link": Equals(snap["self_link"]),
+ "status": Equals("Failed"),
+ "error_message": Equals("Something went wrong"),
+ "builds_collection_link": Equals(build_request_url + "/builds"),
+ }))
+ self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+
+ def test_requestBuilds_not_owner(self):
+ # If the requester is not the owner or a member of the owner team,
+ # build requests are rejected.
+ other_team = self.factory.makeTeam(displayname="Other Team")
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ archive_url = api_url(distroseries.main_archive)
+ other_webservice = webservice_for_person(
+ other_team.teamowner, permission=OAuthPermission.WRITE_PUBLIC)
+ other_webservice.default_api_version = "devel"
+ login(ANONYMOUS)
+ snap = self.makeSnap(
+ owner=other_team, distroseries=distroseries,
+ webservice=other_webservice)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuilds", archive=archive_url,
+ pocket="Updates")
+ self.assertEqual(401, response.status)
+ self.assertEqual(
+ "Test Person cannot create snap package builds owned by Other "
+ "Team.", response.body)
+
def test_requestAutoBuilds(self):
# requestAutoBuilds can be performed over the webservice.
distroseries = self.factory.makeDistroSeries()
Follow ups