Merge ~cjwatson/launchpad:ci-build-upload-job into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 5b43a35a8ece7e9dcf025fbbdfcc9acbf8bd9291
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:ci-build-upload-job
Merge into: launchpad:master
Diff against target: 892 lines (+539/-6)
23 files modified
database/schema/security.cfg (+1/-1)
lib/lp/_schema_circular_imports.py (+1/-0)
lib/lp/services/config/schema-lazr.conf (+6/-0)
lib/lp/soyuz/configure.zcml (+15/-4)
lib/lp/soyuz/enums.py (+6/-0)
lib/lp/soyuz/interfaces/archive.py (+12/-0)
lib/lp/soyuz/interfaces/archivejob.py (+31/-0)
lib/lp/soyuz/model/archive.py (+29/-0)
lib/lp/soyuz/model/archivejob.py (+127/-0)
lib/lp/soyuz/model/publishing.py (+1/-1)
lib/lp/soyuz/tests/__init__.py (+14/-0)
lib/lp/soyuz/tests/data/.gitignore (+2/-0)
lib/lp/soyuz/tests/data/wheel-arch/README.md (+1/-0)
lib/lp/soyuz/tests/data/wheel-arch/pyproject.toml (+3/-0)
lib/lp/soyuz/tests/data/wheel-arch/setup.py (+14/-0)
lib/lp/soyuz/tests/data/wheel-arch/test.c (+1/-0)
lib/lp/soyuz/tests/data/wheel-indep/README.md (+1/-0)
lib/lp/soyuz/tests/data/wheel-indep/pyproject.toml (+3/-0)
lib/lp/soyuz/tests/data/wheel-indep/setup.cfg (+5/-0)
lib/lp/soyuz/tests/test_archive.py (+67/-0)
lib/lp/soyuz/tests/test_archivejob.py (+195/-0)
requirements/launchpad.txt (+2/-0)
setup.cfg (+2/-0)
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+423198@code.launchpad.net

Commit message

Add method to upload a CI build to an archive

Description of the change

This new `Archive.uploadCIBuild` method will be used to publish the output of CI builds in archives that publish via Artifactory. It's somewhat similar to `Archive.copyPackage`, and I considered calling it `copyCIBuild` instead, but I decided to go with "upload" since it's only usable for the initial copy into an archive, not for copies between archives after that.

There's a new `CIBuildUploadJob` which fetches the output files from the librarian and works out how to turn them into `BinaryPackageRelease`s. This is inevitably package-type-specific, and for the time being is only implemented for Python wheels, using `pkginfo` and `wheel-filename`.

Dependencies MP: https://code.launchpad.net/~cjwatson/lp-source-dependencies/+git/lp-source-dependencies/+merge/423187

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/database/schema/security.cfg b/database/schema/security.cfg
2index 0024832..f04f672 100644
3--- a/database/schema/security.cfg
4+++ b/database/schema/security.cfg
5@@ -1433,7 +1433,7 @@ public.archivesigningkey = SELECT, INSERT
6 public.binarypackagebuild = SELECT, INSERT, UPDATE
7 public.binarypackagefile = SELECT, INSERT
8 public.binarypackagename = SELECT, INSERT
9-public.binarypackagepublishinghistory = SELECT
10+public.binarypackagepublishinghistory = SELECT, INSERT
11 public.binarypackagerelease = SELECT, INSERT
12 public.binarysourcereference = SELECT, INSERT
13 public.bug = SELECT, UPDATE
14diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
15index 1d3cb17..6bf443b 100644
16--- a/lib/lp/_schema_circular_imports.py
17+++ b/lib/lp/_schema_circular_imports.py
18@@ -397,6 +397,7 @@ patch_plain_parameter_type(IArchive, 'syncSource', 'from_archive', IArchive)
19 patch_plain_parameter_type(IArchive, 'copyPackage', 'from_archive', IArchive)
20 patch_plain_parameter_type(
21 IArchive, 'copyPackages', 'from_archive', IArchive)
22+patch_plain_parameter_type(IArchive, 'uploadCIBuild', 'ci_build', ICIBuild)
23 patch_entry_return_type(IArchive, 'newSubscription', IArchiveSubscriber)
24 patch_plain_parameter_type(
25 IArchive, 'getArchiveDependency', 'dependency', IArchive)
26diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
27index 45bf05a..4fc8131 100644
28--- a/lib/lp/services/config/schema-lazr.conf
29+++ b/lib/lp/services/config/schema-lazr.conf
30@@ -1914,6 +1914,7 @@ dbuser: process-job-source-groups
31 # can be loaded from.
32 job_sources:
33 IBranchModifiedMailJobSource,
34+ ICIBuildUploadJobSource,
35 ICommercialExpiredJobSource,
36 IExpiringMembershipNotificationJobSource,
37 IGitRepositoryModifiedMailJobSource,
38@@ -1966,6 +1967,11 @@ module: lp.charms.interfaces.charmrecipejob
39 dbuser: charm-build-job
40 crontab_group: MAIN
41
42+[ICIBuildUploadJobSource]
43+module: lp.soyuz.interfaces.archivejob
44+dbuser: uploader
45+crontab_group: FREQUENT
46+
47 [ICommercialExpiredJobSource]
48 module: lp.registry.interfaces.productjob
49 dbuser: product-job
50diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
51index 29cabca..7236f92 100644
52--- a/lib/lp/soyuz/configure.zcml
53+++ b/lib/lp/soyuz/configure.zcml
54@@ -433,21 +433,32 @@
55 <class class="lp.soyuz.model.archivejob.ArchiveJob">
56 <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJob"/>
57 </class>
58- <class class="lp.soyuz.model.archivejob.PackageUploadNotificationJob">
59- <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJob"/>
60- <allow interface="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJob"/>
61- </class>
62 <securedutility
63 component="lp.soyuz.model.archivejob.ArchiveJob"
64 provides="lp.soyuz.interfaces.archivejob.IArchiveJobSource">
65 <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJobSource"/>
66 </securedutility>
67+
68+ <class class="lp.soyuz.model.archivejob.PackageUploadNotificationJob">
69+ <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJob"/>
70+ <allow interface="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJob"/>
71+ </class>
72 <securedutility
73 component="lp.soyuz.model.archivejob.PackageUploadNotificationJob"
74 provides="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJobSource">
75 <allow interface="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJobSource"/>
76 </securedutility>
77
78+ <class class="lp.soyuz.model.archivejob.CIBuildUploadJob">
79+ <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJob"/>
80+ <allow interface="lp.soyuz.interfaces.archivejob.ICIBuildUploadJob"/>
81+ </class>
82+ <securedutility
83+ component="lp.soyuz.model.archivejob.CIBuildUploadJob"
84+ provides="lp.soyuz.interfaces.archivejob.ICIBuildUploadJobSource">
85+ <allow interface="lp.soyuz.interfaces.archivejob.ICIBuildUploadJobSource"/>
86+ </securedutility>
87+
88 <!-- ArchivePermission -->
89
90 <class
91diff --git a/lib/lp/soyuz/enums.py b/lib/lp/soyuz/enums.py
92index 9953f43..39b8ceb 100644
93--- a/lib/lp/soyuz/enums.py
94+++ b/lib/lp/soyuz/enums.py
95@@ -57,6 +57,12 @@ class ArchiveJobType(DBEnumeratedType):
96 or held for approval.
97 """)
98
99+ CI_BUILD_UPLOAD = DBItem(2, """
100+ CI build upload
101+
102+ Upload a CI build to this archive.
103+ """)
104+
105
106 class ArchivePermissionType(DBEnumeratedType):
107 """Archive Permission Type.
108diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
109index e011591..5ee645a 100644
110--- a/lib/lp/soyuz/interfaces/archive.py
111+++ b/lib/lp/soyuz/interfaces/archive.py
112@@ -1672,6 +1672,18 @@ class IArchiveView(IHasBuildRecords):
113 :raises CannotCopy: if there is a problem copying.
114 """
115
116+ @call_with(person=REQUEST_USER)
117+ @operation_parameters(
118+ # Really ICIBuild, patched in _schema_circular_imports.
119+ ci_build=Reference(schema=Interface),
120+ to_series=TextLine(title=_("Target distroseries name")),
121+ to_pocket=TextLine(title=_("Target pocket name")),
122+ to_channel=TextLine(title=_("Target channel"), required=False))
123+ @export_write_operation()
124+ @operation_for_version('devel')
125+ def uploadCIBuild(ci_build, person, to_series, to_pocket, to_channel=None):
126+ """Upload the output of a CI build to this archive."""
127+
128
129 class IArchiveAppend(Interface):
130 """Archive interface for operations restricted by append privilege."""
131diff --git a/lib/lp/soyuz/interfaces/archivejob.py b/lib/lp/soyuz/interfaces/archivejob.py
132index 779bc0b..ae25b08 100644
133--- a/lib/lp/soyuz/interfaces/archivejob.py
134+++ b/lib/lp/soyuz/interfaces/archivejob.py
135@@ -6,21 +6,29 @@
136 __all__ = [
137 'IArchiveJob',
138 'IArchiveJobSource',
139+ 'ICIBuildUploadJob',
140+ 'ICIBuildUploadJobSource',
141 'IPackageUploadNotificationJob',
142 'IPackageUploadNotificationJobSource',
143 ]
144
145
146+from lazr.restful.fields import Reference
147 from zope.interface import (
148 Attribute,
149 Interface,
150 )
151 from zope.schema import (
152+ Choice,
153 Int,
154 Object,
155+ TextLine,
156 )
157
158 from lp import _
159+from lp.code.interfaces.cibuild import ICIBuild
160+from lp.registry.interfaces.distroseries import IDistroSeries
161+from lp.registry.interfaces.pocket import PackagePublishingPocket
162 from lp.services.job.interfaces.job import (
163 IJob,
164 IJobSource,
165@@ -62,3 +70,26 @@ class IPackageUploadNotificationJob(IRunnableJob):
166
167 class IPackageUploadNotificationJobSource(IArchiveJobSource):
168 """Interface for acquiring PackageUploadNotificationJobs."""
169+
170+
171+class ICIBuildUploadJob(IRunnableJob):
172+ """A Job to upload a CI build to an archive."""
173+
174+ ci_build = Reference(
175+ schema=ICIBuild, title=_("CI build to copy"),
176+ required=True, readonly=True)
177+
178+ target_distroseries = Reference(
179+ schema=IDistroSeries, title=_("Target distroseries"),
180+ required=True, readonly=True)
181+
182+ target_pocket = Choice(
183+ title=_("Target pocket"), vocabulary=PackagePublishingPocket,
184+ required=True, readonly=True)
185+
186+ target_channel = TextLine(
187+ title=_("Target channel"), required=False, readonly=True)
188+
189+
190+class ICIBuildUploadJobSource(IArchiveJobSource):
191+ """Interface for acquiring `CIBuildUploadJob`s."""
192diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
193index 727d8b2..4d1ccb1 100644
194--- a/lib/lp/soyuz/model/archive.py
195+++ b/lib/lp/soyuz/model/archive.py
196@@ -187,6 +187,7 @@ from lp.soyuz.interfaces.archive import (
197 VersionRequiresName,
198 )
199 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthTokenSet
200+from lp.soyuz.interfaces.archivejob import ICIBuildUploadJobSource
201 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
202 from lp.soyuz.interfaces.archivesubscriber import (
203 ArchiveSubscriptionError,
204@@ -1990,6 +1991,34 @@ class Archive(SQLBase):
205 sources, self, series, pocket, include_binaries, person=person,
206 check_permissions=False, unembargo=True)
207
208+ def uploadCIBuild(self, ci_build, person, to_series, to_pocket,
209+ to_channel=None):
210+ """See `IArchive`."""
211+ series = self._text_to_series(to_series)
212+ pocket = self._text_to_pocket(to_pocket)
213+ if self.publishing_method != ArchivePublishingMethod.ARTIFACTORY:
214+ raise CannotCopy(
215+ "CI builds may only be uploaded to archives published using "
216+ "Artifactory.")
217+ if ci_build.status != BuildStatus.FULLYBUILT:
218+ raise CannotCopy(
219+ "%r has status '%s', not '%s'." %
220+ (ci_build, ci_build.status.title, BuildStatus.FULLYBUILT))
221+ # Check upload permissions. We don't know the package name until we
222+ # actually run the job; however, per-package upload permissions are
223+ # by source package name, so don't necessarily make sense for CI
224+ # builds anyway. For now, just ignore per-package upload
225+ # permissions.
226+ reason = self.checkUpload(
227+ person=person, distroseries=series, sourcepackagename=None,
228+ component=None, pocket=pocket)
229+ if reason is not None:
230+ raise CannotCopy(reason)
231+ getUtility(ICIBuildUploadJobSource).create(
232+ ci_build=ci_build, requester=person, target_archive=self,
233+ target_distroseries=series, target_pocket=pocket,
234+ target_channel=to_channel)
235+
236 def getAuthToken(self, person):
237 """See `IArchive`."""
238
239diff --git a/lib/lp/soyuz/model/archivejob.py b/lib/lp/soyuz/model/archivejob.py
240index e6785c6..eb918c1 100644
241--- a/lib/lp/soyuz/model/archivejob.py
242+++ b/lib/lp/soyuz/model/archivejob.py
243@@ -3,20 +3,29 @@
244
245 import io
246 import logging
247+import os.path
248+import tempfile
249
250 from lazr.delegates import delegate_to
251+from pkginfo import Wheel
252 from storm.expr import And
253 from storm.locals import (
254 Int,
255 JSON,
256 Reference,
257 )
258+from wheel_filename import parse_wheel_filename
259 from zope.component import getUtility
260 from zope.interface import (
261 implementer,
262 provider,
263 )
264
265+from lp.code.enums import RevisionStatusArtifactType
266+from lp.code.interfaces.cibuild import ICIBuildSet
267+from lp.code.interfaces.revisionstatus import IRevisionStatusArtifactSet
268+from lp.registry.interfaces.distroseries import IDistroSeriesSet
269+from lp.registry.interfaces.pocket import PackagePublishingPocket
270 from lp.services.config import config
271 from lp.services.database.enumcol import DBEnum
272 from lp.services.database.interfaces import IMasterStore
273@@ -26,16 +35,22 @@ from lp.services.job.model.job import (
274 Job,
275 )
276 from lp.services.job.runner import BaseRunnableJob
277+from lp.services.librarian.utils import copy_and_close
278 from lp.soyuz.enums import (
279 ArchiveJobType,
280+ BinaryPackageFormat,
281 PackageUploadStatus,
282 )
283 from lp.soyuz.interfaces.archivejob import (
284 IArchiveJob,
285 IArchiveJobSource,
286+ ICIBuildUploadJob,
287+ ICIBuildUploadJobSource,
288 IPackageUploadNotificationJob,
289 IPackageUploadNotificationJobSource,
290 )
291+from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
292+from lp.soyuz.interfaces.publishing import IPublishingSet
293 from lp.soyuz.interfaces.queue import IPackageUploadSet
294 from lp.soyuz.model.archive import Archive
295
296@@ -165,3 +180,115 @@ class PackageUploadNotificationJob(ArchiveJobDerived):
297 packageupload.notify(
298 status=self.packageupload_status, summary_text=self.summary_text,
299 changes_file_object=changes_file_object, logger=logger)
300+
301+
302+class ScanException(Exception):
303+ """A CI build upload job failed to scan a file."""
304+
305+
306+@implementer(ICIBuildUploadJob)
307+@provider(ICIBuildUploadJobSource)
308+class CIBuildUploadJob(ArchiveJobDerived):
309+
310+ class_job_type = ArchiveJobType.CI_BUILD_UPLOAD
311+
312+ config = config.ICIBuildUploadJobSource
313+
314+ @classmethod
315+ def create(cls, ci_build, requester, target_archive, target_distroseries,
316+ target_pocket, target_channel=None):
317+ """See `ICIBuildUploadJobSource`."""
318+ metadata = {
319+ "ci_build_id": ci_build.id,
320+ "target_distroseries_id": target_distroseries.id,
321+ "target_pocket": target_pocket.title,
322+ "target_channel": target_channel,
323+ }
324+ derived = super().create(target_archive, metadata)
325+ derived.job.requester = requester
326+ return derived
327+
328+ def getOopsVars(self):
329+ vars = super().getOopsVars()
330+ vars.extend([
331+ (key, self.metadata[key])
332+ for key in (
333+ "ci_build_id",
334+ "target_distroseries_id",
335+ "target_pocket",
336+ "target_channel",
337+ )])
338+ return vars
339+
340+ @property
341+ def ci_build(self):
342+ return getUtility(ICIBuildSet).getByID(self.metadata["ci_build_id"])
343+
344+ @property
345+ def target_distroseries(self):
346+ return getUtility(IDistroSeriesSet).get(
347+ self.metadata["target_distroseries_id"])
348+
349+ @property
350+ def target_pocket(self):
351+ return PackagePublishingPocket.getTermByToken(
352+ self.metadata["target_pocket"]).value
353+
354+ @property
355+ def target_channel(self):
356+ return self.metadata["target_channel"]
357+
358+ def _scanFile(self, path):
359+ if path.endswith(".whl"):
360+ try:
361+ parsed_path = parse_wheel_filename(path)
362+ wheel = Wheel(path)
363+ except Exception as e:
364+ raise ScanException("Failed to scan %s" % path) from e
365+ return {
366+ "name": wheel.name,
367+ "version": wheel.version,
368+ "summary": wheel.summary or "",
369+ "description": wheel.description,
370+ "binpackageformat": BinaryPackageFormat.WHL,
371+ "architecturespecific": "any" not in parsed_path.platform_tags,
372+ "homepage": wheel.home_page or "",
373+ }
374+ else:
375+ return None
376+
377+ def run(self):
378+ """See `IRunnableJob`."""
379+ logger = logging.getLogger()
380+ with tempfile.TemporaryDirectory(prefix="ci-build-copy-job") as tmpdir:
381+ binaries = {}
382+ for artifact in getUtility(
383+ IRevisionStatusArtifactSet).findByCIBuild(self.ci_build):
384+ if artifact.artifact_type == RevisionStatusArtifactType.LOG:
385+ continue
386+ name = artifact.library_file.filename
387+ contents = os.path.join(tmpdir, name)
388+ artifact.library_file.open()
389+ copy_and_close(artifact.library_file, open(contents, "wb"))
390+ metadata = self._scanFile(contents)
391+ if metadata is None:
392+ logger.info("No upload handler for %s" % name)
393+ continue
394+ logger.info(
395+ "Uploading %s to %s %s (%s)" % (
396+ name, self.archive.reference,
397+ self.target_distroseries.getSuite(self.target_pocket),
398+ self.target_channel))
399+ metadata["binarypackagename"] = (
400+ getUtility(IBinaryPackageNameSet).ensure(metadata["name"]))
401+ del metadata["name"]
402+ bpr = self.ci_build.createBinaryPackageRelease(**metadata)
403+ bpr.addFile(artifact.library_file)
404+ # The publishBinaries interface was designed for .debs,
405+ # which need extra per-binary "override" information
406+ # (component, etc.). None of this is relevant here.
407+ binaries[bpr] = (None, None, None, None)
408+ if binaries:
409+ getUtility(IPublishingSet).publishBinaries(
410+ self.archive, self.target_distroseries, self.target_pocket,
411+ binaries, channel=self.target_channel)
412diff --git a/lib/lp/soyuz/model/publishing.py b/lib/lp/soyuz/model/publishing.py
413index 8a397b6..b229c47 100644
414--- a/lib/lp/soyuz/model/publishing.py
415+++ b/lib/lp/soyuz/model/publishing.py
416@@ -1097,7 +1097,7 @@ def expand_binary_requests(distroseries, binaries):
417 # Find the DAS in this series corresponding to the original
418 # build arch tag. If it does not exist or is disabled, we should
419 # not publish.
420- target_arch = arch_map.get(bpr.build.arch_tag)
421+ target_arch = arch_map.get((bpr.build or bpr.ci_build).arch_tag)
422 target_archs = [target_arch] if target_arch is not None else []
423 else:
424 target_archs = archs
425diff --git a/lib/lp/soyuz/tests/__init__.py b/lib/lp/soyuz/tests/__init__.py
426index e69de29..7ffe3f9 100644
427--- a/lib/lp/soyuz/tests/__init__.py
428+++ b/lib/lp/soyuz/tests/__init__.py
429@@ -0,0 +1,14 @@
430+# Copyright 2022 Canonical Ltd. This software is licensed under the
431+# GNU Affero General Public License version 3 (see the file LICENSE).
432+
433+import os
434+
435+
436+here = os.path.dirname(os.path.realpath(__file__))
437+
438+
439+def datadir(path):
440+ """Return fully-qualified path inside the test data directory."""
441+ if path.startswith("/"):
442+ raise ValueError("Path is not relative: %s" % path)
443+ return os.path.join(here, "data", path)
444diff --git a/lib/lp/soyuz/tests/data/.gitignore b/lib/lp/soyuz/tests/data/.gitignore
445new file mode 100644
446index 0000000..2955230
447--- /dev/null
448+++ b/lib/lp/soyuz/tests/data/.gitignore
449@@ -0,0 +1,2 @@
450+!dist
451+*.egg-info
452diff --git a/lib/lp/soyuz/tests/data/wheel-arch/README.md b/lib/lp/soyuz/tests/data/wheel-arch/README.md
453new file mode 100644
454index 0000000..3135dc3
455--- /dev/null
456+++ b/lib/lp/soyuz/tests/data/wheel-arch/README.md
457@@ -0,0 +1 @@
458+An example package. Build a wheel from this with `pyproject-build`.
459diff --git a/lib/lp/soyuz/tests/data/wheel-arch/dist/wheel-arch-0.0.1.tar.gz b/lib/lp/soyuz/tests/data/wheel-arch/dist/wheel-arch-0.0.1.tar.gz
460new file mode 100644
461index 0000000..5b61840
462Binary files /dev/null and b/lib/lp/soyuz/tests/data/wheel-arch/dist/wheel-arch-0.0.1.tar.gz differ
463diff --git a/lib/lp/soyuz/tests/data/wheel-arch/dist/wheel_arch-0.0.1-cp310-cp310-linux_x86_64.whl b/lib/lp/soyuz/tests/data/wheel-arch/dist/wheel_arch-0.0.1-cp310-cp310-linux_x86_64.whl
464new file mode 100644
465index 0000000..2131f4d
466Binary files /dev/null and b/lib/lp/soyuz/tests/data/wheel-arch/dist/wheel_arch-0.0.1-cp310-cp310-linux_x86_64.whl differ
467diff --git a/lib/lp/soyuz/tests/data/wheel-arch/pyproject.toml b/lib/lp/soyuz/tests/data/wheel-arch/pyproject.toml
468new file mode 100644
469index 0000000..b0f0765
470--- /dev/null
471+++ b/lib/lp/soyuz/tests/data/wheel-arch/pyproject.toml
472@@ -0,0 +1,3 @@
473+[build-system]
474+requires = ["setuptools>=42"]
475+build-backend = "setuptools.build_meta"
476diff --git a/lib/lp/soyuz/tests/data/wheel-arch/setup.py b/lib/lp/soyuz/tests/data/wheel-arch/setup.py
477new file mode 100755
478index 0000000..d1431d7
479--- /dev/null
480+++ b/lib/lp/soyuz/tests/data/wheel-arch/setup.py
481@@ -0,0 +1,14 @@
482+from setuptools import (
483+ Extension,
484+ setup,
485+ )
486+
487+
488+setup(
489+ name="wheel-arch",
490+ version="0.0.1",
491+ description="Example description",
492+ long_description="Example long description",
493+ url="http://example.com/",
494+ ext_modules=[Extension("_test", sources=["test.c"])],
495+ )
496diff --git a/lib/lp/soyuz/tests/data/wheel-arch/test.c b/lib/lp/soyuz/tests/data/wheel-arch/test.c
497new file mode 100644
498index 0000000..576fc6d
499--- /dev/null
500+++ b/lib/lp/soyuz/tests/data/wheel-arch/test.c
501@@ -0,0 +1 @@
502+#include <Python.h>
503diff --git a/lib/lp/soyuz/tests/data/wheel-indep/README.md b/lib/lp/soyuz/tests/data/wheel-indep/README.md
504new file mode 100644
505index 0000000..3135dc3
506--- /dev/null
507+++ b/lib/lp/soyuz/tests/data/wheel-indep/README.md
508@@ -0,0 +1 @@
509+An example package. Build a wheel from this with `pyproject-build`.
510diff --git a/lib/lp/soyuz/tests/data/wheel-indep/dist/wheel-indep-0.0.1.tar.gz b/lib/lp/soyuz/tests/data/wheel-indep/dist/wheel-indep-0.0.1.tar.gz
511new file mode 100644
512index 0000000..16fc042
513Binary files /dev/null and b/lib/lp/soyuz/tests/data/wheel-indep/dist/wheel-indep-0.0.1.tar.gz differ
514diff --git a/lib/lp/soyuz/tests/data/wheel-indep/dist/wheel_indep-0.0.1-py3-none-any.whl b/lib/lp/soyuz/tests/data/wheel-indep/dist/wheel_indep-0.0.1-py3-none-any.whl
515new file mode 100644
516index 0000000..93b54bd
517Binary files /dev/null and b/lib/lp/soyuz/tests/data/wheel-indep/dist/wheel_indep-0.0.1-py3-none-any.whl differ
518diff --git a/lib/lp/soyuz/tests/data/wheel-indep/pyproject.toml b/lib/lp/soyuz/tests/data/wheel-indep/pyproject.toml
519new file mode 100644
520index 0000000..b0f0765
521--- /dev/null
522+++ b/lib/lp/soyuz/tests/data/wheel-indep/pyproject.toml
523@@ -0,0 +1,3 @@
524+[build-system]
525+requires = ["setuptools>=42"]
526+build-backend = "setuptools.build_meta"
527diff --git a/lib/lp/soyuz/tests/data/wheel-indep/setup.cfg b/lib/lp/soyuz/tests/data/wheel-indep/setup.cfg
528new file mode 100644
529index 0000000..ae136d3
530--- /dev/null
531+++ b/lib/lp/soyuz/tests/data/wheel-indep/setup.cfg
532@@ -0,0 +1,5 @@
533+[metadata]
534+name = wheel-indep
535+version = 0.0.1
536+description = Example description
537+long_description = Example long description
538diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
539index a02d75d..fd3d254 100644
540--- a/lib/lp/soyuz/tests/test_archive.py
541+++ b/lib/lp/soyuz/tests/test_archive.py
542@@ -88,6 +88,7 @@ from lp.soyuz.adapters.overrides import (
543 )
544 from lp.soyuz.enums import (
545 ArchivePermissionType,
546+ ArchivePublishingMethod,
547 ArchivePurpose,
548 ArchiveStatus,
549 PackageCopyPolicy,
550@@ -116,6 +117,7 @@ from lp.soyuz.interfaces.archive import (
551 RedirectedPocket,
552 VersionRequiresName,
553 )
554+from lp.soyuz.interfaces.archivejob import ICIBuildUploadJobSource
555 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
556 from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
557 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
558@@ -3446,6 +3448,71 @@ class TestCopyPackage(TestCaseWithFactory):
559 person=source_archive.owner, move=True)
560
561
562+class TestUploadCIBuild(TestCaseWithFactory):
563+
564+ layer = DatabaseFunctionalLayer
565+
566+ def test_creates_job(self):
567+ # The uploadCIBuild method creates a CIBuildUploadJob with the
568+ # appropriate parameters.
569+ archive = self.factory.makeArchive(
570+ publishing_method=ArchivePublishingMethod.ARTIFACTORY)
571+ series = self.factory.makeDistroSeries(
572+ distribution=archive.distribution)
573+ build = self.factory.makeCIBuild(status=BuildStatus.FULLYBUILT)
574+ with person_logged_in(archive.owner):
575+ archive.uploadCIBuild(
576+ build, archive.owner, series.name, "Release",
577+ to_channel="edge")
578+ [job] = getUtility(ICIBuildUploadJobSource).iterReady()
579+ self.assertThat(job, MatchesStructure.byEquality(
580+ ci_build=build,
581+ target_distroseries=series,
582+ target_pocket=PackagePublishingPocket.RELEASE,
583+ target_channel="edge"))
584+
585+ def test_disallows_non_artifactory_publishing(self):
586+ # CI builds may only be copied into archives published using
587+ # Artifactory.
588+ archive = self.factory.makeArchive()
589+ series = self.factory.makeDistroSeries(
590+ distribution=archive.distribution)
591+ build = self.factory.makeCIBuild(status=BuildStatus.FULLYBUILT)
592+ with person_logged_in(archive.owner):
593+ self.assertRaisesWithContent(
594+ CannotCopy,
595+ "CI builds may only be uploaded to archives published using "
596+ "Artifactory.",
597+ archive.uploadCIBuild,
598+ build, archive.owner, series.name, "Release")
599+
600+ def test_disallows_incomplete_builds(self):
601+ # CI builds with statuses other than FULLYBUILT may not be copied.
602+ archive = self.factory.makeArchive(
603+ publishing_method=ArchivePublishingMethod.ARTIFACTORY)
604+ series = self.factory.makeDistroSeries(
605+ distribution=archive.distribution)
606+ build = self.factory.makeCIBuild(status=BuildStatus.FAILEDTOBUILD)
607+ person = self.factory.makePerson()
608+ self.assertRaisesWithContent(
609+ CannotCopy,
610+ "%r has status 'Failed to build', not 'Successfully built'." % (
611+ build),
612+ archive.uploadCIBuild, build, person, series.name, "Release")
613+
614+ def test_disallows_non_uploaders(self):
615+ # Only people with upload permission may call uploadCIBuild.
616+ archive = self.factory.makeArchive(
617+ publishing_method=ArchivePublishingMethod.ARTIFACTORY)
618+ series = self.factory.makeDistroSeries(
619+ distribution=archive.distribution)
620+ build = self.factory.makeCIBuild(status=BuildStatus.FULLYBUILT)
621+ person = self.factory.makePerson()
622+ self.assertRaisesWithContent(
623+ CannotCopy, "Signer has no upload rights to this PPA.",
624+ archive.uploadCIBuild, build, person, series.name, "Release")
625+
626+
627 class TestgetAllPublishedBinaries(TestCaseWithFactory):
628
629 layer = DatabaseFunctionalLayer
630diff --git a/lib/lp/soyuz/tests/test_archivejob.py b/lib/lp/soyuz/tests/test_archivejob.py
631index f00e606..e6c1c28 100644
632--- a/lib/lp/soyuz/tests/test_archivejob.py
633+++ b/lib/lp/soyuz/tests/test_archivejob.py
634@@ -1,19 +1,36 @@
635 # Copyright 2010-2018 Canonical Ltd. This software is licensed under the
636 # GNU Affero General Public License version 3 (see the file LICENSE).
637
638+import os.path
639+
640 from debian.deb822 import Changes
641+from testtools.matchers import (
642+ Equals,
643+ Is,
644+ MatchesSetwise,
645+ MatchesStructure,
646+ )
647+import transaction
648
649+from lp.code.enums import RevisionStatusArtifactType
650+from lp.code.model.revisionstatus import RevisionStatusArtifact
651+from lp.registry.interfaces.pocket import PackagePublishingPocket
652+from lp.services.database.interfaces import IStore
653 from lp.services.job.runner import JobRunner
654 from lp.services.mail.sendmail import format_address_for_person
655 from lp.soyuz.enums import (
656 ArchiveJobType,
657+ BinaryPackageFileType,
658+ BinaryPackageFormat,
659 PackageUploadStatus,
660 )
661 from lp.soyuz.model.archivejob import (
662 ArchiveJob,
663 ArchiveJobDerived,
664+ CIBuildUploadJob,
665 PackageUploadNotificationJob,
666 )
667+from lp.soyuz.tests import datadir
668 from lp.testing import TestCaseWithFactory
669 from lp.testing.dbuser import dbuser
670 from lp.testing.layers import (
671@@ -117,3 +134,181 @@ class TestPackageUploadNotificationJob(TestCaseWithFactory):
672 self.assertEqual(format_address_for_person(creator), email['To'])
673 self.assertIn('(Accepted)', email['Subject'])
674 self.assertIn('Fake summary', email.get_payload()[0].get_payload())
675+
676+
677+class TestCIBuildUploadJob(TestCaseWithFactory):
678+
679+ layer = LaunchpadZopelessLayer
680+
681+ def test_getOopsVars(self):
682+ archive = self.factory.makeArchive()
683+ distroseries = self.factory.makeDistroSeries(
684+ distribution=archive.distribution)
685+ build = self.factory.makeCIBuild()
686+ job = CIBuildUploadJob.create(
687+ build, build.git_repository.owner, archive, distroseries,
688+ PackagePublishingPocket.RELEASE, target_channel="edge")
689+ expected = [
690+ ("job_id", job.context.job.id),
691+ ("archive_id", archive.id),
692+ ("archive_job_id", job.context.id),
693+ ("archive_job_type", "CI build upload"),
694+ ("ci_build_id", build.id),
695+ ("target_distroseries_id", distroseries.id),
696+ ("target_pocket", "Release"),
697+ ("target_channel", "edge"),
698+ ]
699+ self.assertEqual(expected, job.getOopsVars())
700+
701+ def test_metadata(self):
702+ archive = self.factory.makeArchive()
703+ distroseries = self.factory.makeDistroSeries(
704+ distribution=archive.distribution)
705+ build = self.factory.makeCIBuild()
706+ job = CIBuildUploadJob.create(
707+ build, build.git_repository.owner, archive, distroseries,
708+ PackagePublishingPocket.RELEASE, target_channel="edge")
709+ expected = {
710+ "ci_build_id": build.id,
711+ "target_distroseries_id": distroseries.id,
712+ "target_pocket": "Release",
713+ "target_channel": "edge",
714+ }
715+ self.assertEqual(expected, job.metadata)
716+ self.assertEqual(build, job.ci_build)
717+ self.assertEqual(distroseries, job.target_distroseries)
718+ self.assertEqual(PackagePublishingPocket.RELEASE, job.target_pocket)
719+ self.assertEqual("edge", job.target_channel)
720+
721+ def test__scanFile_wheel_indep(self):
722+ archive = self.factory.makeArchive()
723+ distroseries = self.factory.makeDistroSeries(
724+ distribution=archive.distribution)
725+ build = self.factory.makeCIBuild()
726+ job = CIBuildUploadJob.create(
727+ build, build.git_repository.owner, archive, distroseries,
728+ PackagePublishingPocket.RELEASE, target_channel="edge")
729+ path = "wheel-indep/dist/wheel_indep-0.0.1-py3-none-any.whl"
730+ expected = {
731+ "name": "wheel-indep",
732+ "version": "0.0.1",
733+ "summary": "Example description",
734+ "description": "Example long description\n",
735+ "binpackageformat": BinaryPackageFormat.WHL,
736+ "architecturespecific": False,
737+ "homepage": "",
738+ }
739+ self.assertEqual(expected, job._scanFile(datadir(path)))
740+
741+ def test__scanFile_wheel_arch(self):
742+ archive = self.factory.makeArchive()
743+ distroseries = self.factory.makeDistroSeries(
744+ distribution=archive.distribution)
745+ build = self.factory.makeCIBuild()
746+ job = CIBuildUploadJob.create(
747+ build, build.git_repository.owner, archive, distroseries,
748+ PackagePublishingPocket.RELEASE, target_channel="edge")
749+ path = "wheel-arch/dist/wheel_arch-0.0.1-cp310-cp310-linux_x86_64.whl"
750+ expected = {
751+ "name": "wheel-arch",
752+ "version": "0.0.1",
753+ "summary": "Example description",
754+ "description": "Example long description\n",
755+ "binpackageformat": BinaryPackageFormat.WHL,
756+ "architecturespecific": True,
757+ "homepage": "http://example.com/",
758+ }
759+ self.assertEqual(expected, job._scanFile(datadir(path)))
760+
761+ def test_run_indep(self):
762+ archive = self.factory.makeArchive()
763+ distroseries = self.factory.makeDistroSeries(
764+ distribution=archive.distribution)
765+ dases = [
766+ self.factory.makeDistroArchSeries(distroseries=distroseries)
767+ for _ in range(2)]
768+ build = self.factory.makeCIBuild(distro_arch_series=dases[0])
769+ report = build.getOrCreateRevisionStatusReport("build:0")
770+ report.setLog(b"log data")
771+ path = "wheel-indep/dist/wheel_indep-0.0.1-py3-none-any.whl"
772+ with open(datadir(path), mode="rb") as f:
773+ report.attach(name=os.path.basename(path), data=f.read())
774+ artifact = IStore(RevisionStatusArtifact).find(
775+ RevisionStatusArtifact,
776+ report=report,
777+ artifact_type=RevisionStatusArtifactType.BINARY).one()
778+ job = CIBuildUploadJob.create(
779+ build, build.git_repository.owner, archive, distroseries,
780+ PackagePublishingPocket.RELEASE, target_channel="edge")
781+ transaction.commit()
782+
783+ with dbuser(job.config.dbuser):
784+ JobRunner([job]).runAll()
785+
786+ self.assertThat(archive.getAllPublishedBinaries(), MatchesSetwise(*(
787+ MatchesStructure(
788+ binarypackagename=MatchesStructure.byEquality(
789+ name="wheel-indep"),
790+ binarypackagerelease=MatchesStructure(
791+ ci_build=Equals(build),
792+ binarypackagename=MatchesStructure.byEquality(
793+ name="wheel-indep"),
794+ version=Equals("0.0.1"),
795+ summary=Equals("Example description"),
796+ description=Equals("Example long description\n"),
797+ binpackageformat=Equals(BinaryPackageFormat.WHL),
798+ architecturespecific=Is(False),
799+ homepage=Equals(""),
800+ files=MatchesSetwise(
801+ MatchesStructure.byEquality(
802+ libraryfile=artifact.library_file,
803+ filetype=BinaryPackageFileType.WHL))),
804+ binarypackageformat=Equals(BinaryPackageFormat.WHL),
805+ distroarchseries=Equals(das))
806+ for das in dases)))
807+
808+ def test_run_arch(self):
809+ archive = self.factory.makeArchive()
810+ distroseries = self.factory.makeDistroSeries(
811+ distribution=archive.distribution)
812+ dases = [
813+ self.factory.makeDistroArchSeries(distroseries=distroseries)
814+ for _ in range(2)]
815+ build = self.factory.makeCIBuild(distro_arch_series=dases[0])
816+ report = build.getOrCreateRevisionStatusReport("build:0")
817+ report.setLog(b"log data")
818+ path = "wheel-arch/dist/wheel_arch-0.0.1-cp310-cp310-linux_x86_64.whl"
819+ with open(datadir(path), mode="rb") as f:
820+ report.attach(name=os.path.basename(path), data=f.read())
821+ artifact = IStore(RevisionStatusArtifact).find(
822+ RevisionStatusArtifact,
823+ report=report,
824+ artifact_type=RevisionStatusArtifactType.BINARY).one()
825+ job = CIBuildUploadJob.create(
826+ build, build.git_repository.owner, archive, distroseries,
827+ PackagePublishingPocket.RELEASE, target_channel="edge")
828+ transaction.commit()
829+
830+ with dbuser(job.config.dbuser):
831+ JobRunner([job]).runAll()
832+
833+ self.assertThat(archive.getAllPublishedBinaries(), MatchesSetwise(
834+ MatchesStructure(
835+ binarypackagename=MatchesStructure.byEquality(
836+ name="wheel-arch"),
837+ binarypackagerelease=MatchesStructure(
838+ ci_build=Equals(build),
839+ binarypackagename=MatchesStructure.byEquality(
840+ name="wheel-arch"),
841+ version=Equals("0.0.1"),
842+ summary=Equals("Example description"),
843+ description=Equals("Example long description\n"),
844+ binpackageformat=Equals(BinaryPackageFormat.WHL),
845+ architecturespecific=Is(True),
846+ homepage=Equals("http://example.com/"),
847+ files=MatchesSetwise(
848+ MatchesStructure.byEquality(
849+ libraryfile=artifact.library_file,
850+ filetype=BinaryPackageFileType.WHL))),
851+ binarypackageformat=Equals(BinaryPackageFormat.WHL),
852+ distroarchseries=Equals(dases[0]))))
853diff --git a/requirements/launchpad.txt b/requirements/launchpad.txt
854index 981acfb..c2081c1 100644
855--- a/requirements/launchpad.txt
856+++ b/requirements/launchpad.txt
857@@ -109,6 +109,7 @@ PasteDeploy==2.1.0
858 pathlib2==2.3.2
859 patiencediff==0.2.2
860 pgbouncer==0.0.9
861+pkginfo==1.8.2
862 prettytable==0.7.2
863 psutil==5.4.2
864 psycopg2==2.8.6
865@@ -174,6 +175,7 @@ webencodings==0.5.1
866 WebOb==1.8.5
867 WebTest==2.0.35
868 Werkzeug==1.0.1
869+wheel-filename==1.1.0
870 wrapt==1.12.1
871 wsgi-intercept==1.9.2
872 WSGIProxy2==0.4.6
873diff --git a/setup.cfg b/setup.cfg
874index db4af9b..145a832 100644
875--- a/setup.cfg
876+++ b/setup.cfg
877@@ -66,6 +66,7 @@ install_requires =
878 oops_twisted
879 oops_wsgi
880 paramiko
881+ pkginfo
882 psutil
883 pgbouncer
884 psycopg2
885@@ -110,6 +111,7 @@ install_requires =
886 WebOb
887 WebTest
888 Werkzeug
889+ wheel-filename
890 WSGIProxy2
891 z3c.ptcompat
892 zope.app.http

Subscribers

People subscribed via source and target branches

to status/vote changes: