Merge ~cjwatson/launchpad:charm-recipe-build into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 0a093a3776cb31bea61b65e89cf3b577aeb34015
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charm-recipe-build
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:charm-recipe-distroseries
Diff against target: 1404 lines (+1197/-2)
12 files modified
lib/lp/buildmaster/enums.py (+7/-1)
lib/lp/charms/browser/charmrecipe.py (+27/-0)
lib/lp/charms/browser/configure.zcml (+11/-0)
lib/lp/charms/configure.zcml (+31/-0)
lib/lp/charms/interfaces/charmrecipe.py (+15/-0)
lib/lp/charms/interfaces/charmrecipebuild.py (+204/-0)
lib/lp/charms/interfaces/charmrecipejob.py (+7/-0)
lib/lp/charms/model/charmrecipe.py (+38/-1)
lib/lp/charms/model/charmrecipebuild.py (+463/-0)
lib/lp/charms/model/charmrecipejob.py (+17/-0)
lib/lp/charms/tests/test_charmrecipebuild.py (+334/-0)
lib/lp/testing/factory.py (+43/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Cristian Gonzalez (community) Approve
Review via email: mp+403469@code.launchpad.net

Commit message

Add basic model for charm recipe builds

To post a comment you must log in.
0a093a3... by Colin Watson

Add basic model for charm recipe builds

Revision history for this message
Cristian Gonzalez (cristiangsp) wrote :

Looks good!

review: Approve
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py
2index a96bce0..b3f0abb 100644
3--- a/lib/lp/buildmaster/enums.py
4+++ b/lib/lp/buildmaster/enums.py
5@@ -1,4 +1,4 @@
6-# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
7+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
8 # GNU Affero General Public License version 3 (see the file LICENSE).
9
10 """Common build interfaces."""
11@@ -170,6 +170,12 @@ class BuildFarmJobType(DBEnumeratedType):
12 Build an OCI image from a recipe.
13 """)
14
15+ CHARMRECIPEBUILD = DBItem(8, """
16+ Charm recipe build
17+
18+ Build a charm from a recipe.
19+ """)
20+
21
22 class BuildQueueStatus(DBEnumeratedType):
23 """Build queue status.
24diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
25index ce84fbe..a111fca 100644
26--- a/lib/lp/charms/browser/charmrecipe.py
27+++ b/lib/lp/charms/browser/charmrecipe.py
28@@ -7,14 +7,22 @@ from __future__ import absolute_import, print_function, unicode_literals
29
30 __metaclass__ = type
31 __all__ = [
32+ "CharmRecipeNavigation",
33 "CharmRecipeURL",
34 ]
35
36 from zope.component import getUtility
37 from zope.interface import implementer
38
39+from lp.charms.interfaces.charmrecipe import ICharmRecipe
40+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
41 from lp.registry.interfaces.personproduct import IPersonProductFactory
42+from lp.services.webapp import (
43+ Navigation,
44+ stepthrough,
45+ )
46 from lp.services.webapp.interfaces import ICanonicalUrlData
47+from lp.soyuz.browser.build import get_build_by_id_str
48
49
50 @implementer(ICanonicalUrlData)
51@@ -34,3 +42,22 @@ class CharmRecipeURL:
52 @property
53 def path(self):
54 return "+charm/%s" % self.recipe.name
55+
56+
57+class CharmRecipeNavigation(Navigation):
58+ usedfor = ICharmRecipe
59+
60+ @stepthrough("+build-request")
61+ def traverse_build_request(self, name):
62+ try:
63+ job_id = int(name)
64+ except ValueError:
65+ return None
66+ return self.context.getBuildRequest(job_id)
67+
68+ @stepthrough("+build")
69+ def traverse_build(self, name):
70+ build = get_build_by_id_str(ICharmRecipeBuildSet, name)
71+ if build is None or build.recipe != self.context:
72+ return None
73+ return build
74diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
75index fe38cdb..1f5a8af 100644
76--- a/lib/lp/charms/browser/configure.zcml
77+++ b/lib/lp/charms/browser/configure.zcml
78@@ -11,5 +11,16 @@
79 <browser:url
80 for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
81 urldata="lp.charms.browser.charmrecipe.CharmRecipeURL" />
82+ <browser:navigation
83+ module="lp.charms.browser.charmrecipe"
84+ classes="CharmRecipeNavigation" />
85+ <browser:url
86+ for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
87+ path_expression="string:+build-request/${id}"
88+ attribute_to_parent="recipe" />
89+ <browser:url
90+ for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
91+ path_expression="string:+build/${id}"
92+ attribute_to_parent="recipe" />
93 </facet>
94 </configure>
95diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
96index ddb0b3e..f1deb96 100644
97--- a/lib/lp/charms/configure.zcml
98+++ b/lib/lp/charms/configure.zcml
99@@ -46,6 +46,37 @@
100 interface="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" />
101 </class>
102
103+ <!-- CharmRecipeBuild -->
104+ <class class="lp.charms.model.charmrecipebuild.CharmRecipeBuild">
105+ <require
106+ permission="launchpad.View"
107+ interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildView" />
108+ <require
109+ permission="launchpad.Edit"
110+ interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildEdit" />
111+ <require
112+ permission="launchpad.Admin"
113+ interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildAdmin" />
114+ </class>
115+
116+ <!-- CharmRecipeBuildSet -->
117+ <securedutility
118+ class="lp.charms.model.charmrecipebuild.CharmRecipeBuildSet"
119+ provides="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildSet">
120+ <allow interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildSet" />
121+ </securedutility>
122+ <securedutility
123+ class="lp.charms.model.charmrecipebuild.CharmRecipeBuildSet"
124+ provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
125+ name="CHARMRECIPEBUILD">
126+ <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
127+ </securedutility>
128+
129+ <!-- CharmFile -->
130+ <class class="lp.charms.model.charmrecipebuild.CharmFile">
131+ <allow interface="lp.charms.interfaces.charmrecipebuild.ICharmFile" />
132+ </class>
133+
134 <!-- Charm-related jobs -->
135 <class class="lp.charms.model.charmrecipejob.CharmRecipeJob">
136 <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
137diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
138index 2229819..8b38153 100644
139--- a/lib/lp/charms/interfaces/charmrecipe.py
140+++ b/lib/lp/charms/interfaces/charmrecipe.py
141@@ -31,6 +31,7 @@ from lazr.enum import (
142 )
143 from lazr.restful.declarations import error_status
144 from lazr.restful.fields import (
145+ CollectionField,
146 Reference,
147 ReferenceChoice,
148 )
149@@ -58,6 +59,7 @@ from lp.app.validators.name import name_validator
150 from lp.app.validators.path import path_does_not_escape
151 from lp.code.interfaces.gitref import IGitRef
152 from lp.code.interfaces.gitrepository import IGitRepository
153+from lp.registry.interfaces.person import IPerson
154 from lp.registry.interfaces.product import IProduct
155 from lp.services.fields import (
156 PersonChoice,
157@@ -184,6 +186,16 @@ class ICharmRecipeBuildRequest(Interface):
158 error_message = TextLine(
159 title=_("Error message"), required=True, readonly=True)
160
161+ builds = CollectionField(
162+ title=_("Builds produced by this request"),
163+ # Really ICharmRecipeBuild.
164+ value_type=Reference(schema=Interface),
165+ required=True, readonly=True)
166+
167+ requester = Reference(
168+ title=_("The person requesting the builds."), schema=IPerson,
169+ required=True, readonly=True)
170+
171 channels = Dict(
172 title=_("Source snap channels for builds produced by this request"),
173 key_type=TextLine(), required=False, readonly=True)
174@@ -390,6 +402,9 @@ class ICharmRecipeSet(Interface):
175 def isValidInformationType(information_type, owner, git_ref=None):
176 """Whether the information type context is valid."""
177
178+ def preloadDataForRecipes(recipes, user):
179+ """Load the data related to a list of charm recipes."""
180+
181 def findByGitRepository(repository, paths=None):
182 """Return all charm recipes for the given Git repository.
183
184diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
185new file mode 100644
186index 0000000..284566f
187--- /dev/null
188+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
189@@ -0,0 +1,204 @@
190+# Copyright 2021 Canonical Ltd. This software is licensed under the
191+# GNU Affero General Public License version 3 (see the file LICENSE).
192+
193+"""Charm recipe build interfaces."""
194+
195+from __future__ import absolute_import, print_function, unicode_literals
196+
197+__metaclass__ = type
198+__all__ = [
199+ "ICharmFile",
200+ "ICharmRecipeBuild",
201+ "ICharmRecipeBuildSet",
202+ ]
203+
204+from lazr.restful.fields import Reference
205+from zope.interface import (
206+ Attribute,
207+ Interface,
208+ )
209+from zope.schema import (
210+ Bool,
211+ Datetime,
212+ Dict,
213+ Int,
214+ TextLine,
215+ )
216+
217+from lp import _
218+from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
219+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
220+from lp.charms.interfaces.charmrecipe import (
221+ ICharmRecipe,
222+ ICharmRecipeBuildRequest,
223+ )
224+from lp.registry.interfaces.person import IPerson
225+from lp.services.database.constants import DEFAULT
226+from lp.services.librarian.interfaces import ILibraryFileAlias
227+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
228+
229+
230+class ICharmRecipeBuildView(IPackageBuild):
231+ """`ICharmRecipeBuild` attributes that require launchpad.View."""
232+
233+ build_request = Reference(
234+ ICharmRecipeBuildRequest,
235+ title=_("The build request that caused this build to be created."),
236+ required=True, readonly=True)
237+
238+ requester = Reference(
239+ IPerson,
240+ title=_("The person who requested this build."),
241+ required=True, readonly=True)
242+
243+ recipe = Reference(
244+ ICharmRecipe,
245+ title=_("The charm recipe to build."),
246+ required=True, readonly=True)
247+
248+ distro_arch_series = Reference(
249+ IDistroArchSeries,
250+ title=_("The series and architecture for which to build."),
251+ required=True, readonly=True)
252+
253+ channels = Dict(
254+ title=_("Source snap channels to use for this build."),
255+ description=_(
256+ "A dictionary mapping snap names to channels to use for this "
257+ "build. Currently only 'core', 'core18', 'core20', "
258+ "and 'charmcraft' keys are supported."),
259+ key_type=TextLine())
260+
261+ virtualized = Bool(
262+ title=_("If True, this build is virtualized."), readonly=True)
263+
264+ score = Int(
265+ title=_("Score of the related build farm job (if any)."),
266+ required=False, readonly=True)
267+
268+ can_be_rescored = Bool(
269+ title=_("Can be rescored"),
270+ required=True, readonly=True,
271+ description=_("Whether this build record can be rescored manually."))
272+
273+ can_be_retried = Bool(
274+ title=_("Can be retried"),
275+ required=False, readonly=True,
276+ description=_("Whether this build record can be retried."))
277+
278+ can_be_cancelled = Bool(
279+ title=_("Can be cancelled"),
280+ required=True, readonly=True,
281+ description=_("Whether this build record can be cancelled."))
282+
283+ eta = Datetime(
284+ title=_("The datetime when the build job is estimated to complete."),
285+ readonly=True)
286+
287+ estimate = Bool(
288+ title=_("If true, the date value is an estimate."), readonly=True)
289+
290+ date = Datetime(
291+ title=_(
292+ "The date when the build completed or is estimated to complete."),
293+ readonly=True)
294+
295+ revision_id = TextLine(
296+ title=_("Revision ID"), required=False, readonly=True,
297+ description=_(
298+ "The revision ID of the branch used for this build, if "
299+ "available."))
300+
301+ store_upload_metadata = Attribute(
302+ _("A dict of data about store upload progress."))
303+
304+ def getFiles():
305+ """Retrieve the build's `ICharmFile` records.
306+
307+ :return: A result set of (`ICharmFile`, `ILibraryFileAlias`,
308+ `ILibraryFileContent`).
309+ """
310+
311+ def getFileByName(filename):
312+ """Return the corresponding `ILibraryFileAlias` in this context.
313+
314+ The following file types (and extension) can be looked up:
315+
316+ * Build log: '.txt.gz'
317+ * Upload log: '_log.txt'
318+
319+ Any filename not matching one of these extensions is looked up as a
320+ charm recipe output file.
321+
322+ :param filename: The filename to look up.
323+ :raises NotFoundError: if no file exists with the given name.
324+ :return: The corresponding `ILibraryFileAlias`.
325+ """
326+
327+
328+class ICharmRecipeBuildEdit(Interface):
329+ """`ICharmRecipeBuild` methods that require launchpad.Edit."""
330+
331+ def addFile(lfa):
332+ """Add a file to this build.
333+
334+ :param lfa: An `ILibraryFileAlias`.
335+ :return: An `ICharmFile`.
336+ """
337+
338+ def retry():
339+ """Restore the build record to its initial state.
340+
341+ Build record loses its history, is moved to NEEDSBUILD and a new
342+ non-scored BuildQueue entry is created for it.
343+ """
344+
345+ def cancel():
346+ """Cancel the build if it is either pending or in progress.
347+
348+ Check the can_be_cancelled property prior to calling this method to
349+ find out if cancelling the build is possible.
350+
351+ If the build is in progress, it is marked as CANCELLING until the
352+ buildd manager terminates the build and marks it CANCELLED. If the
353+ build is not in progress, it is marked CANCELLED immediately and is
354+ removed from the build queue.
355+
356+ If the build is not in a cancellable state, this method is a no-op.
357+ """
358+
359+
360+class ICharmRecipeBuildAdmin(Interface):
361+ """`ICharmRecipeBuild` methods that require launchpad.Admin."""
362+
363+ def rescore(score):
364+ """Change the build's score."""
365+
366+
367+class ICharmRecipeBuild(
368+ ICharmRecipeBuildView, ICharmRecipeBuildEdit, ICharmRecipeBuildAdmin):
369+ """A build record for a charm recipe."""
370+
371+
372+class ICharmRecipeBuildSet(ISpecificBuildFarmJobSource):
373+ """Utility to create and access `ICharmRecipeBuild`s."""
374+
375+ def new(build_request, recipe, distro_arch_series, channels=None,
376+ store_upload_metadata=None, date_created=DEFAULT):
377+ """Create an `ICharmRecipeBuild`."""
378+
379+ def preloadBuildsData(builds):
380+ """Load the data related to a list of charm recipe builds."""
381+
382+
383+class ICharmFile(Interface):
384+ """A file produced by a charm recipe build."""
385+
386+ build = Reference(
387+ ICharmRecipeBuild,
388+ title=_("The charm recipe build producing this file."),
389+ required=True, readonly=True)
390+
391+ library_file = Reference(
392+ ILibraryFileAlias, title=_("the library file alias for this file."),
393+ required=True, readonly=True)
394diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
395index 7d0eaf7..9bdc9e5 100644
396--- a/lib/lp/charms/interfaces/charmrecipejob.py
397+++ b/lib/lp/charms/interfaces/charmrecipejob.py
398@@ -20,6 +20,7 @@ from zope.interface import (
399 from zope.schema import (
400 Datetime,
401 Dict,
402+ List,
403 Set,
404 TextLine,
405 )
406@@ -29,6 +30,7 @@ from lp.charms.interfaces.charmrecipe import (
407 ICharmRecipe,
408 ICharmRecipeBuildRequest,
409 )
410+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
411 from lp.registry.interfaces.person import IPerson
412 from lp.services.job.interfaces.job import (
413 IJob,
414@@ -86,6 +88,11 @@ class ICharmRecipeRequestBuildsJob(IRunnableJob):
415 title=_("The build request corresponding to this job."),
416 schema=ICharmRecipeBuildRequest, required=True, readonly=True)
417
418+ builds = List(
419+ title=_("The builds created by this request."),
420+ value_type=Reference(schema=ICharmRecipeBuild),
421+ required=True, readonly=True)
422+
423
424 class ICharmRecipeRequestBuildsJobSource(IJobSource):
425
426diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
427index dc22c2e..7bb0d62 100644
428--- a/lib/lp/charms/model/charmrecipe.py
429+++ b/lib/lp/charms/model/charmrecipe.py
430@@ -47,10 +47,15 @@ from lp.charms.interfaces.charmrecipe import (
431 from lp.charms.interfaces.charmrecipejob import (
432 ICharmRecipeRequestBuildsJobSource,
433 )
434+from lp.code.model.gitcollection import GenericGitCollection
435 from lp.code.model.gitrepository import GitRepository
436 from lp.registry.errors import PrivatePersonLinkageError
437 from lp.registry.interfaces.distribution import IDistributionSet
438-from lp.registry.interfaces.person import validate_public_person
439+from lp.registry.interfaces.person import (
440+ IPersonSet,
441+ validate_public_person,
442+ )
443+from lp.services.database.bulk import load_related
444 from lp.services.database.constants import (
445 DEFAULT,
446 UTC_NOW,
447@@ -130,6 +135,16 @@ class CharmRecipeBuildRequest:
448 return self._job.error_message
449
450 @property
451+ def builds(self):
452+ """See `ICharmRecipeBuildRequest`."""
453+ return self._job.builds
454+
455+ @property
456+ def requester(self):
457+ """See `ICharmRecipeBuildRequest`."""
458+ return self._job.requester
459+
460+ @property
461 def channels(self):
462 """See `ICharmRecipeBuildRequest`."""
463 return self._job.channels
464@@ -422,6 +437,28 @@ class CharmRecipeSet:
465
466 return True
467
468+ def preloadDataForRecipes(self, recipes, user=None):
469+ """See `ICharmRecipeSet`."""
470+ recipes = [removeSecurityProxy(recipe) for recipe in recipes]
471+
472+ person_ids = set()
473+ for recipe in recipes:
474+ person_ids.add(recipe.registrant_id)
475+ person_ids.add(recipe.owner_id)
476+
477+ repositories = load_related(
478+ GitRepository, recipes, ["git_repository_id"])
479+ if repositories:
480+ GenericGitCollection.preloadDataForRepositories(repositories)
481+
482+ # Add repository owners to the list of pre-loaded persons. We need
483+ # the target repository owner as well, since repository unique names
484+ # aren't trigger-maintained.
485+ person_ids.update(repository.owner_id for repository in repositories)
486+
487+ list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
488+ person_ids, need_validity=True))
489+
490 def findByGitRepository(self, repository, paths=None):
491 """See `ICharmRecipeSet`."""
492 clauses = [CharmRecipe.git_repository == repository]
493diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
494new file mode 100644
495index 0000000..214f084
496--- /dev/null
497+++ b/lib/lp/charms/model/charmrecipebuild.py
498@@ -0,0 +1,463 @@
499+# Copyright 2021 Canonical Ltd. This software is licensed under the
500+# GNU Affero General Public License version 3 (see the file LICENSE).
501+
502+"""Charm recipe builds."""
503+
504+from __future__ import absolute_import, print_function, unicode_literals
505+
506+__metaclass__ = type
507+__all__ = [
508+ "CharmFile",
509+ "CharmRecipeBuild",
510+ ]
511+
512+from datetime import timedelta
513+
514+import pytz
515+import six
516+from storm.databases.postgres import JSON
517+from storm.locals import (
518+ Bool,
519+ DateTime,
520+ Desc,
521+ Int,
522+ Reference,
523+ Store,
524+ Unicode,
525+ )
526+from storm.store import EmptyResultSet
527+from zope.component import getUtility
528+from zope.interface import implementer
529+
530+from lp.app.errors import NotFoundError
531+from lp.buildmaster.enums import (
532+ BuildFarmJobType,
533+ BuildQueueStatus,
534+ BuildStatus,
535+ )
536+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
537+from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
538+from lp.buildmaster.model.packagebuild import PackageBuildMixin
539+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
540+from lp.charms.interfaces.charmrecipebuild import (
541+ ICharmFile,
542+ ICharmRecipeBuild,
543+ ICharmRecipeBuildSet,
544+ )
545+from lp.registry.interfaces.pocket import PackagePublishingPocket
546+from lp.registry.interfaces.series import SeriesStatus
547+from lp.registry.model.person import Person
548+from lp.services.config import config
549+from lp.services.database.bulk import load_related
550+from lp.services.database.constants import DEFAULT
551+from lp.services.database.decoratedresultset import DecoratedResultSet
552+from lp.services.database.enumcol import DBEnum
553+from lp.services.database.interfaces import (
554+ IMasterStore,
555+ IStore,
556+ )
557+from lp.services.database.stormbase import StormBase
558+from lp.services.librarian.model import (
559+ LibraryFileAlias,
560+ LibraryFileContent,
561+ )
562+from lp.services.propertycache import (
563+ cachedproperty,
564+ get_property_cache,
565+ )
566+from lp.services.webapp.snapshot import notify_modified
567+
568+
569+@implementer(ICharmRecipeBuild)
570+class CharmRecipeBuild(PackageBuildMixin, StormBase):
571+ """See `ICharmRecipeBuild`."""
572+
573+ __storm_table__ = "CharmRecipeBuild"
574+
575+ job_type = BuildFarmJobType.CHARMRECIPEBUILD
576+
577+ id = Int(name="id", primary=True)
578+
579+ build_request_id = Int(name="build_request", allow_none=False)
580+
581+ requester_id = Int(name="requester", allow_none=False)
582+ requester = Reference(requester_id, "Person.id")
583+
584+ recipe_id = Int(name="recipe", allow_none=False)
585+ recipe = Reference(recipe_id, "CharmRecipe.id")
586+
587+ distro_arch_series_id = Int(name="distro_arch_series", allow_none=False)
588+ distro_arch_series = Reference(
589+ distro_arch_series_id, "DistroArchSeries.id")
590+
591+ channels = JSON("channels", allow_none=True)
592+
593+ processor_id = Int(name="processor", allow_none=False)
594+ processor = Reference(processor_id, "Processor.id")
595+
596+ virtualized = Bool(name="virtualized", allow_none=False)
597+
598+ date_created = DateTime(
599+ name="date_created", tzinfo=pytz.UTC, allow_none=False)
600+ date_started = DateTime(
601+ name="date_started", tzinfo=pytz.UTC, allow_none=True)
602+ date_finished = DateTime(
603+ name="date_finished", tzinfo=pytz.UTC, allow_none=True)
604+ date_first_dispatched = DateTime(
605+ name="date_first_dispatched", tzinfo=pytz.UTC, allow_none=True)
606+
607+ builder_id = Int(name="builder", allow_none=True)
608+ builder = Reference(builder_id, "Builder.id")
609+
610+ status = DBEnum(name="status", enum=BuildStatus, allow_none=False)
611+
612+ log_id = Int(name="log", allow_none=True)
613+ log = Reference(log_id, "LibraryFileAlias.id")
614+
615+ upload_log_id = Int(name="upload_log", allow_none=True)
616+ upload_log = Reference(upload_log_id, "LibraryFileAlias.id")
617+
618+ dependencies = Unicode(name="dependencies", allow_none=True)
619+
620+ failure_count = Int(name="failure_count", allow_none=False)
621+
622+ build_farm_job_id = Int(name="build_farm_job", allow_none=False)
623+ build_farm_job = Reference(build_farm_job_id, "BuildFarmJob.id")
624+
625+ revision_id = Unicode(name="revision_id", allow_none=True)
626+
627+ store_upload_metadata = JSON("store_upload_json_data", allow_none=True)
628+
629+ def __init__(self, build_farm_job, build_request, recipe,
630+ distro_arch_series, processor, virtualized, channels=None,
631+ store_upload_metadata=None, date_created=DEFAULT):
632+ """Construct a `CharmRecipeBuild`."""
633+ requester = build_request.requester
634+ super(CharmRecipeBuild, self).__init__()
635+ self.build_farm_job = build_farm_job
636+ self.build_request_id = build_request.id
637+ self.requester = requester
638+ self.recipe = recipe
639+ self.distro_arch_series = distro_arch_series
640+ self.processor = processor
641+ self.virtualized = virtualized
642+ self.channels = channels
643+ self.store_upload_metadata = store_upload_metadata
644+ self.date_created = date_created
645+ self.status = BuildStatus.NEEDSBUILD
646+
647+ @property
648+ def build_request(self):
649+ return self.recipe.getBuildRequest(self.build_request_id)
650+
651+ @property
652+ def is_private(self):
653+ """See `IBuildFarmJob`."""
654+ return self.recipe.private or self.recipe.owner.private
655+
656+ def __repr__(self):
657+ return "<CharmRecipeBuild ~%s/%s/+charm/%s/+build/%d>" % (
658+ self.recipe.owner.name, self.recipe.project.name, self.recipe.name,
659+ self.id)
660+
661+ @property
662+ def title(self):
663+ return "%s build of /~%s/%s/+charm/%s" % (
664+ self.distro_arch_series.architecturetag, self.recipe.owner.name,
665+ self.recipe.project.name, self.recipe.name)
666+
667+ @property
668+ def distribution(self):
669+ """See `IPackageBuild`."""
670+ return self.distro_arch_series.distroseries.distribution
671+
672+ @property
673+ def distro_series(self):
674+ """See `IPackageBuild`."""
675+ return self.distro_arch_series.distroseries
676+
677+ @property
678+ def archive(self):
679+ """See `IPackageBuild`."""
680+ return self.distribution.main_archive
681+
682+ @property
683+ def pocket(self):
684+ """See `IPackageBuild`."""
685+ return PackagePublishingPocket.UPDATES
686+
687+ @property
688+ def score(self):
689+ """See `ICharmRecipeBuild`."""
690+ if self.buildqueue_record is None:
691+ return None
692+ else:
693+ return self.buildqueue_record.lastscore
694+
695+ @property
696+ def can_be_retried(self):
697+ """See `ICharmRecipeBuild`."""
698+ # First check that the behaviour would accept the build if it
699+ # succeeded.
700+ if self.distro_series.status == SeriesStatus.OBSOLETE:
701+ return False
702+
703+ failed_statuses = [
704+ BuildStatus.FAILEDTOBUILD,
705+ BuildStatus.MANUALDEPWAIT,
706+ BuildStatus.CHROOTWAIT,
707+ BuildStatus.FAILEDTOUPLOAD,
708+ BuildStatus.CANCELLED,
709+ BuildStatus.SUPERSEDED,
710+ ]
711+
712+ # If the build is currently in any of the failed states,
713+ # it may be retried.
714+ return self.status in failed_statuses
715+
716+ @property
717+ def can_be_rescored(self):
718+ """See `ICharmRecipeBuild`."""
719+ return (
720+ self.buildqueue_record is not None and
721+ self.status is BuildStatus.NEEDSBUILD)
722+
723+ @property
724+ def can_be_cancelled(self):
725+ """See `ICharmRecipeBuild`."""
726+ if not self.buildqueue_record:
727+ return False
728+
729+ cancellable_statuses = [
730+ BuildStatus.BUILDING,
731+ BuildStatus.NEEDSBUILD,
732+ ]
733+ return self.status in cancellable_statuses
734+
735+ def retry(self):
736+ """See `ICharmRecipeBuild`."""
737+ assert self.can_be_retried, "Build %s cannot be retried" % self.id
738+ self.build_farm_job.status = self.status = BuildStatus.NEEDSBUILD
739+ self.build_farm_job.date_finished = self.date_finished = None
740+ self.date_started = None
741+ self.build_farm_job.builder = self.builder = None
742+ self.log = None
743+ self.upload_log = None
744+ self.dependencies = None
745+ self.failure_count = 0
746+ self.queueBuild()
747+
748+ def rescore(self, score):
749+ """See `ICharmRecipeBuild`."""
750+ assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
751+ self.buildqueue_record.manualScore(score)
752+
753+ def cancel(self):
754+ """See `ICharmRecipeBuild`."""
755+ if not self.can_be_cancelled:
756+ return
757+ # BuildQueue.cancel() will decide whether to go straight to
758+ # CANCELLED, or go through CANCELLING to let buildd-manager clean up
759+ # the slave.
760+ self.buildqueue_record.cancel()
761+
762+ def calculateScore(self):
763+ """See `IBuildFarmJob`."""
764+ # XXX cjwatson 2021-05-28: We'll probably need something like
765+ # CharmRecipe.relative_build_score at some point.
766+ return 2510
767+
768+ def getMedianBuildDuration(self):
769+ """Return the median duration of our successful builds."""
770+ store = IStore(self)
771+ result = store.find(
772+ (CharmRecipeBuild.date_started, CharmRecipeBuild.date_finished),
773+ CharmRecipeBuild.recipe == self.recipe,
774+ CharmRecipeBuild.processor == self.processor,
775+ CharmRecipeBuild.status == BuildStatus.FULLYBUILT)
776+ result.order_by(Desc(CharmRecipeBuild.date_finished))
777+ durations = [row[1] - row[0] for row in result[:9]]
778+ if len(durations) == 0:
779+ return None
780+ durations.sort()
781+ return durations[len(durations) // 2]
782+
783+ def estimateDuration(self):
784+ """See `IBuildFarmJob`."""
785+ median = self.getMedianBuildDuration()
786+ if median is not None:
787+ return median
788+ return timedelta(minutes=10)
789+
790+ @cachedproperty
791+ def eta(self):
792+ """The datetime when the build job is estimated to complete.
793+
794+ This is the BuildQueue.estimated_duration plus the
795+ Job.date_started or BuildQueue.getEstimatedJobStartTime.
796+ """
797+ if self.buildqueue_record is None:
798+ return None
799+ queue_record = self.buildqueue_record
800+ if queue_record.status == BuildQueueStatus.WAITING:
801+ start_time = queue_record.getEstimatedJobStartTime()
802+ else:
803+ start_time = queue_record.date_started
804+ if start_time is None:
805+ return None
806+ duration = queue_record.estimated_duration
807+ return start_time + duration
808+
809+ @property
810+ def estimate(self):
811+ """If true, the date value is an estimate."""
812+ if self.date_finished is not None:
813+ return False
814+ return self.eta is not None
815+
816+ @property
817+ def date(self):
818+ """The date when the build completed or is estimated to complete."""
819+ if self.estimate:
820+ return self.eta
821+ return self.date_finished
822+
823+ def getFiles(self):
824+ """See `ICharmRecipeBuild`."""
825+ result = Store.of(self).find(
826+ (CharmFile, LibraryFileAlias, LibraryFileContent),
827+ CharmFile.build == self.id,
828+ LibraryFileAlias.id == CharmFile.library_file_id,
829+ LibraryFileContent.id == LibraryFileAlias.contentID)
830+ return result.order_by([LibraryFileAlias.filename, CharmFile.id])
831+
832+ def getFileByName(self, filename):
833+ """See `ICharmRecipeBuild`."""
834+ if filename.endswith(".txt.gz"):
835+ file_object = self.log
836+ elif filename.endswith("_log.txt"):
837+ file_object = self.upload_log
838+ else:
839+ file_object = Store.of(self).find(
840+ LibraryFileAlias,
841+ CharmFile.build == self.id,
842+ LibraryFileAlias.id == CharmFile.library_file_id,
843+ LibraryFileAlias.filename == filename).one()
844+
845+ if file_object is not None and file_object.filename == filename:
846+ return file_object
847+
848+ raise NotFoundError(filename)
849+
850+ def addFile(self, lfa):
851+ """See `ICharmRecipeBuild`."""
852+ charm_file = CharmFile(build=self, library_file=lfa)
853+ IMasterStore(CharmFile).add(charm_file)
854+ return charm_file
855+
856+ def verifySuccessfulUpload(self):
857+ """See `IPackageBuild`."""
858+ return not self.getFiles().is_empty()
859+
860+ def updateStatus(self, status, builder=None, slave_status=None,
861+ date_started=None, date_finished=None,
862+ force_invalid_transition=False):
863+ """See `IBuildFarmJob`."""
864+ edited_fields = set()
865+ with notify_modified(
866+ self, edited_fields,
867+ snapshot_names=("status", "revision_id")) as previous_obj:
868+ super(CharmRecipeBuild, self).updateStatus(
869+ status, builder=builder, slave_status=slave_status,
870+ date_started=date_started, date_finished=date_finished,
871+ force_invalid_transition=force_invalid_transition)
872+ if self.status != previous_obj.status:
873+ edited_fields.add("status")
874+ if slave_status is not None:
875+ revision_id = slave_status.get("revision_id")
876+ if revision_id is not None:
877+ self.revision_id = six.ensure_text(revision_id)
878+ if revision_id != previous_obj.revision_id:
879+ edited_fields.add("revision_id")
880+ # notify_modified evaluates all attributes mentioned in the
881+ # interface, but we may then make changes that affect self.eta.
882+ del get_property_cache(self).eta
883+
884+ def notify(self, extra_info=None):
885+ """See `IPackageBuild`."""
886+ if not config.builddmaster.send_build_notification:
887+ return
888+ if self.status == BuildStatus.FULLYBUILT:
889+ return
890+ # XXX cjwatson 2021-05-28: Send email notifications.
891+
892+
893+@implementer(ICharmRecipeBuildSet)
894+class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
895+ """See `ICharmRecipeBuildSet`."""
896+
897+ def new(self, build_request, recipe, distro_arch_series, channels=None,
898+ store_upload_metadata=None, date_created=DEFAULT):
899+ """See `ICharmRecipeBuildSet`."""
900+ store = IMasterStore(CharmRecipeBuild)
901+ build_farm_job = getUtility(IBuildFarmJobSource).new(
902+ CharmRecipeBuild.job_type, BuildStatus.NEEDSBUILD, date_created)
903+ virtualized = (
904+ not distro_arch_series.processor.supports_nonvirtualized
905+ or recipe.require_virtualized)
906+ build = CharmRecipeBuild(
907+ build_farm_job, build_request, recipe, distro_arch_series,
908+ distro_arch_series.processor, virtualized, channels=channels,
909+ store_upload_metadata=store_upload_metadata,
910+ date_created=date_created)
911+ store.add(build)
912+ return build
913+
914+ def getByID(self, build_id):
915+ """See `ISpecificBuildFarmJobSource`."""
916+ store = IMasterStore(CharmRecipeBuild)
917+ return store.get(CharmRecipeBuild, build_id)
918+
919+ def getByBuildFarmJob(self, build_farm_job):
920+ """See `ISpecificBuildFarmJobSource`."""
921+ return Store.of(build_farm_job).find(
922+ CharmRecipeBuild, build_farm_job_id=build_farm_job.id).one()
923+
924+ def preloadBuildsData(self, builds):
925+ # Circular import.
926+ from lp.charms.model.charmrecipe import CharmRecipe
927+ load_related(Person, builds, ["requester_id"])
928+ lfas = load_related(LibraryFileAlias, builds, ["log_id"])
929+ load_related(LibraryFileContent, lfas, ["contentID"])
930+ recipes = load_related(CharmRecipe, builds, ["recipe_id"])
931+ getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)
932+
933+ def getByBuildFarmJobs(self, build_farm_jobs):
934+ """See `ISpecificBuildFarmJobSource`."""
935+ if len(build_farm_jobs) == 0:
936+ return EmptyResultSet()
937+ rows = Store.of(build_farm_jobs[0]).find(
938+ CharmRecipeBuild, CharmRecipeBuild.build_farm_job_id.is_in(
939+ bfj.id for bfj in build_farm_jobs))
940+ return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
941+
942+
943+@implementer(ICharmFile)
944+class CharmFile(StormBase):
945+ """See `ICharmFile`."""
946+
947+ __storm_table__ = "CharmFile"
948+
949+ id = Int(name="id", primary=True)
950+
951+ build_id = Int(name="build", allow_none=False)
952+ build = Reference(build_id, "CharmRecipeBuild.id")
953+
954+ library_file_id = Int(name="library_file", allow_none=False)
955+ library_file = Reference(library_file_id, "LibraryFileAlias.id")
956+
957+ def __init__(self, build, library_file):
958+ """Construct a `CharmFile`."""
959+ super(CharmFile, self).__init__()
960+ self.build = build
961+ self.library_file = library_file
962diff --git a/lib/lp/charms/model/charmrecipejob.py b/lib/lp/charms/model/charmrecipejob.py
963index 7cba226..2f1645a 100644
964--- a/lib/lp/charms/model/charmrecipejob.py
965+++ b/lib/lp/charms/model/charmrecipejob.py
966@@ -24,6 +24,7 @@ from storm.locals import (
967 Int,
968 Reference,
969 )
970+from storm.store import EmptyResultSet
971 import transaction
972 from zope.component import getUtility
973 from zope.interface import (
974@@ -37,6 +38,7 @@ from lp.charms.interfaces.charmrecipejob import (
975 ICharmRecipeRequestBuildsJob,
976 ICharmRecipeRequestBuildsJobSource,
977 )
978+from lp.charms.model.charmrecipebuild import CharmRecipeBuild
979 from lp.registry.interfaces.person import IPersonSet
980 from lp.services.config import config
981 from lp.services.database.bulk import load_related
982@@ -272,6 +274,21 @@ class CharmRecipeRequestBuildsJob(CharmRecipeJobDerived):
983 """See `ICharmRecipeRequestBuildsJob`."""
984 return self.recipe.getBuildRequest(self.job.id)
985
986+ @property
987+ def builds(self):
988+ """See `ICharmRecipeRequestBuildsJob`."""
989+ build_ids = self.metadata.get("builds")
990+ if build_ids:
991+ return IStore(CharmRecipeBuild).find(
992+ CharmRecipeBuild, CharmRecipeBuild.id.is_in(build_ids))
993+ else:
994+ return EmptyResultSet()
995+
996+ @builds.setter
997+ def builds(self, builds):
998+ """See `ICharmRecipeRequestBuildsJob`."""
999+ self.metadata["builds"] = [build.id for build in builds]
1000+
1001 def run(self):
1002 """See `IRunnableJob`."""
1003 requester = self.requester
1004diff --git a/lib/lp/charms/tests/test_charmrecipebuild.py b/lib/lp/charms/tests/test_charmrecipebuild.py
1005new file mode 100644
1006index 0000000..a6b5557
1007--- /dev/null
1008+++ b/lib/lp/charms/tests/test_charmrecipebuild.py
1009@@ -0,0 +1,334 @@
1010+# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
1011+# GNU Affero General Public License version 3 (see the file LICENSE).
1012+
1013+"""Test charm package build features."""
1014+
1015+from __future__ import absolute_import, print_function, unicode_literals
1016+
1017+__metaclass__ = type
1018+
1019+from datetime import (
1020+ datetime,
1021+ timedelta,
1022+ )
1023+
1024+import pytz
1025+from testtools.matchers import Equals
1026+from zope.component import getUtility
1027+from zope.security.proxy import removeSecurityProxy
1028+
1029+from lp.app.enums import InformationType
1030+from lp.app.errors import NotFoundError
1031+from lp.buildmaster.enums import BuildStatus
1032+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
1033+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
1034+from lp.charms.interfaces.charmrecipe import (
1035+ CHARM_RECIPE_ALLOW_CREATE,
1036+ CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
1037+ )
1038+from lp.charms.interfaces.charmrecipebuild import (
1039+ ICharmRecipeBuild,
1040+ ICharmRecipeBuildSet,
1041+ )
1042+from lp.registry.enums import (
1043+ PersonVisibility,
1044+ TeamMembershipPolicy,
1045+ )
1046+from lp.registry.interfaces.series import SeriesStatus
1047+from lp.services.features.testing import FeatureFixture
1048+from lp.services.propertycache import clear_property_cache
1049+from lp.testing import (
1050+ person_logged_in,
1051+ StormStatementRecorder,
1052+ TestCaseWithFactory,
1053+ )
1054+from lp.testing.layers import LaunchpadZopelessLayer
1055+from lp.testing.matchers import HasQueryCount
1056+
1057+
1058+class TestCharmRecipeBuild(TestCaseWithFactory):
1059+
1060+ layer = LaunchpadZopelessLayer
1061+
1062+ def setUp(self):
1063+ super(TestCharmRecipeBuild, self).setUp()
1064+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
1065+ self.build = self.factory.makeCharmRecipeBuild()
1066+
1067+ def test_implements_interfaces(self):
1068+ # CharmRecipeBuild implements IPackageBuild and ICharmRecipeBuild.
1069+ self.assertProvides(self.build, IPackageBuild)
1070+ self.assertProvides(self.build, ICharmRecipeBuild)
1071+
1072+ def test___repr__(self):
1073+ # CharmRecipeBuild has an informative __repr__.
1074+ self.assertEqual(
1075+ "<CharmRecipeBuild ~%s/%s/+charm/%s/+build/%s>" % (
1076+ self.build.recipe.owner.name, self.build.recipe.project.name,
1077+ self.build.recipe.name, self.build.id),
1078+ repr(self.build))
1079+
1080+ def test_title(self):
1081+ # CharmRecipeBuild has an informative title.
1082+ das = self.build.distro_arch_series
1083+ self.assertEqual(
1084+ "%s build of /~%s/%s/+charm/%s" % (
1085+ das.architecturetag, self.build.recipe.owner.name,
1086+ self.build.recipe.project.name, self.build.recipe.name),
1087+ self.build.title)
1088+
1089+ def test_queueBuild(self):
1090+ # CharmRecipeBuild can create the queue entry for itself.
1091+ bq = self.build.queueBuild()
1092+ self.assertProvides(bq, IBuildQueue)
1093+ self.assertEqual(
1094+ self.build.build_farm_job, removeSecurityProxy(bq)._build_farm_job)
1095+ self.assertEqual(self.build, bq.specific_build)
1096+ self.assertEqual(self.build.virtualized, bq.virtualized)
1097+ self.assertIsNotNone(bq.processor)
1098+ self.assertEqual(bq, self.build.buildqueue_record)
1099+
1100+ def test_is_private(self):
1101+ # A CharmRecipeBuild is private iff its recipe or owner are.
1102+ self.assertFalse(self.build.is_private)
1103+ self.useFixture(FeatureFixture({
1104+ CHARM_RECIPE_ALLOW_CREATE: "on",
1105+ CHARM_RECIPE_PRIVATE_FEATURE_FLAG: "on",
1106+ }))
1107+ private_team = self.factory.makeTeam(
1108+ membership_policy=TeamMembershipPolicy.MODERATED,
1109+ visibility=PersonVisibility.PRIVATE)
1110+ with person_logged_in(private_team.teamowner):
1111+ build = self.factory.makeCharmRecipeBuild(
1112+ requester=private_team.teamowner, owner=private_team,
1113+ information_type=InformationType.PROPRIETARY)
1114+ self.assertTrue(build.is_private)
1115+
1116+ def test_can_be_retried(self):
1117+ ok_cases = [
1118+ BuildStatus.FAILEDTOBUILD,
1119+ BuildStatus.MANUALDEPWAIT,
1120+ BuildStatus.CHROOTWAIT,
1121+ BuildStatus.FAILEDTOUPLOAD,
1122+ BuildStatus.CANCELLED,
1123+ BuildStatus.SUPERSEDED,
1124+ ]
1125+ for status in BuildStatus.items:
1126+ build = self.factory.makeCharmRecipeBuild(status=status)
1127+ if status in ok_cases:
1128+ self.assertTrue(build.can_be_retried)
1129+ else:
1130+ self.assertFalse(build.can_be_retried)
1131+
1132+ def test_can_be_retried_obsolete_series(self):
1133+ # Builds for obsolete series cannot be retried.
1134+ distroseries = self.factory.makeDistroSeries(
1135+ status=SeriesStatus.OBSOLETE)
1136+ das = self.factory.makeDistroArchSeries(distroseries=distroseries)
1137+ build = self.factory.makeCharmRecipeBuild(distro_arch_series=das)
1138+ self.assertFalse(build.can_be_retried)
1139+
1140+ def test_can_be_cancelled(self):
1141+ # For all states that can be cancelled, can_be_cancelled returns True.
1142+ ok_cases = [
1143+ BuildStatus.BUILDING,
1144+ BuildStatus.NEEDSBUILD,
1145+ ]
1146+ for status in BuildStatus.items:
1147+ build = self.factory.makeCharmRecipeBuild()
1148+ build.queueBuild()
1149+ build.updateStatus(status)
1150+ if status in ok_cases:
1151+ self.assertTrue(build.can_be_cancelled)
1152+ else:
1153+ self.assertFalse(build.can_be_cancelled)
1154+
1155+ def test_retry_resets_state(self):
1156+ # Retrying a build resets most of the state attributes, but does
1157+ # not modify the first dispatch time.
1158+ now = datetime.now(pytz.UTC)
1159+ build = self.factory.makeCharmRecipeBuild()
1160+ build.updateStatus(BuildStatus.BUILDING, date_started=now)
1161+ build.updateStatus(BuildStatus.FAILEDTOBUILD)
1162+ build.gotFailure()
1163+ with person_logged_in(build.recipe.owner):
1164+ build.retry()
1165+ self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
1166+ self.assertEqual(now, build.date_first_dispatched)
1167+ self.assertIsNone(build.log)
1168+ self.assertIsNone(build.upload_log)
1169+ self.assertEqual(0, build.failure_count)
1170+
1171+ def test_cancel_not_in_progress(self):
1172+ # The cancel() method for a pending build leaves it in the CANCELLED
1173+ # state.
1174+ self.build.queueBuild()
1175+ self.build.cancel()
1176+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
1177+ self.assertIsNone(self.build.buildqueue_record)
1178+
1179+ def test_cancel_in_progress(self):
1180+ # The cancel() method for a building build leaves it in the
1181+ # CANCELLING state.
1182+ bq = self.build.queueBuild()
1183+ bq.markAsBuilding(self.factory.makeBuilder())
1184+ self.build.cancel()
1185+ self.assertEqual(BuildStatus.CANCELLING, self.build.status)
1186+ self.assertEqual(bq, self.build.buildqueue_record)
1187+
1188+ def test_estimateDuration(self):
1189+ # Without previous builds, the default time estimate is 10m.
1190+ self.assertEqual(600, self.build.estimateDuration().seconds)
1191+
1192+ def test_estimateDuration_with_history(self):
1193+ # Previous successful builds of the same recipe are used for
1194+ # estimates.
1195+ self.factory.makeCharmRecipeBuild(
1196+ requester=self.build.requester, recipe=self.build.recipe,
1197+ distro_arch_series=self.build.distro_arch_series,
1198+ status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
1199+ for i in range(3):
1200+ self.factory.makeCharmRecipeBuild(
1201+ requester=self.build.requester, recipe=self.build.recipe,
1202+ distro_arch_series=self.build.distro_arch_series,
1203+ status=BuildStatus.FAILEDTOBUILD,
1204+ duration=timedelta(seconds=20))
1205+ self.assertEqual(335, self.build.estimateDuration().seconds)
1206+
1207+ def test_build_cookie(self):
1208+ build = self.factory.makeCharmRecipeBuild()
1209+ self.assertEqual('CHARMRECIPEBUILD-%d' % build.id, build.build_cookie)
1210+
1211+ def test_getFileByName_logs(self):
1212+ # getFileByName returns the logs when requested by name.
1213+ self.build.setLog(
1214+ self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz"))
1215+ self.assertEqual(
1216+ self.build.log, self.build.getFileByName("buildlog.txt.gz"))
1217+ self.assertRaises(NotFoundError, self.build.getFileByName, "foo")
1218+ self.build.storeUploadLog("uploaded")
1219+ self.assertEqual(
1220+ self.build.upload_log,
1221+ self.build.getFileByName(self.build.upload_log.filename))
1222+
1223+ def test_getFileByName_uploaded_files(self):
1224+ # getFileByName returns uploaded files when requested by name.
1225+ filenames = ("ubuntu.squashfs", "ubuntu.manifest")
1226+ lfas = []
1227+ for filename in filenames:
1228+ lfa = self.factory.makeLibraryFileAlias(filename=filename)
1229+ lfas.append(lfa)
1230+ self.build.addFile(lfa)
1231+ self.assertContentEqual(
1232+ lfas, [row[1] for row in self.build.getFiles()])
1233+ for filename, lfa in zip(filenames, lfas):
1234+ self.assertEqual(lfa, self.build.getFileByName(filename))
1235+ self.assertRaises(NotFoundError, self.build.getFileByName, "missing")
1236+
1237+ def test_verifySuccessfulUpload(self):
1238+ self.assertFalse(self.build.verifySuccessfulUpload())
1239+ self.factory.makeCharmFile(build=self.build)
1240+ self.assertTrue(self.build.verifySuccessfulUpload())
1241+
1242+ def test_updateStatus_stores_revision_id(self):
1243+ # If the builder reports a revision_id, updateStatus saves it.
1244+ self.assertIsNone(self.build.revision_id)
1245+ self.build.updateStatus(BuildStatus.BUILDING, slave_status={})
1246+ self.assertIsNone(self.build.revision_id)
1247+ self.build.updateStatus(
1248+ BuildStatus.BUILDING, slave_status={"revision_id": "dummy"})
1249+ self.assertEqual("dummy", self.build.revision_id)
1250+
1251+ def addFakeBuildLog(self, build):
1252+ build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
1253+
1254+ def test_log_url(self):
1255+ # The log URL for a charm recipe build will use the recipe context.
1256+ self.addFakeBuildLog(self.build)
1257+ self.assertEqual(
1258+ "http://launchpad.test/~%s/%s/+charm/%s/+build/%d/+files/"
1259+ "mybuildlog.txt" % (
1260+ self.build.recipe.owner.name, self.build.recipe.project.name,
1261+ self.build.recipe.name, self.build.id),
1262+ self.build.log_url)
1263+
1264+ def test_eta(self):
1265+ # CharmRecipeBuild.eta returns a non-None value when it should, or
1266+ # None when there's no start time.
1267+ self.build.queueBuild()
1268+ self.assertIsNone(self.build.eta)
1269+ self.factory.makeBuilder(processors=[self.build.processor])
1270+ clear_property_cache(self.build)
1271+ self.assertIsNotNone(self.build.eta)
1272+
1273+ def test_eta_cached(self):
1274+ # The expensive completion time estimate is cached.
1275+ self.build.queueBuild()
1276+ self.build.eta
1277+ with StormStatementRecorder() as recorder:
1278+ self.build.eta
1279+ self.assertThat(recorder, HasQueryCount(Equals(0)))
1280+
1281+ def test_estimate(self):
1282+ # CharmRecipeBuild.estimate returns True until the job is completed.
1283+ self.build.queueBuild()
1284+ self.factory.makeBuilder(processors=[self.build.processor])
1285+ self.build.updateStatus(BuildStatus.BUILDING)
1286+ self.assertTrue(self.build.estimate)
1287+ self.build.updateStatus(BuildStatus.FULLYBUILT)
1288+ clear_property_cache(self.build)
1289+ self.assertFalse(self.build.estimate)
1290+
1291+
1292+class TestCharmRecipeBuildSet(TestCaseWithFactory):
1293+
1294+ layer = LaunchpadZopelessLayer
1295+
1296+ def setUp(self):
1297+ super(TestCharmRecipeBuildSet, self).setUp()
1298+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
1299+
1300+ def test_getByBuildFarmJob_works(self):
1301+ build = self.factory.makeCharmRecipeBuild()
1302+ self.assertEqual(
1303+ build,
1304+ getUtility(ICharmRecipeBuildSet).getByBuildFarmJob(
1305+ build.build_farm_job))
1306+
1307+ def test_getByBuildFarmJob_returns_None_when_missing(self):
1308+ bpb = self.factory.makeBinaryPackageBuild()
1309+ self.assertIsNone(
1310+ getUtility(ICharmRecipeBuildSet).getByBuildFarmJob(
1311+ bpb.build_farm_job))
1312+
1313+ def test_getByBuildFarmJobs_works(self):
1314+ builds = [self.factory.makeCharmRecipeBuild() for i in range(10)]
1315+ self.assertContentEqual(
1316+ builds,
1317+ getUtility(ICharmRecipeBuildSet).getByBuildFarmJobs(
1318+ [build.build_farm_job for build in builds]))
1319+
1320+ def test_getByBuildFarmJobs_works_empty(self):
1321+ self.assertContentEqual(
1322+ [], getUtility(ICharmRecipeBuildSet).getByBuildFarmJobs([]))
1323+
1324+ def test_virtualized_recipe_requires(self):
1325+ recipe = self.factory.makeCharmRecipe(require_virtualized=True)
1326+ target = self.factory.makeCharmRecipeBuild(recipe=recipe)
1327+ self.assertTrue(target.virtualized)
1328+
1329+ def test_virtualized_processor_requires(self):
1330+ recipe = self.factory.makeCharmRecipe(require_virtualized=False)
1331+ distro_arch_series = self.factory.makeDistroArchSeries()
1332+ distro_arch_series.processor.supports_nonvirtualized = False
1333+ target = self.factory.makeCharmRecipeBuild(
1334+ distro_arch_series=distro_arch_series, recipe=recipe)
1335+ self.assertTrue(target.virtualized)
1336+
1337+ def test_virtualized_no_support(self):
1338+ recipe = self.factory.makeCharmRecipe(require_virtualized=False)
1339+ distro_arch_series = self.factory.makeDistroArchSeries()
1340+ distro_arch_series.processor.supports_nonvirtualized = True
1341+ target = self.factory.makeCharmRecipeBuild(
1342+ recipe=recipe, distro_arch_series=distro_arch_series)
1343+ self.assertFalse(target.virtualized)
1344diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
1345index 1a82f23..e68c408 100644
1346--- a/lib/lp/testing/factory.py
1347+++ b/lib/lp/testing/factory.py
1348@@ -111,6 +111,8 @@ from lp.buildmaster.enums import (
1349 from lp.buildmaster.interfaces.builder import IBuilderSet
1350 from lp.buildmaster.interfaces.processor import IProcessorSet
1351 from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
1352+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
1353+from lp.charms.model.charmrecipebuild import CharmFile
1354 from lp.code.enums import (
1355 BranchMergeProposalStatus,
1356 BranchSubscriptionNotificationLevel,
1357@@ -5160,6 +5162,47 @@ class BareLaunchpadObjectFactory(ObjectFactory):
1358 return recipe.requestBuilds(
1359 requester, channels=channels, architectures=architectures)
1360
1361+ def makeCharmRecipeBuild(self, registrant=None, recipe=None,
1362+ build_request=None, requester=None,
1363+ distro_arch_series=None, channels=None,
1364+ store_upload_metadata=None, date_created=DEFAULT,
1365+ status=BuildStatus.NEEDSBUILD, builder=None,
1366+ duration=None, **kwargs):
1367+ if recipe is None:
1368+ if registrant is None:
1369+ if build_request is not None:
1370+ registrant = build_request.requester
1371+ else:
1372+ registrant = requester
1373+ recipe = self.makeCharmRecipe(registrant=registrant, **kwargs)
1374+ if distro_arch_series is None:
1375+ distro_arch_series = self.makeDistroArchSeries()
1376+ if build_request is None:
1377+ build_request = self.makeCharmRecipeBuildRequest(
1378+ recipe=recipe, requester=requester, channels=channels)
1379+ build = getUtility(ICharmRecipeBuildSet).new(
1380+ build_request, recipe, distro_arch_series, channels=channels,
1381+ store_upload_metadata=store_upload_metadata,
1382+ date_created=date_created)
1383+ if duration is not None:
1384+ removeSecurityProxy(build).updateStatus(
1385+ BuildStatus.BUILDING, builder=builder,
1386+ date_started=build.date_created)
1387+ removeSecurityProxy(build).updateStatus(
1388+ status, builder=builder,
1389+ date_finished=build.date_started + duration)
1390+ else:
1391+ removeSecurityProxy(build).updateStatus(status, builder=builder)
1392+ IStore(build).flush()
1393+ return build
1394+
1395+ def makeCharmFile(self, build=None, library_file=None):
1396+ if build is None:
1397+ build = self.makeCharmRecipeBuild()
1398+ if library_file is None:
1399+ library_file = self.makeLibraryFileAlias()
1400+ return ProxyFactory(CharmFile(build=build, library_file=library_file))
1401+
1402
1403 # Some factory methods return simple Python types. We don't add
1404 # security wrappers for them, as well as for objects created by

Subscribers

People subscribed via source and target branches

to status/vote changes: