Merge ~twom/launchpad:oci-registry-upload into launchpad:master

Proposed by Tom Wardill
Status: Merged
Approved by: Tom Wardill
Approved revision: 8215dfdeeafb07ed27722bcf51b93450df6bbd92
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~twom/launchpad:oci-registry-upload
Merge into: launchpad:master
Diff against target: 1332 lines (+986/-33)
17 files modified
database/schema/security.cfg (+41/-0)
lib/lp/oci/configure.zcml (+19/-1)
lib/lp/oci/interfaces/ocirecipe.py (+6/-0)
lib/lp/oci/interfaces/ocirecipebuild.py (+66/-2)
lib/lp/oci/interfaces/ocirecipebuildjob.py (+25/-1)
lib/lp/oci/interfaces/ociregistryclient.py (+33/-0)
lib/lp/oci/model/ocirecipe.py (+4/-0)
lib/lp/oci/model/ocirecipebuild.py (+46/-14)
lib/lp/oci/model/ocirecipebuildjob.py (+116/-6)
lib/lp/oci/model/ociregistryclient.py (+252/-0)
lib/lp/oci/model/ociregistrycredentials.py (+2/-2)
lib/lp/oci/subscribers/ocirecipebuild.py (+11/-4)
lib/lp/oci/tests/test_ocirecipe.py (+1/-0)
lib/lp/oci/tests/test_ocirecipebuild.py (+1/-0)
lib/lp/oci/tests/test_ocirecipebuildjob.py (+153/-3)
lib/lp/oci/tests/test_ociregistryclient.py (+204/-0)
lib/lp/services/config/schema-lazr.conf (+6/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+381981@code.launchpad.net

Commit message

Upload built images to an OCI registry

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Tom Wardill (twom) :
Revision history for this message
Tom Wardill (twom) :
Revision history for this message
Colin Watson (cjwatson) wrote :

Some things to fix, but mostly looks OK now. As you probably know, landing is blocked until https://portal.admin.canonical.com/C125275 is done.

review: Approve

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 942c5b4..55226e1 100644
3--- a/database/schema/security.cfg
4+++ b/database/schema/security.cfg
5@@ -999,6 +999,7 @@ public.livefsfile = SELECT
6 public.ocifile = SELECT
7 public.ociproject = SELECT
8 public.ociprojectname = SELECT
9+public.ocipushrule = SELECT
10 public.ocirecipe = SELECT
11 public.ocirecipebuild = SELECT, UPDATE
12 public.ocirecipebuildjob = SELECT, INSERT
13@@ -1439,6 +1440,7 @@ public.ocifile = SELECT, INSERT
14 public.ociproject = SELECT
15 public.ociprojectname = SELECT
16 public.ociprojectseries = SELECT
17+public.ocipushrule = SELECT
18 public.ocirecipe = SELECT, UPDATE
19 public.ocirecipebuild = SELECT, UPDATE
20 public.ocirecipebuildjob = SELECT, INSERT, UPDATE
21@@ -2678,3 +2680,42 @@ public.teammembership = SELECT
22 public.teamparticipation = SELECT
23 public.webhook = SELECT
24 public.webhookjob = SELECT, INSERT
25+
26+[oci-build-job]
27+type=user
28+groups=script
29+public.account = SELECT
30+public.archive = SELECT
31+public.branch = SELECT
32+public.builder = SELECT
33+public.buildfarmjob = SELECT, INSERT
34+public.buildqueue = SELECT, INSERT, UPDATE
35+public.distribution = SELECT
36+public.distroarchseries = SELECT
37+public.distroseries = SELECT
38+public.emailaddress = SELECT
39+public.gitref = SELECT
40+public.gitrepository = SELECT
41+public.job = SELECT, INSERT, UPDATE
42+public.libraryfilealias = SELECT
43+public.libraryfilecontent = SELECT
44+public.person = SELECT
45+public.personsettings = SELECT
46+public.pocketchroot = SELECT
47+public.processor = SELECT
48+public.product = SELECT
49+public.ocirecipe = SELECT, UPDATE
50+public.ocirecipearch = SELECT
51+public.ocirecipebuild = SELECT, INSERT, UPDATE
52+public.ocirecipebuildjob = SELECT, UPDATE
53+public.ocifile = SELECT
54+public.ociproject = SELECT
55+public.ociprojectname = SELECT
56+public.ociprojectseries = SELECT
57+public.ocipushrule = SELECT
58+public.ociregistrycredentials = SELECT
59+public.sourcepackagename = SELECT
60+public.teammembership = SELECT
61+public.teamparticipation = SELECT
62+public.webhook = SELECT
63+public.webhookjob = SELECT, INSERT
64diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
65index 565412d..5836861 100644
66--- a/lib/lp/oci/configure.zcml
67+++ b/lib/lp/oci/configure.zcml
68@@ -60,7 +60,7 @@
69 <subscriber
70 for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild
71 lazr.lifecycle.interfaces.IObjectModifiedEvent"
72- handler="lp.oci.subscribers.ocirecipebuild.oci_recipe_build_status_changed" />
73+ handler="lp.oci.subscribers.ocirecipebuild.oci_recipe_build_modified" />
74
75 <!-- OCIRecipeBuildSet -->
76 <securedutility
77@@ -127,4 +127,22 @@
78 interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
79 </securedutility>
80
81+ <!-- OCI related jobs -->
82+ <securedutility
83+ component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
84+ provides="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJobSource">
85+ <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJobSource" />
86+ </securedutility>
87+ <class class="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob">
88+ <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRecipeBuildJob" />
89+ <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJob" />
90+ </class>
91+
92+ <!-- Registry interaction -->
93+ <securedutility
94+ class="lp.oci.model.ociregistryclient.OCIRegistryClient"
95+ provides="lp.oci.interfaces.ociregistryclient.IOCIRegistryClient">
96+ <allow interface="lp.oci.interfaces.ociregistryclient.IOCIRegistryClient" />
97+ </securedutility>
98+
99 </configure>
100diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
101index 25a8967..eda2782 100644
102--- a/lib/lp/oci/interfaces/ocirecipe.py
103+++ b/lib/lp/oci/interfaces/ocirecipe.py
104@@ -214,6 +214,12 @@ class IOCIRecipeView(Interface):
105 # Really IOCIPushRule, patched in _schema_cirular_imports.
106 value_type=Reference(schema=Interface), readonly=True)
107
108+ can_upload_to_registry = Bool(
109+ title=_("Can upload to registry"), required=True, readonly=True,
110+ description=_(
111+ "Whether everything is set up to allow uploading builds of "
112+ "this OCI recipe to a registry."))
113+
114
115 class IOCIRecipeEdit(IWebhookTarget):
116 """`IOCIRecipe` methods that require launchpad.Edit permission."""
117diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
118index 90bb829..a1fbfc0 100644
119--- a/lib/lp/oci/interfaces/ocirecipebuild.py
120+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
121@@ -10,12 +10,24 @@ __all__ = [
122 'IOCIFile',
123 'IOCIRecipeBuild',
124 'IOCIRecipeBuildSet',
125+ 'OCIRecipeBuildRegistryUploadStatus',
126 ]
127
128-from lazr.restful.fields import Reference
129-from zope.interface import Interface
130+from lazr.enum import (
131+ EnumeratedType,
132+ Item,
133+ )
134+from lazr.restful.fields import (
135+ CollectionField,
136+ Reference,
137+ )
138+from zope.interface import (
139+ Attribute,
140+ Interface,
141+ )
142 from zope.schema import (
143 Bool,
144+ Choice,
145 Datetime,
146 Int,
147 TextLine,
148@@ -31,6 +43,38 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
149 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
150
151
152+class OCIRecipeBuildRegistryUploadStatus(EnumeratedType):
153+ """OCI build registry upload status type
154+
155+ OCI builds may be uploaded to a registry. This represents the state of
156+ that process.
157+ """
158+
159+ UNSCHEDULED = Item("""
160+ Unscheduled
161+
162+ No upload of this OCI build to a registry is scheduled.
163+ """)
164+
165+ PENDING = Item("""
166+ Pending
167+
168+ This OCI build is queued for upload to a registry.
169+ """)
170+
171+ FAILEDTOUPLOAD = Item("""
172+ Failed to upload
173+
174+ The last attempt to upload this OCI build to a registry failed.
175+ """)
176+
177+ UPLOADED = Item("""
178+ Uploaded
179+
180+ This OCI build was successfully uploaded to a registry.
181+ """)
182+
183+
184 class IOCIRecipeBuildView(IPackageBuild):
185 """`IOCIRecipeBuild` attributes that require launchpad.View permission."""
186
187@@ -102,6 +146,26 @@ class IOCIRecipeBuildView(IPackageBuild):
188 required=True, readonly=True,
189 description=_("Whether this build record can be cancelled."))
190
191+ manifest = Attribute(_("The manifest of the image."))
192+
193+ digests = Attribute(_("File containing the image digests."))
194+
195+ registry_upload_jobs = CollectionField(
196+ title=_("Registry upload jobs for this build."),
197+ # Really IOCIRegistryUploadJob.
198+ value_type=Reference(schema=Interface),
199+ readonly=True)
200+
201+ # Really IOCIRegistryUploadJob
202+ last_registry_upload_job = Reference(
203+ title=_("Last registry upload job for this build."), schema=Interface)
204+
205+ registry_upload_status = Choice(
206+ title=_("Registry upload status"),
207+ vocabulary=OCIRecipeBuildRegistryUploadStatus,
208+ required=True, readonly=False
209+ )
210+
211
212 class IOCIRecipeBuildEdit(Interface):
213 """`IOCIRecipeBuild` attributes that require launchpad.Edit permission."""
214diff --git a/lib/lp/oci/interfaces/ocirecipebuildjob.py b/lib/lp/oci/interfaces/ocirecipebuildjob.py
215index 6c25b73..7f2537b 100644
216--- a/lib/lp/oci/interfaces/ocirecipebuildjob.py
217+++ b/lib/lp/oci/interfaces/ocirecipebuildjob.py
218@@ -8,17 +8,25 @@ from __future__ import absolute_import, print_function, unicode_literals
219 __metaclass__ = type
220 __all__ = [
221 'IOCIRecipeBuildJob',
222+ 'IOCIRegistryUploadJob',
223+ 'IOCIRegistryUploadJobSource',
224 ]
225
226 from lazr.restful.fields import Reference
227+from zope.component.interfaces import IObjectEvent
228 from zope.interface import (
229 Attribute,
230 Interface,
231 )
232+from zope.schema import TextLine
233
234 from lp import _
235 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
236-from lp.services.job.interfaces.job import IJob
237+from lp.services.job.interfaces.job import (
238+ IJob,
239+ IJobSource,
240+ IRunnableJob,
241+ )
242
243
244 class IOCIRecipeBuildJob(Interface):
245@@ -32,3 +40,19 @@ class IOCIRecipeBuildJob(Interface):
246 schema=IOCIRecipeBuild, required=True, readonly=True)
247
248 json_data = Attribute(_("A dict of data about the job."))
249+
250+
251+class IOCIRegistryUploadJob(IRunnableJob):
252+ """A Job that uploads an OCI image to a registry."""
253+
254+ error_message = TextLine(
255+ title=_("Error message"), required=False, readonly=True)
256+
257+
258+class IOCIRegistryUploadJobSource(IJobSource):
259+
260+ def create(build):
261+ """Upload an OCI image to a registry.
262+
263+ :param build: The OCI recipe build to upload.
264+ """
265diff --git a/lib/lp/oci/interfaces/ociregistryclient.py b/lib/lp/oci/interfaces/ociregistryclient.py
266new file mode 100644
267index 0000000..8ac2aa6
268--- /dev/null
269+++ b/lib/lp/oci/interfaces/ociregistryclient.py
270@@ -0,0 +1,33 @@
271+# Copyright 2020 Canonical Ltd. This software is licensed under the
272+# GNU Affero General Public License version 3 (see the file LICENSE).
273+
274+"""Interface for communication with an OCI registry."""
275+
276+from __future__ import absolute_import, print_function, unicode_literals
277+
278+__metaclass__ = type
279+__all__ = [
280+ 'BlobUploadFailed',
281+ 'IOCIRegistryClient',
282+ 'ManifestUploadFailed',
283+]
284+
285+from zope.interface import Interface
286+
287+
288+class BlobUploadFailed(Exception):
289+ pass
290+
291+
292+class ManifestUploadFailed(Exception):
293+ pass
294+
295+
296+class IOCIRegistryClient(Interface):
297+ """Interface for the API provided by an OCI registry."""
298+
299+ def upload(build):
300+ """Upload an OCI image to a registry.
301+
302+ :param build: The `IOCIRecipeBuild` to upload.
303+ """
304diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
305index 4e3273a..aa69900 100644
306--- a/lib/lp/oci/model/ocirecipe.py
307+++ b/lib/lp/oci/model/ocirecipe.py
308@@ -377,6 +377,10 @@ class OCIRecipe(Storm, WebhookTargetMixin):
309 order_by = Desc(OCIRecipeBuild.id)
310 return self._getBuilds(filter_term, order_by)
311
312+ @property
313+ def can_upload_to_registry(self):
314+ return not self.push_rules.is_empty()
315+
316
317 class OCIRecipeArch(Storm):
318 """Link table to back `OCIRecipe.processors`."""
319diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
320index 595da53..c69e348 100644
321--- a/lib/lp/oci/model/ocirecipebuild.py
322+++ b/lib/lp/oci/model/ocirecipebuild.py
323@@ -45,6 +45,11 @@ from lp.oci.interfaces.ocirecipebuild import (
324 IOCIFile,
325 IOCIRecipeBuild,
326 IOCIRecipeBuildSet,
327+ OCIRecipeBuildRegistryUploadStatus,
328+ )
329+from lp.oci.model.ocirecipebuildjob import (
330+ OCIRecipeBuildJob,
331+ OCIRecipeBuildJobType,
332 )
333 from lp.registry.interfaces.pocket import PackagePublishingPocket
334 from lp.registry.model.person import Person
335@@ -57,6 +62,8 @@ from lp.services.database.interfaces import (
336 IMasterStore,
337 IStore,
338 )
339+from lp.services.job.interfaces.job import JobStatus
340+from lp.services.job.model.job import Job
341 from lp.services.librarian.model import (
342 LibraryFileAlias,
343 LibraryFileContent,
344@@ -354,23 +361,17 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
345
346 @cachedproperty
347 def manifest(self):
348- result = Store.of(self).find(
349- (OCIFile, LibraryFileAlias, LibraryFileContent),
350- OCIFile.build == self.id,
351- LibraryFileAlias.id == OCIFile.library_file_id,
352- LibraryFileContent.id == LibraryFileAlias.contentID,
353- LibraryFileAlias.filename == 'manifest.json')
354- return result.one()
355+ try:
356+ return self.getFileByName("manifest.json")
357+ except NotFoundError:
358+ return None
359
360 @cachedproperty
361 def digests(self):
362- result = Store.of(self).find(
363- (OCIFile, LibraryFileAlias, LibraryFileContent),
364- OCIFile.build == self.id,
365- LibraryFileAlias.id == OCIFile.library_file_id,
366- LibraryFileContent.id == LibraryFileAlias.contentID,
367- LibraryFileAlias.filename == 'digests.json')
368- return result.one()
369+ try:
370+ return self.getFileByName("digests.json")
371+ except NotFoundError:
372+ return None
373
374 def verifySuccessfulUpload(self):
375 """See `IPackageBuild`."""
376@@ -383,6 +384,37 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
377 and self.digests is not None)
378 return layer_files_present and metadata_present
379
380+ @property
381+ def registry_upload_jobs(self):
382+ jobs = Store.of(self).find(
383+ OCIRecipeBuildJob,
384+ OCIRecipeBuildJob.build == self,
385+ OCIRecipeBuildJob.job_type == OCIRecipeBuildJobType.REGISTRY_UPLOAD
386+ )
387+ jobs.order_by(Desc(OCIRecipeBuildJob.job_id))
388+
389+ def preload_jobs(rows):
390+ load_related(Job, rows, ["job_id"])
391+
392+ return DecoratedResultSet(
393+ jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
394+
395+ @cachedproperty
396+ def last_registry_upload_job(self):
397+ return self.registry_upload_jobs.first()
398+
399+ @property
400+ def registry_upload_status(self):
401+ job = self.last_registry_upload_job
402+ if job is None or job.job.status == JobStatus.SUSPENDED:
403+ return OCIRecipeBuildRegistryUploadStatus.UNSCHEDULED
404+ elif job.job.status in (JobStatus.WAITING, JobStatus.RUNNING):
405+ return OCIRecipeBuildRegistryUploadStatus.PENDING
406+ elif job.job.status == JobStatus.COMPLETED:
407+ return OCIRecipeBuildRegistryUploadStatus.UPLOADED
408+ else:
409+ return OCIRecipeBuildRegistryUploadStatus.FAILEDTOUPLOAD
410+
411
412 @implementer(IOCIRecipeBuildSet)
413 class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
414diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py
415index 7d8699c..c17ecf3 100644
416--- a/lib/lp/oci/model/ocirecipebuildjob.py
417+++ b/lib/lp/oci/model/ocirecipebuildjob.py
418@@ -11,20 +11,33 @@ __all__ = [
419 'OCIRecipeBuildJobType',
420 ]
421
422+
423 from lazr.delegates import delegate_to
424 from lazr.enum import (
425 DBEnumeratedType,
426 DBItem,
427 )
428+from lazr.lifecycle.event import ObjectCreatedEvent
429 from storm.databases.postgres import JSON
430 from storm.locals import (
431 Int,
432 Reference,
433 )
434-from zope.interface import implementer
435+import transaction
436+from zope.component import getUtility
437+from zope.event import notify
438+from zope.interface import (
439+ implementer,
440+ provider,
441+ )
442
443 from lp.app.errors import NotFoundError
444-from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob
445+from lp.oci.interfaces.ocirecipebuildjob import (
446+ IOCIRecipeBuildJob,
447+ IOCIRegistryUploadJob,
448+ IOCIRegistryUploadJobSource,
449+ )
450+from lp.oci.interfaces.ociregistryclient import IOCIRegistryClient
451 from lp.services.database.enumcol import DBEnum
452 from lp.services.database.interfaces import IStore
453 from lp.services.database.stormbase import StormBase
454@@ -33,14 +46,13 @@ from lp.services.job.model.job import (
455 Job,
456 )
457 from lp.services.job.runner import BaseRunnableJob
458+from lp.services.propertycache import get_property_cache
459+from lp.services.webapp.snapshot import notify_modified
460
461
462 class OCIRecipeBuildJobType(DBEnumeratedType):
463 """Values that `OCIBuildJobType.job_type` can take."""
464
465- # XXX twom (2020-04-02) This does not currently have a concrete
466- # implementation, awaiting registry upload.
467-
468 REGISTRY_UPLOAD = DBItem(0, """
469 Registry upload
470
471@@ -82,7 +94,7 @@ class OCIRecipeBuildJob(StormBase):
472 self.json_data = json_data
473
474 def makeDerived(self):
475- return OCIRecipeBuildJob.makeSubclass(self)
476+ return OCIRecipeBuildJobDerived.makeSubclass(self)
477
478
479 @delegate_to(IOCIRecipeBuildJob)
480@@ -138,3 +150,101 @@ class OCIRecipeBuildJobDerived(BaseRunnableJob):
481 ('oci_project_name', self.context.build.recipe.oci_project.name)
482 ])
483 return oops_vars
484+
485+
486+@implementer(IOCIRegistryUploadJob)
487+@provider(IOCIRegistryUploadJobSource)
488+class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
489+
490+ class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD
491+
492+ @classmethod
493+ def create(cls, build):
494+ """See `IOCIRegistryUploadJobSource`"""
495+ oci_build_job = OCIRecipeBuildJob(
496+ build, cls.class_job_type, {})
497+ job = cls(oci_build_job)
498+ job.celeryRunOnCommit()
499+ del get_property_cache(build).last_registry_upload_job
500+ notify(ObjectCreatedEvent(build))
501+ return job
502+
503+ # Ideally we'd just override Job._set_status or similar, but
504+ # lazr.delegates makes that difficult, so we use this to override all
505+ # the individual Job lifecycle methods instead.
506+ def _do_lifecycle(self, method_name, manage_transaction=False,
507+ *args, **kwargs):
508+ edited_fields = set()
509+ with notify_modified(self.build, edited_fields) as before_modification:
510+ getattr(super(OCIRegistryUploadJob, self), method_name)(
511+ *args, manage_transaction=manage_transaction, **kwargs)
512+ upload_status = self.build.registry_upload_status
513+ if upload_status != before_modification.registry_upload_status:
514+ edited_fields.add('registry_upload_status')
515+ if edited_fields and manage_transaction:
516+ transaction.commit()
517+
518+ def start(self, *args, **kwargs):
519+ self._do_lifecycle("start", *args, **kwargs)
520+
521+ def complete(self, *args, **kwargs):
522+ self._do_lifecycle("complete", *args, **kwargs)
523+
524+ def fail(self, *args, **kwargs):
525+ self._do_lifecycle("fail", *args, **kwargs)
526+
527+ def queue(self, *args, **kwargs):
528+ self._do_lifecycle("queue", *args, **kwargs)
529+
530+ def suspend(self, *args, **kwargs):
531+ self._do_lifecycle("suspend", *args, **kwargs)
532+
533+ def resume(self, *args, **kwargs):
534+ self._do_lifecycle("resume", *args, **kwargs)
535+
536+ @property
537+ def error_message(self):
538+ """See `IOCIRegistryUploadJob`."""
539+ return self.json_data.get("error_message")
540+
541+ @error_message.setter
542+ def error_message(self, message):
543+ """See `IOCIRegistryUploadJob`."""
544+ self.json_data["error_message"] = message
545+
546+ @property
547+ def error_detail(self):
548+ """See `IOCIRegistryUploadJob`."""
549+ return self.json_data.get("error_detail")
550+
551+ @error_detail.setter
552+ def error_detail(self, detail):
553+ """See `IOCIRegistryUploadJob`."""
554+ self.json_data["error_detail"] = detail
555+
556+ @property
557+ def error_messages(self):
558+ """See `IOCIRegistryUploadJob`."""
559+ return self.json_data.get("error_messages")
560+
561+ @error_messages.setter
562+ def error_messages(self, messages):
563+ """See `IOCIRegistryUploadJob`."""
564+ self.json_data["error_messages"] = messages
565+
566+ def run(self):
567+ """See `IRunnableJob`."""
568+ client = getUtility(IOCIRegistryClient)
569+ # XXX twom 2020-04-16 This is taken from SnapStoreUploadJob
570+ # it will need to gain retry support.
571+ try:
572+ try:
573+ client.upload(self.build)
574+ except Exception as e:
575+ self.error_message = str(e)
576+ self.error_messages = getattr(e, "messages", None)
577+ self.error_detail = getattr(e, "detail", None)
578+ raise
579+ except Exception:
580+ transaction.commit()
581+ raise
582diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
583new file mode 100644
584index 0000000..2152ab8
585--- /dev/null
586+++ b/lib/lp/oci/model/ociregistryclient.py
587@@ -0,0 +1,252 @@
588+# Copyright 2020 Canonical Ltd. This software is licensed under the
589+# GNU Affero General Public License version 3 (see the file LICENSE).
590+
591+"""Client for talking to an OCI registry."""
592+
593+from __future__ import absolute_import, print_function, unicode_literals
594+
595+__metaclass__ = type
596+__all__ = [
597+ 'OCIRegistryClient'
598+]
599+
600+
601+import hashlib
602+from io import BytesIO
603+import json
604+import logging
605+import tarfile
606+
607+from requests.exceptions import HTTPError
608+from zope.interface import implementer
609+
610+from lp.oci.interfaces.ociregistryclient import (
611+ BlobUploadFailed,
612+ IOCIRegistryClient,
613+ ManifestUploadFailed,
614+ )
615+from lp.services.timeout import urlfetch
616+
617+
618+log = logging.getLogger(__name__)
619+
620+
621+@implementer(IOCIRegistryClient)
622+class OCIRegistryClient:
623+
624+ @classmethod
625+ def _getJSONfile(cls, reference):
626+ """Read JSON out of a `LibraryFileAlias`."""
627+ try:
628+ reference.open()
629+ return json.loads(reference.read())
630+ finally:
631+ reference.close()
632+
633+ @classmethod
634+ def _upload(cls, digest, push_rule, name, fileobj):
635+ """Upload a blob to the registry, using a given digest.
636+
637+ :param digest: The digest to store the file under.
638+ :param push_rule: `OCIPushRule` to use for the URL and credentials.
639+ :param name: Name of the image the blob is part of.
640+ :param fileobj: An object that looks like a buffer.
641+
642+ :raises BlobUploadFailed: if the registry does not accept the blob.
643+ """
644+ # Check if it already exists
645+ try:
646+ head_response = urlfetch(
647+ "{}/v2/{}/blobs/{}".format(
648+ push_rule.registry_credentials.url, name, digest),
649+ method="HEAD")
650+ if head_response.status_code == 200:
651+ log.info("{} already found".format(digest))
652+ return
653+ except HTTPError as http_error:
654+ # A 404 is fine, we're about to upload the layer anyway
655+ if http_error.response.status_code != 404:
656+ raise http_error
657+
658+ post_response = urlfetch(
659+ "{}/v2/{}/blobs/uploads/".format(
660+ push_rule.registry_credentials.url, name),
661+ method="POST")
662+
663+ post_location = post_response.headers["Location"]
664+ query_parsed = {"digest": digest}
665+
666+ put_response = urlfetch(
667+ post_location,
668+ params=query_parsed,
669+ data=fileobj,
670+ method="PUT")
671+
672+ if put_response.status_code != 201:
673+ raise BlobUploadFailed(
674+ "Upload of {} for {} failed".format(digest, name))
675+
676+ @classmethod
677+ def _upload_layer(cls, digest, push_rule, name, lfa):
678+ """Upload a layer blob to the registry.
679+
680+ Uses _upload, but opens the LFA and extracts the necessary files
681+ from the .tar.gz first.
682+
683+ :param digest: The digest to store the file under.
684+ :param push_rule: `OCIPushRule` to use for the URL and credentials.
685+ :param name: Name of the image the blob is part of.
686+ :param lfa: The `LibraryFileAlias` for the layer.
687+ """
688+ lfa.open()
689+ try:
690+ un_zipped = tarfile.open(fileobj=lfa, mode='r|gz')
691+ for tarinfo in un_zipped:
692+ if tarinfo.name != 'layer.tar':
693+ continue
694+ fileobj = un_zipped.extractfile(tarinfo)
695+ cls._upload(digest, push_rule, name, fileobj)
696+ finally:
697+ lfa.close()
698+
699+ @classmethod
700+ def _build_registry_manifest(cls, digests, config, config_json,
701+ config_sha, preloaded_data):
702+ """Create an image manifest for the uploading image.
703+
704+ This involves nearly everything as digests and lengths are required.
705+ This method creates a minimal manifest, some fields are missing.
706+
707+ :param digests: Dict of the various digests involved.
708+ :param config: The contents of the manifest config file as a dict.
709+ :param config_json: The config file as a JSON string.
710+ :param config_sha: The sha256sum of the config JSON string.
711+ """
712+ # Create the initial manifest data with empty layer information
713+ manifest = {
714+ "schemaVersion": 2,
715+ "mediaType":
716+ "application/vnd.docker.distribution.manifest.v2+json",
717+ "config": {
718+ "mediaType": "application/vnd.docker.container.image.v1+json",
719+ "size": len(config_json),
720+ "digest": "sha256:{}".format(config_sha),
721+ },
722+ "layers": []}
723+
724+ # Fill in the layer information
725+ for layer in config["rootfs"]["diff_ids"]:
726+ manifest["layers"].append({
727+ "mediaType":
728+ "application/vnd.docker.image.rootfs.diff.tar.gzip",
729+ "size": preloaded_data[layer].content.filesize,
730+ "digest": layer})
731+ return manifest
732+
733+ @classmethod
734+ def _preloadFiles(cls, build, manifest, digests):
735+ """Preload the data from the librarian to avoid multiple fetches
736+ if there is more than one push rule for a build.
737+
738+ :param build: The referencing `OCIRecipeBuild`.
739+ :param manifest: The manifest from the built image.
740+ :param digests: Dict of the various digests involved.
741+ """
742+ data = {}
743+ for section in manifest:
744+ # Load the matching config file for this section
745+ config = cls._getJSONfile(
746+ build.getFileByName(section['Config']))
747+ files = {"config_file": config}
748+ for diff_id in config["rootfs"]["diff_ids"]:
749+ # We may have already seen this diff ID.
750+ if files.get(diff_id):
751+ continue
752+ # Retrieve the layer files.
753+ # This doesn't read the content, so there is potential
754+ # for multiple fetches, but the files can be arbitrary size
755+ # Potentially gigabytes.
756+ files[diff_id] = {}
757+ source_digest = digests[diff_id]["digest"]
758+ _, lfa, _ = build.getLayerFileByDigest(source_digest)
759+ files[diff_id] = lfa
760+ data[section["Config"]] = files
761+ return data
762+
763+ @classmethod
764+ def _calculateTag(cls, build, push_rule):
765+ """Work out the base tag for the image should be.
766+
767+ :param build: `OCIRecipeBuild` representing this build.
768+ :param push_rule: `OCIPushRule` that we are using.
769+ """
770+ # XXX twom 2020-04-17 This needs to include OCIProjectSeries and
771+ # base image name
772+
773+ return "{}".format("edge")
774+
775+ @classmethod
776+ def upload(cls, build):
777+ """Upload the artifacts from an OCIRecipeBuild to a registry.
778+
779+ :param build: `OCIRecipeBuild` representing this build.
780+ :raises ManifestUploadFailed: If the final registry manifest fails to
781+ upload due to network or validity.
782+ """
783+ # Get the required metadata files
784+ manifest = cls._getJSONfile(build.manifest)
785+ digests_list = cls._getJSONfile(build.digests)
786+ digests = {}
787+ for digest_dict in digests_list:
788+ digests.update(digest_dict)
789+
790+ # Preload the requested files
791+ preloaded_data = cls._preloadFiles(build, manifest, digests)
792+
793+ for push_rule in build.recipe.push_rules:
794+ for section in manifest:
795+ # Work out names and tags
796+ image_name = push_rule.image_name
797+ tag = cls._calculateTag(build, push_rule)
798+ file_data = preloaded_data[section["Config"]]
799+ config = file_data["config_file"]
800+ # Upload the layers involved
801+ for diff_id in config["rootfs"]["diff_ids"]:
802+ cls._upload_layer(
803+ diff_id,
804+ push_rule,
805+ image_name,
806+ file_data[diff_id])
807+ # The config file is required in different forms, so we can
808+ # calculate the sha, work these out and upload
809+ config_json = json.dumps(config).encode("UTF-8")
810+ config_sha = hashlib.sha256(config_json).hexdigest()
811+ cls._upload(
812+ "sha256:{}".format(config_sha),
813+ push_rule,
814+ image_name,
815+ BytesIO(config_json))
816+
817+ # Build the registry manifest from the image manifest
818+ # and associated configs
819+ registry_manifest = cls._build_registry_manifest(
820+ digests, config, config_json, config_sha,
821+ preloaded_data[section["Config"]])
822+
823+ # Upload the registry manifest
824+ manifest_response = urlfetch(
825+ "{}/v2/{}/manifests/{}".format(
826+ push_rule.registry_credentials.url,
827+ image_name,
828+ tag),
829+ json=registry_manifest,
830+ headers={
831+ "Content-Type":
832+ "application/"
833+ "vnd.docker.distribution.manifest.v2+json"
834+ },
835+ method="PUT")
836+ if manifest_response.status_code != 201:
837+ raise ManifestUploadFailed(
838+ "Failed to upload manifest for {} in {}".format(
839+ build.recipe.name, build.id))
840diff --git a/lib/lp/oci/model/ociregistrycredentials.py b/lib/lp/oci/model/ociregistrycredentials.py
841index 2a70159..03b0f72 100644
842--- a/lib/lp/oci/model/ociregistrycredentials.py
843+++ b/lib/lp/oci/model/ociregistrycredentials.py
844@@ -80,8 +80,8 @@ class OCIRegistryCredentials(Storm):
845 def getCredentials(self):
846 container = getUtility(IEncryptedContainer, "oci-registry-secrets")
847 try:
848- return json.loads(container.decrypt((
849- self._credentials['credentials_encrypted'])).decode("UTF-8"))
850+ return json.loads(container.decrypt(
851+ self._credentials['credentials_encrypted']).decode("UTF-8"))
852 except CryptoError as e:
853 # XXX twom 2020-03-18 This needs a better error
854 # see SnapStoreClient.UnauthorizedUploadResponse
855diff --git a/lib/lp/oci/subscribers/ocirecipebuild.py b/lib/lp/oci/subscribers/ocirecipebuild.py
856index 3e7f80d..2f3fc01 100644
857--- a/lib/lp/oci/subscribers/ocirecipebuild.py
858+++ b/lib/lp/oci/subscribers/ocirecipebuild.py
859@@ -9,8 +9,10 @@ __metaclass__ = type
860
861 from zope.component import getUtility
862
863+from lp.buildmaster.enums import BuildStatus
864 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
865 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
866+from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
867 from lp.services.features import getFeatureFlag
868 from lp.services.webapp.publisher import canonical_url
869 from lp.services.webhooks.interfaces import IWebhookSet
870@@ -25,7 +27,7 @@ def _trigger_oci_recipe_build_webhook(build, action):
871 }
872 payload.update(compose_webhook_payload(
873 IOCIRecipeBuild, build,
874- ["recipe", "status"]))
875+ ["recipe", "status", "registry_upload_status"]))
876 getUtility(IWebhookSet).trigger(
877 build.recipe, "oci-recipe:build:0.1", payload)
878
879@@ -34,9 +36,14 @@ def oci_recipe_build_created(build, event):
880 """Trigger events when a new OCI recipe build is created."""
881 _trigger_oci_recipe_build_webhook(build, "created")
882
883-
884-def oci_recipe_build_status_changed(build, event):
885+def oci_recipe_build_modified(build, event):
886 """Trigger events when OCI recipe build statuses change."""
887 if event.edited_fields is not None:
888- if "status" in event.edited_fields:
889+ status_changed = "status" in event.edited_fields
890+ registry_changed = "registry_upload_status" in event.edited_fields
891+ if status_changed or registry_changed:
892 _trigger_oci_recipe_build_webhook(build, "status-changed")
893+ if status_changed:
894+ if (build.recipe.can_upload_to_registry and
895+ build.status == BuildStatus.FULLYBUILT):
896+ getUtility(IOCIRegistryUploadJobSource).create(build)
897diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
898index e158e2e..06ed382 100644
899--- a/lib/lp/oci/tests/test_ocirecipe.py
900+++ b/lib/lp/oci/tests/test_ocirecipe.py
901@@ -127,6 +127,7 @@ class TestOCIRecipe(TestCaseWithFactory):
902 "action": Equals("created"),
903 "recipe": Equals(canonical_url(recipe, force_local_path=True)),
904 "status": Equals("Needs building"),
905+ 'registry_upload_status': Equals('Unscheduled'),
906 }
907 with person_logged_in(recipe.owner):
908 delivery = hook.deliveries.one()
909diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
910index 400d78d..0579a8b 100644
911--- a/lib/lp/oci/tests/test_ocirecipebuild.py
912+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
913@@ -182,6 +182,7 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
914 "recipe": Equals(
915 canonical_url(self.build.recipe, force_local_path=True)),
916 "status": Equals("Successfully built"),
917+ 'registry_upload_status': Equals("Unscheduled"),
918 }
919 self.assertThat(
920 logger.output, LogsScheduledWebhooks([
921diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py
922index cf85684..f4628e0 100644
923--- a/lib/lp/oci/tests/test_ocirecipebuildjob.py
924+++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py
925@@ -7,16 +7,65 @@ from __future__ import absolute_import, print_function, unicode_literals
926
927 __metaclass__ = type
928
929-from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
930-from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob
931+from fixtures import FakeLogger
932+from testtools.matchers import (
933+ Equals,
934+ MatchesDict,
935+ MatchesListwise,
936+ MatchesStructure,
937+ )
938+import transaction
939+from zope.interface import implementer
940+
941+from lp.buildmaster.enums import BuildStatus
942+from lp.oci.interfaces.ocirecipe import (
943+ OCI_RECIPE_ALLOW_CREATE,
944+ OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
945+ )
946+from lp.oci.interfaces.ocirecipebuildjob import (
947+ IOCIRecipeBuildJob,
948+ IOCIRegistryUploadJob,
949+ IOCIRegistryUploadJobSource,
950+ )
951+from lp.oci.interfaces.ociregistryclient import IOCIRegistryClient
952 from lp.oci.model.ocirecipebuildjob import (
953 OCIRecipeBuildJob,
954 OCIRecipeBuildJobDerived,
955 OCIRecipeBuildJobType,
956+ OCIRegistryUploadJob,
957 )
958+from lp.services.config import config
959 from lp.services.features.testing import FeatureFixture
960+from lp.services.job.runner import JobRunner
961 from lp.testing import TestCaseWithFactory
962-from lp.testing.layers import DatabaseFunctionalLayer
963+from lp.testing.dbuser import dbuser
964+from lp.testing.fakemethod import FakeMethod
965+from lp.testing.fixture import ZopeUtilityFixture
966+from lp.testing.layers import (
967+ DatabaseFunctionalLayer,
968+ LaunchpadZopelessLayer,
969+ )
970+from lp.testing.mail_helpers import pop_notifications
971+from lp.services.webapp import canonical_url
972+from lp.services.webhooks.testing import LogsScheduledWebhooks
973+
974+
975+def run_isolated_jobs(jobs):
976+ """Run a sequence of jobs, ensuring transaction isolation.
977+
978+ We abort the transaction after each job to make sure that there is no
979+ relevant uncommitted work.
980+ """
981+ for job in jobs:
982+ JobRunner([job]).runAll()
983+ transaction.abort()
984+
985+
986+@implementer(IOCIRegistryClient)
987+class FakeRegistryClient:
988+
989+ def __init__(self):
990+ self.upload = FakeMethod()
991
992
993 class FakeOCIBuildJob(OCIRecipeBuildJobDerived):
994@@ -52,3 +101,104 @@ class TestOCIRecipeBuildJob(TestCaseWithFactory):
995 ('oci_project_name', oci_build.recipe.oci_project.name),
996 ]
997 self.assertEqual(expected, oops)
998+
999+
1000+class TestOCIRegistryUploadJob(TestCaseWithFactory):
1001+
1002+ layer = LaunchpadZopelessLayer
1003+
1004+ def setUp(self):
1005+ super(TestOCIRegistryUploadJob, self).setUp()
1006+ self.useFixture(FeatureFixture({
1007+ 'webhooks.new.enabled': 'true',
1008+ OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: 'on',
1009+ OCI_RECIPE_ALLOW_CREATE: 'on'
1010+ }))
1011+
1012+ def makeOCIRecipeBuild(self, **kwargs):
1013+ ocibuild = self.factory.makeOCIRecipeBuild(
1014+ builder=self.factory.makeBuilder(), **kwargs)
1015+ ocibuild.updateStatus(BuildStatus.FULLYBUILT)
1016+ self.factory.makeWebhook(
1017+ target=ocibuild.recipe, event_types=["oci-recipe:build:0.1"])
1018+ return ocibuild
1019+
1020+ def assertWebhookDeliveries(self, ocibuild,
1021+ expected_registry_upload_statuses, logger):
1022+ hook = ocibuild.recipe.webhooks.one()
1023+ deliveries = list(hook.deliveries)
1024+ deliveries.reverse()
1025+ expected_payloads = [{
1026+ "recipe_build": Equals(
1027+ canonical_url(ocibuild, force_local_path=True)),
1028+ "action": Equals("created"),
1029+ "recipe": Equals(
1030+ canonical_url(ocibuild.recipe, force_local_path=True)),
1031+ "status": Equals("Successfully built"),
1032+ "registry_upload_status": Equals("Pending")}]
1033+ expected_payloads += [{
1034+ "recipe_build": Equals(
1035+ canonical_url(ocibuild, force_local_path=True)),
1036+ "action": Equals("status-changed"),
1037+ "recipe": Equals(
1038+ canonical_url(ocibuild.recipe, force_local_path=True)),
1039+ "status": Equals("Successfully built"),
1040+ "registry_upload_status": Equals(expected),
1041+ } for expected in expected_registry_upload_statuses]
1042+ matchers = [
1043+ MatchesStructure(
1044+ event_type=Equals("oci-recipe:build:0.1"),
1045+ payload=MatchesDict(expected_payload))
1046+ for expected_payload in expected_payloads]
1047+ self.assertThat(deliveries, MatchesListwise(matchers))
1048+ with dbuser(config.IWebhookDeliveryJobSource.dbuser):
1049+ for delivery in deliveries:
1050+ self.assertEqual(
1051+ "<WebhookDeliveryJob for webhook %d on %r>" % (
1052+ hook.id, hook.target),
1053+ repr(delivery))
1054+ self.assertThat(
1055+ logger.output, LogsScheduledWebhooks([
1056+ (hook, "oci-recipe:build:0.1", MatchesDict(
1057+ expected_payload))
1058+ for expected_payload in expected_payloads]))
1059+
1060+ def test_provides_interface(self):
1061+ # `OCIRegistryUploadJob` objects provide `IOCIRegistryUploadJob`.
1062+ ocibuild = self.factory.makeOCIRecipeBuild()
1063+ job = OCIRegistryUploadJob.create(ocibuild)
1064+ self.assertProvides(job, IOCIRegistryUploadJob)
1065+
1066+ def test_run(self):
1067+ logger = self.useFixture(FakeLogger())
1068+ ocibuild = self.makeOCIRecipeBuild()
1069+ self.assertContentEqual([], ocibuild.registry_upload_jobs)
1070+ job = OCIRegistryUploadJob.create(ocibuild)
1071+ client = FakeRegistryClient()
1072+ self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
1073+ with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
1074+ run_isolated_jobs([job])
1075+ self.assertEqual([((ocibuild,), {})], client.upload.calls)
1076+ self.assertContentEqual([job], ocibuild.registry_upload_jobs)
1077+ self.assertIsNone(job.error_message)
1078+ self.assertEqual([], pop_notifications())
1079+ self.assertWebhookDeliveries(
1080+ ocibuild, ["Uploaded"], logger)
1081+
1082+ def test_run_failed(self):
1083+ # A failed run sets the registry upload status to FAILED.
1084+ logger = self.useFixture(FakeLogger())
1085+ ocibuild = self.makeOCIRecipeBuild()
1086+ self.assertContentEqual([], ocibuild.registry_upload_jobs)
1087+ job = OCIRegistryUploadJob.create(ocibuild)
1088+ client = FakeRegistryClient()
1089+ client.upload.failure = ValueError("An upload failure")
1090+ self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
1091+ with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
1092+ run_isolated_jobs([job])
1093+ self.assertEqual([((ocibuild,), {})], client.upload.calls)
1094+ self.assertContentEqual([job], ocibuild.registry_upload_jobs)
1095+ self.assertEqual("An upload failure", job.error_message)
1096+ self.assertEqual([], pop_notifications())
1097+ self.assertWebhookDeliveries(
1098+ ocibuild, ["Failed to upload"], logger)
1099diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
1100new file mode 100644
1101index 0000000..5f83e38
1102--- /dev/null
1103+++ b/lib/lp/oci/tests/test_ociregistryclient.py
1104@@ -0,0 +1,204 @@
1105+# Copyright 2020 Canonical Ltd. This software is licensed under the
1106+# GNU Affero General Public License version 3 (see the file LICENSE).
1107+
1108+"""Tests for the OCI Registry client."""
1109+
1110+from __future__ import absolute_import, print_function, unicode_literals
1111+
1112+__metaclass__ = type
1113+
1114+import json
1115+
1116+from fixtures import MockPatch
1117+from requests.exceptions import HTTPError
1118+import responses
1119+from testtools.matchers import (
1120+ Equals,
1121+ MatchesDict,
1122+ MatchesListwise,
1123+ )
1124+import transaction
1125+
1126+from lp.oci.model.ociregistryclient import OCIRegistryClient
1127+from lp.oci.tests.helpers import OCIConfigHelperMixin
1128+from lp.testing import TestCaseWithFactory
1129+from lp.testing.layers import LaunchpadZopelessLayer
1130+
1131+
1132+class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
1133+
1134+ layer = LaunchpadZopelessLayer
1135+
1136+ def setUp(self):
1137+ super(TestOCIRegistryClient, self).setUp()
1138+ self.setConfig()
1139+ self.manifest = [{
1140+ "Config": "config_file_1.json",
1141+ "Layers": ["layer_1/layer.tar", "layer_2/layer.tar"]}]
1142+ self.digests = [{
1143+ "diff_id_1": {
1144+ "digest": "digest_1",
1145+ "source": "test/base_1",
1146+ "layer_id": "layer_1"
1147+ },
1148+ "diff_id_2": {
1149+ "digest": "digest_2",
1150+ "source": "",
1151+ "layer_id": "layer_2"
1152+ }
1153+ }]
1154+ self.config = {"rootfs": {"diff_ids": ["diff_id_1", "diff_id_2"]}}
1155+ self.build = self.factory.makeOCIRecipeBuild()
1156+ self.factory.makeOCIPushRule(recipe=self.build.recipe)
1157+ self.client = OCIRegistryClient()
1158+
1159+ def _makeFiles(self):
1160+ self.factory.makeOCIFile(
1161+ build=self.build,
1162+ content=json.dumps(self.manifest),
1163+ filename='manifest.json',
1164+ )
1165+ self.factory.makeOCIFile(
1166+ build=self.build,
1167+ content=json.dumps(self.digests),
1168+ filename='digests.json',
1169+ )
1170+ self.factory.makeOCIFile(
1171+ build=self.build,
1172+ content=json.dumps(self.config),
1173+ filename='config_file_1.json'
1174+ )
1175+
1176+ # make layer files
1177+ self.layer_1_file = self.factory.makeOCIFile(
1178+ build=self.build,
1179+ content="digest_1",
1180+ filename="digest_1_filename",
1181+ layer_file_digest="digest_1"
1182+ )
1183+ self.layer_2_file = self.factory.makeOCIFile(
1184+ build=self.build,
1185+ content="digest_2",
1186+ filename="digest_2_filename",
1187+ layer_file_digest="digest_2"
1188+ )
1189+
1190+ transaction.commit()
1191+
1192+ @responses.activate
1193+ def test_upload(self):
1194+ self._makeFiles()
1195+ self.useFixture(MockPatch(
1196+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
1197+ self.useFixture(MockPatch(
1198+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))
1199+
1200+ manifests_url = "{}/v2/{}/manifests/edge".format(
1201+ self.build.recipe.push_rules[0].registry_credentials.url,
1202+ self.build.recipe.push_rules[0].image_name
1203+ )
1204+ responses.add("PUT", manifests_url, status=201)
1205+ self.client.upload(self.build)
1206+
1207+ request = json.loads(responses.calls[0].request.body)
1208+
1209+ self.assertThat(request, MatchesDict({
1210+ "layers": MatchesListwise([
1211+ MatchesDict({
1212+ "mediaType": Equals(
1213+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
1214+ "digest": Equals("diff_id_1"),
1215+ "size": Equals(8)}),
1216+ MatchesDict({
1217+ "mediaType": Equals(
1218+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
1219+ "digest": Equals("diff_id_2"),
1220+ "size": Equals(8)})
1221+ ]),
1222+ "schemaVersion": Equals(2),
1223+ "config": MatchesDict({
1224+ "mediaType": Equals(
1225+ "application/vnd.docker.container.image.v1+json"),
1226+ "digest": Equals(
1227+ "sha256:33b69b4b6e106f9fc7a8b93409"
1228+ "36c85cf7f84b2d017e7b55bee6ab214761f6ab"),
1229+ "size": Equals(52)
1230+ }),
1231+ "mediaType": Equals(
1232+ "application/vnd.docker.distribution.manifest.v2+json")
1233+ }))
1234+
1235+ def test_preloadFiles(self):
1236+ self._makeFiles()
1237+ files = self.client._preloadFiles(
1238+ self.build, self.manifest, self.digests[0])
1239+
1240+ self.assertThat(files, MatchesDict({
1241+ 'config_file_1.json': MatchesDict({
1242+ 'config_file': Equals(self.config),
1243+ 'diff_id_1': Equals(self.layer_1_file.library_file),
1244+ 'diff_id_2': Equals(self.layer_2_file.library_file)})}))
1245+
1246+ def test_calculateTag(self):
1247+ result = self.client._calculateTag(
1248+ self.build, self.build.recipe.push_rules[0])
1249+ self.assertEqual("edge", result)
1250+
1251+ def test_build_registry_manifest(self):
1252+ self._makeFiles()
1253+ preloaded_data = self.client._preloadFiles(
1254+ self.build, self.manifest, self.digests[0])
1255+ manifest = self.client._build_registry_manifest(
1256+ self.digests[0],
1257+ self.config,
1258+ json.dumps(self.config),
1259+ "config-sha",
1260+ preloaded_data["config_file_1.json"])
1261+ self.assertThat(manifest, MatchesDict({
1262+ "layers": MatchesListwise([
1263+ MatchesDict({
1264+ "mediaType": Equals(
1265+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
1266+ "digest": Equals("diff_id_1"),
1267+ "size": Equals(8)}),
1268+ MatchesDict({
1269+ "mediaType": Equals(
1270+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
1271+ "digest": Equals("diff_id_2"),
1272+ "size": Equals(8)})
1273+ ]),
1274+ "schemaVersion": Equals(2),
1275+ "config": MatchesDict({
1276+ "mediaType": Equals(
1277+ "application/vnd.docker.container.image.v1+json"),
1278+ "digest": Equals("sha256:config-sha"),
1279+ "size": Equals(52)
1280+ }),
1281+ "mediaType": Equals(
1282+ "application/vnd.docker.distribution.manifest.v2+json")
1283+ }))
1284+
1285+ @responses.activate
1286+ def test_upload_handles_existing(self):
1287+ blobs_url = "{}/v2/{}/blobs/{}".format(
1288+ self.build.recipe.push_rules[0].registry_credentials.url,
1289+ "test-name",
1290+ "test-digest")
1291+ responses.add("HEAD", blobs_url, status=200)
1292+ self.client._upload(
1293+ "test-digest", self.build.recipe.push_rules[0], "test-name", None)
1294+
1295+ @responses.activate
1296+ def test_upload_raises_non_404(self):
1297+ blobs_url = "{}/v2/{}/blobs/{}".format(
1298+ self.build.recipe.push_rules[0].registry_credentials.url,
1299+ "test-name",
1300+ "test-digest")
1301+ responses.add("HEAD", blobs_url, status=500)
1302+ self.assertRaises(
1303+ HTTPError,
1304+ self.client._upload,
1305+ "test-digest",
1306+ self.build.recipe.push_rules[0],
1307+ "test-name",
1308+ None)
1309diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
1310index 18b452b..ec65b96 100644
1311--- a/lib/lp/services/config/schema-lazr.conf
1312+++ b/lib/lp/services/config/schema-lazr.conf
1313@@ -1821,6 +1821,7 @@ job_sources:
1314 IExpiringMembershipNotificationJobSource,
1315 IGitRepositoryModifiedMailJobSource,
1316 IMembershipNotificationJobSource,
1317+ IOCIRegistryUploadJobSource,
1318 IPackageUploadNotificationJobSource,
1319 IPersonDeactivateJobSource,
1320 IPersonMergeJobSource,
1321@@ -1984,6 +1985,11 @@ module: lp.snappy.interfaces.snapbuildjob
1322 dbuser: snap-build-job
1323 crontab_group: MAIN
1324
1325+[IOCIRegistryUploadJobSource]
1326+module: lp.oci.interfaces.ocirecipebuildjob
1327+dbuser: oci-build-job
1328+crontab_group: MAIN
1329+
1330 [ITeamInvitationNotificationJobSource]
1331 module: lp.registry.interfaces.persontransferjob
1332 dbuser: person-transfer-job

Subscribers

People subscribed via source and target branches

to status/vote changes: