Merge ~cjwatson/launchpad:charm-recipe-build into launchpad:master
- Git
- lp:~cjwatson/launchpad
- charm-recipe-build
- Merge into 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) |
Related bugs: |
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
Description of the change
To post a comment you must log in.
- 0a093a3... by Colin Watson
-
Add basic model for charm recipe builds
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
1 | diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py |
2 | index 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. |
24 | diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py |
25 | index 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 |
74 | diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml |
75 | index 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> |
95 | diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml |
96 | index 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" /> |
137 | diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py |
138 | index 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 | |
184 | diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py |
185 | new file mode 100644 |
186 | index 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) |
394 | diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py |
395 | index 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 | |
426 | diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py |
427 | index 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] |
493 | diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py |
494 | new file mode 100644 |
495 | index 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 |
962 | diff --git a/lib/lp/charms/model/charmrecipejob.py b/lib/lp/charms/model/charmrecipejob.py |
963 | index 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 |
1004 | diff --git a/lib/lp/charms/tests/test_charmrecipebuild.py b/lib/lp/charms/tests/test_charmrecipebuild.py |
1005 | new file mode 100644 |
1006 | index 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) |
1344 | diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py |
1345 | index 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 |
Looks good!