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
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 942c5b4..55226e1 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -999,6 +999,7 @@ public.livefsfile = SELECT
999public.ocifile = SELECT999public.ocifile = SELECT
1000public.ociproject = SELECT1000public.ociproject = SELECT
1001public.ociprojectname = SELECT1001public.ociprojectname = SELECT
1002public.ocipushrule = SELECT
1002public.ocirecipe = SELECT1003public.ocirecipe = SELECT
1003public.ocirecipebuild = SELECT, UPDATE1004public.ocirecipebuild = SELECT, UPDATE
1004public.ocirecipebuildjob = SELECT, INSERT1005public.ocirecipebuildjob = SELECT, INSERT
@@ -1439,6 +1440,7 @@ public.ocifile = SELECT, INSERT
1439public.ociproject = SELECT1440public.ociproject = SELECT
1440public.ociprojectname = SELECT1441public.ociprojectname = SELECT
1441public.ociprojectseries = SELECT1442public.ociprojectseries = SELECT
1443public.ocipushrule = SELECT
1442public.ocirecipe = SELECT, UPDATE1444public.ocirecipe = SELECT, UPDATE
1443public.ocirecipebuild = SELECT, UPDATE1445public.ocirecipebuild = SELECT, UPDATE
1444public.ocirecipebuildjob = SELECT, INSERT, UPDATE1446public.ocirecipebuildjob = SELECT, INSERT, UPDATE
@@ -2678,3 +2680,42 @@ public.teammembership = SELECT
2678public.teamparticipation = SELECT2680public.teamparticipation = SELECT
2679public.webhook = SELECT2681public.webhook = SELECT
2680public.webhookjob = SELECT, INSERT2682public.webhookjob = SELECT, INSERT
2683
2684[oci-build-job]
2685type=user
2686groups=script
2687public.account = SELECT
2688public.archive = SELECT
2689public.branch = SELECT
2690public.builder = SELECT
2691public.buildfarmjob = SELECT, INSERT
2692public.buildqueue = SELECT, INSERT, UPDATE
2693public.distribution = SELECT
2694public.distroarchseries = SELECT
2695public.distroseries = SELECT
2696public.emailaddress = SELECT
2697public.gitref = SELECT
2698public.gitrepository = SELECT
2699public.job = SELECT, INSERT, UPDATE
2700public.libraryfilealias = SELECT
2701public.libraryfilecontent = SELECT
2702public.person = SELECT
2703public.personsettings = SELECT
2704public.pocketchroot = SELECT
2705public.processor = SELECT
2706public.product = SELECT
2707public.ocirecipe = SELECT, UPDATE
2708public.ocirecipearch = SELECT
2709public.ocirecipebuild = SELECT, INSERT, UPDATE
2710public.ocirecipebuildjob = SELECT, UPDATE
2711public.ocifile = SELECT
2712public.ociproject = SELECT
2713public.ociprojectname = SELECT
2714public.ociprojectseries = SELECT
2715public.ocipushrule = SELECT
2716public.ociregistrycredentials = SELECT
2717public.sourcepackagename = SELECT
2718public.teammembership = SELECT
2719public.teamparticipation = SELECT
2720public.webhook = SELECT
2721public.webhookjob = SELECT, INSERT
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 565412d..5836861 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -60,7 +60,7 @@
60 <subscriber60 <subscriber
61 for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild61 for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild
62 lazr.lifecycle.interfaces.IObjectModifiedEvent"62 lazr.lifecycle.interfaces.IObjectModifiedEvent"
63 handler="lp.oci.subscribers.ocirecipebuild.oci_recipe_build_status_changed" />63 handler="lp.oci.subscribers.ocirecipebuild.oci_recipe_build_modified" />
6464
65 <!-- OCIRecipeBuildSet -->65 <!-- OCIRecipeBuildSet -->
66 <securedutility66 <securedutility
@@ -127,4 +127,22 @@
127 interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>127 interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
128 </securedutility>128 </securedutility>
129129
130 <!-- OCI related jobs -->
131 <securedutility
132 component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
133 provides="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJobSource">
134 <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJobSource" />
135 </securedutility>
136 <class class="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob">
137 <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRecipeBuildJob" />
138 <allow interface="lp.oci.interfaces.ocirecipebuildjob.IOCIRegistryUploadJob" />
139 </class>
140
141 <!-- Registry interaction -->
142 <securedutility
143 class="lp.oci.model.ociregistryclient.OCIRegistryClient"
144 provides="lp.oci.interfaces.ociregistryclient.IOCIRegistryClient">
145 <allow interface="lp.oci.interfaces.ociregistryclient.IOCIRegistryClient" />
146 </securedutility>
147
130</configure>148</configure>
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 25a8967..eda2782 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -214,6 +214,12 @@ class IOCIRecipeView(Interface):
214 # Really IOCIPushRule, patched in _schema_cirular_imports.214 # Really IOCIPushRule, patched in _schema_cirular_imports.
215 value_type=Reference(schema=Interface), readonly=True)215 value_type=Reference(schema=Interface), readonly=True)
216216
217 can_upload_to_registry = Bool(
218 title=_("Can upload to registry"), required=True, readonly=True,
219 description=_(
220 "Whether everything is set up to allow uploading builds of "
221 "this OCI recipe to a registry."))
222
217223
218class IOCIRecipeEdit(IWebhookTarget):224class IOCIRecipeEdit(IWebhookTarget):
219 """`IOCIRecipe` methods that require launchpad.Edit permission."""225 """`IOCIRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index 90bb829..a1fbfc0 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -10,12 +10,24 @@ __all__ = [
10 'IOCIFile',10 'IOCIFile',
11 'IOCIRecipeBuild',11 'IOCIRecipeBuild',
12 'IOCIRecipeBuildSet',12 'IOCIRecipeBuildSet',
13 'OCIRecipeBuildRegistryUploadStatus',
13 ]14 ]
1415
15from lazr.restful.fields import Reference16from lazr.enum import (
16from zope.interface import Interface17 EnumeratedType,
18 Item,
19 )
20from lazr.restful.fields import (
21 CollectionField,
22 Reference,
23 )
24from zope.interface import (
25 Attribute,
26 Interface,
27 )
17from zope.schema import (28from zope.schema import (
18 Bool,29 Bool,
30 Choice,
19 Datetime,31 Datetime,
20 Int,32 Int,
21 TextLine,33 TextLine,
@@ -31,6 +43,38 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
31from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries43from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
3244
3345
46class OCIRecipeBuildRegistryUploadStatus(EnumeratedType):
47 """OCI build registry upload status type
48
49 OCI builds may be uploaded to a registry. This represents the state of
50 that process.
51 """
52
53 UNSCHEDULED = Item("""
54 Unscheduled
55
56 No upload of this OCI build to a registry is scheduled.
57 """)
58
59 PENDING = Item("""
60 Pending
61
62 This OCI build is queued for upload to a registry.
63 """)
64
65 FAILEDTOUPLOAD = Item("""
66 Failed to upload
67
68 The last attempt to upload this OCI build to a registry failed.
69 """)
70
71 UPLOADED = Item("""
72 Uploaded
73
74 This OCI build was successfully uploaded to a registry.
75 """)
76
77
34class IOCIRecipeBuildView(IPackageBuild):78class IOCIRecipeBuildView(IPackageBuild):
35 """`IOCIRecipeBuild` attributes that require launchpad.View permission."""79 """`IOCIRecipeBuild` attributes that require launchpad.View permission."""
3680
@@ -102,6 +146,26 @@ class IOCIRecipeBuildView(IPackageBuild):
102 required=True, readonly=True,146 required=True, readonly=True,
103 description=_("Whether this build record can be cancelled."))147 description=_("Whether this build record can be cancelled."))
104148
149 manifest = Attribute(_("The manifest of the image."))
150
151 digests = Attribute(_("File containing the image digests."))
152
153 registry_upload_jobs = CollectionField(
154 title=_("Registry upload jobs for this build."),
155 # Really IOCIRegistryUploadJob.
156 value_type=Reference(schema=Interface),
157 readonly=True)
158
159 # Really IOCIRegistryUploadJob
160 last_registry_upload_job = Reference(
161 title=_("Last registry upload job for this build."), schema=Interface)
162
163 registry_upload_status = Choice(
164 title=_("Registry upload status"),
165 vocabulary=OCIRecipeBuildRegistryUploadStatus,
166 required=True, readonly=False
167 )
168
105169
106class IOCIRecipeBuildEdit(Interface):170class IOCIRecipeBuildEdit(Interface):
107 """`IOCIRecipeBuild` attributes that require launchpad.Edit permission."""171 """`IOCIRecipeBuild` attributes that require launchpad.Edit permission."""
diff --git a/lib/lp/oci/interfaces/ocirecipebuildjob.py b/lib/lp/oci/interfaces/ocirecipebuildjob.py
index 6c25b73..7f2537b 100644
--- a/lib/lp/oci/interfaces/ocirecipebuildjob.py
+++ b/lib/lp/oci/interfaces/ocirecipebuildjob.py
@@ -8,17 +8,25 @@ from __future__ import absolute_import, print_function, unicode_literals
8__metaclass__ = type8__metaclass__ = type
9__all__ = [9__all__ = [
10 'IOCIRecipeBuildJob',10 'IOCIRecipeBuildJob',
11 'IOCIRegistryUploadJob',
12 'IOCIRegistryUploadJobSource',
11 ]13 ]
1214
13from lazr.restful.fields import Reference15from lazr.restful.fields import Reference
16from zope.component.interfaces import IObjectEvent
14from zope.interface import (17from zope.interface import (
15 Attribute,18 Attribute,
16 Interface,19 Interface,
17 )20 )
21from zope.schema import TextLine
1822
19from lp import _23from lp import _
20from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild24from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
21from lp.services.job.interfaces.job import IJob25from lp.services.job.interfaces.job import (
26 IJob,
27 IJobSource,
28 IRunnableJob,
29 )
2230
2331
24class IOCIRecipeBuildJob(Interface):32class IOCIRecipeBuildJob(Interface):
@@ -32,3 +40,19 @@ class IOCIRecipeBuildJob(Interface):
32 schema=IOCIRecipeBuild, required=True, readonly=True)40 schema=IOCIRecipeBuild, required=True, readonly=True)
3341
34 json_data = Attribute(_("A dict of data about the job."))42 json_data = Attribute(_("A dict of data about the job."))
43
44
45class IOCIRegistryUploadJob(IRunnableJob):
46 """A Job that uploads an OCI image to a registry."""
47
48 error_message = TextLine(
49 title=_("Error message"), required=False, readonly=True)
50
51
52class IOCIRegistryUploadJobSource(IJobSource):
53
54 def create(build):
55 """Upload an OCI image to a registry.
56
57 :param build: The OCI recipe build to upload.
58 """
diff --git a/lib/lp/oci/interfaces/ociregistryclient.py b/lib/lp/oci/interfaces/ociregistryclient.py
35new file mode 10064459new file mode 100644
index 0000000..8ac2aa6
--- /dev/null
+++ b/lib/lp/oci/interfaces/ociregistryclient.py
@@ -0,0 +1,33 @@
1# Copyright 2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Interface for communication with an OCI registry."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9__all__ = [
10 'BlobUploadFailed',
11 'IOCIRegistryClient',
12 'ManifestUploadFailed',
13]
14
15from zope.interface import Interface
16
17
18class BlobUploadFailed(Exception):
19 pass
20
21
22class ManifestUploadFailed(Exception):
23 pass
24
25
26class IOCIRegistryClient(Interface):
27 """Interface for the API provided by an OCI registry."""
28
29 def upload(build):
30 """Upload an OCI image to a registry.
31
32 :param build: The `IOCIRecipeBuild` to upload.
33 """
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 4e3273a..aa69900 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -377,6 +377,10 @@ class OCIRecipe(Storm, WebhookTargetMixin):
377 order_by = Desc(OCIRecipeBuild.id)377 order_by = Desc(OCIRecipeBuild.id)
378 return self._getBuilds(filter_term, order_by)378 return self._getBuilds(filter_term, order_by)
379379
380 @property
381 def can_upload_to_registry(self):
382 return not self.push_rules.is_empty()
383
380384
381class OCIRecipeArch(Storm):385class OCIRecipeArch(Storm):
382 """Link table to back `OCIRecipe.processors`."""386 """Link table to back `OCIRecipe.processors`."""
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 595da53..c69e348 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -45,6 +45,11 @@ from lp.oci.interfaces.ocirecipebuild import (
45 IOCIFile,45 IOCIFile,
46 IOCIRecipeBuild,46 IOCIRecipeBuild,
47 IOCIRecipeBuildSet,47 IOCIRecipeBuildSet,
48 OCIRecipeBuildRegistryUploadStatus,
49 )
50from lp.oci.model.ocirecipebuildjob import (
51 OCIRecipeBuildJob,
52 OCIRecipeBuildJobType,
48 )53 )
49from lp.registry.interfaces.pocket import PackagePublishingPocket54from lp.registry.interfaces.pocket import PackagePublishingPocket
50from lp.registry.model.person import Person55from lp.registry.model.person import Person
@@ -57,6 +62,8 @@ from lp.services.database.interfaces import (
57 IMasterStore,62 IMasterStore,
58 IStore,63 IStore,
59 )64 )
65from lp.services.job.interfaces.job import JobStatus
66from lp.services.job.model.job import Job
60from lp.services.librarian.model import (67from lp.services.librarian.model import (
61 LibraryFileAlias,68 LibraryFileAlias,
62 LibraryFileContent,69 LibraryFileContent,
@@ -354,23 +361,17 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
354361
355 @cachedproperty362 @cachedproperty
356 def manifest(self):363 def manifest(self):
357 result = Store.of(self).find(364 try:
358 (OCIFile, LibraryFileAlias, LibraryFileContent),365 return self.getFileByName("manifest.json")
359 OCIFile.build == self.id,366 except NotFoundError:
360 LibraryFileAlias.id == OCIFile.library_file_id,367 return None
361 LibraryFileContent.id == LibraryFileAlias.contentID,
362 LibraryFileAlias.filename == 'manifest.json')
363 return result.one()
364368
365 @cachedproperty369 @cachedproperty
366 def digests(self):370 def digests(self):
367 result = Store.of(self).find(371 try:
368 (OCIFile, LibraryFileAlias, LibraryFileContent),372 return self.getFileByName("digests.json")
369 OCIFile.build == self.id,373 except NotFoundError:
370 LibraryFileAlias.id == OCIFile.library_file_id,374 return None
371 LibraryFileContent.id == LibraryFileAlias.contentID,
372 LibraryFileAlias.filename == 'digests.json')
373 return result.one()
374375
375 def verifySuccessfulUpload(self):376 def verifySuccessfulUpload(self):
376 """See `IPackageBuild`."""377 """See `IPackageBuild`."""
@@ -383,6 +384,37 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
383 and self.digests is not None)384 and self.digests is not None)
384 return layer_files_present and metadata_present385 return layer_files_present and metadata_present
385386
387 @property
388 def registry_upload_jobs(self):
389 jobs = Store.of(self).find(
390 OCIRecipeBuildJob,
391 OCIRecipeBuildJob.build == self,
392 OCIRecipeBuildJob.job_type == OCIRecipeBuildJobType.REGISTRY_UPLOAD
393 )
394 jobs.order_by(Desc(OCIRecipeBuildJob.job_id))
395
396 def preload_jobs(rows):
397 load_related(Job, rows, ["job_id"])
398
399 return DecoratedResultSet(
400 jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
401
402 @cachedproperty
403 def last_registry_upload_job(self):
404 return self.registry_upload_jobs.first()
405
406 @property
407 def registry_upload_status(self):
408 job = self.last_registry_upload_job
409 if job is None or job.job.status == JobStatus.SUSPENDED:
410 return OCIRecipeBuildRegistryUploadStatus.UNSCHEDULED
411 elif job.job.status in (JobStatus.WAITING, JobStatus.RUNNING):
412 return OCIRecipeBuildRegistryUploadStatus.PENDING
413 elif job.job.status == JobStatus.COMPLETED:
414 return OCIRecipeBuildRegistryUploadStatus.UPLOADED
415 else:
416 return OCIRecipeBuildRegistryUploadStatus.FAILEDTOUPLOAD
417
386418
387@implementer(IOCIRecipeBuildSet)419@implementer(IOCIRecipeBuildSet)
388class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):420class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py
index 7d8699c..c17ecf3 100644
--- a/lib/lp/oci/model/ocirecipebuildjob.py
+++ b/lib/lp/oci/model/ocirecipebuildjob.py
@@ -11,20 +11,33 @@ __all__ = [
11 'OCIRecipeBuildJobType',11 'OCIRecipeBuildJobType',
12 ]12 ]
1313
14
14from lazr.delegates import delegate_to15from lazr.delegates import delegate_to
15from lazr.enum import (16from lazr.enum import (
16 DBEnumeratedType,17 DBEnumeratedType,
17 DBItem,18 DBItem,
18 )19 )
20from lazr.lifecycle.event import ObjectCreatedEvent
19from storm.databases.postgres import JSON21from storm.databases.postgres import JSON
20from storm.locals import (22from storm.locals import (
21 Int,23 Int,
22 Reference,24 Reference,
23 )25 )
24from zope.interface import implementer26import transaction
27from zope.component import getUtility
28from zope.event import notify
29from zope.interface import (
30 implementer,
31 provider,
32 )
2533
26from lp.app.errors import NotFoundError34from lp.app.errors import NotFoundError
27from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob35from lp.oci.interfaces.ocirecipebuildjob import (
36 IOCIRecipeBuildJob,
37 IOCIRegistryUploadJob,
38 IOCIRegistryUploadJobSource,
39 )
40from lp.oci.interfaces.ociregistryclient import IOCIRegistryClient
28from lp.services.database.enumcol import DBEnum41from lp.services.database.enumcol import DBEnum
29from lp.services.database.interfaces import IStore42from lp.services.database.interfaces import IStore
30from lp.services.database.stormbase import StormBase43from lp.services.database.stormbase import StormBase
@@ -33,14 +46,13 @@ from lp.services.job.model.job import (
33 Job,46 Job,
34 )47 )
35from lp.services.job.runner import BaseRunnableJob48from lp.services.job.runner import BaseRunnableJob
49from lp.services.propertycache import get_property_cache
50from lp.services.webapp.snapshot import notify_modified
3651
3752
38class OCIRecipeBuildJobType(DBEnumeratedType):53class OCIRecipeBuildJobType(DBEnumeratedType):
39 """Values that `OCIBuildJobType.job_type` can take."""54 """Values that `OCIBuildJobType.job_type` can take."""
4055
41 # XXX twom (2020-04-02) This does not currently have a concrete
42 # implementation, awaiting registry upload.
43
44 REGISTRY_UPLOAD = DBItem(0, """56 REGISTRY_UPLOAD = DBItem(0, """
45 Registry upload57 Registry upload
4658
@@ -82,7 +94,7 @@ class OCIRecipeBuildJob(StormBase):
82 self.json_data = json_data94 self.json_data = json_data
8395
84 def makeDerived(self):96 def makeDerived(self):
85 return OCIRecipeBuildJob.makeSubclass(self)97 return OCIRecipeBuildJobDerived.makeSubclass(self)
8698
8799
88@delegate_to(IOCIRecipeBuildJob)100@delegate_to(IOCIRecipeBuildJob)
@@ -138,3 +150,101 @@ class OCIRecipeBuildJobDerived(BaseRunnableJob):
138 ('oci_project_name', self.context.build.recipe.oci_project.name)150 ('oci_project_name', self.context.build.recipe.oci_project.name)
139 ])151 ])
140 return oops_vars152 return oops_vars
153
154
155@implementer(IOCIRegistryUploadJob)
156@provider(IOCIRegistryUploadJobSource)
157class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
158
159 class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD
160
161 @classmethod
162 def create(cls, build):
163 """See `IOCIRegistryUploadJobSource`"""
164 oci_build_job = OCIRecipeBuildJob(
165 build, cls.class_job_type, {})
166 job = cls(oci_build_job)
167 job.celeryRunOnCommit()
168 del get_property_cache(build).last_registry_upload_job
169 notify(ObjectCreatedEvent(build))
170 return job
171
172 # Ideally we'd just override Job._set_status or similar, but
173 # lazr.delegates makes that difficult, so we use this to override all
174 # the individual Job lifecycle methods instead.
175 def _do_lifecycle(self, method_name, manage_transaction=False,
176 *args, **kwargs):
177 edited_fields = set()
178 with notify_modified(self.build, edited_fields) as before_modification:
179 getattr(super(OCIRegistryUploadJob, self), method_name)(
180 *args, manage_transaction=manage_transaction, **kwargs)
181 upload_status = self.build.registry_upload_status
182 if upload_status != before_modification.registry_upload_status:
183 edited_fields.add('registry_upload_status')
184 if edited_fields and manage_transaction:
185 transaction.commit()
186
187 def start(self, *args, **kwargs):
188 self._do_lifecycle("start", *args, **kwargs)
189
190 def complete(self, *args, **kwargs):
191 self._do_lifecycle("complete", *args, **kwargs)
192
193 def fail(self, *args, **kwargs):
194 self._do_lifecycle("fail", *args, **kwargs)
195
196 def queue(self, *args, **kwargs):
197 self._do_lifecycle("queue", *args, **kwargs)
198
199 def suspend(self, *args, **kwargs):
200 self._do_lifecycle("suspend", *args, **kwargs)
201
202 def resume(self, *args, **kwargs):
203 self._do_lifecycle("resume", *args, **kwargs)
204
205 @property
206 def error_message(self):
207 """See `IOCIRegistryUploadJob`."""
208 return self.json_data.get("error_message")
209
210 @error_message.setter
211 def error_message(self, message):
212 """See `IOCIRegistryUploadJob`."""
213 self.json_data["error_message"] = message
214
215 @property
216 def error_detail(self):
217 """See `IOCIRegistryUploadJob`."""
218 return self.json_data.get("error_detail")
219
220 @error_detail.setter
221 def error_detail(self, detail):
222 """See `IOCIRegistryUploadJob`."""
223 self.json_data["error_detail"] = detail
224
225 @property
226 def error_messages(self):
227 """See `IOCIRegistryUploadJob`."""
228 return self.json_data.get("error_messages")
229
230 @error_messages.setter
231 def error_messages(self, messages):
232 """See `IOCIRegistryUploadJob`."""
233 self.json_data["error_messages"] = messages
234
235 def run(self):
236 """See `IRunnableJob`."""
237 client = getUtility(IOCIRegistryClient)
238 # XXX twom 2020-04-16 This is taken from SnapStoreUploadJob
239 # it will need to gain retry support.
240 try:
241 try:
242 client.upload(self.build)
243 except Exception as e:
244 self.error_message = str(e)
245 self.error_messages = getattr(e, "messages", None)
246 self.error_detail = getattr(e, "detail", None)
247 raise
248 except Exception:
249 transaction.commit()
250 raise
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
141new file mode 100644251new file mode 100644
index 0000000..2152ab8
--- /dev/null
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -0,0 +1,252 @@
1# Copyright 2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Client for talking to an OCI registry."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9__all__ = [
10 'OCIRegistryClient'
11]
12
13
14import hashlib
15from io import BytesIO
16import json
17import logging
18import tarfile
19
20from requests.exceptions import HTTPError
21from zope.interface import implementer
22
23from lp.oci.interfaces.ociregistryclient import (
24 BlobUploadFailed,
25 IOCIRegistryClient,
26 ManifestUploadFailed,
27 )
28from lp.services.timeout import urlfetch
29
30
31log = logging.getLogger(__name__)
32
33
34@implementer(IOCIRegistryClient)
35class OCIRegistryClient:
36
37 @classmethod
38 def _getJSONfile(cls, reference):
39 """Read JSON out of a `LibraryFileAlias`."""
40 try:
41 reference.open()
42 return json.loads(reference.read())
43 finally:
44 reference.close()
45
46 @classmethod
47 def _upload(cls, digest, push_rule, name, fileobj):
48 """Upload a blob to the registry, using a given digest.
49
50 :param digest: The digest to store the file under.
51 :param push_rule: `OCIPushRule` to use for the URL and credentials.
52 :param name: Name of the image the blob is part of.
53 :param fileobj: An object that looks like a buffer.
54
55 :raises BlobUploadFailed: if the registry does not accept the blob.
56 """
57 # Check if it already exists
58 try:
59 head_response = urlfetch(
60 "{}/v2/{}/blobs/{}".format(
61 push_rule.registry_credentials.url, name, digest),
62 method="HEAD")
63 if head_response.status_code == 200:
64 log.info("{} already found".format(digest))
65 return
66 except HTTPError as http_error:
67 # A 404 is fine, we're about to upload the layer anyway
68 if http_error.response.status_code != 404:
69 raise http_error
70
71 post_response = urlfetch(
72 "{}/v2/{}/blobs/uploads/".format(
73 push_rule.registry_credentials.url, name),
74 method="POST")
75
76 post_location = post_response.headers["Location"]
77 query_parsed = {"digest": digest}
78
79 put_response = urlfetch(
80 post_location,
81 params=query_parsed,
82 data=fileobj,
83 method="PUT")
84
85 if put_response.status_code != 201:
86 raise BlobUploadFailed(
87 "Upload of {} for {} failed".format(digest, name))
88
89 @classmethod
90 def _upload_layer(cls, digest, push_rule, name, lfa):
91 """Upload a layer blob to the registry.
92
93 Uses _upload, but opens the LFA and extracts the necessary files
94 from the .tar.gz first.
95
96 :param digest: The digest to store the file under.
97 :param push_rule: `OCIPushRule` to use for the URL and credentials.
98 :param name: Name of the image the blob is part of.
99 :param lfa: The `LibraryFileAlias` for the layer.
100 """
101 lfa.open()
102 try:
103 un_zipped = tarfile.open(fileobj=lfa, mode='r|gz')
104 for tarinfo in un_zipped:
105 if tarinfo.name != 'layer.tar':
106 continue
107 fileobj = un_zipped.extractfile(tarinfo)
108 cls._upload(digest, push_rule, name, fileobj)
109 finally:
110 lfa.close()
111
112 @classmethod
113 def _build_registry_manifest(cls, digests, config, config_json,
114 config_sha, preloaded_data):
115 """Create an image manifest for the uploading image.
116
117 This involves nearly everything as digests and lengths are required.
118 This method creates a minimal manifest, some fields are missing.
119
120 :param digests: Dict of the various digests involved.
121 :param config: The contents of the manifest config file as a dict.
122 :param config_json: The config file as a JSON string.
123 :param config_sha: The sha256sum of the config JSON string.
124 """
125 # Create the initial manifest data with empty layer information
126 manifest = {
127 "schemaVersion": 2,
128 "mediaType":
129 "application/vnd.docker.distribution.manifest.v2+json",
130 "config": {
131 "mediaType": "application/vnd.docker.container.image.v1+json",
132 "size": len(config_json),
133 "digest": "sha256:{}".format(config_sha),
134 },
135 "layers": []}
136
137 # Fill in the layer information
138 for layer in config["rootfs"]["diff_ids"]:
139 manifest["layers"].append({
140 "mediaType":
141 "application/vnd.docker.image.rootfs.diff.tar.gzip",
142 "size": preloaded_data[layer].content.filesize,
143 "digest": layer})
144 return manifest
145
146 @classmethod
147 def _preloadFiles(cls, build, manifest, digests):
148 """Preload the data from the librarian to avoid multiple fetches
149 if there is more than one push rule for a build.
150
151 :param build: The referencing `OCIRecipeBuild`.
152 :param manifest: The manifest from the built image.
153 :param digests: Dict of the various digests involved.
154 """
155 data = {}
156 for section in manifest:
157 # Load the matching config file for this section
158 config = cls._getJSONfile(
159 build.getFileByName(section['Config']))
160 files = {"config_file": config}
161 for diff_id in config["rootfs"]["diff_ids"]:
162 # We may have already seen this diff ID.
163 if files.get(diff_id):
164 continue
165 # Retrieve the layer files.
166 # This doesn't read the content, so there is potential
167 # for multiple fetches, but the files can be arbitrary size
168 # Potentially gigabytes.
169 files[diff_id] = {}
170 source_digest = digests[diff_id]["digest"]
171 _, lfa, _ = build.getLayerFileByDigest(source_digest)
172 files[diff_id] = lfa
173 data[section["Config"]] = files
174 return data
175
176 @classmethod
177 def _calculateTag(cls, build, push_rule):
178 """Work out the base tag for the image should be.
179
180 :param build: `OCIRecipeBuild` representing this build.
181 :param push_rule: `OCIPushRule` that we are using.
182 """
183 # XXX twom 2020-04-17 This needs to include OCIProjectSeries and
184 # base image name
185
186 return "{}".format("edge")
187
188 @classmethod
189 def upload(cls, build):
190 """Upload the artifacts from an OCIRecipeBuild to a registry.
191
192 :param build: `OCIRecipeBuild` representing this build.
193 :raises ManifestUploadFailed: If the final registry manifest fails to
194 upload due to network or validity.
195 """
196 # Get the required metadata files
197 manifest = cls._getJSONfile(build.manifest)
198 digests_list = cls._getJSONfile(build.digests)
199 digests = {}
200 for digest_dict in digests_list:
201 digests.update(digest_dict)
202
203 # Preload the requested files
204 preloaded_data = cls._preloadFiles(build, manifest, digests)
205
206 for push_rule in build.recipe.push_rules:
207 for section in manifest:
208 # Work out names and tags
209 image_name = push_rule.image_name
210 tag = cls._calculateTag(build, push_rule)
211 file_data = preloaded_data[section["Config"]]
212 config = file_data["config_file"]
213 # Upload the layers involved
214 for diff_id in config["rootfs"]["diff_ids"]:
215 cls._upload_layer(
216 diff_id,
217 push_rule,
218 image_name,
219 file_data[diff_id])
220 # The config file is required in different forms, so we can
221 # calculate the sha, work these out and upload
222 config_json = json.dumps(config).encode("UTF-8")
223 config_sha = hashlib.sha256(config_json).hexdigest()
224 cls._upload(
225 "sha256:{}".format(config_sha),
226 push_rule,
227 image_name,
228 BytesIO(config_json))
229
230 # Build the registry manifest from the image manifest
231 # and associated configs
232 registry_manifest = cls._build_registry_manifest(
233 digests, config, config_json, config_sha,
234 preloaded_data[section["Config"]])
235
236 # Upload the registry manifest
237 manifest_response = urlfetch(
238 "{}/v2/{}/manifests/{}".format(
239 push_rule.registry_credentials.url,
240 image_name,
241 tag),
242 json=registry_manifest,
243 headers={
244 "Content-Type":
245 "application/"
246 "vnd.docker.distribution.manifest.v2+json"
247 },
248 method="PUT")
249 if manifest_response.status_code != 201:
250 raise ManifestUploadFailed(
251 "Failed to upload manifest for {} in {}".format(
252 build.recipe.name, build.id))
diff --git a/lib/lp/oci/model/ociregistrycredentials.py b/lib/lp/oci/model/ociregistrycredentials.py
index 2a70159..03b0f72 100644
--- a/lib/lp/oci/model/ociregistrycredentials.py
+++ b/lib/lp/oci/model/ociregistrycredentials.py
@@ -80,8 +80,8 @@ class OCIRegistryCredentials(Storm):
80 def getCredentials(self):80 def getCredentials(self):
81 container = getUtility(IEncryptedContainer, "oci-registry-secrets")81 container = getUtility(IEncryptedContainer, "oci-registry-secrets")
82 try:82 try:
83 return json.loads(container.decrypt((83 return json.loads(container.decrypt(
84 self._credentials['credentials_encrypted'])).decode("UTF-8"))84 self._credentials['credentials_encrypted']).decode("UTF-8"))
85 except CryptoError as e:85 except CryptoError as e:
86 # XXX twom 2020-03-18 This needs a better error86 # XXX twom 2020-03-18 This needs a better error
87 # see SnapStoreClient.UnauthorizedUploadResponse87 # see SnapStoreClient.UnauthorizedUploadResponse
diff --git a/lib/lp/oci/subscribers/ocirecipebuild.py b/lib/lp/oci/subscribers/ocirecipebuild.py
index 3e7f80d..2f3fc01 100644
--- a/lib/lp/oci/subscribers/ocirecipebuild.py
+++ b/lib/lp/oci/subscribers/ocirecipebuild.py
@@ -9,8 +9,10 @@ __metaclass__ = type
99
10from zope.component import getUtility10from zope.component import getUtility
1111
12from lp.buildmaster.enums import BuildStatus
12from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG13from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
13from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild14from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
15from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
14from lp.services.features import getFeatureFlag16from lp.services.features import getFeatureFlag
15from lp.services.webapp.publisher import canonical_url17from lp.services.webapp.publisher import canonical_url
16from lp.services.webhooks.interfaces import IWebhookSet18from lp.services.webhooks.interfaces import IWebhookSet
@@ -25,7 +27,7 @@ def _trigger_oci_recipe_build_webhook(build, action):
25 }27 }
26 payload.update(compose_webhook_payload(28 payload.update(compose_webhook_payload(
27 IOCIRecipeBuild, build,29 IOCIRecipeBuild, build,
28 ["recipe", "status"]))30 ["recipe", "status", "registry_upload_status"]))
29 getUtility(IWebhookSet).trigger(31 getUtility(IWebhookSet).trigger(
30 build.recipe, "oci-recipe:build:0.1", payload)32 build.recipe, "oci-recipe:build:0.1", payload)
3133
@@ -34,9 +36,14 @@ def oci_recipe_build_created(build, event):
34 """Trigger events when a new OCI recipe build is created."""36 """Trigger events when a new OCI recipe build is created."""
35 _trigger_oci_recipe_build_webhook(build, "created")37 _trigger_oci_recipe_build_webhook(build, "created")
3638
3739def oci_recipe_build_modified(build, event):
38def oci_recipe_build_status_changed(build, event):
39 """Trigger events when OCI recipe build statuses change."""40 """Trigger events when OCI recipe build statuses change."""
40 if event.edited_fields is not None:41 if event.edited_fields is not None:
41 if "status" in event.edited_fields:42 status_changed = "status" in event.edited_fields
43 registry_changed = "registry_upload_status" in event.edited_fields
44 if status_changed or registry_changed:
42 _trigger_oci_recipe_build_webhook(build, "status-changed")45 _trigger_oci_recipe_build_webhook(build, "status-changed")
46 if status_changed:
47 if (build.recipe.can_upload_to_registry and
48 build.status == BuildStatus.FULLYBUILT):
49 getUtility(IOCIRegistryUploadJobSource).create(build)
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index e158e2e..06ed382 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -127,6 +127,7 @@ class TestOCIRecipe(TestCaseWithFactory):
127 "action": Equals("created"),127 "action": Equals("created"),
128 "recipe": Equals(canonical_url(recipe, force_local_path=True)),128 "recipe": Equals(canonical_url(recipe, force_local_path=True)),
129 "status": Equals("Needs building"),129 "status": Equals("Needs building"),
130 'registry_upload_status': Equals('Unscheduled'),
130 }131 }
131 with person_logged_in(recipe.owner):132 with person_logged_in(recipe.owner):
132 delivery = hook.deliveries.one()133 delivery = hook.deliveries.one()
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 400d78d..0579a8b 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -182,6 +182,7 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
182 "recipe": Equals(182 "recipe": Equals(
183 canonical_url(self.build.recipe, force_local_path=True)),183 canonical_url(self.build.recipe, force_local_path=True)),
184 "status": Equals("Successfully built"),184 "status": Equals("Successfully built"),
185 'registry_upload_status': Equals("Unscheduled"),
185 }186 }
186 self.assertThat(187 self.assertThat(
187 logger.output, LogsScheduledWebhooks([188 logger.output, LogsScheduledWebhooks([
diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py
index cf85684..f4628e0 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildjob.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py
@@ -7,16 +7,65 @@ from __future__ import absolute_import, print_function, unicode_literals
77
8__metaclass__ = type8__metaclass__ = type
99
10from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE10from fixtures import FakeLogger
11from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob11from testtools.matchers import (
12 Equals,
13 MatchesDict,
14 MatchesListwise,
15 MatchesStructure,
16 )
17import transaction
18from zope.interface import implementer
19
20from lp.buildmaster.enums import BuildStatus
21from lp.oci.interfaces.ocirecipe import (
22 OCI_RECIPE_ALLOW_CREATE,
23 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
24 )
25from lp.oci.interfaces.ocirecipebuildjob import (
26 IOCIRecipeBuildJob,
27 IOCIRegistryUploadJob,
28 IOCIRegistryUploadJobSource,
29 )
30from lp.oci.interfaces.ociregistryclient import IOCIRegistryClient
12from lp.oci.model.ocirecipebuildjob import (31from lp.oci.model.ocirecipebuildjob import (
13 OCIRecipeBuildJob,32 OCIRecipeBuildJob,
14 OCIRecipeBuildJobDerived,33 OCIRecipeBuildJobDerived,
15 OCIRecipeBuildJobType,34 OCIRecipeBuildJobType,
35 OCIRegistryUploadJob,
16 )36 )
37from lp.services.config import config
17from lp.services.features.testing import FeatureFixture38from lp.services.features.testing import FeatureFixture
39from lp.services.job.runner import JobRunner
18from lp.testing import TestCaseWithFactory40from lp.testing import TestCaseWithFactory
19from lp.testing.layers import DatabaseFunctionalLayer41from lp.testing.dbuser import dbuser
42from lp.testing.fakemethod import FakeMethod
43from lp.testing.fixture import ZopeUtilityFixture
44from lp.testing.layers import (
45 DatabaseFunctionalLayer,
46 LaunchpadZopelessLayer,
47 )
48from lp.testing.mail_helpers import pop_notifications
49from lp.services.webapp import canonical_url
50from lp.services.webhooks.testing import LogsScheduledWebhooks
51
52
53def run_isolated_jobs(jobs):
54 """Run a sequence of jobs, ensuring transaction isolation.
55
56 We abort the transaction after each job to make sure that there is no
57 relevant uncommitted work.
58 """
59 for job in jobs:
60 JobRunner([job]).runAll()
61 transaction.abort()
62
63
64@implementer(IOCIRegistryClient)
65class FakeRegistryClient:
66
67 def __init__(self):
68 self.upload = FakeMethod()
2069
2170
22class FakeOCIBuildJob(OCIRecipeBuildJobDerived):71class FakeOCIBuildJob(OCIRecipeBuildJobDerived):
@@ -52,3 +101,104 @@ class TestOCIRecipeBuildJob(TestCaseWithFactory):
52 ('oci_project_name', oci_build.recipe.oci_project.name),101 ('oci_project_name', oci_build.recipe.oci_project.name),
53 ]102 ]
54 self.assertEqual(expected, oops)103 self.assertEqual(expected, oops)
104
105
106class TestOCIRegistryUploadJob(TestCaseWithFactory):
107
108 layer = LaunchpadZopelessLayer
109
110 def setUp(self):
111 super(TestOCIRegistryUploadJob, self).setUp()
112 self.useFixture(FeatureFixture({
113 'webhooks.new.enabled': 'true',
114 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: 'on',
115 OCI_RECIPE_ALLOW_CREATE: 'on'
116 }))
117
118 def makeOCIRecipeBuild(self, **kwargs):
119 ocibuild = self.factory.makeOCIRecipeBuild(
120 builder=self.factory.makeBuilder(), **kwargs)
121 ocibuild.updateStatus(BuildStatus.FULLYBUILT)
122 self.factory.makeWebhook(
123 target=ocibuild.recipe, event_types=["oci-recipe:build:0.1"])
124 return ocibuild
125
126 def assertWebhookDeliveries(self, ocibuild,
127 expected_registry_upload_statuses, logger):
128 hook = ocibuild.recipe.webhooks.one()
129 deliveries = list(hook.deliveries)
130 deliveries.reverse()
131 expected_payloads = [{
132 "recipe_build": Equals(
133 canonical_url(ocibuild, force_local_path=True)),
134 "action": Equals("created"),
135 "recipe": Equals(
136 canonical_url(ocibuild.recipe, force_local_path=True)),
137 "status": Equals("Successfully built"),
138 "registry_upload_status": Equals("Pending")}]
139 expected_payloads += [{
140 "recipe_build": Equals(
141 canonical_url(ocibuild, force_local_path=True)),
142 "action": Equals("status-changed"),
143 "recipe": Equals(
144 canonical_url(ocibuild.recipe, force_local_path=True)),
145 "status": Equals("Successfully built"),
146 "registry_upload_status": Equals(expected),
147 } for expected in expected_registry_upload_statuses]
148 matchers = [
149 MatchesStructure(
150 event_type=Equals("oci-recipe:build:0.1"),
151 payload=MatchesDict(expected_payload))
152 for expected_payload in expected_payloads]
153 self.assertThat(deliveries, MatchesListwise(matchers))
154 with dbuser(config.IWebhookDeliveryJobSource.dbuser):
155 for delivery in deliveries:
156 self.assertEqual(
157 "<WebhookDeliveryJob for webhook %d on %r>" % (
158 hook.id, hook.target),
159 repr(delivery))
160 self.assertThat(
161 logger.output, LogsScheduledWebhooks([
162 (hook, "oci-recipe:build:0.1", MatchesDict(
163 expected_payload))
164 for expected_payload in expected_payloads]))
165
166 def test_provides_interface(self):
167 # `OCIRegistryUploadJob` objects provide `IOCIRegistryUploadJob`.
168 ocibuild = self.factory.makeOCIRecipeBuild()
169 job = OCIRegistryUploadJob.create(ocibuild)
170 self.assertProvides(job, IOCIRegistryUploadJob)
171
172 def test_run(self):
173 logger = self.useFixture(FakeLogger())
174 ocibuild = self.makeOCIRecipeBuild()
175 self.assertContentEqual([], ocibuild.registry_upload_jobs)
176 job = OCIRegistryUploadJob.create(ocibuild)
177 client = FakeRegistryClient()
178 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
179 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
180 run_isolated_jobs([job])
181 self.assertEqual([((ocibuild,), {})], client.upload.calls)
182 self.assertContentEqual([job], ocibuild.registry_upload_jobs)
183 self.assertIsNone(job.error_message)
184 self.assertEqual([], pop_notifications())
185 self.assertWebhookDeliveries(
186 ocibuild, ["Uploaded"], logger)
187
188 def test_run_failed(self):
189 # A failed run sets the registry upload status to FAILED.
190 logger = self.useFixture(FakeLogger())
191 ocibuild = self.makeOCIRecipeBuild()
192 self.assertContentEqual([], ocibuild.registry_upload_jobs)
193 job = OCIRegistryUploadJob.create(ocibuild)
194 client = FakeRegistryClient()
195 client.upload.failure = ValueError("An upload failure")
196 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
197 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
198 run_isolated_jobs([job])
199 self.assertEqual([((ocibuild,), {})], client.upload.calls)
200 self.assertContentEqual([job], ocibuild.registry_upload_jobs)
201 self.assertEqual("An upload failure", job.error_message)
202 self.assertEqual([], pop_notifications())
203 self.assertWebhookDeliveries(
204 ocibuild, ["Failed to upload"], logger)
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
55new file mode 100644205new file mode 100644
index 0000000..5f83e38
--- /dev/null
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -0,0 +1,204 @@
1# Copyright 2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for the OCI Registry client."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10import json
11
12from fixtures import MockPatch
13from requests.exceptions import HTTPError
14import responses
15from testtools.matchers import (
16 Equals,
17 MatchesDict,
18 MatchesListwise,
19 )
20import transaction
21
22from lp.oci.model.ociregistryclient import OCIRegistryClient
23from lp.oci.tests.helpers import OCIConfigHelperMixin
24from lp.testing import TestCaseWithFactory
25from lp.testing.layers import LaunchpadZopelessLayer
26
27
28class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
29
30 layer = LaunchpadZopelessLayer
31
32 def setUp(self):
33 super(TestOCIRegistryClient, self).setUp()
34 self.setConfig()
35 self.manifest = [{
36 "Config": "config_file_1.json",
37 "Layers": ["layer_1/layer.tar", "layer_2/layer.tar"]}]
38 self.digests = [{
39 "diff_id_1": {
40 "digest": "digest_1",
41 "source": "test/base_1",
42 "layer_id": "layer_1"
43 },
44 "diff_id_2": {
45 "digest": "digest_2",
46 "source": "",
47 "layer_id": "layer_2"
48 }
49 }]
50 self.config = {"rootfs": {"diff_ids": ["diff_id_1", "diff_id_2"]}}
51 self.build = self.factory.makeOCIRecipeBuild()
52 self.factory.makeOCIPushRule(recipe=self.build.recipe)
53 self.client = OCIRegistryClient()
54
55 def _makeFiles(self):
56 self.factory.makeOCIFile(
57 build=self.build,
58 content=json.dumps(self.manifest),
59 filename='manifest.json',
60 )
61 self.factory.makeOCIFile(
62 build=self.build,
63 content=json.dumps(self.digests),
64 filename='digests.json',
65 )
66 self.factory.makeOCIFile(
67 build=self.build,
68 content=json.dumps(self.config),
69 filename='config_file_1.json'
70 )
71
72 # make layer files
73 self.layer_1_file = self.factory.makeOCIFile(
74 build=self.build,
75 content="digest_1",
76 filename="digest_1_filename",
77 layer_file_digest="digest_1"
78 )
79 self.layer_2_file = self.factory.makeOCIFile(
80 build=self.build,
81 content="digest_2",
82 filename="digest_2_filename",
83 layer_file_digest="digest_2"
84 )
85
86 transaction.commit()
87
88 @responses.activate
89 def test_upload(self):
90 self._makeFiles()
91 self.useFixture(MockPatch(
92 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
93 self.useFixture(MockPatch(
94 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))
95
96 manifests_url = "{}/v2/{}/manifests/edge".format(
97 self.build.recipe.push_rules[0].registry_credentials.url,
98 self.build.recipe.push_rules[0].image_name
99 )
100 responses.add("PUT", manifests_url, status=201)
101 self.client.upload(self.build)
102
103 request = json.loads(responses.calls[0].request.body)
104
105 self.assertThat(request, MatchesDict({
106 "layers": MatchesListwise([
107 MatchesDict({
108 "mediaType": Equals(
109 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
110 "digest": Equals("diff_id_1"),
111 "size": Equals(8)}),
112 MatchesDict({
113 "mediaType": Equals(
114 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
115 "digest": Equals("diff_id_2"),
116 "size": Equals(8)})
117 ]),
118 "schemaVersion": Equals(2),
119 "config": MatchesDict({
120 "mediaType": Equals(
121 "application/vnd.docker.container.image.v1+json"),
122 "digest": Equals(
123 "sha256:33b69b4b6e106f9fc7a8b93409"
124 "36c85cf7f84b2d017e7b55bee6ab214761f6ab"),
125 "size": Equals(52)
126 }),
127 "mediaType": Equals(
128 "application/vnd.docker.distribution.manifest.v2+json")
129 }))
130
131 def test_preloadFiles(self):
132 self._makeFiles()
133 files = self.client._preloadFiles(
134 self.build, self.manifest, self.digests[0])
135
136 self.assertThat(files, MatchesDict({
137 'config_file_1.json': MatchesDict({
138 'config_file': Equals(self.config),
139 'diff_id_1': Equals(self.layer_1_file.library_file),
140 'diff_id_2': Equals(self.layer_2_file.library_file)})}))
141
142 def test_calculateTag(self):
143 result = self.client._calculateTag(
144 self.build, self.build.recipe.push_rules[0])
145 self.assertEqual("edge", result)
146
147 def test_build_registry_manifest(self):
148 self._makeFiles()
149 preloaded_data = self.client._preloadFiles(
150 self.build, self.manifest, self.digests[0])
151 manifest = self.client._build_registry_manifest(
152 self.digests[0],
153 self.config,
154 json.dumps(self.config),
155 "config-sha",
156 preloaded_data["config_file_1.json"])
157 self.assertThat(manifest, MatchesDict({
158 "layers": MatchesListwise([
159 MatchesDict({
160 "mediaType": Equals(
161 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
162 "digest": Equals("diff_id_1"),
163 "size": Equals(8)}),
164 MatchesDict({
165 "mediaType": Equals(
166 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
167 "digest": Equals("diff_id_2"),
168 "size": Equals(8)})
169 ]),
170 "schemaVersion": Equals(2),
171 "config": MatchesDict({
172 "mediaType": Equals(
173 "application/vnd.docker.container.image.v1+json"),
174 "digest": Equals("sha256:config-sha"),
175 "size": Equals(52)
176 }),
177 "mediaType": Equals(
178 "application/vnd.docker.distribution.manifest.v2+json")
179 }))
180
181 @responses.activate
182 def test_upload_handles_existing(self):
183 blobs_url = "{}/v2/{}/blobs/{}".format(
184 self.build.recipe.push_rules[0].registry_credentials.url,
185 "test-name",
186 "test-digest")
187 responses.add("HEAD", blobs_url, status=200)
188 self.client._upload(
189 "test-digest", self.build.recipe.push_rules[0], "test-name", None)
190
191 @responses.activate
192 def test_upload_raises_non_404(self):
193 blobs_url = "{}/v2/{}/blobs/{}".format(
194 self.build.recipe.push_rules[0].registry_credentials.url,
195 "test-name",
196 "test-digest")
197 responses.add("HEAD", blobs_url, status=500)
198 self.assertRaises(
199 HTTPError,
200 self.client._upload,
201 "test-digest",
202 self.build.recipe.push_rules[0],
203 "test-name",
204 None)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 18b452b..ec65b96 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1821,6 +1821,7 @@ job_sources:
1821 IExpiringMembershipNotificationJobSource,1821 IExpiringMembershipNotificationJobSource,
1822 IGitRepositoryModifiedMailJobSource,1822 IGitRepositoryModifiedMailJobSource,
1823 IMembershipNotificationJobSource,1823 IMembershipNotificationJobSource,
1824 IOCIRegistryUploadJobSource,
1824 IPackageUploadNotificationJobSource,1825 IPackageUploadNotificationJobSource,
1825 IPersonDeactivateJobSource,1826 IPersonDeactivateJobSource,
1826 IPersonMergeJobSource,1827 IPersonMergeJobSource,
@@ -1984,6 +1985,11 @@ module: lp.snappy.interfaces.snapbuildjob
1984dbuser: snap-build-job1985dbuser: snap-build-job
1985crontab_group: MAIN1986crontab_group: MAIN
19861987
1988[IOCIRegistryUploadJobSource]
1989module: lp.oci.interfaces.ocirecipebuildjob
1990dbuser: oci-build-job
1991crontab_group: MAIN
1992
1987[ITeamInvitationNotificationJobSource]1993[ITeamInvitationNotificationJobSource]
1988module: lp.registry.interfaces.persontransferjob1994module: lp.registry.interfaces.persontransferjob
1989dbuser: person-transfer-job1995dbuser: person-transfer-job

Subscribers

People subscribed via source and target branches

to status/vote changes: