Merge ~pappacena/launchpad:oci-multi-arch-upload into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: caa19b04584a4e979701275cbd70ead4ed4334d8
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:oci-multi-arch-upload
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:oci-unify-request-builds
Diff against target: 1057 lines (+607/-56)
10 files modified
database/schema/security.cfg (+1/-0)
lib/lp/oci/interfaces/ocirecipe.py (+10/-0)
lib/lp/oci/interfaces/ocirecipejob.py (+12/-0)
lib/lp/oci/interfaces/ociregistryclient.py (+3/-0)
lib/lp/oci/model/ocirecipe.py (+16/-1)
lib/lp/oci/model/ocirecipebuildjob.py (+117/-3)
lib/lp/oci/model/ocirecipejob.py (+12/-0)
lib/lp/oci/model/ociregistryclient.py (+92/-23)
lib/lp/oci/tests/test_ocirecipebuildjob.py (+249/-5)
lib/lp/oci/tests/test_ociregistryclient.py (+95/-24)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+391783@code.launchpad.net

Commit message

Upload multi-arch OCI images

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) :
6c75d70... by Thiago F. Pappacena

Merge branch 'master' into oci-multi-arch-upload

Revision history for this message
Colin Watson (cjwatson) wrote :

I can't effectively review the actual manifest handling - it's probably best to ask Tom to check that over.

review: Needs Fixing
2870200... by Thiago F. Pappacena

Code style, typo and better code documentation

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Replied to the comments about `allBuildsUploaded` method. I would like your opinion on that while I work on the job retry details.

Revision history for this message
Thiago F. Pappacena (pappacena) :
08c1c68... by Thiago F. Pappacena

Refactoring and adding tests for concurrent oci img upload jobs check

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Added retry strategy and some tests to ensure that we are using database locks the way we expect, to queue up the check for manifest list upload.

4bad06d... by Thiago F. Pappacena

Merge branch 'master' into oci-multi-arch-upload

8b6e704... by Thiago F. Pappacena

Fixing exception catching in test

15622d5... by Thiago F. Pappacena

Fixing _upload_layer mock return value in tests

Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
bd79d6e... by Thiago F. Pappacena

Checking also final result on concurrent OCIRecipeUploadJob

caa19b0... by Thiago F. Pappacena

Refactoring

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Addressed all comments. As suggested by twom, I'll test it with microk8s before merging it.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Tested with microk8s and it seems to be ok.

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 8a9a31b..d5a23f1 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2710,6 +2710,7 @@ public.account = SELECT
2710public.archive = SELECT2710public.archive = SELECT
2711public.branch = SELECT2711public.branch = SELECT
2712public.builder = SELECT2712public.builder = SELECT
2713public.builderprocessor = SELECT
2713public.buildfarmjob = SELECT, INSERT2714public.buildfarmjob = SELECT, INSERT
2714public.buildqueue = SELECT, INSERT, UPDATE2715public.buildqueue = SELECT, INSERT, UPDATE
2715public.distribution = SELECT2716public.distribution = SELECT
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 0bd3cd2..131c4d3 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -178,6 +178,16 @@ class IOCIRecipeBuildRequest(Interface):
178 title=_("If set, limit builds to these architecture tags."),178 title=_("If set, limit builds to these architecture tags."),
179 value_type=TextLine(), required=False, readonly=True)179 value_type=TextLine(), required=False, readonly=True)
180180
181 uploaded_manifests = Dict(
182 title=_("A dict of manifest information per build."),
183 key_type=Int(), value_type=Dict(),
184 required=False, readonly=True)
185
186 def addUploadedManifest(build_id, manifest_info):
187 """Add the manifest information for one of the builds in this
188 BuildRequest.
189 """
190
181191
182class IOCIRecipeView(Interface):192class IOCIRecipeView(Interface):
183 """`IOCIRecipe` attributes that require launchpad.View permission."""193 """`IOCIRecipe` attributes that require launchpad.View permission."""
diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py
index 104a2ff..2b59faf 100644
--- a/lib/lp/oci/interfaces/ocirecipejob.py
+++ b/lib/lp/oci/interfaces/ocirecipejob.py
@@ -19,6 +19,8 @@ from zope.interface import (
19 )19 )
20from zope.schema import (20from zope.schema import (
21 Datetime,21 Datetime,
22 Dict,
23 Int,
22 List,24 List,
23 TextLine,25 TextLine,
24 )26 )
@@ -78,6 +80,16 @@ class IOCIRecipeRequestBuildsJob(IRunnableJob):
78 error_message = TextLine(80 error_message = TextLine(
79 title=_("Error message"), required=False, readonly=True)81 title=_("Error message"), required=False, readonly=True)
8082
83 uploaded_manifests = Dict(
84 title=_("A dict of manifest information per build."),
85 key_type=Int(), value_type=Dict(),
86 required=False, readonly=True)
87
88 def addUploadedManifest(build_id, manifest_info):
89 """Add the manifest information for one of the builds in this
90 BuildRequest.
91 """
92
8193
82class IOCIRecipeRequestBuildsJobSource(IJobSource):94class IOCIRecipeRequestBuildsJobSource(IJobSource):
8395
diff --git a/lib/lp/oci/interfaces/ociregistryclient.py b/lib/lp/oci/interfaces/ociregistryclient.py
index 184a93f..71af23a 100644
--- a/lib/lp/oci/interfaces/ociregistryclient.py
+++ b/lib/lp/oci/interfaces/ociregistryclient.py
@@ -54,3 +54,6 @@ class IOCIRegistryClient(Interface):
5454
55 :param build: The `IOCIRecipeBuild` to upload.55 :param build: The `IOCIRecipeBuild` to upload.
56 """56 """
57
58 def uploadManifestList(build_request):
59 """Upload the "fat manifest" which aggregates all platforms built."""
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index fd21dde..ab7f655 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -38,7 +38,10 @@ from zope.component import (
38from zope.event import notify38from zope.event import notify
39from zope.interface import implementer39from zope.interface import implementer
40from zope.security.interfaces import Unauthorized40from zope.security.interfaces import Unauthorized
41from zope.security.proxy import removeSecurityProxy41from zope.security.proxy import (
42 isinstance as zope_isinstance,
43 removeSecurityProxy,
44 )
4245
43from lp.app.interfaces.launchpad import ILaunchpadCelebrities46from lp.app.interfaces.launchpad import ILaunchpadCelebrities
44from lp.app.interfaces.security import IAuthorization47from lp.app.interfaces.security import IAuthorization
@@ -697,6 +700,13 @@ class OCIRecipeBuildRequest:
697 return self.job.date_finished700 return self.job.date_finished
698701
699 @property702 @property
703 def uploaded_manifests(self):
704 return self.job.uploaded_manifests
705
706 def addUploadedManifest(self, build_id, manifest_info):
707 self.job.addUploadedManifest(build_id, manifest_info)
708
709 @property
700 def status(self):710 def status(self):
701 status_map = {711 status_map = {
702 JobStatus.WAITING: OCIRecipeBuildRequestStatus.PENDING,712 JobStatus.WAITING: OCIRecipeBuildRequestStatus.PENDING,
@@ -714,3 +724,8 @@ class OCIRecipeBuildRequest:
714 @property724 @property
715 def builds(self):725 def builds(self):
716 return self.job.builds726 return self.job.builds
727
728 def __eq__(self, other):
729 if not zope_isinstance(other, self.__class__):
730 return False
731 return self.id == other.id
diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py
index 73fa4d6..991b9a4 100644
--- a/lib/lp/oci/model/ocirecipebuildjob.py
+++ b/lib/lp/oci/model/ocirecipebuildjob.py
@@ -11,6 +11,8 @@ __all__ = [
11 'OCIRecipeBuildJobType',11 'OCIRecipeBuildJobType',
12 ]12 ]
1313
14from datetime import timedelta
15import random
1416
15from lazr.delegates import delegate_to17from lazr.delegates import delegate_to
16from lazr.enum import (18from lazr.enum import (
@@ -41,8 +43,12 @@ from lp.oci.interfaces.ociregistryclient import (
41 OCIRegistryError,43 OCIRegistryError,
42 )44 )
43from lp.services.database.enumcol import DBEnum45from lp.services.database.enumcol import DBEnum
44from lp.services.database.interfaces import IStore46from lp.services.database.interfaces import (
47 IMasterStore,
48 IStore,
49 )
45from lp.services.database.stormbase import StormBase50from lp.services.database.stormbase import StormBase
51from lp.services.job.interfaces.job import JobStatus
46from lp.services.job.model.job import (52from lp.services.job.model.job import (
47 EnumeratedSubclass,53 EnumeratedSubclass,
48 Job,54 Job,
@@ -156,16 +162,34 @@ class OCIRecipeBuildJobDerived(
156@implementer(IOCIRegistryUploadJob)162@implementer(IOCIRegistryUploadJob)
157@provider(IOCIRegistryUploadJobSource)163@provider(IOCIRegistryUploadJobSource)
158class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):164class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
165 """Manages the OCI image upload to registries.
166
167 This job coordinates with other OCIRegistryUploadJob in a way that the
168 last job uploading its layers and manifest will also upload the
169 manifest list with all the previously built OCI images uploaded from
170 other architectures in the same build request. To avoid race conditions,
171 we synchronize that using a SELECT ... FOR UPDATE at database level to
172 make sure that the status is consistent across all the upload jobs.
173 """
159174
160 class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD175 class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD
161176
177 class ManifestListUploadError(Exception):
178 pass
179
180 retry_error_types = (ManifestListUploadError, )
181 max_retries = 5
182
162 @classmethod183 @classmethod
163 def create(cls, build):184 def create(cls, build):
164 """See `IOCIRegistryUploadJobSource`"""185 """See `IOCIRegistryUploadJobSource`"""
165 edited_fields = set()186 edited_fields = set()
166 with notify_modified(build, edited_fields) as before_modification:187 with notify_modified(build, edited_fields) as before_modification:
188 json_data = {
189 "build_uploaded": False,
190 }
167 oci_build_job = OCIRecipeBuildJob(191 oci_build_job = OCIRecipeBuildJob(
168 build, cls.class_job_type, {})192 build, cls.class_job_type, json_data)
169 job = cls(oci_build_job)193 job = cls(oci_build_job)
170 job.celeryRunOnCommit()194 job.celeryRunOnCommit()
171 del get_property_cache(build).last_registry_upload_job195 del get_property_cache(build).last_registry_upload_job
@@ -174,6 +198,14 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
174 edited_fields.add("registry_upload_status")198 edited_fields.add("registry_upload_status")
175 return job199 return job
176200
201 @property
202 def retry_delay(self):
203 dithering_secs = int(random.random() * 60)
204 # Adds some random seconds between 0 and 60 to minimize the
205 # likelihood of synchronized retries holding locks on the database
206 # at the same time.
207 return timedelta(minutes=10, seconds=dithering_secs)
208
177 # Ideally we'd just override Job._set_status or similar, but209 # Ideally we'd just override Job._set_status or similar, but
178 # lazr.delegates makes that difficult, so we use this to override all210 # lazr.delegates makes that difficult, so we use this to override all
179 # the individual Job lifecycle methods instead.211 # the individual Job lifecycle methods instead.
@@ -227,6 +259,74 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
227 """See `IOCIRegistryUploadJob`."""259 """See `IOCIRegistryUploadJob`."""
228 self.json_data["errors"] = errors260 self.json_data["errors"] = errors
229261
262 @property
263 def build_uploaded(self):
264 return self.json_data.get("build_uploaded", False)
265
266 @build_uploaded.setter
267 def build_uploaded(self, value):
268 self.json_data["build_uploaded"] = bool(value)
269
270 def allBuildsUploaded(self, build_request):
271 """Returns True if all builds of the given build_request already
272 finished uploading. False otherwise.
273
274 Note that this method locks all upload jobs at database level,
275 preventing them from updating their status until the end of the
276 current transaction. Use it with caution.
277 """
278 builds = list(build_request.builds)
279 uploads_per_build = {i: list(i.registry_upload_jobs) for i in builds}
280 upload_jobs = sum(uploads_per_build.values(), [])
281
282 # Lock the Job rows, so no other job updates its status until the
283 # end of this job's transaction. This is done to avoid race conditions,
284 # where 2 upload jobs could be running simultaneously and none of them
285 # realises that is the last upload.
286 # Note also that new upload jobs might be created between the
287 # transaction begin and this lock takes place, but in this case the
288 # new upload is either a retry from a failed upload, or the first
289 # upload for one of the existing builds. Either way, we would see that
290 # build as "not uploaded yet", which is ok for this method, and the
291 # new job will block until these locks are released, so we should be
292 # safe.
293 store = IMasterStore(builds[0])
294 placeholders = ', '.join('?' for _ in upload_jobs)
295 sql = (
296 "SELECT id, status FROM job WHERE id IN (%s) FOR UPDATE"
297 % placeholders)
298 job_status = {
299 job_id: JobStatus.items[status] for job_id, status in
300 store.execute(sql, [i.job_id for i in upload_jobs])}
301
302 for build, upload_jobs in uploads_per_build.items():
303 has_finished_upload = any(
304 job_status[i.job_id] == JobStatus.COMPLETED
305 or i.job_id == self.job_id
306 for i in upload_jobs)
307 if not has_finished_upload:
308 return False
309 return True
310
311 def uploadManifestList(self, client):
312 """Uploads the aggregated manifest list for all builds in the
313 current build request.
314 """
315 # The "allBuildsUploaded" call will lock, on the database,
316 # all upload jobs for update until this transaction finishes.
317 # So, make sure this is the last thing being done by this job.
318 build_request = self.build.build_request
319 if not build_request:
320 return
321 try:
322 if self.allBuildsUploaded(build_request):
323 client.uploadManifestList(build_request)
324 except OCIRegistryError:
325 # Do not retry automatically on known OCI registry errors.
326 raise
327 except Exception as e:
328 raise self.ManifestListUploadError(str(e))
329
230 def run(self):330 def run(self):
231 """See `IRunnableJob`."""331 """See `IRunnableJob`."""
232 client = getUtility(IOCIRegistryClient)332 client = getUtility(IOCIRegistryClient)
@@ -234,7 +334,21 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived):
234 # it will need to gain retry support.334 # it will need to gain retry support.
235 try:335 try:
236 try:336 try:
237 client.upload(self.build)337 if not self.build_uploaded:
338 client.upload(self.build)
339 self.build_uploaded = True
340
341 self.uploadManifestList(client)
342 # Force this job status to COMPLETED in the same transaction we
343 # called `allBuildsUpdated` (in the uploadManifestList call
344 # above) to release the lock already including the new status.
345 # This way, any other transaction that was blocked waiting to
346 # get the info about the upload jobs will immediately have the
347 # new status of this job, avoiding race conditions. Keep the
348 # `manage_transaction=False` to prevent the method from
349 # commiting at the wrong moment.
350 self.complete(manage_transaction=False)
351
238 except OCIRegistryError as e:352 except OCIRegistryError as e:
239 self.error_summary = str(e)353 self.error_summary = str(e)
240 self.errors = e.errors354 self.errors = e.errors
diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py
index 15eac4f..3320cff 100644
--- a/lib/lp/oci/model/ocirecipejob.py
+++ b/lib/lp/oci/model/ocirecipejob.py
@@ -169,6 +169,8 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
169 "requester": requester.id,169 "requester": requester.id,
170 "architectures": (170 "architectures": (
171 list(architectures) if architectures is not None else None),171 list(architectures) if architectures is not None else None),
172 # A dict of build_id: manifest location
173 "uploaded_manifests": {}
172 }174 }
173 recipe_job = OCIRecipeJob(recipe, cls.class_job_type, metadata)175 recipe_job = OCIRecipeJob(recipe, cls.class_job_type, metadata)
174 job = cls(recipe_job)176 job = cls(recipe_job)
@@ -258,6 +260,16 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
258 architectures = self.metadata["architectures"]260 architectures = self.metadata["architectures"]
259 return set(architectures) if architectures is not None else None261 return set(architectures) if architectures is not None else None
260262
263 @property
264 def uploaded_manifests(self):
265 return {
266 # Converts keys to integer since saving json to database
267 # converts them to strings.
268 int(k): v for k, v in self.metadata["uploaded_manifests"].items()}
269
270 def addUploadedManifest(self, build_id, manifest_info):
271 self.metadata["uploaded_manifests"][int(build_id)] = manifest_info
272
261 def run(self):273 def run(self):
262 """See `IRunnableJob`."""274 """See `IRunnableJob`."""
263 requester = self.requester275 requester = self.requester
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 7561267..7c67b57 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -229,17 +229,59 @@ class OCIRegistryClient:
229 """229 """
230 # XXX twom 2020-04-17 This needs to include OCIProjectSeries and230 # XXX twom 2020-04-17 This needs to include OCIProjectSeries and
231 # base image name231 # base image name
232
233 return "{}".format("edge")232 return "{}".format("edge")
234233
235 @classmethod234 @classmethod
235 def _uploadRegistryManifest(cls, http_client, registry_manifest,
236 push_rule, build=None):
237 """Uploads the build manifest, returning its content information.
238
239 The returned information can be used to create a Manifest list
240 including the uploaded manifest, for example, in order to create
241 multi-architecture images.
242
243 :return: A dict with {"digest": "sha256:xxx", "size": total_bytes}
244 """
245 digest = None
246 data = json.dumps(registry_manifest)
247 size = len(data)
248 content_type = registry_manifest.get(
249 "mediaType",
250 "application/vnd.docker.distribution.manifest.v2+json")
251 if build is None:
252 # When uploading a manifest list, use the tag.
253 tag = cls._calculateTag(build, push_rule)
254 else:
255 # When uploading individual build manifests, use their digest.
256 tag = "sha256:%s" % hashlib.sha256(data).hexdigest()
257 try:
258 manifest_response = http_client.requestPath(
259 "/manifests/{}".format(tag),
260 data=data,
261 headers={"Content-Type": content_type},
262 method="PUT")
263 digest = manifest_response.headers.get("Docker-Content-Digest")
264 except HTTPError as http_error:
265 manifest_response = http_error.response
266 if manifest_response.status_code != 201:
267 if build:
268 msg = "Failed to upload manifest for {} ({}) in {}".format(
269 build.recipe.name, push_rule.registry_url, build.id)
270 else:
271 msg = ("Failed to upload manifest of manifests for"
272 " {} ({})").format(
273 push_rule.recipe.name, push_rule.registry_url)
274 raise cls._makeRegistryError(
275 ManifestUploadFailed, msg, manifest_response)
276 return {"digest": digest, "size": size}
277
278 @classmethod
236 def _upload_to_push_rule(279 def _upload_to_push_rule(
237 cls, push_rule, build, manifest, digests, preloaded_data):280 cls, push_rule, build, manifest, digests, preloaded_data):
238 http_client = RegistryHTTPClient.getInstance(push_rule)281 http_client = RegistryHTTPClient.getInstance(push_rule)
239282
240 for section in manifest:283 for section in manifest:
241 # Work out names and tags284 # Work out names
242 tag = cls._calculateTag(build, push_rule)
243 file_data = preloaded_data[section["Config"]]285 file_data = preloaded_data[section["Config"]]
244 config = file_data["config_file"]286 config = file_data["config_file"]
245 # Upload the layers involved287 # Upload the layers involved
@@ -269,24 +311,13 @@ class OCIRegistryClient:
269 layer_sizes)311 layer_sizes)
270312
271 # Upload the registry manifest313 # Upload the registry manifest
272 try:314 manifest = cls._uploadRegistryManifest(
273 manifest_response = http_client.requestPath(315 http_client, registry_manifest, push_rule, build)
274 "/manifests/{}".format(tag),316
275 json=registry_manifest,317 # Save the uploaded manifest location, so we can use it in case
276 headers={318 # this is a multi-arch image upload.
277 "Content-Type":319 if build.build_request:
278 "application/"320 build.build_request.addUploadedManifest(build.id, manifest)
279 "vnd.docker.distribution.manifest.v2+json"
280 },
281 method="PUT")
282 except HTTPError as http_error:
283 manifest_response = http_error.response
284 if manifest_response.status_code != 201:
285 raise cls._makeRegistryError(
286 ManifestUploadFailed,
287 "Failed to upload manifest for {} ({}) in {}".format(
288 build.recipe.name, push_rule.registry_url, build.id),
289 manifest_response)
290321
291 @classmethod322 @classmethod
292 def upload(cls, build):323 def upload(cls, build):
@@ -318,6 +349,43 @@ class OCIRegistryClient:
318 elif len(exceptions) > 1:349 elif len(exceptions) > 1:
319 raise MultipleOCIRegistryError(exceptions)350 raise MultipleOCIRegistryError(exceptions)
320351
352 @classmethod
353 def makeMultiArchManifest(cls, build_request):
354 """Returns the multi-arch manifest content including all builds of
355 the given build_request.
356 """
357 manifests = []
358 for build in build_request.builds:
359 build_manifest = build_request.uploaded_manifests.get(build.id)
360 if not build_manifest:
361 continue
362 digest = build_manifest["digest"]
363 size = build_manifest["size"]
364 arch = build.processor.name
365 manifests.append({
366 "mediaType": ("application/"
367 "vnd.docker.distribution.manifest.v2+json"),
368 "size": size,
369 "digest": digest,
370 "platform": {"architecture": arch, "os": "linux"}
371 })
372 return {
373 "schemaVersion": 2,
374 "mediaType": ("application/"
375 "vnd.docker.distribution.manifest.list.v2+json"),
376 "manifests": manifests}
377
378 @classmethod
379 def uploadManifestList(cls, build_request):
380 """Uploads to all build_request.recipe.push_rules the manifest list
381 for the builds in the given build_request.
382 """
383 multi_manifest_content = cls.makeMultiArchManifest(build_request)
384 for push_rule in build_request.recipe.push_rules:
385 http_client = RegistryHTTPClient.getInstance(push_rule)
386 cls._uploadRegistryManifest(
387 http_client, multi_manifest_content, push_rule, build=None)
388
321389
322class OCIRegistryAuthenticationError(Exception):390class OCIRegistryAuthenticationError(Exception):
323 def __init__(self, msg, http_error=None):391 def __init__(self, msg, http_error=None):
@@ -436,8 +504,8 @@ class BearerTokenRegistryClient(RegistryHTTPClient):
436504
437 :param auth_retry: Should we authenticate and retry the request if505 :param auth_retry: Should we authenticate and retry the request if
438 it fails with HTTP 401 code?"""506 it fails with HTTP 401 code?"""
507 headers = request_kwargs.pop("headers", {})
439 try:508 try:
440 headers = request_kwargs.pop("headers", {})
441 if self.auth_token is not None:509 if self.auth_token is not None:
442 headers["Authorization"] = "Bearer %s" % self.auth_token510 headers["Authorization"] = "Bearer %s" % self.auth_token
443 return proxy_urlfetch(url, headers=headers, **request_kwargs)511 return proxy_urlfetch(url, headers=headers, **request_kwargs)
@@ -445,5 +513,6 @@ class BearerTokenRegistryClient(RegistryHTTPClient):
445 if auth_retry and e.response.status_code == 401:513 if auth_retry and e.response.status_code == 401:
446 self.authenticate(e.response)514 self.authenticate(e.response)
447 return self.request(515 return self.request(
448 url, auth_retry=False, *args, **request_kwargs)516 url, auth_retry=False, headers=headers,
517 *args, **request_kwargs)
449 raise518 raise
diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py
index fd3e0ff..7b66c1d 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildjob.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py
@@ -7,6 +7,13 @@ from __future__ import absolute_import, print_function, unicode_literals
77
8__metaclass__ = type8__metaclass__ = type
99
10from datetime import (
11 datetime,
12 timedelta,
13 )
14import threading
15import time
16
10from fixtures import FakeLogger17from fixtures import FakeLogger
11from testtools.matchers import (18from testtools.matchers import (
12 Equals,19 Equals,
@@ -14,11 +21,15 @@ from testtools.matchers import (
14 MatchesDict,21 MatchesDict,
15 MatchesListwise,22 MatchesListwise,
16 MatchesStructure,23 MatchesStructure,
24 Not,
17 )25 )
18import transaction26import transaction
27from zope.component import getUtility
19from zope.interface import implementer28from zope.interface import implementer
29from zope.security.proxy import removeSecurityProxy
2030
21from lp.buildmaster.enums import BuildStatus31from lp.buildmaster.enums import BuildStatus
32from lp.buildmaster.interfaces.processor import IProcessorSet
22from lp.oci.interfaces.ocirecipe import (33from lp.oci.interfaces.ocirecipe import (
23 OCI_RECIPE_ALLOW_CREATE,34 OCI_RECIPE_ALLOW_CREATE,
24 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,35 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -27,6 +38,7 @@ from lp.oci.interfaces.ocirecipebuildjob import (
27 IOCIRecipeBuildJob,38 IOCIRecipeBuildJob,
28 IOCIRegistryUploadJob,39 IOCIRegistryUploadJob,
29 )40 )
41from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
30from lp.oci.interfaces.ociregistryclient import (42from lp.oci.interfaces.ociregistryclient import (
31 BlobUploadFailed,43 BlobUploadFailed,
32 IOCIRegistryClient,44 IOCIRegistryClient,
@@ -37,12 +49,17 @@ from lp.oci.model.ocirecipebuildjob import (
37 OCIRecipeBuildJobType,49 OCIRecipeBuildJobType,
38 OCIRegistryUploadJob,50 OCIRegistryUploadJob,
39 )51 )
52from lp.services.compat import mock
40from lp.services.config import config53from lp.services.config import config
41from lp.services.features.testing import FeatureFixture54from lp.services.features.testing import FeatureFixture
55from lp.services.job.interfaces.job import JobStatus
42from lp.services.job.runner import JobRunner56from lp.services.job.runner import JobRunner
43from lp.services.webapp import canonical_url57from lp.services.webapp import canonical_url
44from lp.services.webhooks.testing import LogsScheduledWebhooks58from lp.services.webhooks.testing import LogsScheduledWebhooks
45from lp.testing import TestCaseWithFactory59from lp.testing import (
60 admin_logged_in,
61 TestCaseWithFactory,
62 )
46from lp.testing.dbuser import dbuser63from lp.testing.dbuser import dbuser
47from lp.testing.fakemethod import FakeMethod64from lp.testing.fakemethod import FakeMethod
48from lp.testing.fixture import ZopeUtilityFixture65from lp.testing.fixture import ZopeUtilityFixture
@@ -69,6 +86,7 @@ class FakeRegistryClient:
6986
70 def __init__(self):87 def __init__(self):
71 self.upload = FakeMethod()88 self.upload = FakeMethod()
89 self.uploadManifestList = FakeMethod()
7290
7391
74class FakeOCIBuildJob(OCIRecipeBuildJobDerived):92class FakeOCIBuildJob(OCIRecipeBuildJobDerived):
@@ -122,22 +140,28 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory):
122 ocibuild = self.factory.makeOCIRecipeBuild(140 ocibuild = self.factory.makeOCIRecipeBuild(
123 builder=self.factory.makeBuilder(), **kwargs)141 builder=self.factory.makeBuilder(), **kwargs)
124 ocibuild.updateStatus(BuildStatus.FULLYBUILT)142 ocibuild.updateStatus(BuildStatus.FULLYBUILT)
125 self.factory.makeWebhook(143 self.makeWebhook(ocibuild.recipe)
126 target=ocibuild.recipe, event_types=["oci-recipe:build:0.1"])
127 return ocibuild144 return ocibuild
128145
146 def makeWebhook(self, recipe):
147 self.factory.makeWebhook(
148 target=recipe, event_types=["oci-recipe:build:0.1"])
149
129 def assertWebhookDeliveries(self, ocibuild,150 def assertWebhookDeliveries(self, ocibuild,
130 expected_registry_upload_statuses, logger):151 expected_registry_upload_statuses, logger):
131 hook = ocibuild.recipe.webhooks.one()152 hook = ocibuild.recipe.webhooks.one()
132 deliveries = list(hook.deliveries)153 deliveries = list(hook.deliveries)
133 deliveries.reverse()154 deliveries.reverse()
155 build_req_url = (
156 None if ocibuild.build_request is None
157 else canonical_url(ocibuild.build_request, force_local_path=True))
134 expected_payloads = [{158 expected_payloads = [{
135 "recipe_build": Equals(159 "recipe_build": Equals(
136 canonical_url(ocibuild, force_local_path=True)),160 canonical_url(ocibuild, force_local_path=True)),
137 "action": Equals("status-changed"),161 "action": Equals("status-changed"),
138 "recipe": Equals(162 "recipe": Equals(
139 canonical_url(ocibuild.recipe, force_local_path=True)),163 canonical_url(ocibuild.recipe, force_local_path=True)),
140 "build_request": Is(None),164 "build_request": Equals(build_req_url),
141 "status": Equals("Successfully built"),165 "status": Equals("Successfully built"),
142 "registry_upload_status": Equals(expected),166 "registry_upload_status": Equals(expected),
143 } for expected in expected_registry_upload_statuses]167 } for expected in expected_registry_upload_statuses]
@@ -165,22 +189,242 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory):
165 job = OCIRegistryUploadJob.create(ocibuild)189 job = OCIRegistryUploadJob.create(ocibuild)
166 self.assertProvides(job, IOCIRegistryUploadJob)190 self.assertProvides(job, IOCIRegistryUploadJob)
167191
192 def makeRecipe(self, include_i386=True, include_amd64=True):
193 i386 = getUtility(IProcessorSet).getByName("386")
194 amd64 = getUtility(IProcessorSet).getByName("amd64")
195 recipe = self.factory.makeOCIRecipe()
196 distroseries = self.factory.makeDistroSeries(
197 distribution=recipe.oci_project.distribution)
198 distro_i386 = self.factory.makeDistroArchSeries(
199 distroseries=distroseries, architecturetag="i386",
200 processor=i386)
201 distro_i386.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
202 distro_amd64 = self.factory.makeDistroArchSeries(
203 distroseries=distroseries, architecturetag="amd64",
204 processor=amd64)
205 distro_amd64.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
206
207 archs = []
208 if include_i386:
209 archs.append(i386)
210 if include_amd64:
211 archs.append(amd64)
212 recipe.setProcessors(archs)
213 return recipe
214
215 def makeBuildRequest(self, include_i386=True, include_amd64=True):
216 recipe = self.makeRecipe(include_i386, include_amd64)
217 # Creates a build request with a build in it.
218 build_request = recipe.requestBuilds(recipe.owner)
219 with admin_logged_in():
220 jobs = getUtility(IOCIRecipeRequestBuildsJobSource).iterReady()
221 with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser):
222 JobRunner(jobs).runAll()
223 return build_request
224
168 def test_run(self):225 def test_run(self):
169 logger = self.useFixture(FakeLogger())226 logger = self.useFixture(FakeLogger())
170 ocibuild = self.makeOCIRecipeBuild()227 build_request = self.makeBuildRequest(include_i386=False)
228 recipe = build_request.recipe
229
230 self.assertEqual(1, build_request.builds.count())
231 ocibuild = build_request.builds[0]
232 ocibuild.updateStatus(BuildStatus.FULLYBUILT)
233 self.makeWebhook(recipe)
234
171 self.assertContentEqual([], ocibuild.registry_upload_jobs)235 self.assertContentEqual([], ocibuild.registry_upload_jobs)
172 job = OCIRegistryUploadJob.create(ocibuild)236 job = OCIRegistryUploadJob.create(ocibuild)
173 client = FakeRegistryClient()237 client = FakeRegistryClient()
174 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))238 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
175 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):239 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
176 run_isolated_jobs([job])240 run_isolated_jobs([job])
241
177 self.assertEqual([((ocibuild,), {})], client.upload.calls)242 self.assertEqual([((ocibuild,), {})], client.upload.calls)
243 self.assertEqual([((build_request,), {})],
244 client.uploadManifestList.calls)
178 self.assertContentEqual([job], ocibuild.registry_upload_jobs)245 self.assertContentEqual([job], ocibuild.registry_upload_jobs)
179 self.assertIsNone(job.error_summary)246 self.assertIsNone(job.error_summary)
180 self.assertIsNone(job.errors)247 self.assertIsNone(job.errors)
181 self.assertEqual([], pop_notifications())248 self.assertEqual([], pop_notifications())
182 self.assertWebhookDeliveries(ocibuild, ["Pending", "Uploaded"], logger)249 self.assertWebhookDeliveries(ocibuild, ["Pending", "Uploaded"], logger)
183250
251 def test_run_multiple_architectures(self):
252 build_request = self.makeBuildRequest()
253 builds = build_request.builds
254 self.assertEqual(2, builds.count())
255 self.assertEqual(builds[0].build_request, builds[1].build_request)
256
257 upload_jobs = []
258 for build in builds:
259 self.assertContentEqual([], build.registry_upload_jobs)
260 upload_jobs.append(OCIRegistryUploadJob.create(build))
261
262 client = FakeRegistryClient()
263 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
264
265 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
266 JobRunner([upload_jobs[0]]).runAll()
267 self.assertEqual([((builds[0],), {})], client.upload.calls)
268 self.assertEqual([], client.uploadManifestList.calls)
269
270 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
271 JobRunner([upload_jobs[1]]).runAll()
272 self.assertEqual(
273 [((builds[0],), {}), ((builds[1],), {})], client.upload.calls)
274 self.assertEqual([((builds[1].build_request, ), {})],
275 client.uploadManifestList.calls)
276
277 def test_failing_upload_does_not_retries_automatically(self):
278 build_request = self.makeBuildRequest(include_i386=False)
279 builds = build_request.builds
280 self.assertEqual(1, builds.count())
281
282 build = builds.one()
283 self.assertContentEqual([], build.registry_upload_jobs)
284 upload_job = OCIRegistryUploadJob.create(build)
285
286 client = mock.Mock()
287 client.upload.side_effect = Exception("Nope! Error.")
288 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
289
290 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
291 JobRunner([upload_job]).runAll()
292 self.assertEqual(1, client.upload.call_count)
293 self.assertEqual(0, client.uploadManifestList.call_count)
294 self.assertEqual(JobStatus.FAILED, upload_job.status)
295 self.assertFalse(upload_job.build_uploaded)
296
297 def test_failing_upload_manifest_list_retries(self):
298 build_request = self.makeBuildRequest(include_i386=False)
299 builds = build_request.builds
300 self.assertEqual(1, builds.count())
301
302 build = builds.one()
303 self.assertContentEqual([], build.registry_upload_jobs)
304 upload_job = OCIRegistryUploadJob.create(build)
305
306 client = mock.Mock()
307 client.uploadManifestList.side_effect = (
308 OCIRegistryUploadJob.ManifestListUploadError("Nope! Error."))
309 self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient))
310
311 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
312 JobRunner([upload_job]).runAll()
313 self.assertEqual(1, client.upload.call_count)
314 self.assertEqual(1, client.uploadManifestList.call_count)
315 self.assertEqual(JobStatus.WAITING, upload_job.status)
316 self.assertTrue(upload_job.is_pending)
317 self.assertTrue(upload_job.build_uploaded)
318
319 # Retry should skip client.upload and only run
320 # client.uploadManifestList:
321 client.uploadManifestList.side_effect = None
322 with dbuser(config.IOCIRegistryUploadJobSource.dbuser):
323 JobRunner([upload_job]).runAll()
324 self.assertEqual(1, client.upload.call_count)
325 self.assertEqual(2, client.uploadManifestList.call_count)
326 self.assertEqual(JobStatus.COMPLETED, upload_job.status)
327 self.assertTrue(upload_job.build_uploaded)
328
329 def test_allBuildsUploaded_lock_between_two_jobs(self):
330 """Simple test to ensure that allBuildsUploaded method locks
331 rows in the database and make concurrent calls wait for that.
332
333 This is not a 100% reliable way to check that concurrent calls to
334 allBuildsUploaded will queue up since it relies on the
335 execution time, but it's a "good enough" approach: this test might
336 pass if the machine running it is *really, really* slow, but a failure
337 here will indicate that something is for sure wrong.
338 """
339
340 class AllBuildsUploadedChecker(threading.Thread):
341 """Thread to run upload_job.allBuildsUploaded tracking the time."""
342 def __init__(self, build_request):
343 super(AllBuildsUploadedChecker, self).__init__()
344 self.build_request = build_request
345 self.upload_job = None
346 # Locks the measurement start until we finished running the
347 # bootstrap code. Parent thread should call waitBootstrap
348 # after self.start().
349 self.bootstrap_lock = threading.Lock()
350 self.bootstrap_lock.acquire()
351 self.result = None
352 self.error = None
353 self.start_date = None
354 self.end_date = None
355
356 @property
357 def lock_duration(self):
358 return self.end_date - self.start_date
359
360 def waitBootstrap(self):
361 """Wait until self.bootstrap finishes running."""
362 self.bootstrap_lock.acquire()
363 # We don't actually need the lock... just wanted to wait
364 # for it. let's release it then.
365 self.bootstrap_lock.release()
366
367 def bootstrap(self):
368 try:
369 build = self.build_request.builds[1]
370 self.upload_job = OCIRegistryUploadJob.create(build)
371 finally:
372 self.bootstrap_lock.release()
373
374 def run(self):
375 with admin_logged_in():
376 self.bootstrap()
377 self.start_date = datetime.now()
378 try:
379 self.result = self.upload_job.allBuildsUploaded(
380 self.build_request)
381 except Exception as e:
382 self.error = e
383 self.end_date = datetime.now()
384
385 # Create a build request with 2 builds.
386 build_request = self.makeBuildRequest()
387 builds = build_request.builds
388 self.assertEqual(2, builds.count())
389
390 # Create the upload job for the first build.
391 upload_job1 = OCIRegistryUploadJob.create(builds[0])
392 upload_job1 = removeSecurityProxy(upload_job1)
393
394 # How long the lock will be held by the first job, in seconds.
395 # Adjust to minimize false positives: a number too small here might
396 # make the test pass even if the lock is not correctly implemented.
397 # A number too big will slow down the test execution...
398 waiting_time = 2
399 # Start a clean transaction and lock the rows at database level.
400 transaction.commit()
401 self.assertFalse(upload_job1.allBuildsUploaded(build_request))
402
403 # Start, in parallel, another upload job to run `allBuildsUploaded`.
404 concurrent_checker = AllBuildsUploadedChecker(build_request)
405 concurrent_checker.start()
406 # Wait until concurrent_checker is ready to measure the time waiting
407 # for the database lock.
408 concurrent_checker.waitBootstrap()
409
410 # Wait a bit and release the database lock by committing current
411 # transaction.
412 time.sleep(waiting_time)
413 # Let's force the first job to be finished, just to make sure the
414 # second job will realise it's the last one running.
415 upload_job1.start()
416 upload_job1.complete()
417 transaction.commit()
418
419 # Now, the concurrent checker should have already finished running,
420 # without any error and it should have taken at least the
421 # waiting_time to finish running (since it was waiting).
422 concurrent_checker.join()
423 self.assertIsNone(concurrent_checker.error)
424 self.assertTrue(concurrent_checker.result)
425 self.assertGreaterEqual(
426 concurrent_checker.lock_duration, timedelta(seconds=waiting_time))
427
184 def test_run_failed_registry_error(self):428 def test_run_failed_registry_error(self):
185 # A run that fails with a registry error sets the registry upload429 # A run that fails with a registry error sets the registry upload
186 # status to FAILED, and stores the detailed errors.430 # status to FAILED, and stores the detailed errors.
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index 3c5aa8c..8de88b4 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -11,6 +11,7 @@ from functools import partial
11import io11import io
12import json12import json
13import os13import os
14import re
14import tarfile15import tarfile
15import uuid16import uuid
1617
@@ -33,13 +34,17 @@ from testtools.matchers import (
33 Raises,34 Raises,
34 )35 )
35import transaction36import transaction
37from zope.component import getUtility
36from zope.security.proxy import removeSecurityProxy38from zope.security.proxy import removeSecurityProxy
3739
40from lp.buildmaster.interfaces.processor import IProcessorSet
41from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
38from lp.oci.interfaces.ociregistryclient import (42from lp.oci.interfaces.ociregistryclient import (
39 BlobUploadFailed,43 BlobUploadFailed,
40 ManifestUploadFailed,44 ManifestUploadFailed,
41 MultipleOCIRegistryError,45 MultipleOCIRegistryError,
42 )46 )
47from lp.oci.model.ocirecipe import OCIRecipeBuildRequest
43from lp.oci.model.ociregistryclient import (48from lp.oci.model.ociregistryclient import (
44 BearerTokenRegistryClient,49 BearerTokenRegistryClient,
45 OCIRegistryAuthenticationError,50 OCIRegistryAuthenticationError,
@@ -50,6 +55,7 @@ from lp.oci.model.ociregistryclient import (
50from lp.oci.tests.helpers import OCIConfigHelperMixin55from lp.oci.tests.helpers import OCIConfigHelperMixin
51from lp.services.compat import mock56from lp.services.compat import mock
52from lp.testing import TestCaseWithFactory57from lp.testing import TestCaseWithFactory
58from lp.testing.fixture import ZopeUtilityFixture
53from lp.testing.layers import (59from lp.testing.layers import (
54 DatabaseFunctionalLayer,60 DatabaseFunctionalLayer,
55 LaunchpadZopelessLayer,61 LaunchpadZopelessLayer,
@@ -142,6 +148,23 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
142148
143 transaction.commit()149 transaction.commit()
144150
151 def addManifestResponses(self, push_rule, status_code=201, json=None):
152 """Add responses for manifest upload URLs."""
153 # PUT to "anonymous" architecture-specific manifest.
154 manifests_url = "{}/v2/{}/manifests/sha256:.*".format(
155 push_rule.registry_credentials.url,
156 push_rule.image_name
157 )
158 responses.add(
159 "PUT", re.compile(manifests_url), status=status_code, json=json)
160
161 # PUT to tagged multi-arch manifest.
162 manifests_url = "{}/v2/{}/manifests/edge".format(
163 push_rule.registry_credentials.url,
164 push_rule.image_name
165 )
166 responses.add("PUT", manifests_url, status=status_code, json=json)
167
145 @responses.activate168 @responses.activate
146 def test_upload(self):169 def test_upload(self):
147 self._makeFiles()170 self._makeFiles()
@@ -154,11 +177,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
154 push_rule = self.build.recipe.push_rules[0]177 push_rule = self.build.recipe.push_rules[0]
155 responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)178 responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
156179
157 manifests_url = "{}/v2/{}/manifests/edge".format(180 self.addManifestResponses(push_rule)
158 push_rule.registry_credentials.url,
159 push_rule.image_name
160 )
161 responses.add("PUT", manifests_url, status=201)
162181
163 self.client.upload(self.build)182 self.client.upload(self.build)
164183
@@ -196,7 +215,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
196 _upload_fixture = self.useFixture(MockPatch(215 _upload_fixture = self.useFixture(MockPatch(
197 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))216 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
198 self.useFixture(MockPatch(217 self.useFixture(MockPatch(
199 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))218 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
219 return_value=999))
200220
201 self.push_rule.registry_credentials.setCredentials({221 self.push_rule.registry_credentials.setCredentials({
202 "username": "test-username",222 "username": "test-username",
@@ -206,9 +226,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
206 push_rule = self.build.recipe.push_rules[0]226 push_rule = self.build.recipe.push_rules[0]
207 responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)227 responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
208228
209 manifests_url = "{}/v2/{}/manifests/edge".format(229 self.addManifestResponses(push_rule)
210 push_rule.registry_credentials.url, push_rule.image_name)
211 responses.add("PUT", manifests_url, status=201)
212230
213 self.client.upload(self.build)231 self.client.upload(self.build)
214232
@@ -222,7 +240,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
222 upload_fixture = self.useFixture(MockPatch(240 upload_fixture = self.useFixture(MockPatch(
223 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))241 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
224 self.useFixture(MockPatch(242 self.useFixture(MockPatch(
225 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))243 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
244 return_value=999))
226245
227 push_rules = [246 push_rules = [
228 self.push_rule,247 self.push_rule,
@@ -237,10 +256,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
237 responses.add(256 responses.add(
238 "GET", "%s/v2/" % push_rule.registry_url, status=200)257 "GET", "%s/v2/" % push_rule.registry_url, status=200)
239258
240 manifests_url = "{}/v2/{}/manifests/edge".format(
241 push_rule.registry_credentials.url, push_rule.image_name)
242 status = 400 if i < 2 else 201259 status = 400 if i < 2 else 201
243 responses.add("PUT", manifests_url, status=status)260 self.addManifestResponses(push_rule, status_code=status)
244261
245 error = self.assertRaises(262 error = self.assertRaises(
246 MultipleOCIRegistryError, self.client.upload, self.build)263 MultipleOCIRegistryError, self.client.upload, self.build)
@@ -466,15 +483,13 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
466 self.useFixture(MockPatch(483 self.useFixture(MockPatch(
467 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))484 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
468 self.useFixture(MockPatch(485 self.useFixture(MockPatch(
469 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))486 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
487 return_value=999))
470488
471 push_rule = self.build.recipe.push_rules[0]489 push_rule = self.build.recipe.push_rules[0]
472 responses.add(490 responses.add(
473 "GET", "{}/v2/".format(push_rule.registry_url), status=200)491 "GET", "{}/v2/".format(push_rule.registry_url), status=200)
474492
475 manifests_url = "{}/v2/{}/manifests/edge".format(
476 push_rule.registry_credentials.url,
477 push_rule.image_name)
478 put_errors = [493 put_errors = [
479 {494 {
480 "code": "MANIFEST_INVALID",495 "code": "MANIFEST_INVALID",
@@ -482,8 +497,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
482 "detail": [],497 "detail": [],
483 },498 },
484 ]499 ]
485 responses.add(500 self.addManifestResponses(
486 "PUT", manifests_url, status=400, json={"errors": put_errors})501 push_rule, status_code=400, json={"errors": put_errors})
487502
488 expected_msg = "Failed to upload manifest for {} ({}) in {}".format(503 expected_msg = "Failed to upload manifest for {} ({}) in {}".format(
489 self.build.recipe.name, self.push_rule.registry_url, self.build.id)504 self.build.recipe.name, self.push_rule.registry_url, self.build.id)
@@ -503,16 +518,14 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
503 self.useFixture(MockPatch(518 self.useFixture(MockPatch(
504 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))519 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
505 self.useFixture(MockPatch(520 self.useFixture(MockPatch(
506 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer"))521 "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
522 return_value=999))
507523
508 push_rule = self.build.recipe.push_rules[0]524 push_rule = self.build.recipe.push_rules[0]
509 responses.add(525 responses.add(
510 "GET", "{}/v2/".format(push_rule.registry_url), status=200)526 "GET", "{}/v2/".format(push_rule.registry_url), status=200)
511527
512 manifests_url = "{}/v2/{}/manifests/edge".format(528 self.addManifestResponses(push_rule, status_code=200)
513 push_rule.registry_credentials.url,
514 push_rule.image_name)
515 responses.add("PUT", manifests_url, status=200)
516529
517 expected_msg = "Failed to upload manifest for {} ({}) in {}".format(530 expected_msg = "Failed to upload manifest for {} ({}) in {}".format(
518 self.build.recipe.name, self.push_rule.registry_url, self.build.id)531 self.build.recipe.name, self.push_rule.registry_url, self.build.id)
@@ -526,6 +539,64 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
526 Equals(expected_msg)),539 Equals(expected_msg)),
527 MatchesStructure(errors=Is(None))))))540 MatchesStructure(errors=Is(None))))))
528541
542 @responses.activate
543 def test_multi_arch_manifest_upload(self):
544 """Ensure that multi-arch manifest upload works and tags correctly
545 the uploaded image."""
546 # Creates a build request with 2 builds.
547 recipe = self.build.recipe
548 build1 = self.build
549 build2 = self.factory.makeOCIRecipeBuild(
550 recipe=recipe)
551 naked_build1 = removeSecurityProxy(build1)
552 naked_build2 = removeSecurityProxy(build1)
553 naked_build1.processor = getUtility(IProcessorSet).getByName('386')
554 naked_build2.processor = getUtility(IProcessorSet).getByName('amd64')
555
556 # Creates a mock IOCIRecipeRequestBuildsJobSource, as it was created
557 # by the celery job and triggered the 2 registry uploads already.
558 job = mock.Mock()
559 job.builds = [build1, build2]
560 job.uploaded_manifests = {
561 build1.id: {"digest": "build1digest", "size": 123},
562 build2.id: {"digest": "build2digest", "size": 321},
563 }
564 job_source = mock.Mock()
565 job_source.getByOCIRecipeAndID.return_value = job
566 self.useFixture(
567 ZopeUtilityFixture(job_source, IOCIRecipeRequestBuildsJobSource))
568 build_request = OCIRecipeBuildRequest(recipe, -1)
569
570 push_rule = self.build.recipe.push_rules[0]
571 responses.add(
572 "GET", "{}/v2/".format(push_rule.registry_url), status=200)
573 self.addManifestResponses(push_rule, status_code=201)
574
575 self.client.uploadManifestList(build_request)
576 self.assertEqual(2, len(responses.calls))
577 auth_call, manifest_call = responses.calls
578 self.assertEndsWith(
579 manifest_call.request.url,
580 "/v2/%s/manifests/edge" % push_rule.image_name)
581 self.assertEqual({
582 "schemaVersion": 2,
583 "mediaType": "application/"
584 "vnd.docker.distribution.manifest.list.v2+json",
585 "manifests": [{
586 "platform": {"os": "linux", "architecture": "amd64"},
587 "mediaType": "application/"
588 "vnd.docker.distribution.manifest.v2+json",
589 "digest": "build1digest",
590 "size": 123
591 }, {
592 "platform": {"os": "linux", "architecture": "386"},
593 "mediaType": "application/"
594 "vnd.docker.distribution.manifest.v2+json",
595 "digest": "build2digest",
596 "size": 321
597 }]
598 }, json.loads(manifest_call.request.body))
599
529600
530class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin,601class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
531 TestCaseWithFactory):602 TestCaseWithFactory):