launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #22646
[Merge] lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad with lp:~cjwatson/launchpad/snap-daily-builds-request-builds as a prerequisite.
Commit message:
Convert the snap web UI to use Snap.requestBuilds.
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-ui/+merge/348299
The hard bit here was sorting out the JavaScript that auto-refreshes bits of Snap:+index. I went for converting Snap.getBuildSummariesForSnapBuildIds into a more general Snap.getBuildSummaries that handles both build requests and builds, making it possible to extend the existing updater.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml 2017-09-01 12:57:34 +0000
+++ lib/lp/app/browser/configure.zcml 2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2015 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).
-->
@@ -591,6 +591,13 @@
name="image"
/>
+ <adapter
+ for="lp.snappy.interfaces.snap.ISnapBuildRequest"
+ provides="zope.traversing.interfaces.IPathAdapter"
+ factory="lp.app.browser.tales.SnapBuildRequestImageDisplayAPI"
+ name="image"
+ />
+
<!-- TALES badges: namespace -->
<adapter
=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py 2017-11-10 11:23:27 +0000
+++ lib/lp/app/browser/tales.py 2018-06-20 16:53:58 +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).
"""Implementation of the lp: htmlform: fmt: namespaces in TALES."""
@@ -95,6 +95,7 @@
)
from lp.services.webapp.session import get_cookie_domain
from lp.services.webapp.url import urlappend
+from lp.snappy.interfaces.snap import SnapBuildRequestStatus
from lp.soyuz.enums import ArchivePurpose
from lp.soyuz.interfaces.archive import (
IArchive,
@@ -1164,6 +1165,39 @@
return self.icon_template % (alt, title, source)
+class SnapBuildRequestImageDisplayAPI(ObjectImageDisplayAPI):
+ """Adapter for ISnapBuildRequest objects to an image.
+
+ Used for image:icon.
+ """
+ icon_template = (
+ '<img width="%(width)s" height="14" alt="%(alt)s" '
+ 'title="%(title)s" src="%(src)s" />')
+
+ def icon(self):
+ """Return the appropriate <img> tag for the build request icon."""
+ icon_map = {
+ SnapBuildRequestStatus.PENDING: {'src': "/@@/processing"},
+ SnapBuildRequestStatus.FAILED: {
+ 'src': "/@@/build-failed",
+ 'width': "16",
+ },
+ SnapBuildRequestStatus.COMPLETED: {'src': "/@@/build-success"},
+ }
+
+ alt = '[%s]' % self._context.status.name
+ title = self._context.status.title
+ source = icon_map[self._context.status].get('src')
+ width = icon_map[self._context.status].get('width', '14')
+
+ return self.icon_template % {
+ 'alt': alt,
+ 'title': title,
+ 'src': source,
+ 'width': width,
+ }
+
+
class BadgeDisplayAPI:
"""Adapter for IHasBadges to the images for the badges.
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/browser/snap.py 2018-06-20 16:53:58 +0000
@@ -248,11 +248,16 @@
archive = Reference(IArchive, title=u'Source archive', required=True)
distro_arch_series = List(
Choice(vocabulary='SnapDistroArchSeries'),
- title=u'Architectures', required=True)
+ title=u'Architectures', required=True,
+ description=(
+ u'If you do not explicitly select any architectures, then '
+ u'the snap package will be built for all architectures '
+ u'allowed by its configuration.'))
pocket = Choice(
title=u'Pocket', vocabulary=PackagePublishingPocket, required=True,
- description=u'The package stream within the source distribution '
- 'series to use when building the snap package.')
+ description=(
+ u'The package stream within the source distribution series '
+ u'to use when building the snap package.'))
custom_widget('archive', SnapArchiveWidget)
custom_widget('distro_arch_series', LabeledMultiCheckBoxWidget)
@@ -271,18 +276,10 @@
"""See `LaunchpadFormView`."""
return {
'archive': self.context.distro_series.main_archive,
- 'distro_arch_series': self.context.getAllowedArchitectures(),
+ 'distro_arch_series': [],
'pocket': PackagePublishingPocket.UPDATES,
}
- def validate(self, data):
- """See `LaunchpadFormView`."""
- arches = data.get('distro_arch_series', [])
- if not arches:
- self.setFieldError(
- 'distro_arch_series',
- "You need to select at least one architecture.")
-
def requestBuild(self, data):
"""User action for requesting a number of builds.
@@ -309,12 +306,18 @@
@action('Request builds', name='request')
def request_action(self, action, data):
- builds, informational = self.requestBuild(data)
+ if data['distro_arch_series']:
+ builds, informational = self.requestBuild(data)
+ already_pending = informational.get('already_pending')
+ notification_text = new_builds_notification_text(
+ builds, already_pending)
+ self.request.response.addNotification(notification_text)
+ else:
+ self.context.requestBuilds(
+ self.user, data['archive'], data['pocket'])
+ self.request.response.addNotification(
+ _('Builds will be dispatched soon.'))
self.next_url = self.cancel_url
- already_pending = informational.get('already_pending')
- notification_text = new_builds_notification_text(
- builds, already_pending)
- self.request.response.addNotification(notification_text)
class ISnapEditSchema(Interface):
=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py 2018-06-20 16:53:58 +0000
@@ -26,6 +26,9 @@
import responses
import soupmatchers
from testtools.matchers import (
+ AfterPreprocessing,
+ Equals,
+ Is,
MatchesSetwise,
MatchesStructure,
)
@@ -33,6 +36,7 @@
from zope.component import getUtility
from zope.publisher.interfaces import NotFound
from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -64,6 +68,7 @@
ISnapSet,
SNAP_PRIVATE_FEATURE_FLAG,
SNAP_TESTING_FLAGS,
+ SnapBuildRequestStatus,
SnapPrivateFeatureDisabled,
)
from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
@@ -1427,6 +1432,9 @@
Architectures:
amd64
i386
+ If you do not explicitly select any architectures, then the snap
+ package will be built for all architectures allowed by its
+ configuration.
Pocket:
Release
Security
@@ -1446,12 +1454,13 @@
self.assertRaises(
Unauthorized, self.getViewBrowser, self.snap, "+request-builds")
- def test_request_builds_action(self):
- # Requesting a build creates pending builds.
+ def test_request_builds_with_architectures_action(self):
+ # Requesting a build with architectures selected creates pending
+ # builds.
browser = self.getViewBrowser(
self.snap, "+request-builds", user=self.person)
- self.assertTrue(browser.getControl("amd64").selected)
- self.assertTrue(browser.getControl("i386").selected)
+ browser.getControl("amd64").selected = True
+ browser.getControl("i386").selected = True
browser.getControl("Request builds").click()
login_person(self.person)
@@ -1467,44 +1476,74 @@
self.assertContentEqual(
[2510], set(build.buildqueue_record.lastscore for build in builds))
- def test_request_builds_ppa(self):
- # Selecting a different archive creates builds in that archive.
+ def test_request_builds_with_architectures_ppa(self):
+ # Selecting a different archive with architectures selected creates
+ # builds in that archive.
ppa = self.factory.makeArchive(
distribution=self.ubuntu, owner=self.person, name="snap-ppa")
browser = self.getViewBrowser(
self.snap, "+request-builds", user=self.person)
browser.getControl("PPA").click()
browser.getControl(name="field.archive.ppa").value = ppa.reference
- self.assertTrue(browser.getControl("amd64").selected)
- browser.getControl("i386").selected = False
+ browser.getControl("amd64").selected = True
+ self.assertFalse(browser.getControl("i386").selected)
browser.getControl("Request builds").click()
login_person(self.person)
builds = self.snap.pending_builds
self.assertEqual([ppa], [build.archive for build in builds])
- def test_request_builds_no_architectures(self):
- # Selecting no architectures causes a validation failure.
- browser = self.getViewBrowser(
- self.snap, "+request-builds", user=self.person)
- browser.getControl("amd64").selected = False
- browser.getControl("i386").selected = False
- browser.getControl("Request builds").click()
- self.assertIn(
- "You need to select at least one architecture.",
- extract_text(find_main_content(browser.contents)))
-
- def test_request_builds_rejects_duplicate(self):
- # A duplicate build request causes a notification.
+ def test_request_builds_with_architectures_rejects_duplicate(self):
+ # A duplicate build request with architectures selected causes a
+ # notification.
self.snap.requestBuild(
self.person, self.ubuntu.main_archive, self.distroseries["amd64"],
PackagePublishingPocket.UPDATES)
browser = self.getViewBrowser(
self.snap, "+request-builds", user=self.person)
- self.assertTrue(browser.getControl("amd64").selected)
- self.assertTrue(browser.getControl("i386").selected)
+ browser.getControl("amd64").selected = True
+ browser.getControl("i386").selected = True
browser.getControl("Request builds").click()
main_text = extract_text(find_main_content(browser.contents))
self.assertIn("1 new build has been queued.", main_text)
self.assertIn(
"An identical build is already pending for amd64.", main_text)
+
+ def test_request_builds_no_architectures_action(self):
+ # Requesting a build with no architectures selected creates a
+ # pending build request.
+ browser = self.getViewBrowser(
+ self.snap, "+request-builds", user=self.person)
+ self.assertFalse(browser.getControl("amd64").selected)
+ self.assertFalse(browser.getControl("i386").selected)
+ browser.getControl("Request builds").click()
+
+ login_person(self.person)
+ [request] = self.snap.pending_build_requests
+ self.assertThat(removeSecurityProxy(request), MatchesStructure(
+ snap=Equals(self.snap),
+ status=Equals(SnapBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ builds=AfterPreprocessing(list, Equals([])),
+ archive=Equals(self.ubuntu.main_archive),
+ _job=MatchesStructure(
+ requester=Equals(self.person),
+ pocket=Equals(PackagePublishingPocket.UPDATES),
+ channels=Is(None))))
+
+ def test_request_builds_no_architectures_ppa(self):
+ # Selecting a different archive with no architectures selected
+ # creates a build request targeting that archive.
+ ppa = self.factory.makeArchive(
+ distribution=self.ubuntu, owner=self.person, name="snap-ppa")
+ browser = self.getViewBrowser(
+ self.snap, "+request-builds", user=self.person)
+ browser.getControl("PPA").click()
+ browser.getControl(name="field.archive.ppa").value = ppa.reference
+ self.assertFalse(browser.getControl("amd64").selected)
+ self.assertFalse(browser.getControl("i386").selected)
+ browser.getControl("Request builds").click()
+
+ login_person(self.person)
+ [request] = self.snap.pending_build_requests
+ self.assertEqual(ppa, request.archive)
=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/interfaces/snap.py 2018-06-20 16:53:58 +0000
@@ -307,6 +307,11 @@
value_type=Reference(schema=Interface),
required=True, readonly=True))
+ archive = Reference(
+ IArchive,
+ title=u"The source archive for builds produced by this request",
+ required=True, readonly=True)
+
class ISnapView(Interface):
"""`ISnap` attributes that require launchpad.View permission."""
@@ -435,21 +440,46 @@
:return: `ISnapBuildRequest`.
"""
+ pending_build_requests = List(
+ value_type=Reference(ISnapBuildRequest),
+ title=u"Pending build requests", required=True, readonly=True)
+
+ # XXX cjwatson 2018-06-20: Deprecated as an exported method; can become
+ # an internal helper method once production JavaScript no longer uses
+ # it.
@operation_parameters(
snap_build_ids=List(
- title=_("A list of snap build ids."),
- value_type=Int()))
+ title=_("A list of snap build IDs."), value_type=Int()))
@export_read_operation()
@operation_for_version("devel")
def getBuildSummariesForSnapBuildIds(snap_build_ids):
"""Return a dictionary containing a summary of the build statuses.
- :param snap_build_ids: A list of snap build ids.
+ :param snap_build_ids: A list of snap build IDs.
:type source_ids: ``list``
:return: A dict consisting of the overall status summaries for the
given snap builds.
"""
+ @operation_parameters(
+ request_ids=List(
+ title=_("A list of snap build request IDs."), value_type=Int(),
+ required=False),
+ build_ids=List(
+ title=_("A list of snap build IDs."), value_type=Int(),
+ required=False))
+ @export_read_operation()
+ @operation_for_version("devel")
+ def getBuildSummaries(request_ids=None, build_ids=None):
+ """Return a dictionary containing a summary of build information.
+
+ :param request_ids: A list of snap build request IDs.
+ :param build_ids: A list of snap build IDs.
+ :return: A dict of {"requests", "builds"}, consisting of the overall
+ status summaries for the given snap build requests and snap
+ builds respectively.
+ """
+
builds = exported(doNotSnapshot(CollectionField(
title=_("All builds of this snap package."),
description=_(
=== modified file 'lib/lp/snappy/interfaces/snapjob.py'
--- lib/lp/snappy/interfaces/snapjob.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/interfaces/snapjob.py 2018-06-20 16:53:58 +0000
@@ -96,6 +96,16 @@
for these builds.
"""
+ def findBySnap(snap, statuses=None, job_ids=None):
+ """Find jobs for a snap.
+
+ :param snap: A snap package to search for.
+ :param statuses: An optional iterable of `JobStatus`es to search for.
+ :param job_ids: An optional iterable of job IDs to search for.
+ :return: A sequence of `SnapRequestBuildsJob`s with the specified
+ snap.
+ """
+
def getBySnapAndID(snap, job_id):
"""Get a job by snap and job ID.
@@ -103,3 +113,10 @@
:raises: `NotFoundError` if there is no job with the specified snap
and ID, or its `job_type` is not `SnapJobType.REQUEST_BUILDS`.
"""
+
+ def findBuildsForJobs(jobs):
+ """Find builds resulting from an iterable of `SnapRequestBuildJob`s.
+
+ :return: A dictionary mapping `SnapRequestBuildJob` IDs to lists of
+ their resulting builds.
+ """
=== modified file 'lib/lp/snappy/javascript/snap.update_build_statuses.js'
--- lib/lp/snappy/javascript/snap.update_build_statuses.js 2017-08-31 13:35:55 +0000
+++ lib/lp/snappy/javascript/snap.update_build_statuses.js 2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-/* Copyright 2016 Canonical Ltd. This software is licensed under the
+/* Copyright 2016-2018 Canonical Ltd. This software is licensed under the
* GNU Affero General Public License version 3 (see the file LICENSE).
*
* The lp.snappy.snap.update_build_statuses module uses the
@@ -15,11 +15,113 @@
module.pending_states = [
"NEEDSBUILD", "BUILDING", "UPLOADING", "CANCELLING"];
+ module.update_date_built = function(node, build_summary) {
+ node.set("innerHTML", build_summary.when_complete);
+ if (build_summary.when_complete_estimate) {
+ node.appendChild(document.createTextNode(' (estimated)'));
+ }
+ if (build_summary.build_log_url !== null) {
+ var new_link = Y.Node.create(
+ '<a class="sprite download">buildlog</a>');
+ new_link.setAttribute('href', build_summary.build_log_url);
+ node.appendChild(document.createTextNode(' '));
+ node.appendChild(new_link);
+ if (build_summary.build_log_size !== null) {
+ node.appendChild(document.createTextNode(' '));
+ node.append("(" + build_summary.build_log_size + " bytes)");
+ }
+ }
+ };
+
module.domUpdate = function(table, data_object) {
- Y.each(data_object, function(build_summary, build_id) {
+ var tbody = table.one('tbody');
+ if (tbody === null) {
+ return;
+ }
+ var tbody_changed = false;
+
+ Y.each(data_object['requests'], function(request_summary, request_id) {
+ var tr_elem = tbody.one('tr#request-' + request_id);
+ if (tr_elem === null) {
+ return;
+ }
+
+ if (request_summary['status'] === 'FAILED') {
+ // XXX cjwatson 2018-06-18: Maybe we should show the error
+ // message in this case, but we don't show non-pending
+ // requests in the non-JS case, so it's not clear where
+ // would be appropriate.
+ tr_elem.remove();
+ tbody_changed = true;
+ return;
+ } else if (request_summary['status'] === 'COMPLETED') {
+ // Insert rows for the new builds.
+ Y.Array.each(request_summary['builds'],
+ function(build_summary) {
+ // Construct the new row.
+ var new_row = Y.Node.create('<tr/>')
+ .set('id', 'build-' + build_summary.id);
+ var new_build_status = Y.Node.create('<td/>');
+ new_build_status.append('<img/>');
+ new_build_status.appendChild('<a/>')
+ .set('href', build_summary.self_link);
+ Y.lp.buildmaster.buildstatus.update_build_status(
+ new_build_status, build_summary);
+ new_row.append(new_build_status);
+ var new_datebuilt = Y.Node.create(
+ '<td class="datebuilt"/>');
+ if (build_summary.when_complete !== null) {
+ module.update_date_built(new_datebuilt, build_summary);
+ }
+ new_row.append(new_datebuilt);
+ var new_arch = Y.Node.create('<td/>');
+ var new_arch_link = Y.Node.create(
+ '<a class="sprite distribution"/>');
+ new_arch_link.set(
+ 'href', build_summary.distro_arch_series_link);
+ new_arch_link.appendChild(document.createTextNode(
+ build_summary.architecture_tag));
+ new_arch.append(new_arch_link);
+ new_row.append(new_arch);
+ new_row.append(
+ '<td>' + build_summary.archive_link + '</td>');
+
+ // Insert the new row, maintaining descending-ID sorted
+ // order.
+ var tr_next = null;
+ tbody.get('children').some(function(tr) {
+ var tr_id = tr.get('id');
+ if (tr_id !== null &&
+ tr_id.substr(0, 6) === 'build-') {
+ var build_id = parseInt(
+ tr_id.replace('build-', ''), 10);
+ if (!isNaN(build_id) &&
+ build_id < build_summary.id) {
+ tr_next = tr;
+ return true;
+ }
+ }
+ return false;
+ });
+ tbody.insert(new_row, tr_next);
+ });
+
+ // Remove the completed build request row.
+ tr_elem.remove();
+ tbody_changed = true;
+ return;
+ }
+ });
+
+ if (tbody_changed) {
+ var anim = Y.lp.anim.green_flash({node: tbody});
+ anim.run();
+ }
+
+ Y.each(data_object['builds'], function(build_summary, build_id) {
var ui_changed = false;
- var tr_elem = Y.one("tr#build-" + build_id);
+ var tr_elem = tbody.one("tr#build-" + build_id);
if (tr_elem === null) {
return;
}
@@ -38,25 +140,7 @@
if (build_summary.when_complete !== null) {
ui_changed = true;
- td_datebuilt.set("innerHTML", build_summary.when_complete);
- if (build_summary.when_complete_estimate) {
- td_datebuilt.appendChild(
- document.createTextNode(' (estimated)'));
- }
- if (build_summary.build_log_url !== null) {
- var new_link = Y.Node.create(
- '<a class="sprite download">buildlog</a>');
- new_link.setAttribute(
- 'href', build_summary.build_log_url);
- td_datebuilt.appendChild(document.createTextNode(' '));
- td_datebuilt.appendChild(new_link);
- if (build_summary.build_log_size !== null) {
- td_datebuilt.appendChild(
- document.createTextNode(' '));
- td_datebuilt.append(
- "(" + build_summary.build_log_size + " bytes)");
- }
- }
+ module.update_date_built(td_datebuilt, build_summary);
}
if (ui_changed) {
@@ -67,32 +151,46 @@
};
module.parameterEvaluator = function(table_node) {
- var td_list = table_node.all('td.build_status');
- var pending = td_list.filter("." + module.pending_states.join(",."));
- if (pending.size() === 0) {
+ var td_request_list = table_node.all('td.request_status');
+ var pending_requests = td_request_list.filter('.PENDING');
+ var td_build_list = table_node.all('td.build_status');
+ var pending_builds = td_build_list.filter(
+ "." + module.pending_states.join(",."));
+ if (pending_requests.size() === 0 && pending_builds.size() === 0) {
return null;
}
- var snap_build_ids = [];
- Y.each(pending, function(node) {
- var elem_id = node.ancestor().get('id');
- var snap_build_id = elem_id.replace('build-', '');
- snap_build_ids.push(snap_build_id);
- });
-
- return {snap_build_ids: snap_build_ids};
+ var request_ids = [];
+ Y.each(pending_requests, function(node) {
+ var elem_id = node.ancestor().get('id');
+ var request_id = elem_id.replace('request-', '');
+ request_ids.push(request_id);
+ });
+
+ var build_ids = [];
+ Y.each(pending_builds, function(node) {
+ var elem_id = node.ancestor().get('id');
+ var build_id = elem_id.replace('build-', '');
+ build_ids.push(build_id);
+ });
+
+ return {request_ids: request_ids, build_ids: build_ids};
};
module.stopUpdatesCheck = function(table_node) {
- // Stop updating when there aren't any builds to update
- var td_list = table_node.all('td.build_status');
- var pending = td_list.filter("." + module.pending_states.join(",."));
- return (pending.size() === 0);
+ // Stop updating when there aren't any build requests or builds to
+ // update.
+ var td_request_list = table_node.all('td.request_status');
+ var pending_requests = td_request_list.filter('.PENDING');
+ var td_build_list = table_node.all('td.build_status');
+ var pending_builds = td_build_list.filter(
+ "." + module.pending_states.join(",."));
+ return pending_requests.size() === 0 && pending_builds.size() === 0;
};
module.config = {
uri: null,
- api_method_name: 'getBuildSummariesForSnapBuildIds',
+ api_method_name: 'getBuildSummaries',
lp_client: null,
domUpdateFunction: module.domUpdate,
parameterEvaluatorFunction: module.parameterEvaluator,
=== modified file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html'
--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html 2017-08-31 13:35:55 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html 2018-06-20 16:53:58 +0000
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<!--
-Copyright 2016 Canonical Ltd. This software is licensed under the
+Copyright 2016-2018 Canonical Ltd. This software is licensed under the
GNU Affero General Public License version 3 (see the file LICENSE).
-->
@@ -62,19 +62,10 @@
</tr>
</thead>
<tbody>
- <tr id="build-1">
- <td class="build_status NEEDSBUILD">
- <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
- <a href="snap/+build/1">Needs building</a>
- </td>
- <td class="datebuilt">
- in 1 minute (estimated)
- </td>
- <td>
- <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
- </td>
- <td>
- <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
+ <tr id="request-1">
+ <td class="request_status PENDING">
+ <img width="14" height="14" alt="[PENDING]" title="Pending" src="/@@/build-needed" />
+ Pending build request
</td>
</tr>
<tr id="build-2">
@@ -94,6 +85,21 @@
<a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
</td>
</tr>
+ <tr id="build-1">
+ <td class="build_status NEEDSBUILD">
+ <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
+ <a href="snap/+build/1">Needs building</a>
+ </td>
+ <td class="datebuilt">
+ in 1 minute (estimated)
+ </td>
+ <td>
+ <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
+ </td>
+ <td>
+ <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
+ </td>
+ </tr>
</tbody>
</table>
=== modified file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js'
--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js 2017-08-31 13:35:55 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js 2018-06-20 16:53:58 +0000
@@ -1,4 +1,4 @@
-/* Copyright 2016 Canonical Ltd. This software is licensed under the
+/* Copyright 2016-2018 Canonical Ltd. This software is licensed under the
* GNU Affero General Public License version 3 (see the file LICENSE). */
YUI.add('lp.snappy.snap.update_build_statuses.test', function (Y) {
@@ -10,13 +10,42 @@
name: 'lp.snappy.snap.update_build_statuses_tests',
setUp: function () {
- this.table = Y.one('table#latest-builds-listing');
- this.tr_build_1 = Y.one('tr#build-1');
+ // Clone the table from the test data so that we can reliably
+ // restore it.
+ this.table = Y.one('table#latest-builds-listing').cloneNode(true);
+ this.tbody = this.table.one('tbody');
+ this.tr_request_1 = this.tbody.one('tr#request-1');
+ this.tr_build_1 = this.tbody.one('tr#build-1');
this.td_status = this.tr_build_1.one('td.build_status');
this.td_datebuilt = this.tr_build_1.one("td.datebuilt");
- this.td_status_class = this.td_status.getAttribute("class");
- this.td_status_img = this.td_status.one("img");
- this.td_status_a = this.td_status.one("a");
+ },
+
+ assert_node_matches: function(expected, node) {
+ Y.each(expected, function(value, key) {
+ if (key === "tag") {
+ Y.Assert.areEqual(
+ value, node.get("tagName").toLowerCase());
+ } else if (key === "attrs") {
+ Y.each(value, function(attr_value, attr_key) {
+ Y.Assert.areEqual(
+ attr_value, node.getAttribute(attr_key));
+ });
+ } else if (key === "text") {
+ Y.Assert.areEqual(value, node.get("text").trim());
+ } else if (key === "children") {
+ var children = [];
+ node.get("children").each(function(child) {
+ children.push(child);
+ });
+ Y.Array.each(Y.Array.zip(value, children), function(item) {
+ Y.Assert.isObject(item[0]);
+ Y.Assert.isObject(item[1]);
+ this.assert_node_matches(item[0], item[1]);
+ }, this);
+ } else {
+ Y.Assert.fail("unhandled key " + key);
+ }
+ }, this);
},
test_dom_updater_plugin_attached: function() {
@@ -32,167 +61,354 @@
test_parameter_evaluator: function() {
// parameterEvaluator should return an object with the ids of
- // builds in pending states.
+ // build requests and builds in pending states.
var params = module.parameterEvaluator(this.table);
Y.lp.testing.assert.assert_equal_structure(
- {snap_build_ids: ["1"]}, params);
+ {request_ids: ["1"], build_ids: ["1"]}, params);
},
test_parameter_evaluator_empty: function() {
// parameterEvaluator should return empty if no builds remaining
// in pending states.
+ this.tr_request_1.remove();
this.td_status.setAttribute("class", "build_status FULLYBUILT");
var params = module.parameterEvaluator(this.table);
Y.Assert.isNull(params);
- // reset td class to the original value
- this.td_status.setAttribute("class", this.td_status_class);
},
test_stop_updates_check: function() {
- // stopUpdatesCheck should return false if pending builds exist.
- Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
- // stopUpdatesCheck should return true if no pending builds exist.
+ // stopUpdatesCheck should return false if pending build
+ // requests or pending builds exist.
+ Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
+ this.tr_request_1.one('td.request_status')
+ .setAttribute('class', 'request_status COMPLETED');
+ Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
+ // stopUpdatesCheck should return true if no pending build
+ // requests or pending builds exist.
this.td_status.setAttribute("class", "build_status FULLYBUILT");
Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
+ this.tr_request_1.remove();
+ Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
for (var i = 0; i < module.pending_states.length; i++) {
this.td_status.setAttribute(
"class", "build_status " + module.pending_states[i]);
Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
}
- // reset td class to the original value
- this.td_status.setAttribute("class", this.td_status_class);
- },
-
- test_update_build_status_dom_building: function() {
- var original_a_href = this.td_status_a.get("href");
- var data = {
- "1": {
- "status": "BUILDING",
- "build_log_url": null,
- "when_complete_estimate": true,
- "buildstate": "Currently building",
- "build_log_size": null,
- "when_complete": "in 1 minute"
- }
- };
- module.domUpdate(this.table, data);
- Y.Assert.areEqual(
- "build_status BUILDING", this.td_status.getAttribute("class"));
- Y.Assert.areEqual(
- "Currently building", this.td_status.get("text").trim());
- Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
- Y.Assert.areEqual(
- "Currently building", this.td_status_img.get("title"));
- Y.Assert.areEqual(
- "file:///@@/processing", this.td_status_img.get("src"));
- Y.Assert.areEqual("14", this.td_status_img.get("width"));
- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
- },
-
- test_update_build_status_dom_building: function() {
- var original_a_href = this.td_status_a.get("href");
- var data = {
- "1": {
- "status": "BUILDING",
- "build_log_url": null,
- "when_complete_estimate": true,
- "buildstate": "Currently building",
- "build_log_size": null,
- "when_complete": "in 1 minute"
- }
- };
- module.domUpdate(this.table, data);
- Y.Assert.areEqual(
- "build_status BUILDING", this.td_status.getAttribute("class"));
- Y.Assert.areEqual(
- "Currently building", this.td_status.get("text").trim());
- Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
- Y.Assert.areEqual(
- "Currently building", this.td_status_img.get("title"));
- Y.Assert.areEqual(
- "file:///@@/processing", this.td_status_img.get("src"));
- Y.Assert.areEqual("14", this.td_status_img.get("width"));
- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+ },
+
+ test_update_build_request_status_dom_completed: function() {
+ var data = {
+ "requests": {
+ "1": {
+ "status": "COMPLETED",
+ "error_message": null,
+ "builds": [
+ {
+ "self_link": "/~max/+snap/snap/+build/3",
+ "id": 3,
+ "distro_arch_series_link":
+ "/ubuntu/hoary/amd64",
+ "architecture_tag": "amd64",
+ "archive_link":
+ '<a href="/ubuntu" ' +
+ 'class="sprite distribution">Primary ' +
+ 'Archive for Ubuntu Linux</a>',
+ "status": "NEEDSBUILD",
+ "build_log_url": null,
+ "when_complete_estimate": false,
+ "buildstate": "Needs building",
+ "build_log_size": null,
+ "when_complete": null
+ },
+ {
+ "self_link": "/~max/+snap/snap/+build/4",
+ "id": 4,
+ "distro_arch_series_link":
+ "/ubuntu/hoary/i386",
+ "architecture_tag": "i386",
+ "archive_link":
+ '<a href="/ubuntu" ' +
+ 'class="sprite distribution">Primary ' +
+ 'Archive for Ubuntu Linux</a>',
+ "status": "BUILDING",
+ "build_log_url": null,
+ "when_complete_estimate": true,
+ "buildstate": "Currently building",
+ "build_log_size": null,
+ "when_complete": "in 1 minute"
+ }
+ ]
+ }
+ },
+ "builds": {}
+ };
+ module.domUpdate(this.table, data);
+ Y.ArrayAssert.itemsAreEqual(
+ ["build-4", "build-3", "build-2", "build-1"],
+ this.tbody.get("children").get("id"));
+ this.assert_node_matches({
+ "tag": "tr",
+ "attrs": {"id": "build-3"},
+ "children": [
+ {
+ "tag": "td",
+ "attrs": {"class": "build_status NEEDSBUILD"},
+ "children": [
+ {
+ "tag": "img",
+ "attrs": {
+ "alt": "[NEEDSBUILD]",
+ "title": "Needs building",
+ "src": "/@@/build-needed",
+ "width": "14"
+ }
+ },
+ {
+ "tag": "a",
+ "attrs": {"href": "/~max/+snap/snap/+build/3"},
+ "text": "Needs building"
+ }
+ ]
+ },
+ {
+ "tag": "td",
+ "attrs": {"class": "datebuilt"},
+ "text": "",
+ "children": []
+ },
+ {
+ "tag": "td",
+ "children": [{
+ "tag": "a",
+ "attrs": {
+ "class": "sprite distribution",
+ "href": "/ubuntu/hoary/amd64"
+ },
+ "text": "amd64"
+ }]
+ },
+ {
+ "tag": "td",
+ "children": [{
+ "tag": "a",
+ "attrs": {
+ "class": "sprite distribution",
+ "href": "/ubuntu"
+ },
+ "text": "Primary Archive for Ubuntu Linux"
+ }]
+ }
+ ]
+ }, this.tbody.one("tr#build-3"));
+ this.assert_node_matches({
+ "tag": "tr",
+ "attrs": {"id": "build-4"},
+ "children": [
+ {
+ "tag": "td",
+ "attrs": {"class": "build_status BUILDING"},
+ "children": [
+ {
+ "tag": "img",
+ "attrs": {
+ "alt": "[BUILDING]",
+ "title": "Currently building",
+ "src": "/@@/processing",
+ "width": "14"
+ }
+ },
+ {
+ "tag": "a",
+ "attrs": {"href": "/~max/+snap/snap/+build/4"},
+ "text": "Currently building"
+ }
+ ]
+ },
+ {
+ "tag": "td",
+ "attrs": {"class": "datebuilt"},
+ "text": "in 1 minute (estimated)",
+ "children": []
+ },
+ {
+ "tag": "td",
+ "children": [{
+ "tag": "a",
+ "attrs": {
+ "class": "sprite distribution",
+ "href": "/ubuntu/hoary/i386"
+ },
+ "text": "i386"
+ }]
+ },
+ {
+ "tag": "td",
+ "children": [{
+ "tag": "a",
+ "attrs": {
+ "class": "sprite distribution",
+ "href": "/ubuntu"
+ },
+ "text": "Primary Archive for Ubuntu Linux"
+ }]
+ }
+ ]
+ }, this.tbody.one("tr#build-4"));
+ },
+
+ test_update_build_request_status_dom_failed: function() {
+ var data = {
+ "requests": {
+ "1": {
+ "status": "FAILED",
+ "error_message": "Something went wrong",
+ "builds": []
+ }
+ },
+ "builds": {}
+ };
+ module.domUpdate(this.table, data);
+ Y.ArrayAssert.itemsAreEqual(
+ ["build-2", "build-1"], this.tbody.get("children").get("id"));
+ },
+
+ test_update_build_status_dom_building: function() {
+ var original_a_href = this.td_status.one("a").getAttribute("href");
+ var data = {
+ "requests": {},
+ "builds": {
+ "1": {
+ "status": "BUILDING",
+ "build_log_url": null,
+ "when_complete_estimate": true,
+ "buildstate": "Currently building",
+ "build_log_size": null,
+ "when_complete": "in 1 minute"
+ }
+ }
+ };
+ module.domUpdate(this.table, data);
+ this.assert_node_matches({
+ "attrs": {"class": "build_status BUILDING"},
+ "text": "Currently building",
+ "children": [
+ {
+ "tag": "img",
+ "attrs": {
+ "alt": "[BUILDING]",
+ "title": "Currently building",
+ "src": "/@@/processing",
+ "width": "14"
+ }
+ },
+ {
+ "tag": "a",
+ "attrs": {"href": original_a_href}
+ }
+ ]
+ }, this.td_status);
},
test_update_build_status_dom_failedtobuild: function() {
- var original_a_href = this.td_status_a.get("href");
+ var original_a_href = this.td_status.one("a").getAttribute("href");
var data = {
- "1": {
- "status": "FAILEDTOBUILD",
- "build_log_url": null,
- "when_complete_estimate": false,
- "buildstate": "Failed to build",
- "build_log_size": null,
- "when_complete": "1 minute ago"
+ "requests": {},
+ "builds": {
+ "1": {
+ "status": "FAILEDTOBUILD",
+ "build_log_url": null,
+ "when_complete_estimate": false,
+ "buildstate": "Failed to build",
+ "build_log_size": null,
+ "when_complete": "1 minute ago"
+ }
}
};
module.domUpdate(this.table, data);
- Y.Assert.areEqual(
- "build_status FAILEDTOBUILD",
- this.td_status.getAttribute("class"));
- Y.Assert.areEqual(
- "Failed to build", this.td_status.get("text").trim());
- Y.Assert.areEqual(
- "[FAILEDTOBUILD]", this.td_status_img.get("alt"));
- Y.Assert.areEqual(
- "Failed to build", this.td_status_img.get("title"));
- Y.Assert.areEqual(
- "file:///@@/build-failed", this.td_status_img.get("src"));
- Y.Assert.areEqual("16", this.td_status_img.get("width"));
- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+ this.assert_node_matches({
+ "attrs": {"class": "build_status FAILEDTOBUILD"},
+ "text": "Failed to build",
+ "children": [
+ {
+ "tag": "img",
+ "attrs": {
+ "alt": "[FAILEDTOBUILD]",
+ "title": "Failed to build",
+ "src": "/@@/build-failed",
+ "width": "16"
+ }
+ },
+ {
+ "tag": "a",
+ "attrs": {"href": original_a_href}
+ }
+ ]
+ }, this.td_status);
},
test_update_build_status_dom_chrootwait: function() {
- var original_a_href = this.td_status_a.get("href");
+ var original_a_href = this.td_status.one("a").getAttribute("href");
var data = {
- "1": {
- "status": "CHROOTWAIT",
- "build_log_url": null,
- "when_complete_estimate": false,
- "buildstate": "Chroot problem",
- "build_log_size": null,
- "when_complete": "1 minute ago"
+ "requests": {},
+ "builds": {
+ "1": {
+ "status": "CHROOTWAIT",
+ "build_log_url": null,
+ "when_complete_estimate": false,
+ "buildstate": "Chroot problem",
+ "build_log_size": null,
+ "when_complete": "1 minute ago"
+ }
}
};
module.domUpdate(this.table, data);
- Y.Assert.areEqual(
- "build_status CHROOTWAIT",
- this.td_status.getAttribute("class"));
- Y.Assert.areEqual(
- "Chroot problem", this.td_status.get("text").trim());
- Y.Assert.areEqual("[CHROOTWAIT]", this.td_status_img.get("alt"));
- Y.Assert.areEqual(
- "Chroot problem", this.td_status_img.get("title"));
- Y.Assert.areEqual(
- "file:///@@/build-chrootwait", this.td_status_img.get("src"));
- Y.Assert.areEqual("14", this.td_status_img.get("width"));
- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+ this.assert_node_matches({
+ "attrs": {"class": "build_status CHROOTWAIT"},
+ "text": "Chroot problem",
+ "children": [
+ {
+ "tag": "img",
+ "attrs": {
+ "alt": "[CHROOTWAIT]",
+ "title": "Chroot problem",
+ "src": "/@@/build-chrootwait",
+ "width": "14"
+ }
+ },
+ {
+ "tag": "a",
+ "attrs": {"href": original_a_href}
+ }
+ ]
+ }, this.td_status);
},
test_update_build_date_dom: function() {
var data = {
- "1": {
- "status": "NEEDSBUILD",
- "build_log_url": "/+build/1/+files/build1.txt.gz",
- "when_complete_estimate": true,
- "buildstate": "Needs building",
- "build_log_size": 12345,
- "when_complete": "in 30 seconds"
+ "requests": {},
+ "builds": {
+ "1": {
+ "status": "NEEDSBUILD",
+ "build_log_url": "/+build/1/+files/build1.txt.gz",
+ "when_complete_estimate": true,
+ "buildstate": "Needs building",
+ "build_log_size": 12345,
+ "when_complete": "in 30 seconds"
+ }
}
};
module.domUpdate(this.table, data);
- Y.Assert.areEqual(
- "in 30 seconds (estimated) buildlog (12345 bytes)",
- this.td_datebuilt.get("text").trim());
- var td_datebuilt_a = this.td_datebuilt.one("a");
- Y.Assert.isNotNull(td_datebuilt_a);
- Y.Assert.areEqual("buildlog", td_datebuilt_a.get("text").trim());
- Y.Assert.areEqual(
- "sprite download", td_datebuilt_a.getAttribute("class"));
- Y.Assert.areEqual(
- "file://" + data["1"].build_log_url,
- td_datebuilt_a.get("href"));
+ this.assert_node_matches({
+ "text": "in 30 seconds (estimated) buildlog (12345 bytes)",
+ "children": [{
+ "tag": "a",
+ "attrs": {
+ "class": "sprite download",
+ "href": data["builds"]["1"].build_log_url
+ },
+ "text": "buildlog"
+ }]
+ }, this.td_datebuilt);
}
}));
=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/model/snap.py 2018-06-20 16:53:58 +0000
@@ -43,7 +43,10 @@
from zope.security.interfaces import Unauthorized
from zope.security.proxy import removeSecurityProxy
-from lp.app.browser.tales import DateTimeFormatterAPI
+from lp.app.browser.tales import (
+ ArchiveFormatterAPI,
+ DateTimeFormatterAPI,
+ )
from lp.app.enums import PRIVATE_INFORMATION_TYPES
from lp.app.errors import (
IncompatibleArguments,
@@ -117,7 +120,9 @@
LibraryFileContent,
)
from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
+from lp.services.webapp.authorization import precache_permission_for_objects
from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webapp.publisher import canonical_url
from lp.services.webhooks.interfaces import IWebhookSet
from lp.services.webhooks.model import WebhookTargetMixin
from lp.snappy.adapters.buildarch import determine_architectures_to_build
@@ -175,9 +180,19 @@
webservice-friendly view of an asynchronous build request.
"""
- def __init__(self, snap, id):
- self._job = getUtility(ISnapRequestBuildsJobSource).getBySnapAndID(
- snap, id)
+ def __init__(self, snap, id, job=None):
+ if job is None:
+ job_source = getUtility(ISnapRequestBuildsJobSource)
+ job = job_source.getBySnapAndID(snap, id)
+ if snap != job.snap:
+ raise AssertionError(
+ "Mismatched SnapRequestBuildsJob: expected %r, got %r" %
+ (snap, job.snap))
+ if id != job.job_id:
+ raise AssertionError(
+ "Mismatched SnapRequestBuildsJob: expected %d, got %d" %
+ (id, job.job_id))
+ self._job = job
self.snap = snap
self.id = id
@@ -203,6 +218,11 @@
"""See `ISnapBuildRequest`."""
return self._job.builds
+ @property
+ def archive(self):
+ """See `ISnapBuildRequest`."""
+ return self._job.archive
+
@implementer(ISnap, IHasOwner)
class Snap(Storm, WebhookTargetMixin):
@@ -626,6 +646,15 @@
"""See `ISnap`."""
return SnapBuildRequest(self, job_id)
+ @property
+ def pending_build_requests(self):
+ """See `ISnap`."""
+ job_source = getUtility(ISnapRequestBuildsJobSource)
+ return [
+ SnapBuildRequest(self, job.job_id, job=job)
+ for job in job_source.findBySnap(
+ self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))]
+
def _getBuilds(self, filter_term, order_by):
"""The actual query to get the builds."""
query_args = [
@@ -656,6 +685,10 @@
order_by = Desc(SnapBuild.id)
builds = self._getBuilds(filter_term, order_by)
+ # The user can obviously see this snap, and Snap._getBuilds ensures
+ # that they can see the relevant archive for each build as well.
+ precache_permission_for_objects(None, "launchpad.View", builds)
+
# Prefetch data to keep DB query count constant
lfas = load_related(LibraryFileAlias, builds, ["log_id"])
load_related(LibraryFileContent, lfas, ["contentID"])
@@ -681,6 +714,67 @@
}
return result
+ def getBuildSummaries(self, request_ids=None, build_ids=None):
+ """See `ISnap`."""
+ all_build_ids = []
+ result = {"requests": {}, "builds": {}}
+
+ if request_ids:
+ job_source = getUtility(ISnapRequestBuildsJobSource)
+ jobs = job_source.findBySnap(self, job_ids=request_ids)
+ requests = [
+ SnapBuildRequest(self, job.job_id, job=job) for job in jobs]
+ builds_by_request = job_source.findBuildsForJobs(jobs)
+ for builds in builds_by_request.values():
+ # It's safe to remove the proxy here, because the IDs will
+ # go through Snap._getBuilds which checks visibility. This
+ # saves an Archive query per build in the security adapter.
+ all_build_ids.extend(
+ [removeSecurityProxy(build).id for build in builds])
+ else:
+ requests = []
+
+ if build_ids:
+ all_build_ids.extend(build_ids)
+
+ all_build_summaries = self.getBuildSummariesForSnapBuildIds(
+ all_build_ids)
+
+ for request in requests:
+ build_summaries = []
+ for build in sorted(
+ builds_by_request[request.id], key=attrgetter("id"),
+ reverse=True):
+ if build.id in all_build_summaries:
+ # Include enough information for
+ # snap.update_build_statuses.js to populate new build
+ # rows.
+ build_summary = {
+ "self_link": canonical_url(
+ build, path_only_if_possible=True),
+ "id": build.id,
+ "distro_arch_series_link": canonical_url(
+ build.distro_arch_series,
+ path_only_if_possible=True),
+ "architecture_tag": (
+ build.distro_arch_series.architecturetag),
+ "archive_link": ArchiveFormatterAPI(
+ build.archive).link(None),
+ }
+ build_summary.update(all_build_summaries[build.id])
+ build_summaries.append(build_summary)
+ result["requests"][request.id] = {
+ "status": request.status.name,
+ "error_message": request.error_message,
+ "builds": build_summaries,
+ }
+
+ for build_id in (build_ids or []):
+ if build_id in all_build_summaries:
+ result["builds"][build_id] = all_build_summaries[build_id]
+
+ return result
+
@property
def builds(self):
"""See `ISnap`."""
=== modified file 'lib/lp/snappy/model/snapjob.py'
--- lib/lp/snappy/model/snapjob.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/model/snapjob.py 2018-06-20 16:53:58 +0000
@@ -12,6 +12,8 @@
'SnapRequestBuildsJob',
]
+from itertools import chain
+
from lazr.delegates import delegate_to
from lazr.enum import (
DBEnumeratedType,
@@ -29,11 +31,14 @@
implementer,
provider,
)
+from zope.security.proxy import removeSecurityProxy
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.bulk import load_related
+from lp.services.database.decoratedresultset import DecoratedResultSet
from lp.services.database.enumcol import EnumCol
from lp.services.database.interfaces import (
IMasterStore,
@@ -58,7 +63,10 @@
ISnapRequestBuildsJobSource,
)
from lp.snappy.model.snapbuild import SnapBuild
-from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.archive import (
+ Archive,
+ get_enabled_archive_filter,
+ )
class SnapJobType(DBEnumeratedType):
@@ -188,6 +196,29 @@
return job
@classmethod
+ def findBySnap(cls, snap, statuses=None, job_ids=None):
+ """See `ISnapRequestBuildsJobSource`."""
+ clauses = [
+ SnapJob.snap == snap,
+ SnapJob.job_type == cls.class_job_type,
+ ]
+ if statuses is not None:
+ clauses.extend([
+ SnapJob.job == Job.id,
+ Job._status.is_in(statuses),
+ ])
+ if job_ids is not None:
+ clauses.append(SnapJob.job_id.is_in(job_ids))
+ snap_jobs = IStore(SnapJob).find(SnapJob, *clauses)
+
+ def preload_jobs(rows):
+ load_related(Job, rows, ["job_id"])
+
+ return DecoratedResultSet(
+ snap_jobs, lambda snap_job: cls(snap_job),
+ pre_iter_hook=preload_jobs)
+
+ @classmethod
def getBySnapAndID(cls, snap, job_id):
"""See `ISnapRequestBuildsJobSource`."""
snap_job = IStore(SnapJob).find(
@@ -201,6 +232,31 @@
(job_id, snap))
return cls(snap_job)
+ @classmethod
+ def findBuildsForJobs(cls, jobs, user=None):
+ """See `ISnapRequestBuildsJobSource`."""
+ build_ids = {
+ job.job_id: removeSecurityProxy(job).metadata.get("builds") or []
+ for job in jobs}
+ all_build_ids = set(chain.from_iterable(build_ids.values()))
+ if all_build_ids:
+ all_builds = {
+ build.id: build for build in IStore(SnapBuild).find(
+ SnapBuild,
+ SnapBuild.id.is_in(all_build_ids),
+ SnapBuild.archive_id == Archive.id,
+ Archive._enabled == True,
+ get_enabled_archive_filter(
+ user, include_public=True, include_subscribed=True))
+ }
+ else:
+ all_builds = {}
+ return {
+ job.job_id: [
+ all_builds[build_id] for build_id in build_ids[job.job_id]
+ if build_id in all_builds]
+ for job in jobs}
+
def getOperationDescription(self):
return "requesting builds of %s" % self.snap.name
@@ -246,11 +302,11 @@
def builds(self):
"""See `ISnapRequestBuildsJob`."""
build_ids = self.metadata.get("builds")
- if build_ids is None:
- return EmptyResultSet()
- else:
+ if build_ids:
return IStore(SnapBuild).find(
SnapBuild, SnapBuild.id.is_in(build_ids))
+ else:
+ return EmptyResultSet()
@builds.setter
def builds(self, builds):
=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt 2018-04-21 10:15:26 +0000
+++ lib/lp/snappy/templates/snap-index.pt 2018-06-20 16:53:58 +0000
@@ -141,6 +141,18 @@
</tr>
</thead>
<tbody>
+ <tal:snap-build-requests repeat="request context/pending_build_requests">
+ <tr tal:attributes="id string:request-${request/id}">
+ <td colspan="3"
+ tal:attributes="class string:request_status ${request/status/name}">
+ <span tal:replace="structure request/image:icon"/>
+ <tal:title replace="request/status/title"/> build request
+ </td>
+ <td>
+ <tal:archive replace="structure request/archive/fmt:link"/>
+ </td>
+ </tr>
+ </tal:snap-build-requests>
<tal:snap-builds repeat="build view/builds">
<tr tal:attributes="id string:build-${build/id}">
<td tal:attributes="class string:build_status ${build/status/name}">
=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py 2018-06-20 16:53:58 +0000
+++ lib/lp/snappy/tests/test_snap.py 2018-06-20 16:53:58 +0000
@@ -12,6 +12,7 @@
timedelta,
)
import json
+from operator import attrgetter
from textwrap import dedent
from urlparse import urlsplit
@@ -381,7 +382,8 @@
snap=Equals(snap),
status=Equals(SnapBuildRequestStatus.PENDING),
error_message=Is(None),
- builds=AfterPreprocessing(set, MatchesSetwise())))
+ builds=AfterPreprocessing(set, MatchesSetwise()),
+ archive=Equals(snap.distro_series.main_archive)))
[job] = getUtility(ISnapRequestBuildsJobSource).iterReady()
self.assertThat(job, MatchesStructure(
job_id=Equals(request.id),
@@ -656,6 +658,172 @@
1, 5)
self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+ def test_getBuildSummaries(self):
+ snap1 = self.factory.makeSnap()
+ snap2 = self.factory.makeSnap()
+ request11 = self.factory.makeSnapBuildRequest(snap=snap1)
+ request12 = self.factory.makeSnapBuildRequest(snap=snap1)
+ request2 = self.factory.makeSnapBuildRequest(snap=snap2)
+ self.factory.makeSnapBuildRequest()
+ build11 = self.factory.makeSnapBuild(snap=snap1)
+ build12 = self.factory.makeSnapBuild(snap=snap1)
+ build2 = self.factory.makeSnapBuild(snap=snap2)
+ self.factory.makeSnapBuild()
+ summary1 = snap1.getBuildSummaries(
+ request_ids=[request11.id, request12.id],
+ build_ids=[build11.id, build12.id])
+ summary2 = snap2.getBuildSummaries(
+ request_ids=[request2.id], build_ids=[build2.id])
+ request_summary_matcher = MatchesDict({
+ "status": Equals("PENDING"),
+ "error_message": Is(None),
+ "builds": Equals([]),
+ })
+ build_summary_matcher = MatchesDict({
+ "status": Equals("NEEDSBUILD"),
+ "buildstate": Equals("Needs building"),
+ "when_complete": Is(None),
+ "when_complete_estimate": Is(False),
+ "build_log_url": Is(None),
+ "build_log_size": Is(None),
+ })
+ self.assertThat(summary1, MatchesDict({
+ "requests": MatchesDict({
+ request11.id: request_summary_matcher,
+ request12.id: request_summary_matcher,
+ }),
+ "builds": MatchesDict({
+ build11.id: build_summary_matcher,
+ build12.id: build_summary_matcher,
+ }),
+ }))
+ self.assertThat(summary2, MatchesDict({
+ "requests": MatchesDict({request2.id: request_summary_matcher}),
+ "builds": MatchesDict({build2.id: build_summary_matcher}),
+ }))
+
+ def test_getBuildSummaries_empty_input(self):
+ snap = self.factory.makeSnap()
+ self.factory.makeSnapBuildRequest(snap=snap)
+ self.assertEqual(
+ {"requests": {}, "builds": {}},
+ snap.getBuildSummaries(request_ids=None, build_ids=None))
+ self.assertEqual(
+ {"requests": {}, "builds": {}},
+ snap.getBuildSummaries(request_ids=[], build_ids=[]))
+ self.assertEqual(
+ {"requests": {}, "builds": {}},
+ snap.getBuildSummaries(request_ids=(), build_ids=()))
+
+ def test_getBuildSummaries_not_matching_snap(self):
+ # getBuildSummaries does not return information for other snaps.
+ snap1 = self.factory.makeSnap()
+ snap2 = self.factory.makeSnap()
+ self.factory.makeSnapBuildRequest(snap=snap1)
+ self.factory.makeSnapBuild(snap=snap1)
+ request2 = self.factory.makeSnapBuildRequest(snap=snap2)
+ build2 = self.factory.makeSnapBuild(snap=snap2)
+ summary1 = snap1.getBuildSummaries(
+ request_ids=[request2.id], build_ids=[build2.id])
+ self.assertEqual({"requests": {}, "builds": {}}, summary1)
+
+ def test_getBuildSummaries_request_error_message_field(self):
+ # The error_message field for a build request should be None unless
+ # the build request failed.
+ snap = self.factory.makeSnap()
+ request = self.factory.makeSnapBuildRequest(snap=snap)
+ self.assertIsNone(request.error_message)
+ summary = snap.getBuildSummaries(request_ids=[request.id])
+ self.assertIsNone(summary["requests"][request.id]["error_message"])
+ job = removeSecurityProxy(request)._job
+ removeSecurityProxy(job).error_message = "Boom"
+ summary = snap.getBuildSummaries(request_ids=[request.id])
+ self.assertEqual(
+ "Boom", summary["requests"][request.id]["error_message"])
+
+ def test_getBuildSummaries_request_builds_field(self):
+ # The builds field should be an empty list unless the build request
+ # has completed and produced builds.
+ self.useFixture(GitHostingFixture(blob=dedent("""\
+ architectures:
+ - build-on: mips64el
+ - build-on: riscv64
+ """)))
+ job = self.makeRequestBuildsJob(["mips64el", "riscv64", "sh4"])
+ snap = job.snap
+ request = snap.getBuildRequest(job.job_id)
+ self.assertEqual([], list(request.builds))
+ summary = snap.getBuildSummaries(request_ids=[request.id])
+ self.assertEqual([], summary["requests"][request.id]["builds"])
+ with person_logged_in(job.requester):
+ with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
+ JobRunner([job]).runAll()
+ summary = snap.getBuildSummaries(request_ids=[request.id])
+ expected_snap_url = "/~%s/+snap/%s" % (snap.owner.name, snap.name)
+ builds = sorted(request.builds, key=attrgetter("id"), reverse=True)
+ expected_builds = [
+ {
+ "self_link": expected_snap_url + "/+build/%d" % build.id,
+ "id": build.id,
+ "distro_arch_series_link": "/%s/%s/%s" % (
+ snap.distro_series.distribution.name,
+ snap.distro_series.name,
+ build.distro_arch_series.architecturetag),
+ "architecture_tag": build.distro_arch_series.architecturetag,
+ "archive_link": (
+ '<a href="/%s" class="sprite distribution">%s</a>' % (
+ build.archive.distribution.name,
+ build.archive.displayname)),
+ "status": "NEEDSBUILD",
+ "buildstate": "Needs building",
+ "when_complete": None,
+ "when_complete_estimate": False,
+ "build_log_url": None,
+ "build_log_size": None,
+ } for build in builds]
+ self.assertEqual(
+ expected_builds, summary["requests"][request.id]["builds"])
+
+ def test_getBuildSummaries_query_count(self):
+ # The DB query count remains constant regardless of the number of
+ # requests and the number of builds resulting from them.
+ self.useFixture(GitHostingFixture(blob=dedent("""\
+ architectures:
+ - build-on: mips64el
+ - build-on: riscv64
+ """)))
+ job = self.makeRequestBuildsJob(["mips64el", "riscv64", "sh4"])
+ snap = job.snap
+ request_ids = []
+ build_ids = []
+
+ def create_items():
+ request = self.factory.makeSnapBuildRequest(
+ snap=snap, archive=self.factory.makeArchive())
+ request_ids.append(request.id)
+ job = removeSecurityProxy(request)._job
+ with person_logged_in(snap.owner.teamowner):
+ # Using the normal job runner interferes with SQL statement
+ # recording, so we run the job by hand.
+ job.start()
+ job.run()
+ job.complete()
+ # XXX cjwatson 2018-06-20: Queued builds with
+ # BuildQueueStatus.WAITING incur extra queries per build due to
+ # estimating start times. For the moment, we dodge this by
+ # starting the builds.
+ for build in job.builds:
+ build.buildqueue_record.markAsBuilding(
+ self.factory.makeBuilder())
+ build_ids.append(self.factory.makeSnapBuild(
+ snap=snap, archive=self.factory.makeArchive()).id)
+
+ recorder1, recorder2 = record_two_runs(
+ lambda: snap.getBuildSummaries(
+ request_ids=request_ids, build_ids=build_ids),
+ create_items, 1, 5)
+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
class TestSnapDeleteWithBuilds(TestCaseWithFactory):
=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py 2018-04-21 10:01:22 +0000
+++ lib/lp/testing/factory.py 2018-06-20 16:53:58 +0000
@@ -4710,6 +4710,19 @@
IStore(snap).flush()
return snap
+ def makeSnapBuildRequest(self, snap=None, requester=None, archive=None,
+ pocket=PackagePublishingPocket.UPDATES,
+ channels=None):
+ """Make a new SnapBuildRequest."""
+ if snap is None:
+ snap = self.makeSnap()
+ if requester is None:
+ requester = snap.owner.teamowner
+ if archive is None:
+ archive = snap.distro_series.main_archive
+ return snap.requestBuilds(
+ requester, archive, pocket, channels=channels)
+
def makeSnapBuild(self, requester=None, registrant=None, snap=None,
archive=None, distroarchseries=None, pocket=None,
channels=None, date_created=DEFAULT,
Follow ups