Merge ~pappacena/launchpad:oci-unify-request-builds into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 5c5893c9e2f1dfc0ff08a0bbfd06d5646da1af12
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:oci-unify-request-builds
Merge into: launchpad:master
Diff against target: 401 lines (+123/-57)
7 files modified
lib/lp/oci/browser/ocirecipe.py (+9/-30)
lib/lp/oci/browser/tests/test_ocirecipe.py (+31/-13)
lib/lp/oci/interfaces/ocirecipe.py (+14/-2)
lib/lp/oci/interfaces/ocirecipejob.py (+15/-2)
lib/lp/oci/model/ocirecipe.py (+17/-7)
lib/lp/oci/model/ocirecipejob.py (+28/-3)
lib/lp/oci/templates/ocirecipe-index.pt (+9/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+391645@code.launchpad.net

Commit message

Use IOCIRecipeRequestBuildsJobSource when requesting builds from the web interface

To post a comment you must log in.
d560b56... by Thiago F. Pappacena

Merge branch 'master' into oci-unify-request-builds

Revision history for this message
Colin Watson (cjwatson) wrote :

I'd like the internal API to be shaped a bit differently in some places, but this is along the right lines.

review: Needs Fixing
d00a405... by Thiago F. Pappacena

Refactoring template display of pending OCI build requests

85294fe... by Thiago F. Pappacena

Refactoring OCIRecipe.requestBuilds to receive a list of architectures

7d63a1d... by Thiago F. Pappacena

Refactoring the way we get pending OCI build jobs

ecb5a47... by Thiago F. Pappacena

Adding back requestBuild to OCIRecipe interface

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes.

5c5893c... by Thiago F. Pappacena

Merge branch 'master' into oci-unify-request-builds

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
2index cf11bfb..460b108 100644
3--- a/lib/lp/oci/browser/ocirecipe.py
4+++ b/lib/lp/oci/browser/ocirecipe.py
5@@ -64,7 +64,6 @@ from lp.oci.interfaces.ocirecipe import (
6 NoSuchOCIRecipe,
7 OCI_RECIPE_ALLOW_CREATE,
8 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
9- OCIRecipeBuildAlreadyPending,
10 OCIRecipeFeatureDisabled,
11 )
12 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
13@@ -74,7 +73,6 @@ from lp.oci.interfaces.ociregistrycredentials import (
14 user_can_edit_credentials_for_owner,
15 )
16 from lp.services.features import getFeatureFlag
17-from lp.services.helpers import english_list
18 from lp.services.propertycache import cachedproperty
19 from lp.services.webapp import (
20 canonical_url,
21@@ -644,36 +642,17 @@ class OCIRecipeRequestBuildsView(LaunchpadFormView):
22 'distro_arch_series',
23 'You need to select at least one architecture.')
24
25- def requestBuilds(self, data):
26- """User action for requesting a number of builds.
27-
28- We raise exceptions for most errors, but if there's already a
29- pending build for a particular architecture, we simply record that
30- so that other builds can be queued and a message displayed to the
31- caller.
32- """
33- informational = {}
34- builds = []
35- already_pending = []
36- for arch in data['distro_arch_series']:
37- try:
38- build = self.context.requestBuild(self.user, arch)
39- builds.append(build)
40- except OCIRecipeBuildAlreadyPending:
41- already_pending.append(arch)
42- if already_pending:
43- informational['already_pending'] = (
44- "An identical build is already pending for %s." %
45- english_list(arch.architecturetag for arch in already_pending))
46- return builds, informational
47-
48 @action('Request builds', name='request')
49 def request_action(self, action, data):
50- builds, informational = self.requestBuilds(data)
51- already_pending = informational.get('already_pending')
52- notification_text = new_builds_notification_text(
53- builds, already_pending)
54- self.request.response.addNotification(notification_text)
55+ if data.get('distro_arch_series'):
56+ architectures = [
57+ arch.architecturetag for arch in data['distro_arch_series']]
58+ else:
59+ architectures = None
60+ self.context.requestBuilds(self.user, architectures)
61+ self.request.response.addNotification(
62+ "Your builds were scheduled and should start soon. "
63+ "Refresh this page for details.")
64 self.next_url = self.cancel_url
65
66
67diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
68index 72f9c83..82422f1 100644
69--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
70+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
71@@ -50,18 +50,22 @@ from lp.oci.interfaces.ocirecipe import (
72 IOCIRecipeSet,
73 OCI_RECIPE_ALLOW_CREATE,
74 )
75+from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
76 from lp.oci.interfaces.ociregistrycredentials import (
77 IOCIRegistryCredentialsSet,
78 )
79 from lp.oci.tests.helpers import OCIConfigHelperMixin
80 from lp.registry.interfaces.person import IPersonSet
81+from lp.services.config import config
82 from lp.services.database.constants import UTC_NOW
83 from lp.services.database.interfaces import IStore
84 from lp.services.features.testing import FeatureFixture
85+from lp.services.job.runner import JobRunner
86 from lp.services.propertycache import get_property_cache
87 from lp.services.webapp import canonical_url
88 from lp.services.webapp.servers import LaunchpadTestRequest
89 from lp.testing import (
90+ admin_logged_in,
91 anonymous_logged_in,
92 BrowserTestCase,
93 login,
94@@ -71,6 +75,7 @@ from lp.testing import (
95 TestCaseWithFactory,
96 time_counter,
97 )
98+from lp.testing.dbuser import dbuser
99 from lp.testing.layers import (
100 DatabaseFunctionalLayer,
101 LaunchpadFunctionalLayer,
102@@ -938,6 +943,30 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
103 self.assertRaises(
104 Unauthorized, self.getViewBrowser, self.recipe, "+request-builds")
105
106+ def runRequestBuildJobs(self):
107+ with admin_logged_in():
108+ jobs = getUtility(IOCIRecipeRequestBuildsJobSource).iterReady()
109+ with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser):
110+ JobRunner(jobs).runAll()
111+
112+ def test_pending_build_requests_not_shown_if_absent(self):
113+ self.recipe.requestBuilds(self.recipe.owner)
114+ browser = self.getViewBrowser(self.recipe, user=self.person)
115+ content = extract_text(find_main_content(browser.contents))
116+ self.assertIn(
117+ "You have 1 pending build request. "
118+ "The builds should start automatically soon.",
119+ content.replace("\n", " "))
120+
121+ # Run the build request, so we don't have any pending one. The
122+ # message should be gone then.
123+ self.runRequestBuildJobs()
124+ browser = self.getViewBrowser(self.recipe, user=self.person)
125+ content = extract_text(find_main_content(browser.contents))
126+ self.assertNotIn(
127+ "The builds should start automatically soon.",
128+ content.replace("\n", " "))
129+
130 def test_request_builds_action(self):
131 # Requesting a build creates pending builds.
132 browser = self.getViewBrowser(
133@@ -946,6 +975,8 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
134 self.assertTrue(browser.getControl("i386").selected)
135 browser.getControl("Request builds").click()
136
137+ self.runRequestBuildJobs()
138+
139 login_person(self.person)
140 builds = self.recipe.pending_builds
141 self.assertContentEqual(
142@@ -954,19 +985,6 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
143 self.assertContentEqual(
144 [2510], set(build.buildqueue_record.lastscore for build in builds))
145
146- def test_request_builds_rejects_duplicate(self):
147- # A duplicate build request causes a notification.
148- self.recipe.requestBuild(self.person, self.distroseries["amd64"])
149- browser = self.getViewBrowser(
150- self.recipe, "+request-builds", user=self.person)
151- self.assertTrue(browser.getControl("amd64").selected)
152- self.assertTrue(browser.getControl("i386").selected)
153- browser.getControl("Request builds").click()
154- main_text = extract_text(find_main_content(browser.contents))
155- self.assertIn("1 new build has been queued.", main_text)
156- self.assertIn(
157- "An identical build is already pending for amd64.", main_text)
158-
159 def test_request_builds_no_architectures(self):
160 # Selecting no architectures causes a validation failure.
161 browser = self.getViewBrowser(
162diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
163index 718cdd3..0bd3cd2 100644
164--- a/lib/lp/oci/interfaces/ocirecipe.py
165+++ b/lib/lp/oci/interfaces/ocirecipe.py
166@@ -54,6 +54,7 @@ from zope.schema import (
167 Dict,
168 Int,
169 List,
170+ Set,
171 Text,
172 TextLine,
173 )
174@@ -173,6 +174,10 @@ class IOCIRecipeBuildRequest(Interface):
175 value_type=Reference(schema=Interface),
176 required=True, readonly=True)))
177
178+ architectures = Set(
179+ title=_("If set, limit builds to these architecture tags."),
180+ value_type=TextLine(), required=False, readonly=True)
181+
182
183 class IOCIRecipeView(Interface):
184 """`IOCIRecipe` attributes that require launchpad.View permission."""
185@@ -202,6 +207,9 @@ class IOCIRecipeView(Interface):
186 "The architectures that are available to be enabled or disabled for "
187 "this OCI recipe.")
188
189+ pending_build_requests = Attribute(
190+ "The list of build requests that didn't trigger builds yet.")
191+
192 # This should only be set by using IOCIProject.setOfficialRecipe
193 official = Bool(
194 title=_("OCI project official"),
195@@ -265,6 +273,10 @@ class IOCIRecipeView(Interface):
196 def requestBuild(requester, architecture):
197 """Request that the OCI recipe is built.
198
199+ Please, note that this method does not associate the created build
200+ with an OCIRecipeBuildRequest. So, prefer using the
201+ OCIRecipe.requestBuilds (plural).
202+
203 :param requester: The person requesting the build.
204 :param architecture: The architecture to build for.
205 :return: `IOCIRecipeBuild`.
206@@ -273,7 +285,7 @@ class IOCIRecipeView(Interface):
207 @call_with(requester=REQUEST_USER)
208 @export_factory_operation(IOCIRecipeBuildRequest, [])
209 @operation_for_version("devel")
210- def requestBuilds(requester):
211+ def requestBuilds(requester, architectures=None):
212 """Request that the OCI recipe is built for all available
213 architectures.
214
215@@ -281,7 +293,7 @@ class IOCIRecipeView(Interface):
216 :return: A `IOCIRecipeBuildRequest` instance.
217 """
218
219- def requestBuildsFromJob(requester):
220+ def requestBuildsFromJob(requester, architectures=None):
221 """Synchronous part of requesting builds, that should be called as a
222 Celery task.
223
224diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py
225index 17d9fd5..104a2ff 100644
226--- a/lib/lp/oci/interfaces/ocirecipejob.py
227+++ b/lib/lp/oci/interfaces/ocirecipejob.py
228@@ -81,13 +81,26 @@ class IOCIRecipeRequestBuildsJob(IRunnableJob):
229
230 class IOCIRecipeRequestBuildsJobSource(IJobSource):
231
232- def create(oci_recipe, requester):
233+ def create(oci_recipe, requester, architectures=None):
234 """Request builds of an OCI Recipe.
235
236 :param oci_recipe: The OCI recipe to build.
237 :param requester: The person requesting the builds.
238+ :param architectures: Build only for this list of
239+ architectures, if they are available for the recipe. If
240+ None, build for all available architectures.
241 """
242
243- def getByOCIRecipeAndID(self, oci_recipe, job_id):
244+ def getByOCIRecipeAndID(recipe, job_id):
245 """Retrieve the build job by OCI recipe and the given job ID.
246 """
247+
248+ def findByOCIRecipe(recipe, statuses=None, job_ids=None):
249+ """Find jobs for an OCI recipe.
250+
251+ :param oci_recipe: An OCI recipe to search for.
252+ :param statuses: An optional iterable of `JobStatus`es to search for.
253+ :param job_ids: An optional iterable of job IDs to search for.
254+ :return: A sequence of `OCIRecipeRequestBuildsJob`s with the specified
255+ OCI recipe.
256+ """
257diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
258index 3ca817c..15fa5be 100644
259--- a/lib/lp/oci/model/ocirecipe.py
260+++ b/lib/lp/oci/model/ocirecipe.py
261@@ -421,32 +421,42 @@ class OCIRecipe(Storm, WebhookTargetMixin):
262 def getBuildRequest(self, job_id):
263 return OCIRecipeBuildRequest(self, job_id)
264
265- def requestBuildsFromJob(self, requester, build_request=None):
266+ def requestBuildsFromJob(self, requester, build_request=None,
267+ architectures=None):
268 self._checkRequestBuild(requester)
269- distro_arch_series_to_build = set(self.getAllowedArchitectures())
270+ distro_arch_series = set(self.getAllowedArchitectures())
271
272 builds = []
273- for das in distro_arch_series_to_build:
274+ for das in distro_arch_series:
275+ if (architectures is not None
276+ and das.architecturetag not in architectures):
277+ continue
278 try:
279 builds.append(self.requestBuild(
280 requester, das, build_request=build_request))
281 except OCIRecipeBuildAlreadyPending:
282 pass
283
284- # If we have distro_arch_series_to_build, but they all failed to due
285+ # If we have distro_arch_series, but they all failed to due
286 # to pending builds, we fail the job.
287- if len(distro_arch_series_to_build) > 0 and len(builds) == 0:
288+ if len(distro_arch_series) > 0 and len(builds) == 0:
289 raise OCIRecipeBuildAlreadyPending
290
291 return builds
292
293- def requestBuilds(self, requester):
294+ def requestBuilds(self, requester, architectures=None):
295 self._checkRequestBuild(requester)
296 job = getUtility(IOCIRecipeRequestBuildsJobSource).create(
297- self, requester)
298+ self, requester, architectures)
299 return self.getBuildRequest(job.job_id)
300
301 @property
302+ def pending_build_requests(self):
303+ util = getUtility(IOCIRecipeRequestBuildsJobSource)
304+ return util.findByOCIRecipe(
305+ self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))
306+
307+ @property
308 def push_rules(self):
309 rules = IStore(self).find(
310 OCIPushRule,
311diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py
312index 095181e..15eac4f 100644
313--- a/lib/lp/oci/model/ocirecipejob.py
314+++ b/lib/lp/oci/model/ocirecipejob.py
315@@ -17,6 +17,7 @@ from lazr.enum import (
316 )
317 import six
318 from storm.databases.postgres import JSON
319+from storm.expr import Desc
320 from storm.properties import Int
321 from storm.references import Reference
322 from storm.store import EmptyResultSet
323@@ -162,9 +163,13 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
324 config = config.IOCIRecipeRequestBuildsJobSource
325
326 @classmethod
327- def create(cls, recipe, requester):
328+ def create(cls, recipe, requester, architectures=None):
329 """See `OCIRecipeRequestBuildsJob`."""
330- metadata = {"requester": requester.id}
331+ metadata = {
332+ "requester": requester.id,
333+ "architectures": (
334+ list(architectures) if architectures is not None else None),
335+ }
336 recipe_job = OCIRecipeJob(recipe, cls.class_job_type, metadata)
337 job = cls(recipe_job)
338 job.celeryRunOnCommit()
339@@ -180,6 +185,20 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
340 raise NotFoundError("Could not find job ID %s" % job_id)
341 return cls(job)
342
343+ @classmethod
344+ def findByOCIRecipe(cls, recipe, statuses=None, job_ids=None):
345+ conditions = [
346+ OCIRecipeJob.recipe == recipe,
347+ OCIRecipeJob.job_type == cls.class_job_type]
348+ if statuses is not None:
349+ conditions.append(Job._status.is_in(statuses))
350+ if job_ids is not None:
351+ conditions.append(OCIRecipeJob.job_id.is_in(job_ids))
352+ return IStore(OCIRecipeJob).find(
353+ (OCIRecipeJob, Job),
354+ OCIRecipeJob.job_id == Job.id,
355+ *conditions).order_by(Desc(OCIRecipeJob.job_id))
356+
357 def getOperationDescription(self):
358 return "requesting builds of %s" % self.recipe
359
360@@ -234,6 +253,11 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
361 """See `OCIRecipeRequestBuildsJob`."""
362 self.metadata["builds"] = [build.id for build in builds]
363
364+ @property
365+ def architectures(self):
366+ architectures = self.metadata["architectures"]
367+ return set(architectures) if architectures is not None else None
368+
369 def run(self):
370 """See `IRunnableJob`."""
371 requester = self.requester
372@@ -243,7 +267,8 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
373 return
374 try:
375 self.builds = self.recipe.requestBuildsFromJob(
376- requester, build_request=self.build_request)
377+ requester, build_request=self.build_request,
378+ architectures=self.architectures)
379 self.error_message = None
380 except self.retry_error_types:
381 raise
382diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
383index 5c3ca56..9a9aefc 100644
384--- a/lib/lp/oci/templates/ocirecipe-index.pt
385+++ b/lib/lp/oci/templates/ocirecipe-index.pt
386@@ -84,6 +84,15 @@
387 </div>
388
389 <h2>Latest builds</h2>
390+ <div tal:define="count context/pending_build_requests/count|nothing;
391+ plural string: build requests;
392+ singular string: build request;"
393+ tal:condition="count">
394+ You have <span tal:replace="count" /> pending
395+ <tal:plural
396+ metal:use-macro="context/@@+base-layout-macros/plural-message"/>.
397+ The builds should start automatically soon.
398+ </div>
399 <table id="latest-builds-listing" class="listing"
400 style="margin-bottom: 1em;">
401 <thead>