Merge ~cjwatson/launchpad:charm-recipe-request-builds into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 7cb2458a8c0c5c02504902d5b1b0602d35215711
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charm-recipe-request-builds
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:charm-recipe-build
Diff against target: 483 lines (+318/-1)
5 files modified
lib/lp/charms/interfaces/charmrecipe.py (+35/-0)
lib/lp/charms/model/charmrecipe.py (+101/-0)
lib/lp/charms/tests/test_charmrecipe.py (+148/-0)
lib/lp/security.py (+30/-0)
lib/lp/testing/factory.py (+4/-1)
Reviewer Review Type Date Requested Status
Cristian Gonzalez (community) Approve
Review via email: mp+403563@code.launchpad.net

Commit message

Implement CharmRecipe.requestBuild

To post a comment you must log in.
Revision history for this message
Cristian Gonzalez (cristiangsp) wrote :

Looks good!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
2index 8b38153..d625615 100644
3--- a/lib/lp/charms/interfaces/charmrecipe.py
4+++ b/lib/lp/charms/interfaces/charmrecipe.py
5@@ -12,6 +12,8 @@ __all__ = [
6 "CHARM_RECIPE_ALLOW_CREATE",
7 "CHARM_RECIPE_BUILD_DISTRIBUTION",
8 "CHARM_RECIPE_PRIVATE_FEATURE_FLAG",
9+ "CharmRecipeBuildAlreadyPending",
10+ "CharmRecipeBuildDisallowedArchitecture",
11 "CharmRecipeBuildRequestStatus",
12 "CharmRecipeFeatureDisabled",
13 "CharmRecipeNotOwner",
14@@ -139,6 +141,25 @@ class BadCharmRecipeSearchContext(Exception):
15 """The context is not valid for a charm recipe search."""
16
17
18+@error_status(http_client.BAD_REQUEST)
19+class CharmRecipeBuildAlreadyPending(Exception):
20+ """A build was requested when an identical build was already pending."""
21+
22+ def __init__(self):
23+ super(CharmRecipeBuildAlreadyPending, self).__init__(
24+ "An identical build of this charm recipe is already pending.")
25+
26+
27+@error_status(http_client.BAD_REQUEST)
28+class CharmRecipeBuildDisallowedArchitecture(Exception):
29+ """A build was requested for a disallowed architecture."""
30+
31+ def __init__(self, das):
32+ super(CharmRecipeBuildDisallowedArchitecture, self).__init__(
33+ "This charm recipe is not allowed to build for %s/%s." %
34+ (das.distroseries.name, das.architecturetag))
35+
36+
37 class CharmRecipeBuildRequestStatus(EnumeratedType):
38 """The status of a request to build a charm recipe."""
39
40@@ -233,6 +254,20 @@ class ICharmRecipeView(Interface):
41 def visibleByUser(user):
42 """Can the specified user see this charm recipe?"""
43
44+ def requestBuild(build_request, distro_arch_series, channels=None):
45+ """Request a single build of this charm recipe.
46+
47+ This method is for internal use; external callers should use
48+ `requestBuilds` instead.
49+
50+ :param build_request: The `ICharmRecipeBuildRequest` job being
51+ processed.
52+ :param distro_arch_series: The architecture to build for.
53+ :param channels: A dictionary mapping snap names to channels to use
54+ for this build.
55+ :return: `ICharmRecipeBuild`.
56+ """
57+
58 def requestBuilds(requester, channels=None, architectures=None):
59 """Request that the charm recipe be built.
60
61diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
62index 7bb0d62..cbb078d 100644
63--- a/lib/lp/charms/model/charmrecipe.py
64+++ b/lib/lp/charms/model/charmrecipe.py
65@@ -10,16 +10,23 @@ __all__ = [
66 "CharmRecipe",
67 ]
68
69+from operator import itemgetter
70+
71+from lazr.lifecycle.event import ObjectCreatedEvent
72 import pytz
73 from storm.databases.postgres import JSON
74 from storm.locals import (
75 Bool,
76 DateTime,
77 Int,
78+ Join,
79+ Or,
80 Reference,
81+ Store,
82 Unicode,
83 )
84 from zope.component import getUtility
85+from zope.event import notify
86 from zope.interface import implementer
87 from zope.security.proxy import removeSecurityProxy
88
89@@ -29,10 +36,13 @@ from lp.app.enums import (
90 PUBLIC_INFORMATION_TYPES,
91 )
92 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
93+from lp.buildmaster.enums import BuildStatus
94 from lp.charms.interfaces.charmrecipe import (
95 CHARM_RECIPE_ALLOW_CREATE,
96 CHARM_RECIPE_BUILD_DISTRIBUTION,
97 CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
98+ CharmRecipeBuildAlreadyPending,
99+ CharmRecipeBuildDisallowedArchitecture,
100 CharmRecipeBuildRequestStatus,
101 CharmRecipeFeatureDisabled,
102 CharmRecipeNotOwner,
103@@ -44,9 +54,11 @@ from lp.charms.interfaces.charmrecipe import (
104 ICharmRecipeSet,
105 NoSourceForCharmRecipe,
106 )
107+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
108 from lp.charms.interfaces.charmrecipejob import (
109 ICharmRecipeRequestBuildsJobSource,
110 )
111+from lp.charms.model.charmrecipebuild import CharmRecipeBuild
112 from lp.code.model.gitcollection import GenericGitCollection
113 from lp.code.model.gitrepository import GitRepository
114 from lp.registry.errors import PrivatePersonLinkageError
115@@ -55,11 +67,15 @@ from lp.registry.interfaces.person import (
116 IPersonSet,
117 validate_public_person,
118 )
119+from lp.registry.model.distribution import Distribution
120+from lp.registry.model.distroseries import DistroSeries
121+from lp.registry.model.series import ACTIVE_STATUSES
122 from lp.services.database.bulk import load_related
123 from lp.services.database.constants import (
124 DEFAULT,
125 UTC_NOW,
126 )
127+from lp.services.database.decoratedresultset import DecoratedResultSet
128 from lp.services.database.enumcol import DBEnum
129 from lp.services.database.interfaces import (
130 IMasterStore,
131@@ -68,10 +84,15 @@ from lp.services.database.interfaces import (
132 from lp.services.database.stormbase import StormBase
133 from lp.services.features import getFeatureFlag
134 from lp.services.job.interfaces.job import JobStatus
135+from lp.services.librarian.model import LibraryFileAlias
136 from lp.services.propertycache import (
137 cachedproperty,
138 get_property_cache,
139 )
140+from lp.soyuz.model.distroarchseries import (
141+ DistroArchSeries,
142+ PocketChroot,
143+ )
144
145
146 def charm_recipe_modified(recipe, event):
147@@ -342,6 +363,48 @@ class CharmRecipe(StormBase):
148 # more privacy infrastructure.
149 return False
150
151+ def _isBuildableArchitectureAllowed(self, das):
152+ """Check whether we may build for a buildable `DistroArchSeries`.
153+
154+ The caller is assumed to have already checked that a suitable chroot
155+ is available (either directly or via
156+ `DistroSeries.buildable_architectures`).
157+ """
158+ return (
159+ das.enabled
160+ and (
161+ das.processor.supports_virtualized
162+ or not self.require_virtualized))
163+
164+ def _isArchitectureAllowed(self, das):
165+ """Check whether we may build for a `DistroArchSeries`."""
166+ return (
167+ das.getChroot() is not None
168+ and self._isBuildableArchitectureAllowed(das))
169+
170+ def getAllowedArchitectures(self):
171+ """See `IOCIRecipe`."""
172+ store = Store.of(self)
173+ origin = [
174+ DistroArchSeries,
175+ Join(DistroSeries,
176+ DistroArchSeries.distroseries == DistroSeries.id),
177+ Join(Distribution, DistroSeries.distribution == Distribution.id),
178+ Join(PocketChroot,
179+ PocketChroot.distroarchseries == DistroArchSeries.id),
180+ Join(LibraryFileAlias,
181+ PocketChroot.chroot == LibraryFileAlias.id),
182+ ]
183+ # Preload DistroSeries and Distribution, since we'll need those in
184+ # determine_architectures_to_build.
185+ results = store.using(*origin).find(
186+ (DistroArchSeries, DistroSeries, Distribution),
187+ DistroSeries.status.is_in(ACTIVE_STATUSES))
188+ all_buildable_dases = DecoratedResultSet(results, itemgetter(0))
189+ return [
190+ das for das in all_buildable_dases
191+ if self._isBuildableArchitectureAllowed(das)]
192+
193 def _checkRequestBuild(self, requester):
194 """May `requester` request builds of this charm recipe?"""
195 if not requester.inTeam(self.owner):
196@@ -349,6 +412,44 @@ class CharmRecipe(StormBase):
197 "%s cannot create charm recipe builds owned by %s." %
198 (requester.display_name, self.owner.display_name))
199
200+ def requestBuild(self, build_request, distro_arch_series, channels=None):
201+ """Request a single build of this charm recipe.
202+
203+ This method is for internal use; external callers should use
204+ `requestBuilds` instead.
205+
206+ :param build_request: The `ICharmRecipeBuildRequest` job being
207+ processed.
208+ :param distro_arch_series: The architecture to build for.
209+ :param channels: A dictionary mapping snap names to channels to use
210+ for this build.
211+ :return: `ICharmRecipeBuild`.
212+ """
213+ self._checkRequestBuild(build_request.requester)
214+ if not self._isArchitectureAllowed(distro_arch_series):
215+ raise CharmRecipeBuildDisallowedArchitecture(distro_arch_series)
216+
217+ if not channels:
218+ channels_clause = Or(
219+ CharmRecipeBuild.channels == None,
220+ CharmRecipeBuild.channels == {})
221+ else:
222+ channels_clause = CharmRecipeBuild.channels == channels
223+ pending = IStore(self).find(
224+ CharmRecipeBuild,
225+ CharmRecipeBuild.recipe == self,
226+ CharmRecipeBuild.processor == distro_arch_series.processor,
227+ channels_clause,
228+ CharmRecipeBuild.status == BuildStatus.NEEDSBUILD)
229+ if pending.any() is not None:
230+ raise CharmRecipeBuildAlreadyPending
231+
232+ build = getUtility(ICharmRecipeBuildSet).new(
233+ build_request, self, distro_arch_series, channels=channels)
234+ build.queueBuild()
235+ notify(ObjectCreatedEvent(build, user=build_request.requester))
236+ return build
237+
238 def requestBuilds(self, requester, channels=None, architectures=None):
239 """See `ICharmRecipe`."""
240 self._checkRequestBuild(requester)
241diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
242index bf5d0d0..cd53f3e 100644
243--- a/lib/lp/charms/tests/test_charmrecipe.py
244+++ b/lib/lp/charms/tests/test_charmrecipe.py
245@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
246
247 __metaclass__ = type
248
249+from storm.locals import Store
250 from testtools.matchers import (
251 Equals,
252 Is,
253@@ -18,9 +19,21 @@ from zope.component import getUtility
254 from zope.security.proxy import removeSecurityProxy
255
256 from lp.app.enums import InformationType
257+from lp.buildmaster.enums import (
258+ BuildQueueStatus,
259+ BuildStatus,
260+ )
261+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
262+from lp.buildmaster.interfaces.processor import (
263+ IProcessorSet,
264+ ProcessorNotFound,
265+ )
266+from lp.buildmaster.model.buildqueue import BuildQueue
267 from lp.charms.interfaces.charmrecipe import (
268 CHARM_RECIPE_ALLOW_CREATE,
269 CHARM_RECIPE_BUILD_DISTRIBUTION,
270+ CharmRecipeBuildAlreadyPending,
271+ CharmRecipeBuildDisallowedArchitecture,
272 CharmRecipeBuildRequestStatus,
273 CharmRecipeFeatureDisabled,
274 CharmRecipePrivateFeatureDisabled,
275@@ -28,6 +41,7 @@ from lp.charms.interfaces.charmrecipe import (
276 ICharmRecipeSet,
277 NoSourceForCharmRecipe,
278 )
279+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
280 from lp.charms.interfaces.charmrecipejob import (
281 ICharmRecipeRequestBuildsJobSource,
282 )
283@@ -170,6 +184,140 @@ class TestCharmRecipe(TestCaseWithFactory):
284 current_series,
285 removeSecurityProxy(recipe)._default_distro_series)
286
287+ def makeBuildableDistroArchSeries(self, architecturetag=None,
288+ processor=None,
289+ supports_virtualized=True,
290+ supports_nonvirtualized=True, **kwargs):
291+ if architecturetag is None:
292+ architecturetag = self.factory.getUniqueUnicode("arch")
293+ if processor is None:
294+ try:
295+ processor = getUtility(IProcessorSet).getByName(
296+ architecturetag)
297+ except ProcessorNotFound:
298+ processor = self.factory.makeProcessor(
299+ name=architecturetag,
300+ supports_virtualized=supports_virtualized,
301+ supports_nonvirtualized=supports_nonvirtualized)
302+ das = self.factory.makeDistroArchSeries(
303+ architecturetag=architecturetag, processor=processor, **kwargs)
304+ fake_chroot = self.factory.makeLibraryFileAlias(
305+ filename="fake_chroot.tar.gz", db_only=True)
306+ das.addOrUpdateChroot(fake_chroot)
307+ return das
308+
309+ def test_requestBuild(self):
310+ # requestBuild creates a new CharmRecipeBuild.
311+ recipe = self.factory.makeCharmRecipe()
312+ das = self.makeBuildableDistroArchSeries()
313+ build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
314+ build = recipe.requestBuild(build_request, das)
315+ self.assertTrue(ICharmRecipeBuild.providedBy(build))
316+ self.assertThat(build, MatchesStructure(
317+ requester=Equals(recipe.owner.teamowner),
318+ distro_arch_series=Equals(das),
319+ channels=Is(None),
320+ status=Equals(BuildStatus.NEEDSBUILD),
321+ ))
322+ store = Store.of(build)
323+ store.flush()
324+ build_queue = store.find(
325+ BuildQueue,
326+ BuildQueue._build_farm_job_id ==
327+ removeSecurityProxy(build).build_farm_job_id).one()
328+ self.assertProvides(build_queue, IBuildQueue)
329+ self.assertEqual(recipe.require_virtualized, build_queue.virtualized)
330+ self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
331+
332+ def test_requestBuild_score(self):
333+ # Build requests have a relatively low queue score (2510).
334+ recipe = self.factory.makeCharmRecipe()
335+ das = self.makeBuildableDistroArchSeries()
336+ build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
337+ build = recipe.requestBuild(build_request, das)
338+ queue_record = build.buildqueue_record
339+ queue_record.score()
340+ self.assertEqual(2510, queue_record.lastscore)
341+
342+ def test_requestBuild_channels(self):
343+ # requestBuild can select non-default channels.
344+ recipe = self.factory.makeCharmRecipe()
345+ das = self.makeBuildableDistroArchSeries()
346+ build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
347+ build = recipe.requestBuild(
348+ build_request, das, channels={"charmcraft": "edge"})
349+ self.assertEqual({"charmcraft": "edge"}, build.channels)
350+
351+ def test_requestBuild_rejects_repeats(self):
352+ # requestBuild refuses if there is already a pending build.
353+ recipe = self.factory.makeCharmRecipe()
354+ distro_series = self.factory.makeDistroSeries()
355+ arches = [
356+ self.makeBuildableDistroArchSeries(distroseries=distro_series)
357+ for _ in range(2)]
358+ build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
359+ old_build = recipe.requestBuild(build_request, arches[0])
360+ self.assertRaises(
361+ CharmRecipeBuildAlreadyPending, recipe.requestBuild,
362+ build_request, arches[0])
363+ # We can build for a different distroarchseries.
364+ recipe.requestBuild(build_request, arches[1])
365+ # channels=None and channels={} are treated as equivalent, but
366+ # anything else allows a new build.
367+ self.assertRaises(
368+ CharmRecipeBuildAlreadyPending, recipe.requestBuild,
369+ build_request, arches[0], channels={})
370+ recipe.requestBuild(
371+ build_request, arches[0], channels={"core": "edge"})
372+ self.assertRaises(
373+ CharmRecipeBuildAlreadyPending, recipe.requestBuild,
374+ build_request, arches[0], channels={"core": "edge"})
375+ # Changing the status of the old build allows a new build.
376+ old_build.updateStatus(BuildStatus.BUILDING)
377+ old_build.updateStatus(BuildStatus.FULLYBUILT)
378+ recipe.requestBuild(build_request, arches[0])
379+
380+ def test_requestBuild_virtualization(self):
381+ # New builds are virtualized if any of the processor or recipe
382+ # require it.
383+ recipe = self.factory.makeCharmRecipe()
384+ distro_series = self.factory.makeDistroSeries()
385+ dases = {}
386+ for proc_nonvirt in True, False:
387+ das = self.makeBuildableDistroArchSeries(
388+ distroseries=distro_series, supports_virtualized=True,
389+ supports_nonvirtualized=proc_nonvirt)
390+ dases[proc_nonvirt] = das
391+ for proc_nonvirt, recipe_virt, build_virt in (
392+ (True, False, False),
393+ (True, True, True),
394+ (False, False, True),
395+ (False, True, True),
396+ ):
397+ das = dases[proc_nonvirt]
398+ recipe = self.factory.makeCharmRecipe(
399+ require_virtualized=recipe_virt)
400+ build_request = self.factory.makeCharmRecipeBuildRequest(
401+ recipe=recipe)
402+ build = recipe.requestBuild(build_request, das)
403+ self.assertEqual(build_virt, build.virtualized)
404+
405+ def test_requestBuild_nonvirtualized(self):
406+ # A non-virtualized processor can build a charm recipe iff the
407+ # recipe has require_virtualized set to False.
408+ recipe = self.factory.makeCharmRecipe()
409+ distro_series = self.factory.makeDistroSeries()
410+ das = self.makeBuildableDistroArchSeries(
411+ distroseries=distro_series, supports_virtualized=False,
412+ supports_nonvirtualized=True)
413+ build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
414+ self.assertRaises(
415+ CharmRecipeBuildDisallowedArchitecture, recipe.requestBuild,
416+ build_request, das)
417+ with admin_logged_in():
418+ recipe.require_virtualized = False
419+ recipe.requestBuild(build_request, das)
420+
421 def test_requestBuilds(self):
422 # requestBuilds schedules a job and returns a corresponding
423 # CharmRecipeBuildRequest.
424diff --git a/lib/lp/security.py b/lib/lp/security.py
425index e11855f..6108048 100644
426--- a/lib/lp/security.py
427+++ b/lib/lp/security.py
428@@ -69,6 +69,7 @@ from lp.charms.interfaces.charmrecipe import (
429 ICharmRecipe,
430 ICharmRecipeBuildRequest,
431 )
432+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
433 from lp.code.interfaces.branch import (
434 IBranch,
435 user_has_special_branch_access,
436@@ -3668,3 +3669,32 @@ class ViewCharmRecipeBuildRequest(DelegatedAuthorization):
437 def __init__(self, obj):
438 super(ViewCharmRecipeBuildRequest, self).__init__(
439 obj, obj.recipe, 'launchpad.View')
440+
441+
442+class ViewCharmRecipeBuild(DelegatedAuthorization):
443+ permission = 'launchpad.View'
444+ usedfor = ICharmRecipeBuild
445+
446+ def iter_objects(self):
447+ yield self.obj.recipe
448+
449+
450+class EditCharmRecipeBuild(AdminByBuilddAdmin):
451+ permission = 'launchpad.Edit'
452+ usedfor = ICharmRecipeBuild
453+
454+ def checkAuthenticated(self, user):
455+ """Check edit access for snap package builds.
456+
457+ Allow admins, buildd admins, and the owner of the charm recipe.
458+ (Note that the requester of the build is required to be in the team
459+ that owns the charm recipe.)
460+ """
461+ auth_recipe = EditCharmRecipe(self.obj.recipe)
462+ if auth_recipe.checkAuthenticated(user):
463+ return True
464+ return super(EditCharmRecipeBuild, self).checkAuthenticated(user)
465+
466+
467+class AdminCharmRecipeBuild(AdminByBuilddAdmin):
468+ usedfor = ICharmRecipeBuild
469diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
470index e68c408..4c125a5 100644
471--- a/lib/lp/testing/factory.py
472+++ b/lib/lp/testing/factory.py
473@@ -5158,7 +5158,10 @@ class BareLaunchpadObjectFactory(ObjectFactory):
474 if recipe is None:
475 recipe = self.makeCharmRecipe()
476 if requester is None:
477- requester = recipe.owner.teamowner
478+ if recipe.owner.is_team:
479+ requester = recipe.owner.teamowner
480+ else:
481+ requester = recipe.owner
482 return recipe.requestBuilds(
483 requester, channels=channels, architectures=architectures)
484

Subscribers

People subscribed via source and target branches

to status/vote changes: