launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24574
[Merge] ~cjwatson/launchpad:oci-recipe-build-ui into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-build-ui into launchpad:master with ~cjwatson/launchpad:oci-recipe-processors as a prerequisite.
Commit message:
Add OCIRecipe:+request-builds view
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1847444 in Launchpad itself: "Support OCI image building"
https://bugs.launchpad.net/launchpad/+bug/1847444
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/381910
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-build-ui into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index e3fcc08..548a725 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -35,6 +35,12 @@
template="../templates/ocirecipe-new.pt" />
<browser:page
for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+ class="lp.oci.browser.ocirecipe.OCIRecipeRequestBuildsView"
+ permission="launchpad.Edit"
+ name="+request-builds"
+ template="../templates/ocirecipe-request-builds.pt" />
+ <browser:page
+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
class="lp.oci.browser.ocirecipe.OCIRecipeAdminView"
permission="launchpad.Admin"
name="+admin"
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 1c5d696..9eafed3 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -13,6 +13,7 @@ __all__ = [
'OCIRecipeEditView',
'OCIRecipeNavigation',
'OCIRecipeNavigationMenu',
+ 'OCIRecipeRequestBuildsView',
'OCIRecipeView',
]
@@ -22,6 +23,10 @@ from lazr.restful.interface import (
)
from zope.component import getUtility
from zope.interface import Interface
+from zope.schema import (
+ Choice,
+ List,
+ )
from lp.app.browser.launchpadform import (
action,
@@ -30,6 +35,7 @@ from lp.app.browser.launchpadform import (
)
from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
from lp.app.browser.tales import format_link
+from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
from lp.buildmaster.interfaces.processor import IProcessorSet
from lp.code.browser.widgets.gitref import GitRefWidget
from lp.oci.interfaces.ocirecipe import (
@@ -38,10 +44,12 @@ from lp.oci.interfaces.ocirecipe import (
NoSuchOCIRecipe,
OCI_RECIPE_ALLOW_CREATE,
OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
+ OCIRecipeBuildAlreadyPending,
OCIRecipeFeatureDisabled,
)
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
from lp.services.features import getFeatureFlag
+from lp.services.helpers import english_list
from lp.services.propertycache import cachedproperty
from lp.services.webapp import (
canonical_url,
@@ -51,6 +59,7 @@ from lp.services.webapp import (
Navigation,
NavigationMenu,
stepthrough,
+ structured,
)
from lp.services.webapp.breadcrumb import NameBreadcrumb
from lp.services.webhooks.browser import WebhookTargetNavigationMixin
@@ -148,6 +157,88 @@ def builds_for_recipe(recipe):
return builds
+def new_builds_notification_text(builds, already_pending=None):
+ nr_builds = len(builds)
+ if not nr_builds:
+ builds_text = "All requested builds are already queued."
+ elif nr_builds == 1:
+ builds_text = "1 new build has been queued."
+ else:
+ builds_text = "%d new builds have been queued." % nr_builds
+ if nr_builds and already_pending:
+ return structured("<p>%s</p><p>%s</p>", builds_text, already_pending)
+ else:
+ return builds_text
+
+
+class OCIRecipeRequestBuildsView(LaunchpadFormView):
+ """A view for requesting builds of an OCI recipe."""
+
+ @property
+ def label(self):
+ return 'Request builds for %s' % self.context.name
+
+ page_title = 'Request builds'
+
+ class schema(Interface):
+ """Schema for requesting a build."""
+
+ distro_arch_series = List(
+ Choice(vocabulary='OCIRecipeDistroArchSeries'),
+ title='Architectures', required=True)
+
+ custom_widget_distro_arch_series = LabeledMultiCheckBoxWidget
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+ @property
+ def initial_values(self):
+ """See `LaunchpadFormView`."""
+ return {'distro_arch_series': self.context.getAllowedArchitectures()}
+
+ 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 requestBuilds(self, data):
+ """User action for requesting a number of builds.
+
+ We raise exceptions for most errors, but if there's already a
+ pending build for a particular architecture, we simply record that
+ so that other builds can be queued and a message displayed to the
+ caller.
+ """
+ informational = {}
+ builds = []
+ already_pending = []
+ for arch in data['distro_arch_series']:
+ try:
+ build = self.context.requestBuild(self.user, arch)
+ builds.append(build)
+ except OCIRecipeBuildAlreadyPending:
+ already_pending.append(arch)
+ if already_pending:
+ informational['already_pending'] = (
+ "An identical build is already pending for %s." %
+ english_list(arch.architecturetag for arch in already_pending))
+ return builds, informational
+
+ @action('Request builds', name='request')
+ def request_action(self, action, data):
+ builds, informational = self.requestBuilds(data)
+ already_pending = informational.get('already_pending')
+ notification_text = new_builds_notification_text(
+ builds, already_pending)
+ self.request.response.addNotification(notification_text)
+ self.next_url = self.cancel_url
+
+
class IOCIRecipeEditSchema(Interface):
"""Schema for adding or editing an OCI recipe."""
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index b840d8c..a956071 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -698,3 +698,90 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
self.setStatus(build, BuildStatus.FULLYBUILT)
del get_property_cache(view).builds
self.assertEqual(list(reversed(builds[1:])), view.builds)
+
+
+class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
+
+ def setUp(self):
+ super(TestOCIRecipeRequestBuildsView, self).setUp()
+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+ self.distroseries = self.factory.makeDistroSeries(
+ distribution=self.ubuntu, name="shiny", displayname="Shiny")
+ self.architectures = []
+ for processor, architecture in ("386", "i386"), ("amd64", "amd64"):
+ das = self.factory.makeDistroArchSeries(
+ distroseries=self.distroseries, architecturetag=architecture,
+ processor=getUtility(IProcessorSet).getByName(processor))
+ das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+ self.architectures.append(das)
+ self.useFixture(FeatureFixture({
+ OCI_RECIPE_ALLOW_CREATE: "on",
+ "oci.build_series.%s" % self.distroseries.distribution.name:
+ self.distroseries.name,
+ }))
+ oci_project = self.factory.makeOCIProject(
+ pillar=self.distroseries.distribution,
+ ociprojectname="oci-project-name")
+ self.recipe = self.factory.makeOCIRecipe(
+ name="recipe-name", registrant=self.person, owner=self.person,
+ oci_project=oci_project)
+
+ def test_request_builds_page(self):
+ # The +request-builds page is sane.
+ self.assertTextMatchesExpressionIgnoreWhitespace("""
+ Request builds for recipe-name
+ oci-project-name OCI project
+ recipe-name
+ Request builds
+ Architectures:
+ amd64
+ i386
+ or
+ Cancel
+ """,
+ self.getMainText(self.recipe, "+request-builds", user=self.person))
+
+ def test_request_builds_not_owner(self):
+ # A user without launchpad.Edit cannot request builds.
+ self.assertRaises(
+ Unauthorized, self.getViewBrowser, self.recipe, "+request-builds")
+
+ def test_request_builds_action(self):
+ # Requesting a build creates pending builds.
+ browser = self.getViewBrowser(
+ self.recipe, "+request-builds", user=self.person)
+ self.assertTrue(browser.getControl("amd64").selected)
+ self.assertTrue(browser.getControl("i386").selected)
+ browser.getControl("Request builds").click()
+
+ login_person(self.person)
+ builds = self.recipe.pending_builds
+ self.assertContentEqual(
+ ["amd64", "i386"],
+ [build.distro_arch_series.architecturetag for build in builds])
+ self.assertContentEqual(
+ [2510], set(build.buildqueue_record.lastscore for build in builds))
+
+ def test_request_builds_rejects_duplicate(self):
+ # A duplicate build request causes a notification.
+ self.recipe.requestBuild(self.person, self.distroseries["amd64"])
+ browser = self.getViewBrowser(
+ self.recipe, "+request-builds", user=self.person)
+ self.assertTrue(browser.getControl("amd64").selected)
+ self.assertTrue(browser.getControl("i386").selected)
+ 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(self):
+ # Selecting no architectures causes a validation failure.
+ browser = self.getViewBrowser(
+ self.recipe, "+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)))
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 43ea399..565412d 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -9,6 +9,7 @@
i18n_domain="launchpad">
<include package=".browser" />
+ <include file="vocabularies.zcml" />
<webservice:register module="lp.oci.interfaces.webservice" />
diff --git a/lib/lp/oci/templates/ocirecipe-request-builds.pt b/lib/lp/oci/templates/ocirecipe-request-builds.pt
new file mode 100644
index 0000000..cba9e8e
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-request-builds.pt
@@ -0,0 +1,23 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <metal:formbody fill-slot="widgets">
+ <table class="form">
+ <tal:widget define="widget nocall:view/widgets/distro_arch_series">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </table>
+ </metal:formbody>
+ </div>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
new file mode 100644
index 0000000..2e44624
--- /dev/null
+++ b/lib/lp/oci/vocabularies.py
@@ -0,0 +1,30 @@
+# Copyright 2020 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCI vocabularies."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = []
+
+from zope.schema.vocabulary import SimpleTerm
+
+from lp.services.webapp.vocabulary import StormVocabularyBase
+from lp.soyuz.model.distroarchseries import DistroArchSeries
+
+
+class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
+ """All architectures of an OCI recipe's distribution series."""
+
+ _table = DistroArchSeries
+
+ def toTerm(self, das):
+ return SimpleTerm(das, das.id, das.architecturetag)
+
+ def __iter__(self):
+ for obj in self.context.getAllowedArchitectures():
+ yield self.toTerm(obj)
+
+ def __len__(self):
+ return len(self.context.getAllowedArchitectures())
diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
new file mode 100644
index 0000000..fae4a6d
--- /dev/null
+++ b/lib/lp/oci/vocabularies.zcml
@@ -0,0 +1,18 @@
+<!-- Copyright 2020 Canonical Ltd. This software is licensed under the
+ GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure xmlns="http://namespaces.zope.org/zope">
+
+ <securedutility
+ name="OCIRecipeDistroArchSeries"
+ component="lp.oci.vocabularies.OCIRecipeDistroArchSeriesVocabulary"
+ provides="zope.schema.interfaces.IVocabularyFactory">
+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+ </securedutility>
+
+ <class class="lp.oci.vocabularies.OCIRecipeDistroArchSeriesVocabulary">
+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+ </class>
+
+</configure>