Merge lp:~cjwatson/launchpad/snapbuild-basic-model into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 17662
Proposed branch: lp:~cjwatson/launchpad/snapbuild-basic-model
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-basic-model
Diff against target: 1010 lines (+923/-0)
10 files modified
lib/lp/buildmaster/enums.py (+6/-0)
lib/lp/security.py (+31/-0)
lib/lp/snappy/browser/configure.zcml (+10/-0)
lib/lp/snappy/browser/snap.py (+28/-0)
lib/lp/snappy/browser/snapbuild.py (+17/-0)
lib/lp/snappy/configure.zcml (+31/-0)
lib/lp/snappy/interfaces/snapbuild.py (+161/-0)
lib/lp/snappy/model/snapbuild.py (+358/-0)
lib/lp/snappy/tests/test_snapbuild.py (+226/-0)
lib/lp/testing/factory.py (+55/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snapbuild-basic-model
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+265691@code.launchpad.net

Commit message

Add basic model for snap builds.

Description of the change

Add basic model for snap builds. The links between Snap and SnapBuild aren't in place yet for the sake of branch size, but that will come next; this completes the core database modelling.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/buildmaster/enums.py'
2--- lib/lp/buildmaster/enums.py 2014-06-20 11:12:40 +0000
3+++ lib/lp/buildmaster/enums.py 2015-08-03 09:55:48 +0000
4@@ -157,6 +157,12 @@
5 Build a live filesystem from an archive.
6 """)
7
8+ SNAPBUILD = DBItem(6, """
9+ Snap package build
10+
11+ Build a snap package from a recipe.
12+ """)
13+
14
15 class BuildQueueStatus(DBEnumeratedType):
16 """Build queue status.
17
18=== modified file 'lib/lp/security.py'
19--- lib/lp/security.py 2015-07-23 14:32:50 +0000
20+++ lib/lp/security.py 2015-08-03 09:55:48 +0000
21@@ -192,6 +192,7 @@
22 ILanguageSet,
23 )
24 from lp.snappy.interfaces.snap import ISnap
25+from lp.snappy.interfaces.snapbuild import ISnapBuild
26 from lp.soyuz.interfaces.archive import IArchive
27 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
28 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
29@@ -3116,3 +3117,33 @@
30 return (
31 user.in_ppa_self_admins
32 and EditSnap(self.obj).checkAuthenticated(user))
33+
34+
35+class ViewSnapBuild(DelegatedAuthorization):
36+ permission = 'launchpad.View'
37+ usedfor = ISnapBuild
38+
39+ def iter_objects(self):
40+ yield self.obj.snap
41+ yield self.obj.archive
42+
43+
44+class EditSnapBuild(AdminByBuilddAdmin):
45+ permission = 'launchpad.Edit'
46+ usedfor = ISnapBuild
47+
48+ def checkAuthenticated(self, user):
49+ """Check edit access for snap package builds.
50+
51+ Allow admins, buildd admins, and the owner of the snap package.
52+ (Note that the requester of the build is required to be in the team
53+ that owns the snap package.)
54+ """
55+ auth_snap = EditSnap(self.obj.snap)
56+ if auth_snap.checkAuthenticated(user):
57+ return True
58+ return super(EditSnapBuild, self).checkAuthenticated(user)
59+
60+
61+class AdminSnapBuild(AdminByBuilddAdmin):
62+ usedfor = ISnapBuild
63
64=== modified file 'lib/lp/snappy/browser/configure.zcml'
65--- lib/lp/snappy/browser/configure.zcml 2015-07-23 14:32:50 +0000
66+++ lib/lp/snappy/browser/configure.zcml 2015-08-03 09:55:48 +0000
67@@ -13,5 +13,15 @@
68 for="lp.snappy.interfaces.snap.ISnap"
69 path_expression="string:+snap/${name}"
70 attribute_to_parent="owner" />
71+ <browser:navigation
72+ module="lp.snappy.browser.snap"
73+ classes="SnapNavigation" />
74+ <browser:url
75+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
76+ path_expression="string:+build/${id}"
77+ attribute_to_parent="snap" />
78+ <browser:navigation
79+ module="lp.snappy.browser.snapbuild"
80+ classes="SnapBuildNavigation" />
81 </facet>
82 </configure>
83
84=== added file 'lib/lp/snappy/browser/snap.py'
85--- lib/lp/snappy/browser/snap.py 1970-01-01 00:00:00 +0000
86+++ lib/lp/snappy/browser/snap.py 2015-08-03 09:55:48 +0000
87@@ -0,0 +1,28 @@
88+# Copyright 2015 Canonical Ltd. This software is licensed under the
89+# GNU Affero General Public License version 3 (see the file LICENSE).
90+
91+"""Snap views."""
92+
93+__metaclass__ = type
94+__all__ = [
95+ 'SnapNavigation',
96+ ]
97+
98+from lp.services.webapp import (
99+ Navigation,
100+ stepthrough,
101+ )
102+from lp.snappy.interfaces.snap import ISnap
103+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
104+from lp.soyuz.browser.build import get_build_by_id_str
105+
106+
107+class SnapNavigation(Navigation):
108+ usedfor = ISnap
109+
110+ @stepthrough('+build')
111+ def traverse_build(self, name):
112+ build = get_build_by_id_str(ISnapBuildSet, name)
113+ if build is None or build.snap != self.context:
114+ return None
115+ return build
116
117=== added file 'lib/lp/snappy/browser/snapbuild.py'
118--- lib/lp/snappy/browser/snapbuild.py 1970-01-01 00:00:00 +0000
119+++ lib/lp/snappy/browser/snapbuild.py 2015-08-03 09:55:48 +0000
120@@ -0,0 +1,17 @@
121+# Copyright 2015 Canonical Ltd. This software is licensed under the
122+# GNU Affero General Public License version 3 (see the file LICENSE).
123+
124+"""SnapBuild views."""
125+
126+__metaclass__ = type
127+__all__ = [
128+ 'SnapBuildNavigation',
129+ ]
130+
131+from lp.services.librarian.browser import FileNavigationMixin
132+from lp.services.webapp import Navigation
133+from lp.snappy.interfaces.snapbuild import ISnapBuild
134+
135+
136+class SnapBuildNavigation(Navigation, FileNavigationMixin):
137+ usedfor = ISnapBuild
138
139=== modified file 'lib/lp/snappy/configure.zcml'
140--- lib/lp/snappy/configure.zcml 2015-07-30 12:25:44 +0000
141+++ lib/lp/snappy/configure.zcml 2015-08-03 09:55:48 +0000
142@@ -40,4 +40,35 @@
143 <allow interface="lp.snappy.interfaces.snap.ISnapSet" />
144 </securedutility>
145
146+ <!-- SnapBuild -->
147+ <class class="lp.snappy.model.snapbuild.SnapBuild">
148+ <require
149+ permission="launchpad.View"
150+ interface="lp.snappy.interfaces.snapbuild.ISnapBuildView" />
151+ <require
152+ permission="launchpad.Edit"
153+ interface="lp.snappy.interfaces.snapbuild.ISnapBuildEdit" />
154+ <require
155+ permission="launchpad.Admin"
156+ interface="lp.snappy.interfaces.snapbuild.ISnapBuildAdmin" />
157+ </class>
158+
159+ <!-- SnapBuildSet -->
160+ <securedutility
161+ class="lp.snappy.model.snapbuild.SnapBuildSet"
162+ provides="lp.snappy.interfaces.snapbuild.ISnapBuildSet">
163+ <allow interface="lp.snappy.interfaces.snapbuild.ISnapBuildSet" />
164+ </securedutility>
165+ <securedutility
166+ class="lp.snappy.model.snapbuild.SnapBuildSet"
167+ provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
168+ name="SNAPBUILD">
169+ <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
170+ </securedutility>
171+
172+ <!-- SnapFile -->
173+ <class class="lp.snappy.model.snapbuild.SnapFile">
174+ <allow interface="lp.snappy.interfaces.snapbuild.ISnapFile" />
175+ </class>
176+
177 </configure>
178
179=== added file 'lib/lp/snappy/interfaces/snapbuild.py'
180--- lib/lp/snappy/interfaces/snapbuild.py 1970-01-01 00:00:00 +0000
181+++ lib/lp/snappy/interfaces/snapbuild.py 2015-08-03 09:55:48 +0000
182@@ -0,0 +1,161 @@
183+# Copyright 2015 Canonical Ltd. This software is licensed under the
184+# GNU Affero General Public License version 3 (see the file LICENSE).
185+
186+"""Snap package build interfaces."""
187+
188+__metaclass__ = type
189+
190+__all__ = [
191+ 'ISnapBuild',
192+ 'ISnapBuildSet',
193+ 'ISnapFile',
194+ ]
195+
196+from lazr.restful.fields import Reference
197+from zope.interface import (
198+ Attribute,
199+ Interface,
200+ )
201+from zope.schema import (
202+ Bool,
203+ Choice,
204+ Int,
205+ )
206+
207+from lp import _
208+from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
209+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
210+from lp.registry.interfaces.person import IPerson
211+from lp.registry.interfaces.pocket import PackagePublishingPocket
212+from lp.services.database.constants import DEFAULT
213+from lp.services.librarian.interfaces import ILibraryFileAlias
214+from lp.snappy.interfaces.snap import ISnap
215+from lp.soyuz.interfaces.archive import IArchive
216+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
217+
218+
219+class ISnapFile(Interface):
220+ """A file produced by a snap package build."""
221+
222+ snapbuild = Attribute("The snap package build producing this file.")
223+
224+ libraryfile = Reference(
225+ ILibraryFileAlias, title=_("The library file alias for this file."),
226+ required=True, readonly=True)
227+
228+
229+class ISnapBuildView(IPackageBuild):
230+ """`ISnapBuild` attributes that require launchpad.View permission."""
231+
232+ requester = Reference(
233+ IPerson,
234+ title=_("The person who requested this build."),
235+ required=True, readonly=True)
236+
237+ snap = Reference(
238+ ISnap,
239+ title=_("The snap package to build."),
240+ required=True, readonly=True)
241+
242+ archive = Reference(
243+ IArchive,
244+ title=_("The archive from which to build the snap package."),
245+ required=True, readonly=True)
246+
247+ distro_arch_series = Reference(
248+ IDistroArchSeries,
249+ title=_("The series and architecture for which to build."),
250+ required=True, readonly=True)
251+
252+ pocket = Choice(
253+ title=_("The pocket for which to build."),
254+ vocabulary=PackagePublishingPocket, required=True, readonly=True)
255+
256+ virtualized = Bool(
257+ title=_("If True, this build is virtualized."), readonly=True)
258+
259+ score = Int(
260+ title=_("Score of the related build farm job (if any)."),
261+ required=False, readonly=True)
262+
263+ can_be_rescored = Bool(
264+ title=_("Can be rescored"),
265+ required=True, readonly=True,
266+ description=_("Whether this build record can be rescored manually."))
267+
268+ can_be_cancelled = Bool(
269+ title=_("Can be cancelled"),
270+ required=True, readonly=True,
271+ description=_("Whether this build record can be cancelled."))
272+
273+ def getFiles():
274+ """Retrieve the build's `ISnapFile` records.
275+
276+ :return: A result set of (`ISnapFile`, `ILibraryFileAlias`,
277+ `ILibraryFileContent`).
278+ """
279+
280+ def getFileByName(filename):
281+ """Return the corresponding `ILibraryFileAlias` in this context.
282+
283+ The following file types (and extension) can be looked up:
284+
285+ * Build log: '.txt.gz'
286+ * Upload log: '_log.txt'
287+
288+ Any filename not matching one of these extensions is looked up as a
289+ snap package output file.
290+
291+ :param filename: The filename to look up.
292+ :raises NotFoundError: if no file exists with the given name.
293+ :return: The corresponding `ILibraryFileAlias`.
294+ """
295+
296+ def getFileUrls():
297+ """URLs for all the files produced by this build.
298+
299+ :return: A collection of URLs for this build."""
300+
301+
302+class ISnapBuildEdit(Interface):
303+ """`ISnapBuild` attributes that require launchpad.Edit."""
304+
305+ def addFile(lfa):
306+ """Add a file to this build.
307+
308+ :param lfa: An `ILibraryFileAlias`.
309+ :return: An `ISnapFile`.
310+ """
311+
312+ def cancel():
313+ """Cancel the build if it is either pending or in progress.
314+
315+ Check the can_be_cancelled property prior to calling this method to
316+ find out if cancelling the build is possible.
317+
318+ If the build is in progress, it is marked as CANCELLING until the
319+ buildd manager terminates the build and marks it CANCELLED. If the
320+ build is not in progress, it is marked CANCELLED immediately and is
321+ removed from the build queue.
322+
323+ If the build is not in a cancellable state, this method is a no-op.
324+ """
325+
326+
327+class ISnapBuildAdmin(Interface):
328+ """`ISnapBuild` attributes that require launchpad.Admin."""
329+
330+ def rescore(score):
331+ """Change the build's score."""
332+
333+
334+class ISnapBuild(ISnapBuildView, ISnapBuildEdit, ISnapBuildAdmin):
335+ """Build information for snap package builds."""
336+
337+
338+class ISnapBuildSet(ISpecificBuildFarmJobSource):
339+ """Utility for `ISnapBuild`."""
340+
341+ def new(requester, snap, archive, distro_arch_series, pocket,
342+ date_created=DEFAULT):
343+ """Create an `ISnapBuild`."""
344
345=== added file 'lib/lp/snappy/model/snapbuild.py'
346--- lib/lp/snappy/model/snapbuild.py 1970-01-01 00:00:00 +0000
347+++ lib/lp/snappy/model/snapbuild.py 2015-08-03 09:55:48 +0000
348@@ -0,0 +1,358 @@
349+# Copyright 2015 Canonical Ltd. This software is licensed under the
350+# GNU Affero General Public License version 3 (see the file LICENSE).
351+
352+__metaclass__ = type
353+__all__ = [
354+ 'SnapBuild',
355+ 'SnapFile',
356+ ]
357+
358+from datetime import timedelta
359+
360+import pytz
361+from storm.locals import (
362+ Bool,
363+ DateTime,
364+ Desc,
365+ Int,
366+ Reference,
367+ Store,
368+ Storm,
369+ Unicode,
370+ )
371+from storm.store import EmptyResultSet
372+from zope.component import getUtility
373+from zope.interface import implementer
374+
375+from lp.app.errors import NotFoundError
376+from lp.buildmaster.enums import (
377+ BuildFarmJobType,
378+ BuildStatus,
379+ )
380+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
381+from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
382+from lp.buildmaster.model.packagebuild import PackageBuildMixin
383+from lp.registry.interfaces.pocket import PackagePublishingPocket
384+from lp.registry.model.person import Person
385+from lp.services.database.bulk import load_related
386+from lp.services.database.constants import DEFAULT
387+from lp.services.database.decoratedresultset import DecoratedResultSet
388+from lp.services.database.enumcol import DBEnum
389+from lp.services.database.interfaces import (
390+ IMasterStore,
391+ IStore,
392+ )
393+from lp.services.features import getFeatureFlag
394+from lp.services.librarian.browser import ProxiedLibraryFileAlias
395+from lp.services.librarian.model import (
396+ LibraryFileAlias,
397+ LibraryFileContent,
398+ )
399+from lp.snappy.interfaces.snap import (
400+ SNAP_FEATURE_FLAG,
401+ SnapFeatureDisabled,
402+ )
403+from lp.snappy.interfaces.snapbuild import (
404+ ISnapBuild,
405+ ISnapBuildSet,
406+ ISnapFile,
407+ )
408+from lp.soyuz.interfaces.component import IComponentSet
409+from lp.soyuz.model.archive import Archive
410+
411+
412+@implementer(ISnapFile)
413+class SnapFile(Storm):
414+ """See `ISnap`."""
415+
416+ __storm_table__ = 'SnapFile'
417+
418+ id = Int(name='id', primary=True)
419+
420+ snapbuild_id = Int(name='snapbuild', allow_none=False)
421+ snapbuild = Reference(snapbuild_id, 'SnapBuild.id')
422+
423+ libraryfile_id = Int(name='libraryfile', allow_none=False)
424+ libraryfile = Reference(libraryfile_id, 'LibraryFileAlias.id')
425+
426+ def __init__(self, snapbuild, libraryfile):
427+ """Construct a `SnapFile`."""
428+ super(SnapFile, self).__init__()
429+ self.snapbuild = snapbuild
430+ self.libraryfile = libraryfile
431+
432+
433+@implementer(ISnapBuild)
434+class SnapBuild(PackageBuildMixin, Storm):
435+ """See `ISnapBuild`."""
436+
437+ __storm_table__ = 'SnapBuild'
438+
439+ job_type = BuildFarmJobType.SNAPBUILD
440+
441+ id = Int(name='id', primary=True)
442+
443+ build_farm_job_id = Int(name='build_farm_job', allow_none=False)
444+ build_farm_job = Reference(build_farm_job_id, 'BuildFarmJob.id')
445+
446+ requester_id = Int(name='requester', allow_none=False)
447+ requester = Reference(requester_id, 'Person.id')
448+
449+ snap_id = Int(name='snap', allow_none=False)
450+ snap = Reference(snap_id, 'Snap.id')
451+
452+ archive_id = Int(name='archive', allow_none=False)
453+ archive = Reference(archive_id, 'Archive.id')
454+
455+ distro_arch_series_id = Int(name='distro_arch_series', allow_none=False)
456+ distro_arch_series = Reference(
457+ distro_arch_series_id, 'DistroArchSeries.id')
458+
459+ pocket = DBEnum(enum=PackagePublishingPocket, allow_none=False)
460+
461+ processor_id = Int(name='processor', allow_none=False)
462+ processor = Reference(processor_id, 'Processor.id')
463+ virtualized = Bool(name='virtualized')
464+
465+ date_created = DateTime(
466+ name='date_created', tzinfo=pytz.UTC, allow_none=False)
467+ date_started = DateTime(name='date_started', tzinfo=pytz.UTC)
468+ date_finished = DateTime(name='date_finished', tzinfo=pytz.UTC)
469+ date_first_dispatched = DateTime(
470+ name='date_first_dispatched', tzinfo=pytz.UTC)
471+
472+ builder_id = Int(name='builder')
473+ builder = Reference(builder_id, 'Builder.id')
474+
475+ status = DBEnum(name='status', enum=BuildStatus, allow_none=False)
476+
477+ log_id = Int(name='log')
478+ log = Reference(log_id, 'LibraryFileAlias.id')
479+
480+ upload_log_id = Int(name='upload_log')
481+ upload_log = Reference(upload_log_id, 'LibraryFileAlias.id')
482+
483+ dependencies = Unicode(name='dependencies')
484+
485+ failure_count = Int(name='failure_count', allow_none=False)
486+
487+ def __init__(self, build_farm_job, requester, snap, archive,
488+ distro_arch_series, pocket, processor, virtualized,
489+ date_created):
490+ """Construct a `SnapBuild`."""
491+ if not getFeatureFlag(SNAP_FEATURE_FLAG):
492+ raise SnapFeatureDisabled
493+ super(SnapBuild, self).__init__()
494+ self.build_farm_job = build_farm_job
495+ self.requester = requester
496+ self.snap = snap
497+ self.archive = archive
498+ self.distro_arch_series = distro_arch_series
499+ self.pocket = pocket
500+ self.processor = processor
501+ self.virtualized = virtualized
502+ self.date_created = date_created
503+ self.status = BuildStatus.NEEDSBUILD
504+
505+ @property
506+ def is_private(self):
507+ """See `IBuildFarmJob`."""
508+ return self.snap.owner.private or self.archive.private
509+
510+ @property
511+ def title(self):
512+ das = self.distro_arch_series
513+ name = self.snap.name
514+ return "%s build of %s snap package in %s %s" % (
515+ das.architecturetag, name, das.distroseries.distribution.name,
516+ das.distroseries.getSuite(self.pocket))
517+
518+ @property
519+ def distribution(self):
520+ """See `IPackageBuild`."""
521+ return self.distro_arch_series.distroseries.distribution
522+
523+ @property
524+ def distro_series(self):
525+ """See `IPackageBuild`."""
526+ return self.distro_arch_series.distroseries
527+
528+ @property
529+ def current_component(self):
530+ component = self.archive.default_component
531+ if component is not None:
532+ return component
533+ else:
534+ # XXX cjwatson 2015-07-17: Hardcode to multiverse for the time
535+ # being.
536+ return getUtility(IComponentSet)["multiverse"]
537+
538+ @property
539+ def score(self):
540+ """See `ISnapBuild`."""
541+ if self.buildqueue_record is None:
542+ return None
543+ else:
544+ return self.buildqueue_record.lastscore
545+
546+ @property
547+ def can_be_rescored(self):
548+ """See `ISnapBuild`."""
549+ return (
550+ self.buildqueue_record is not None and
551+ self.status is BuildStatus.NEEDSBUILD)
552+
553+ @property
554+ def can_be_cancelled(self):
555+ """See `ISnapBuild`."""
556+ if not self.buildqueue_record:
557+ return False
558+
559+ cancellable_statuses = [
560+ BuildStatus.BUILDING,
561+ BuildStatus.NEEDSBUILD,
562+ ]
563+ return self.status in cancellable_statuses
564+
565+ def rescore(self, score):
566+ """See `ISnapBuild`."""
567+ assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
568+ self.buildqueue_record.manualScore(score)
569+
570+ def cancel(self):
571+ """See `ISnapBuild`."""
572+ if not self.can_be_cancelled:
573+ return
574+ # BuildQueue.cancel() will decide whether to go straight to
575+ # CANCELLED, or go through CANCELLING to let buildd-manager clean up
576+ # the slave.
577+ self.buildqueue_record.cancel()
578+
579+ def calculateScore(self):
580+ return 2505 + self.archive.relative_build_score
581+
582+ def getMedianBuildDuration(self):
583+ """Return the median duration of our successful builds."""
584+ store = IStore(self)
585+ result = store.find(
586+ (SnapBuild.date_started, SnapBuild.date_finished),
587+ SnapBuild.snap == self.snap_id,
588+ SnapBuild.distro_arch_series == self.distro_arch_series_id,
589+ SnapBuild.status == BuildStatus.FULLYBUILT)
590+ result.order_by(Desc(SnapBuild.date_finished))
591+ durations = [row[1] - row[0] for row in result[:9]]
592+ if len(durations) == 0:
593+ return None
594+ durations.sort()
595+ return durations[len(durations) // 2]
596+
597+ def estimateDuration(self):
598+ """See `IBuildFarmJob`."""
599+ median = self.getMedianBuildDuration()
600+ if median is not None:
601+ return median
602+ return timedelta(minutes=30)
603+
604+ def getFiles(self):
605+ """See `ISnapBuild`."""
606+ result = Store.of(self).find(
607+ (SnapFile, LibraryFileAlias, LibraryFileContent),
608+ SnapFile.snapbuild == self.id,
609+ LibraryFileAlias.id == SnapFile.libraryfile_id,
610+ LibraryFileContent.id == LibraryFileAlias.contentID)
611+ return result.order_by([LibraryFileAlias.filename, SnapFile.id])
612+
613+ def getFileByName(self, filename):
614+ """See `ISnapBuild`."""
615+ if filename.endswith(".txt.gz"):
616+ file_object = self.log
617+ elif filename.endswith("_log.txt"):
618+ file_object = self.upload_log
619+ else:
620+ file_object = Store.of(self).find(
621+ LibraryFileAlias,
622+ SnapFile.snapbuild == self.id,
623+ LibraryFileAlias.id == SnapFile.libraryfile_id,
624+ LibraryFileAlias.filename == filename).one()
625+
626+ if file_object is not None and file_object.filename == filename:
627+ return file_object
628+
629+ raise NotFoundError(filename)
630+
631+ def addFile(self, lfa):
632+ """See `ISnapBuild`."""
633+ snapfile = SnapFile(snapbuild=self, libraryfile=lfa)
634+ IMasterStore(SnapFile).add(snapfile)
635+ return snapfile
636+
637+ def verifySuccessfulUpload(self):
638+ """See `IPackageBuild`."""
639+ return not self.getFiles().is_empty()
640+
641+ def lfaUrl(self, lfa):
642+ """Return the URL for a LibraryFileAlias in this context."""
643+ if lfa is None:
644+ return None
645+ return ProxiedLibraryFileAlias(lfa, self).http_url
646+
647+ @property
648+ def log_url(self):
649+ """See `IBuildFarmJob`."""
650+ return self.lfaUrl(self.log)
651+
652+ @property
653+ def upload_log_url(self):
654+ """See `IPackageBuild`."""
655+ return self.lfaUrl(self.upload_log)
656+
657+ def getFileUrls(self):
658+ return [self.lfaUrl(lfa) for _, lfa, _ in self.getFiles()]
659+
660+
661+@implementer(ISnapBuildSet)
662+class SnapBuildSet(SpecificBuildFarmJobSourceMixin):
663+
664+ def new(self, requester, snap, archive, distro_arch_series, pocket,
665+ date_created=DEFAULT):
666+ """See `ISnapBuildSet`."""
667+ store = IMasterStore(SnapBuild)
668+ build_farm_job = getUtility(IBuildFarmJobSource).new(
669+ SnapBuild.job_type, BuildStatus.NEEDSBUILD, date_created, None,
670+ archive)
671+ snapbuild = SnapBuild(
672+ build_farm_job, requester, snap, archive, distro_arch_series,
673+ pocket, distro_arch_series.processor,
674+ not distro_arch_series.processor.supports_nonvirtualized
675+ or snap.require_virtualized or archive.require_virtualized,
676+ date_created)
677+ store.add(snapbuild)
678+ return snapbuild
679+
680+ def getByID(self, build_id):
681+ """See `ISpecificBuildFarmJobSource`."""
682+ store = IMasterStore(SnapBuild)
683+ return store.get(SnapBuild, build_id)
684+
685+ def getByBuildFarmJob(self, build_farm_job):
686+ """See `ISpecificBuildFarmJobSource`."""
687+ return Store.of(build_farm_job).find(
688+ SnapBuild, build_farm_job_id=build_farm_job.id).one()
689+
690+ def preloadBuildsData(self, builds):
691+ # Circular import.
692+ from lp.snappy.model.snap import Snap
693+ load_related(Person, builds, ["requester_id"])
694+ load_related(LibraryFileAlias, builds, ["log_id"])
695+ archives = load_related(Archive, builds, ["archive_id"])
696+ load_related(Person, archives, ["ownerID"])
697+ load_related(Snap, builds, ["snap_id"])
698+
699+ def getByBuildFarmJobs(self, build_farm_jobs):
700+ """See `ISpecificBuildFarmJobSource`."""
701+ if len(build_farm_jobs) == 0:
702+ return EmptyResultSet()
703+ rows = Store.of(build_farm_jobs[0]).find(
704+ SnapBuild, SnapBuild.build_farm_job_id.is_in(
705+ bfj.id for bfj in build_farm_jobs))
706+ return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
707
708=== added file 'lib/lp/snappy/tests/test_snapbuild.py'
709--- lib/lp/snappy/tests/test_snapbuild.py 1970-01-01 00:00:00 +0000
710+++ lib/lp/snappy/tests/test_snapbuild.py 2015-08-03 09:55:48 +0000
711@@ -0,0 +1,226 @@
712+# Copyright 2015 Canonical Ltd. This software is licensed under the
713+# GNU Affero General Public License version 3 (see the file LICENSE).
714+
715+"""Test snap package build features."""
716+
717+__metaclass__ = type
718+
719+from datetime import timedelta
720+
721+from zope.component import getUtility
722+from zope.security.proxy import removeSecurityProxy
723+
724+from lp.app.errors import NotFoundError
725+from lp.buildmaster.enums import BuildStatus
726+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
727+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
728+from lp.registry.enums import PersonVisibility
729+from lp.services.features.testing import FeatureFixture
730+from lp.snappy.interfaces.snap import (
731+ SNAP_FEATURE_FLAG,
732+ SnapFeatureDisabled,
733+ )
734+from lp.snappy.interfaces.snapbuild import (
735+ ISnapBuild,
736+ ISnapBuildSet,
737+ )
738+from lp.soyuz.enums import ArchivePurpose
739+from lp.testing import (
740+ person_logged_in,
741+ TestCaseWithFactory,
742+ )
743+from lp.testing.layers import LaunchpadZopelessLayer
744+
745+
746+class TestSnapBuildFeatureFlag(TestCaseWithFactory):
747+
748+ layer = LaunchpadZopelessLayer
749+
750+ def test_feature_flag_disabled(self):
751+ # Without a feature flag, we will not create new SnapBuilds.
752+ class MockSnap:
753+ require_virtualized = False
754+
755+ self.assertRaises(
756+ SnapFeatureDisabled, getUtility(ISnapBuildSet).new,
757+ None, MockSnap(), self.factory.makeArchive(),
758+ self.factory.makeDistroArchSeries(), None)
759+
760+
761+class TestSnapBuild(TestCaseWithFactory):
762+
763+ layer = LaunchpadZopelessLayer
764+
765+ def setUp(self):
766+ super(TestSnapBuild, self).setUp()
767+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
768+ self.build = self.factory.makeSnapBuild()
769+
770+ def test_implements_interfaces(self):
771+ # SnapBuild implements IPackageBuild and ISnapBuild.
772+ self.assertProvides(self.build, IPackageBuild)
773+ self.assertProvides(self.build, ISnapBuild)
774+
775+ def test_queueBuild(self):
776+ # SnapBuild can create the queue entry for itself.
777+ bq = self.build.queueBuild()
778+ self.assertProvides(bq, IBuildQueue)
779+ self.assertEqual(
780+ self.build.build_farm_job, removeSecurityProxy(bq)._build_farm_job)
781+ self.assertEqual(self.build, bq.specific_build)
782+ self.assertEqual(self.build.virtualized, bq.virtualized)
783+ self.assertIsNotNone(bq.processor)
784+ self.assertEqual(bq, self.build.buildqueue_record)
785+
786+ def test_current_component_primary(self):
787+ # SnapBuilds for primary archives always build in multiverse for the
788+ # time being.
789+ self.assertEqual(ArchivePurpose.PRIMARY, self.build.archive.purpose)
790+ self.assertEqual("multiverse", self.build.current_component.name)
791+
792+ def test_current_component_ppa(self):
793+ # PPAs only have indices for main, so SnapBuilds for PPAs always
794+ # build in main.
795+ build = self.factory.makeSnapBuild(archive=self.factory.makeArchive())
796+ self.assertEqual("main", build.current_component.name)
797+
798+ def test_is_private(self):
799+ # A SnapBuild is private iff its Snap and archive are.
800+ self.assertFalse(self.build.is_private)
801+ private_team = self.factory.makeTeam(
802+ visibility=PersonVisibility.PRIVATE)
803+ with person_logged_in(private_team.teamowner):
804+ build = self.factory.makeSnapBuild(
805+ requester=private_team.teamowner, owner=private_team)
806+ self.assertTrue(build.is_private)
807+ private_archive = self.factory.makeArchive(private=True)
808+ with person_logged_in(private_archive.owner):
809+ build = self.factory.makeSnapBuild(archive=private_archive)
810+ self.assertTrue(build.is_private)
811+
812+ def test_can_be_cancelled(self):
813+ # For all states that can be cancelled, can_be_cancelled returns True.
814+ ok_cases = [
815+ BuildStatus.BUILDING,
816+ BuildStatus.NEEDSBUILD,
817+ ]
818+ for status in BuildStatus:
819+ if status in ok_cases:
820+ self.assertTrue(self.build.can_be_cancelled)
821+ else:
822+ self.assertFalse(self.build.can_be_cancelled)
823+
824+ def test_cancel_not_in_progress(self):
825+ # The cancel() method for a pending build leaves it in the CANCELLED
826+ # state.
827+ self.build.queueBuild()
828+ self.build.cancel()
829+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
830+ self.assertIsNone(self.build.buildqueue_record)
831+
832+ def test_cancel_in_progress(self):
833+ # The cancel() method for a building build leaves it in the
834+ # CANCELLING state.
835+ bq = self.build.queueBuild()
836+ bq.markAsBuilding(self.factory.makeBuilder())
837+ self.build.cancel()
838+ self.assertEqual(BuildStatus.CANCELLING, self.build.status)
839+ self.assertEqual(bq, self.build.buildqueue_record)
840+
841+ def test_estimateDuration(self):
842+ # Without previous builds, the default time estimate is 30m.
843+ self.assertEqual(1800, self.build.estimateDuration().seconds)
844+
845+ def test_estimateDuration_with_history(self):
846+ # Previous successful builds of the same snap package are used for
847+ # estimates.
848+ self.factory.makeSnapBuild(
849+ requester=self.build.requester, snap=self.build.snap,
850+ distroarchseries=self.build.distro_arch_series,
851+ status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
852+ for i in range(3):
853+ self.factory.makeSnapBuild(
854+ requester=self.build.requester, snap=self.build.snap,
855+ distroarchseries=self.build.distro_arch_series,
856+ status=BuildStatus.FAILEDTOBUILD,
857+ duration=timedelta(seconds=20))
858+ self.assertEqual(335, self.build.estimateDuration().seconds)
859+
860+ def test_build_cookie(self):
861+ build = self.factory.makeSnapBuild()
862+ self.assertEqual('SNAPBUILD-%d' % build.id, build.build_cookie)
863+
864+ def test_getFileByName_logs(self):
865+ # getFileByName returns the logs when requested by name.
866+ self.build.setLog(
867+ self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz"))
868+ self.assertEqual(
869+ self.build.log, self.build.getFileByName("buildlog.txt.gz"))
870+ self.assertRaises(NotFoundError, self.build.getFileByName, "foo")
871+ self.build.storeUploadLog("uploaded")
872+ self.assertEqual(
873+ self.build.upload_log,
874+ self.build.getFileByName(self.build.upload_log.filename))
875+
876+ def test_getFileByName_uploaded_files(self):
877+ # getFileByName returns uploaded files when requested by name.
878+ filenames = ("ubuntu.squashfs", "ubuntu.manifest")
879+ lfas = []
880+ for filename in filenames:
881+ lfa = self.factory.makeLibraryFileAlias(filename=filename)
882+ lfas.append(lfa)
883+ self.build.addFile(lfa)
884+ self.assertContentEqual(
885+ lfas, [row[1] for row in self.build.getFiles()])
886+ for filename, lfa in zip(filenames, lfas):
887+ self.assertEqual(lfa, self.build.getFileByName(filename))
888+ self.assertRaises(NotFoundError, self.build.getFileByName, "missing")
889+
890+ def test_verifySuccessfulUpload(self):
891+ self.assertFalse(self.build.verifySuccessfulUpload())
892+ self.factory.makeSnapFile(snapbuild=self.build)
893+ self.assertTrue(self.build.verifySuccessfulUpload())
894+
895+ def addFakeBuildLog(self, build):
896+ build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
897+
898+ def test_log_url(self):
899+ # The log URL for a snap package build will use the archive context.
900+ self.addFakeBuildLog(self.build)
901+ self.assertEqual(
902+ "http://launchpad.dev/~%s/+snap/%s/+build/%d/+files/"
903+ "mybuildlog.txt" % (
904+ self.build.snap.owner.name, self.build.snap.name,
905+ self.build.id),
906+ self.build.log_url)
907+
908+
909+class TestSnapBuildSet(TestCaseWithFactory):
910+
911+ layer = LaunchpadZopelessLayer
912+
913+ def setUp(self):
914+ super(TestSnapBuildSet, self).setUp()
915+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
916+
917+ def test_getByBuildFarmJob_works(self):
918+ build = self.factory.makeSnapBuild()
919+ self.assertEqual(
920+ build,
921+ getUtility(ISnapBuildSet).getByBuildFarmJob(build.build_farm_job))
922+
923+ def test_getByBuildFarmJob_returns_None_when_missing(self):
924+ bpb = self.factory.makeBinaryPackageBuild()
925+ self.assertIsNone(
926+ getUtility(ISnapBuildSet).getByBuildFarmJob(bpb.build_farm_job))
927+
928+ def test_getByBuildFarmJobs_works(self):
929+ builds = [self.factory.makeSnapBuild() for i in range(10)]
930+ self.assertContentEqual(
931+ builds,
932+ getUtility(ISnapBuildSet).getByBuildFarmJobs(
933+ [build.build_farm_job for build in builds]))
934+
935+ def test_getByBuildFarmJobs_works_empty(self):
936+ self.assertContentEqual(
937+ [], getUtility(ISnapBuildSet).getByBuildFarmJobs([]))
938
939=== modified file 'lib/lp/testing/factory.py'
940--- lib/lp/testing/factory.py 2015-07-23 14:32:50 +0000
941+++ lib/lp/testing/factory.py 2015-08-03 09:55:48 +0000
942@@ -265,6 +265,8 @@
943 from lp.services.worlddata.interfaces.country import ICountrySet
944 from lp.services.worlddata.interfaces.language import ILanguageSet
945 from lp.snappy.interfaces.snap import ISnapSet
946+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
947+from lp.snappy.model.snapbuild import SnapFile
948 from lp.soyuz.adapters.overrides import SourceOverride
949 from lp.soyuz.adapters.packagelocation import PackageLocation
950 from lp.soyuz.enums import (
951@@ -4559,6 +4561,59 @@
952 IStore(snap).flush()
953 return snap
954
955+ def makeSnapBuild(self, requester=None, registrant=None, snap=None,
956+ archive=None, distroarchseries=None, pocket=None,
957+ date_created=DEFAULT, status=BuildStatus.NEEDSBUILD,
958+ builder=None, duration=None, **kwargs):
959+ """Make a new SnapBuild."""
960+ if requester is None:
961+ requester = self.makePerson()
962+ if snap is None:
963+ if "distroseries" in kwargs:
964+ distroseries = kwargs["distroseries"]
965+ del kwargs["distroseries"]
966+ elif distroarchseries is not None:
967+ distroseries = distroarchseries.distroseries
968+ elif archive is not None:
969+ distroseries = self.makeDistroSeries(
970+ distribution=archive.distribution)
971+ else:
972+ distroseries = None
973+ if registrant is None:
974+ registrant = requester
975+ snap = self.makeSnap(
976+ registrant=registrant, distroseries=distroseries, **kwargs)
977+ if archive is None:
978+ archive = snap.distro_series.main_archive
979+ if distroarchseries is None:
980+ distroarchseries = self.makeDistroArchSeries(
981+ distroseries=snap.distro_series)
982+ if pocket is None:
983+ pocket = PackagePublishingPocket.RELEASE
984+ snapbuild = getUtility(ISnapBuildSet).new(
985+ requester, snap, archive, distroarchseries, pocket,
986+ date_created=date_created)
987+ if duration is not None:
988+ removeSecurityProxy(snapbuild).updateStatus(
989+ BuildStatus.BUILDING, builder=builder,
990+ date_started=snapbuild.date_created)
991+ removeSecurityProxy(snapbuild).updateStatus(
992+ status, builder=builder,
993+ date_finished=snapbuild.date_started + duration)
994+ else:
995+ removeSecurityProxy(snapbuild).updateStatus(
996+ status, builder=builder)
997+ IStore(snapbuild).flush()
998+ return snapbuild
999+
1000+ def makeSnapFile(self, snapbuild=None, libraryfile=None):
1001+ if snapbuild is None:
1002+ snapbuild = self.makeSnapBuild()
1003+ if libraryfile is None:
1004+ libraryfile = self.makeLibraryFileAlias()
1005+ return ProxyFactory(
1006+ SnapFile(snapbuild=snapbuild, libraryfile=libraryfile))
1007+
1008
1009 # Some factory methods return simple Python types. We don't add
1010 # security wrappers for them, as well as for objects created by