Merge ~pappacena/launchpad:oci-multi-arch-upload into launchpad:master
- Git
- lp:~pappacena/launchpad
- oci-multi-arch-upload
- Merge into master
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) |
Related bugs: |
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
Description of the change
Thiago F. Pappacena (pappacena) : | # |
- 6c75d70... by Thiago F. Pappacena
-
Merge branch 'master' into oci-multi-
arch-upload
- 2870200... by Thiago F. Pappacena
-
Code style, typo and better code documentation
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.
Thiago F. Pappacena (pappacena) : | # |
- 08c1c68... by Thiago F. Pappacena
-
Refactoring and adding tests for concurrent oci img upload jobs check
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
Colin Watson (cjwatson) : | # |
Colin Watson (cjwatson) : | # |
- bd79d6e... by Thiago F. Pappacena
-
Checking also final result on concurrent OCIRecipeUploadJob
- caa19b0... by Thiago F. Pappacena
-
Refactoring
Thiago F. Pappacena (pappacena) wrote : | # |
Addressed all comments. As suggested by twom, I'll test it with microk8s before merging it.
Thiago F. Pappacena (pappacena) wrote : | # |
Tested with microk8s and it seems to be ok.
Preview Diff
1 | diff --git a/database/schema/security.cfg b/database/schema/security.cfg | |||
2 | index 8a9a31b..d5a23f1 100644 | |||
3 | --- a/database/schema/security.cfg | |||
4 | +++ b/database/schema/security.cfg | |||
5 | @@ -2710,6 +2710,7 @@ public.account = SELECT | |||
6 | 2710 | public.archive = SELECT | 2710 | public.archive = SELECT |
7 | 2711 | public.branch = SELECT | 2711 | public.branch = SELECT |
8 | 2712 | public.builder = SELECT | 2712 | public.builder = SELECT |
9 | 2713 | public.builderprocessor = SELECT | ||
10 | 2713 | public.buildfarmjob = SELECT, INSERT | 2714 | public.buildfarmjob = SELECT, INSERT |
11 | 2714 | public.buildqueue = SELECT, INSERT, UPDATE | 2715 | public.buildqueue = SELECT, INSERT, UPDATE |
12 | 2715 | public.distribution = SELECT | 2716 | public.distribution = SELECT |
13 | diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py | |||
14 | index 0bd3cd2..131c4d3 100644 | |||
15 | --- a/lib/lp/oci/interfaces/ocirecipe.py | |||
16 | +++ b/lib/lp/oci/interfaces/ocirecipe.py | |||
17 | @@ -178,6 +178,16 @@ class IOCIRecipeBuildRequest(Interface): | |||
18 | 178 | title=_("If set, limit builds to these architecture tags."), | 178 | title=_("If set, limit builds to these architecture tags."), |
19 | 179 | value_type=TextLine(), required=False, readonly=True) | 179 | value_type=TextLine(), required=False, readonly=True) |
20 | 180 | 180 | ||
21 | 181 | uploaded_manifests = Dict( | ||
22 | 182 | title=_("A dict of manifest information per build."), | ||
23 | 183 | key_type=Int(), value_type=Dict(), | ||
24 | 184 | required=False, readonly=True) | ||
25 | 185 | |||
26 | 186 | def addUploadedManifest(build_id, manifest_info): | ||
27 | 187 | """Add the manifest information for one of the builds in this | ||
28 | 188 | BuildRequest. | ||
29 | 189 | """ | ||
30 | 190 | |||
31 | 181 | 191 | ||
32 | 182 | class IOCIRecipeView(Interface): | 192 | class IOCIRecipeView(Interface): |
33 | 183 | """`IOCIRecipe` attributes that require launchpad.View permission.""" | 193 | """`IOCIRecipe` attributes that require launchpad.View permission.""" |
34 | diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py | |||
35 | index 104a2ff..2b59faf 100644 | |||
36 | --- a/lib/lp/oci/interfaces/ocirecipejob.py | |||
37 | +++ b/lib/lp/oci/interfaces/ocirecipejob.py | |||
38 | @@ -19,6 +19,8 @@ from zope.interface import ( | |||
39 | 19 | ) | 19 | ) |
40 | 20 | from zope.schema import ( | 20 | from zope.schema import ( |
41 | 21 | Datetime, | 21 | Datetime, |
42 | 22 | Dict, | ||
43 | 23 | Int, | ||
44 | 22 | List, | 24 | List, |
45 | 23 | TextLine, | 25 | TextLine, |
46 | 24 | ) | 26 | ) |
47 | @@ -78,6 +80,16 @@ class IOCIRecipeRequestBuildsJob(IRunnableJob): | |||
48 | 78 | error_message = TextLine( | 80 | error_message = TextLine( |
49 | 79 | title=_("Error message"), required=False, readonly=True) | 81 | title=_("Error message"), required=False, readonly=True) |
50 | 80 | 82 | ||
51 | 83 | uploaded_manifests = Dict( | ||
52 | 84 | title=_("A dict of manifest information per build."), | ||
53 | 85 | key_type=Int(), value_type=Dict(), | ||
54 | 86 | required=False, readonly=True) | ||
55 | 87 | |||
56 | 88 | def addUploadedManifest(build_id, manifest_info): | ||
57 | 89 | """Add the manifest information for one of the builds in this | ||
58 | 90 | BuildRequest. | ||
59 | 91 | """ | ||
60 | 92 | |||
61 | 81 | 93 | ||
62 | 82 | class IOCIRecipeRequestBuildsJobSource(IJobSource): | 94 | class IOCIRecipeRequestBuildsJobSource(IJobSource): |
63 | 83 | 95 | ||
64 | diff --git a/lib/lp/oci/interfaces/ociregistryclient.py b/lib/lp/oci/interfaces/ociregistryclient.py | |||
65 | index 184a93f..71af23a 100644 | |||
66 | --- a/lib/lp/oci/interfaces/ociregistryclient.py | |||
67 | +++ b/lib/lp/oci/interfaces/ociregistryclient.py | |||
68 | @@ -54,3 +54,6 @@ class IOCIRegistryClient(Interface): | |||
69 | 54 | 54 | ||
70 | 55 | :param build: The `IOCIRecipeBuild` to upload. | 55 | :param build: The `IOCIRecipeBuild` to upload. |
71 | 56 | """ | 56 | """ |
72 | 57 | |||
73 | 58 | def uploadManifestList(build_request): | ||
74 | 59 | """Upload the "fat manifest" which aggregates all platforms built.""" | ||
75 | diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py | |||
76 | index fd21dde..ab7f655 100644 | |||
77 | --- a/lib/lp/oci/model/ocirecipe.py | |||
78 | +++ b/lib/lp/oci/model/ocirecipe.py | |||
79 | @@ -38,7 +38,10 @@ from zope.component import ( | |||
80 | 38 | from zope.event import notify | 38 | from zope.event import notify |
81 | 39 | from zope.interface import implementer | 39 | from zope.interface import implementer |
82 | 40 | from zope.security.interfaces import Unauthorized | 40 | from zope.security.interfaces import Unauthorized |
84 | 41 | from zope.security.proxy import removeSecurityProxy | 41 | from zope.security.proxy import ( |
85 | 42 | isinstance as zope_isinstance, | ||
86 | 43 | removeSecurityProxy, | ||
87 | 44 | ) | ||
88 | 42 | 45 | ||
89 | 43 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities | 46 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
90 | 44 | from lp.app.interfaces.security import IAuthorization | 47 | from lp.app.interfaces.security import IAuthorization |
91 | @@ -697,6 +700,13 @@ class OCIRecipeBuildRequest: | |||
92 | 697 | return self.job.date_finished | 700 | return self.job.date_finished |
93 | 698 | 701 | ||
94 | 699 | @property | 702 | @property |
95 | 703 | def uploaded_manifests(self): | ||
96 | 704 | return self.job.uploaded_manifests | ||
97 | 705 | |||
98 | 706 | def addUploadedManifest(self, build_id, manifest_info): | ||
99 | 707 | self.job.addUploadedManifest(build_id, manifest_info) | ||
100 | 708 | |||
101 | 709 | @property | ||
102 | 700 | def status(self): | 710 | def status(self): |
103 | 701 | status_map = { | 711 | status_map = { |
104 | 702 | JobStatus.WAITING: OCIRecipeBuildRequestStatus.PENDING, | 712 | JobStatus.WAITING: OCIRecipeBuildRequestStatus.PENDING, |
105 | @@ -714,3 +724,8 @@ class OCIRecipeBuildRequest: | |||
106 | 714 | @property | 724 | @property |
107 | 715 | def builds(self): | 725 | def builds(self): |
108 | 716 | return self.job.builds | 726 | return self.job.builds |
109 | 727 | |||
110 | 728 | def __eq__(self, other): | ||
111 | 729 | if not zope_isinstance(other, self.__class__): | ||
112 | 730 | return False | ||
113 | 731 | return self.id == other.id | ||
114 | diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py | |||
115 | index 73fa4d6..991b9a4 100644 | |||
116 | --- a/lib/lp/oci/model/ocirecipebuildjob.py | |||
117 | +++ b/lib/lp/oci/model/ocirecipebuildjob.py | |||
118 | @@ -11,6 +11,8 @@ __all__ = [ | |||
119 | 11 | 'OCIRecipeBuildJobType', | 11 | 'OCIRecipeBuildJobType', |
120 | 12 | ] | 12 | ] |
121 | 13 | 13 | ||
122 | 14 | from datetime import timedelta | ||
123 | 15 | import random | ||
124 | 14 | 16 | ||
125 | 15 | from lazr.delegates import delegate_to | 17 | from lazr.delegates import delegate_to |
126 | 16 | from lazr.enum import ( | 18 | from lazr.enum import ( |
127 | @@ -41,8 +43,12 @@ from lp.oci.interfaces.ociregistryclient import ( | |||
128 | 41 | OCIRegistryError, | 43 | OCIRegistryError, |
129 | 42 | ) | 44 | ) |
130 | 43 | from lp.services.database.enumcol import DBEnum | 45 | from lp.services.database.enumcol import DBEnum |
132 | 44 | from lp.services.database.interfaces import IStore | 46 | from lp.services.database.interfaces import ( |
133 | 47 | IMasterStore, | ||
134 | 48 | IStore, | ||
135 | 49 | ) | ||
136 | 45 | from lp.services.database.stormbase import StormBase | 50 | from lp.services.database.stormbase import StormBase |
137 | 51 | from lp.services.job.interfaces.job import JobStatus | ||
138 | 46 | from lp.services.job.model.job import ( | 52 | from lp.services.job.model.job import ( |
139 | 47 | EnumeratedSubclass, | 53 | EnumeratedSubclass, |
140 | 48 | Job, | 54 | Job, |
141 | @@ -156,16 +162,34 @@ class OCIRecipeBuildJobDerived( | |||
142 | 156 | @implementer(IOCIRegistryUploadJob) | 162 | @implementer(IOCIRegistryUploadJob) |
143 | 157 | @provider(IOCIRegistryUploadJobSource) | 163 | @provider(IOCIRegistryUploadJobSource) |
144 | 158 | class OCIRegistryUploadJob(OCIRecipeBuildJobDerived): | 164 | class OCIRegistryUploadJob(OCIRecipeBuildJobDerived): |
145 | 165 | """Manages the OCI image upload to registries. | ||
146 | 166 | |||
147 | 167 | This job coordinates with other OCIRegistryUploadJob in a way that the | ||
148 | 168 | last job uploading its layers and manifest will also upload the | ||
149 | 169 | manifest list with all the previously built OCI images uploaded from | ||
150 | 170 | other architectures in the same build request. To avoid race conditions, | ||
151 | 171 | we synchronize that using a SELECT ... FOR UPDATE at database level to | ||
152 | 172 | make sure that the status is consistent across all the upload jobs. | ||
153 | 173 | """ | ||
154 | 159 | 174 | ||
155 | 160 | class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD | 175 | class_job_type = OCIRecipeBuildJobType.REGISTRY_UPLOAD |
156 | 161 | 176 | ||
157 | 177 | class ManifestListUploadError(Exception): | ||
158 | 178 | pass | ||
159 | 179 | |||
160 | 180 | retry_error_types = (ManifestListUploadError, ) | ||
161 | 181 | max_retries = 5 | ||
162 | 182 | |||
163 | 162 | @classmethod | 183 | @classmethod |
164 | 163 | def create(cls, build): | 184 | def create(cls, build): |
165 | 164 | """See `IOCIRegistryUploadJobSource`""" | 185 | """See `IOCIRegistryUploadJobSource`""" |
166 | 165 | edited_fields = set() | 186 | edited_fields = set() |
167 | 166 | with notify_modified(build, edited_fields) as before_modification: | 187 | with notify_modified(build, edited_fields) as before_modification: |
168 | 188 | json_data = { | ||
169 | 189 | "build_uploaded": False, | ||
170 | 190 | } | ||
171 | 167 | oci_build_job = OCIRecipeBuildJob( | 191 | oci_build_job = OCIRecipeBuildJob( |
173 | 168 | build, cls.class_job_type, {}) | 192 | build, cls.class_job_type, json_data) |
174 | 169 | job = cls(oci_build_job) | 193 | job = cls(oci_build_job) |
175 | 170 | job.celeryRunOnCommit() | 194 | job.celeryRunOnCommit() |
176 | 171 | del get_property_cache(build).last_registry_upload_job | 195 | del get_property_cache(build).last_registry_upload_job |
177 | @@ -174,6 +198,14 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived): | |||
178 | 174 | edited_fields.add("registry_upload_status") | 198 | edited_fields.add("registry_upload_status") |
179 | 175 | return job | 199 | return job |
180 | 176 | 200 | ||
181 | 201 | @property | ||
182 | 202 | def retry_delay(self): | ||
183 | 203 | dithering_secs = int(random.random() * 60) | ||
184 | 204 | # Adds some random seconds between 0 and 60 to minimize the | ||
185 | 205 | # likelihood of synchronized retries holding locks on the database | ||
186 | 206 | # at the same time. | ||
187 | 207 | return timedelta(minutes=10, seconds=dithering_secs) | ||
188 | 208 | |||
189 | 177 | # Ideally we'd just override Job._set_status or similar, but | 209 | # Ideally we'd just override Job._set_status or similar, but |
190 | 178 | # lazr.delegates makes that difficult, so we use this to override all | 210 | # lazr.delegates makes that difficult, so we use this to override all |
191 | 179 | # the individual Job lifecycle methods instead. | 211 | # the individual Job lifecycle methods instead. |
192 | @@ -227,6 +259,74 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived): | |||
193 | 227 | """See `IOCIRegistryUploadJob`.""" | 259 | """See `IOCIRegistryUploadJob`.""" |
194 | 228 | self.json_data["errors"] = errors | 260 | self.json_data["errors"] = errors |
195 | 229 | 261 | ||
196 | 262 | @property | ||
197 | 263 | def build_uploaded(self): | ||
198 | 264 | return self.json_data.get("build_uploaded", False) | ||
199 | 265 | |||
200 | 266 | @build_uploaded.setter | ||
201 | 267 | def build_uploaded(self, value): | ||
202 | 268 | self.json_data["build_uploaded"] = bool(value) | ||
203 | 269 | |||
204 | 270 | def allBuildsUploaded(self, build_request): | ||
205 | 271 | """Returns True if all builds of the given build_request already | ||
206 | 272 | finished uploading. False otherwise. | ||
207 | 273 | |||
208 | 274 | Note that this method locks all upload jobs at database level, | ||
209 | 275 | preventing them from updating their status until the end of the | ||
210 | 276 | current transaction. Use it with caution. | ||
211 | 277 | """ | ||
212 | 278 | builds = list(build_request.builds) | ||
213 | 279 | uploads_per_build = {i: list(i.registry_upload_jobs) for i in builds} | ||
214 | 280 | upload_jobs = sum(uploads_per_build.values(), []) | ||
215 | 281 | |||
216 | 282 | # Lock the Job rows, so no other job updates its status until the | ||
217 | 283 | # end of this job's transaction. This is done to avoid race conditions, | ||
218 | 284 | # where 2 upload jobs could be running simultaneously and none of them | ||
219 | 285 | # realises that is the last upload. | ||
220 | 286 | # Note also that new upload jobs might be created between the | ||
221 | 287 | # transaction begin and this lock takes place, but in this case the | ||
222 | 288 | # new upload is either a retry from a failed upload, or the first | ||
223 | 289 | # upload for one of the existing builds. Either way, we would see that | ||
224 | 290 | # build as "not uploaded yet", which is ok for this method, and the | ||
225 | 291 | # new job will block until these locks are released, so we should be | ||
226 | 292 | # safe. | ||
227 | 293 | store = IMasterStore(builds[0]) | ||
228 | 294 | placeholders = ', '.join('?' for _ in upload_jobs) | ||
229 | 295 | sql = ( | ||
230 | 296 | "SELECT id, status FROM job WHERE id IN (%s) FOR UPDATE" | ||
231 | 297 | % placeholders) | ||
232 | 298 | job_status = { | ||
233 | 299 | job_id: JobStatus.items[status] for job_id, status in | ||
234 | 300 | store.execute(sql, [i.job_id for i in upload_jobs])} | ||
235 | 301 | |||
236 | 302 | for build, upload_jobs in uploads_per_build.items(): | ||
237 | 303 | has_finished_upload = any( | ||
238 | 304 | job_status[i.job_id] == JobStatus.COMPLETED | ||
239 | 305 | or i.job_id == self.job_id | ||
240 | 306 | for i in upload_jobs) | ||
241 | 307 | if not has_finished_upload: | ||
242 | 308 | return False | ||
243 | 309 | return True | ||
244 | 310 | |||
245 | 311 | def uploadManifestList(self, client): | ||
246 | 312 | """Uploads the aggregated manifest list for all builds in the | ||
247 | 313 | current build request. | ||
248 | 314 | """ | ||
249 | 315 | # The "allBuildsUploaded" call will lock, on the database, | ||
250 | 316 | # all upload jobs for update until this transaction finishes. | ||
251 | 317 | # So, make sure this is the last thing being done by this job. | ||
252 | 318 | build_request = self.build.build_request | ||
253 | 319 | if not build_request: | ||
254 | 320 | return | ||
255 | 321 | try: | ||
256 | 322 | if self.allBuildsUploaded(build_request): | ||
257 | 323 | client.uploadManifestList(build_request) | ||
258 | 324 | except OCIRegistryError: | ||
259 | 325 | # Do not retry automatically on known OCI registry errors. | ||
260 | 326 | raise | ||
261 | 327 | except Exception as e: | ||
262 | 328 | raise self.ManifestListUploadError(str(e)) | ||
263 | 329 | |||
264 | 230 | def run(self): | 330 | def run(self): |
265 | 231 | """See `IRunnableJob`.""" | 331 | """See `IRunnableJob`.""" |
266 | 232 | client = getUtility(IOCIRegistryClient) | 332 | client = getUtility(IOCIRegistryClient) |
267 | @@ -234,7 +334,21 @@ class OCIRegistryUploadJob(OCIRecipeBuildJobDerived): | |||
268 | 234 | # it will need to gain retry support. | 334 | # it will need to gain retry support. |
269 | 235 | try: | 335 | try: |
270 | 236 | try: | 336 | try: |
272 | 237 | client.upload(self.build) | 337 | if not self.build_uploaded: |
273 | 338 | client.upload(self.build) | ||
274 | 339 | self.build_uploaded = True | ||
275 | 340 | |||
276 | 341 | self.uploadManifestList(client) | ||
277 | 342 | # Force this job status to COMPLETED in the same transaction we | ||
278 | 343 | # called `allBuildsUpdated` (in the uploadManifestList call | ||
279 | 344 | # above) to release the lock already including the new status. | ||
280 | 345 | # This way, any other transaction that was blocked waiting to | ||
281 | 346 | # get the info about the upload jobs will immediately have the | ||
282 | 347 | # new status of this job, avoiding race conditions. Keep the | ||
283 | 348 | # `manage_transaction=False` to prevent the method from | ||
284 | 349 | # commiting at the wrong moment. | ||
285 | 350 | self.complete(manage_transaction=False) | ||
286 | 351 | |||
287 | 238 | except OCIRegistryError as e: | 352 | except OCIRegistryError as e: |
288 | 239 | self.error_summary = str(e) | 353 | self.error_summary = str(e) |
289 | 240 | self.errors = e.errors | 354 | self.errors = e.errors |
290 | diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py | |||
291 | index 15eac4f..3320cff 100644 | |||
292 | --- a/lib/lp/oci/model/ocirecipejob.py | |||
293 | +++ b/lib/lp/oci/model/ocirecipejob.py | |||
294 | @@ -169,6 +169,8 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived): | |||
295 | 169 | "requester": requester.id, | 169 | "requester": requester.id, |
296 | 170 | "architectures": ( | 170 | "architectures": ( |
297 | 171 | list(architectures) if architectures is not None else None), | 171 | list(architectures) if architectures is not None else None), |
298 | 172 | # A dict of build_id: manifest location | ||
299 | 173 | "uploaded_manifests": {} | ||
300 | 172 | } | 174 | } |
301 | 173 | recipe_job = OCIRecipeJob(recipe, cls.class_job_type, metadata) | 175 | recipe_job = OCIRecipeJob(recipe, cls.class_job_type, metadata) |
302 | 174 | job = cls(recipe_job) | 176 | job = cls(recipe_job) |
303 | @@ -258,6 +260,16 @@ class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived): | |||
304 | 258 | architectures = self.metadata["architectures"] | 260 | architectures = self.metadata["architectures"] |
305 | 259 | return set(architectures) if architectures is not None else None | 261 | return set(architectures) if architectures is not None else None |
306 | 260 | 262 | ||
307 | 263 | @property | ||
308 | 264 | def uploaded_manifests(self): | ||
309 | 265 | return { | ||
310 | 266 | # Converts keys to integer since saving json to database | ||
311 | 267 | # converts them to strings. | ||
312 | 268 | int(k): v for k, v in self.metadata["uploaded_manifests"].items()} | ||
313 | 269 | |||
314 | 270 | def addUploadedManifest(self, build_id, manifest_info): | ||
315 | 271 | self.metadata["uploaded_manifests"][int(build_id)] = manifest_info | ||
316 | 272 | |||
317 | 261 | def run(self): | 273 | def run(self): |
318 | 262 | """See `IRunnableJob`.""" | 274 | """See `IRunnableJob`.""" |
319 | 263 | requester = self.requester | 275 | requester = self.requester |
320 | diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py | |||
321 | index 7561267..7c67b57 100644 | |||
322 | --- a/lib/lp/oci/model/ociregistryclient.py | |||
323 | +++ b/lib/lp/oci/model/ociregistryclient.py | |||
324 | @@ -229,17 +229,59 @@ class OCIRegistryClient: | |||
325 | 229 | """ | 229 | """ |
326 | 230 | # XXX twom 2020-04-17 This needs to include OCIProjectSeries and | 230 | # XXX twom 2020-04-17 This needs to include OCIProjectSeries and |
327 | 231 | # base image name | 231 | # base image name |
328 | 232 | |||
329 | 233 | return "{}".format("edge") | 232 | return "{}".format("edge") |
330 | 234 | 233 | ||
331 | 235 | @classmethod | 234 | @classmethod |
332 | 235 | def _uploadRegistryManifest(cls, http_client, registry_manifest, | ||
333 | 236 | push_rule, build=None): | ||
334 | 237 | """Uploads the build manifest, returning its content information. | ||
335 | 238 | |||
336 | 239 | The returned information can be used to create a Manifest list | ||
337 | 240 | including the uploaded manifest, for example, in order to create | ||
338 | 241 | multi-architecture images. | ||
339 | 242 | |||
340 | 243 | :return: A dict with {"digest": "sha256:xxx", "size": total_bytes} | ||
341 | 244 | """ | ||
342 | 245 | digest = None | ||
343 | 246 | data = json.dumps(registry_manifest) | ||
344 | 247 | size = len(data) | ||
345 | 248 | content_type = registry_manifest.get( | ||
346 | 249 | "mediaType", | ||
347 | 250 | "application/vnd.docker.distribution.manifest.v2+json") | ||
348 | 251 | if build is None: | ||
349 | 252 | # When uploading a manifest list, use the tag. | ||
350 | 253 | tag = cls._calculateTag(build, push_rule) | ||
351 | 254 | else: | ||
352 | 255 | # When uploading individual build manifests, use their digest. | ||
353 | 256 | tag = "sha256:%s" % hashlib.sha256(data).hexdigest() | ||
354 | 257 | try: | ||
355 | 258 | manifest_response = http_client.requestPath( | ||
356 | 259 | "/manifests/{}".format(tag), | ||
357 | 260 | data=data, | ||
358 | 261 | headers={"Content-Type": content_type}, | ||
359 | 262 | method="PUT") | ||
360 | 263 | digest = manifest_response.headers.get("Docker-Content-Digest") | ||
361 | 264 | except HTTPError as http_error: | ||
362 | 265 | manifest_response = http_error.response | ||
363 | 266 | if manifest_response.status_code != 201: | ||
364 | 267 | if build: | ||
365 | 268 | msg = "Failed to upload manifest for {} ({}) in {}".format( | ||
366 | 269 | build.recipe.name, push_rule.registry_url, build.id) | ||
367 | 270 | else: | ||
368 | 271 | msg = ("Failed to upload manifest of manifests for" | ||
369 | 272 | " {} ({})").format( | ||
370 | 273 | push_rule.recipe.name, push_rule.registry_url) | ||
371 | 274 | raise cls._makeRegistryError( | ||
372 | 275 | ManifestUploadFailed, msg, manifest_response) | ||
373 | 276 | return {"digest": digest, "size": size} | ||
374 | 277 | |||
375 | 278 | @classmethod | ||
376 | 236 | def _upload_to_push_rule( | 279 | def _upload_to_push_rule( |
377 | 237 | cls, push_rule, build, manifest, digests, preloaded_data): | 280 | cls, push_rule, build, manifest, digests, preloaded_data): |
378 | 238 | http_client = RegistryHTTPClient.getInstance(push_rule) | 281 | http_client = RegistryHTTPClient.getInstance(push_rule) |
379 | 239 | 282 | ||
380 | 240 | for section in manifest: | 283 | for section in manifest: |
383 | 241 | # Work out names and tags | 284 | # Work out names |
382 | 242 | tag = cls._calculateTag(build, push_rule) | ||
384 | 243 | file_data = preloaded_data[section["Config"]] | 285 | file_data = preloaded_data[section["Config"]] |
385 | 244 | config = file_data["config_file"] | 286 | config = file_data["config_file"] |
386 | 245 | # Upload the layers involved | 287 | # Upload the layers involved |
387 | @@ -269,24 +311,13 @@ class OCIRegistryClient: | |||
388 | 269 | layer_sizes) | 311 | layer_sizes) |
389 | 270 | 312 | ||
390 | 271 | # Upload the registry manifest | 313 | # Upload the registry manifest |
409 | 272 | try: | 314 | manifest = cls._uploadRegistryManifest( |
410 | 273 | manifest_response = http_client.requestPath( | 315 | http_client, registry_manifest, push_rule, build) |
411 | 274 | "/manifests/{}".format(tag), | 316 | |
412 | 275 | json=registry_manifest, | 317 | # Save the uploaded manifest location, so we can use it in case |
413 | 276 | headers={ | 318 | # this is a multi-arch image upload. |
414 | 277 | "Content-Type": | 319 | if build.build_request: |
415 | 278 | "application/" | 320 | build.build_request.addUploadedManifest(build.id, manifest) |
398 | 279 | "vnd.docker.distribution.manifest.v2+json" | ||
399 | 280 | }, | ||
400 | 281 | method="PUT") | ||
401 | 282 | except HTTPError as http_error: | ||
402 | 283 | manifest_response = http_error.response | ||
403 | 284 | if manifest_response.status_code != 201: | ||
404 | 285 | raise cls._makeRegistryError( | ||
405 | 286 | ManifestUploadFailed, | ||
406 | 287 | "Failed to upload manifest for {} ({}) in {}".format( | ||
407 | 288 | build.recipe.name, push_rule.registry_url, build.id), | ||
408 | 289 | manifest_response) | ||
416 | 290 | 321 | ||
417 | 291 | @classmethod | 322 | @classmethod |
418 | 292 | def upload(cls, build): | 323 | def upload(cls, build): |
419 | @@ -318,6 +349,43 @@ class OCIRegistryClient: | |||
420 | 318 | elif len(exceptions) > 1: | 349 | elif len(exceptions) > 1: |
421 | 319 | raise MultipleOCIRegistryError(exceptions) | 350 | raise MultipleOCIRegistryError(exceptions) |
422 | 320 | 351 | ||
423 | 352 | @classmethod | ||
424 | 353 | def makeMultiArchManifest(cls, build_request): | ||
425 | 354 | """Returns the multi-arch manifest content including all builds of | ||
426 | 355 | the given build_request. | ||
427 | 356 | """ | ||
428 | 357 | manifests = [] | ||
429 | 358 | for build in build_request.builds: | ||
430 | 359 | build_manifest = build_request.uploaded_manifests.get(build.id) | ||
431 | 360 | if not build_manifest: | ||
432 | 361 | continue | ||
433 | 362 | digest = build_manifest["digest"] | ||
434 | 363 | size = build_manifest["size"] | ||
435 | 364 | arch = build.processor.name | ||
436 | 365 | manifests.append({ | ||
437 | 366 | "mediaType": ("application/" | ||
438 | 367 | "vnd.docker.distribution.manifest.v2+json"), | ||
439 | 368 | "size": size, | ||
440 | 369 | "digest": digest, | ||
441 | 370 | "platform": {"architecture": arch, "os": "linux"} | ||
442 | 371 | }) | ||
443 | 372 | return { | ||
444 | 373 | "schemaVersion": 2, | ||
445 | 374 | "mediaType": ("application/" | ||
446 | 375 | "vnd.docker.distribution.manifest.list.v2+json"), | ||
447 | 376 | "manifests": manifests} | ||
448 | 377 | |||
449 | 378 | @classmethod | ||
450 | 379 | def uploadManifestList(cls, build_request): | ||
451 | 380 | """Uploads to all build_request.recipe.push_rules the manifest list | ||
452 | 381 | for the builds in the given build_request. | ||
453 | 382 | """ | ||
454 | 383 | multi_manifest_content = cls.makeMultiArchManifest(build_request) | ||
455 | 384 | for push_rule in build_request.recipe.push_rules: | ||
456 | 385 | http_client = RegistryHTTPClient.getInstance(push_rule) | ||
457 | 386 | cls._uploadRegistryManifest( | ||
458 | 387 | http_client, multi_manifest_content, push_rule, build=None) | ||
459 | 388 | |||
460 | 321 | 389 | ||
461 | 322 | class OCIRegistryAuthenticationError(Exception): | 390 | class OCIRegistryAuthenticationError(Exception): |
462 | 323 | def __init__(self, msg, http_error=None): | 391 | def __init__(self, msg, http_error=None): |
463 | @@ -436,8 +504,8 @@ class BearerTokenRegistryClient(RegistryHTTPClient): | |||
464 | 436 | 504 | ||
465 | 437 | :param auth_retry: Should we authenticate and retry the request if | 505 | :param auth_retry: Should we authenticate and retry the request if |
466 | 438 | it fails with HTTP 401 code?""" | 506 | it fails with HTTP 401 code?""" |
467 | 507 | headers = request_kwargs.pop("headers", {}) | ||
468 | 439 | try: | 508 | try: |
469 | 440 | headers = request_kwargs.pop("headers", {}) | ||
470 | 441 | if self.auth_token is not None: | 509 | if self.auth_token is not None: |
471 | 442 | headers["Authorization"] = "Bearer %s" % self.auth_token | 510 | headers["Authorization"] = "Bearer %s" % self.auth_token |
472 | 443 | return proxy_urlfetch(url, headers=headers, **request_kwargs) | 511 | return proxy_urlfetch(url, headers=headers, **request_kwargs) |
473 | @@ -445,5 +513,6 @@ class BearerTokenRegistryClient(RegistryHTTPClient): | |||
474 | 445 | if auth_retry and e.response.status_code == 401: | 513 | if auth_retry and e.response.status_code == 401: |
475 | 446 | self.authenticate(e.response) | 514 | self.authenticate(e.response) |
476 | 447 | return self.request( | 515 | return self.request( |
478 | 448 | url, auth_retry=False, *args, **request_kwargs) | 516 | url, auth_retry=False, headers=headers, |
479 | 517 | *args, **request_kwargs) | ||
480 | 449 | raise | 518 | raise |
481 | diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py | |||
482 | index fd3e0ff..7b66c1d 100644 | |||
483 | --- a/lib/lp/oci/tests/test_ocirecipebuildjob.py | |||
484 | +++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py | |||
485 | @@ -7,6 +7,13 @@ from __future__ import absolute_import, print_function, unicode_literals | |||
486 | 7 | 7 | ||
487 | 8 | __metaclass__ = type | 8 | __metaclass__ = type |
488 | 9 | 9 | ||
489 | 10 | from datetime import ( | ||
490 | 11 | datetime, | ||
491 | 12 | timedelta, | ||
492 | 13 | ) | ||
493 | 14 | import threading | ||
494 | 15 | import time | ||
495 | 16 | |||
496 | 10 | from fixtures import FakeLogger | 17 | from fixtures import FakeLogger |
497 | 11 | from testtools.matchers import ( | 18 | from testtools.matchers import ( |
498 | 12 | Equals, | 19 | Equals, |
499 | @@ -14,11 +21,15 @@ from testtools.matchers import ( | |||
500 | 14 | MatchesDict, | 21 | MatchesDict, |
501 | 15 | MatchesListwise, | 22 | MatchesListwise, |
502 | 16 | MatchesStructure, | 23 | MatchesStructure, |
503 | 24 | Not, | ||
504 | 17 | ) | 25 | ) |
505 | 18 | import transaction | 26 | import transaction |
506 | 27 | from zope.component import getUtility | ||
507 | 19 | from zope.interface import implementer | 28 | from zope.interface import implementer |
508 | 29 | from zope.security.proxy import removeSecurityProxy | ||
509 | 20 | 30 | ||
510 | 21 | from lp.buildmaster.enums import BuildStatus | 31 | from lp.buildmaster.enums import BuildStatus |
511 | 32 | from lp.buildmaster.interfaces.processor import IProcessorSet | ||
512 | 22 | from lp.oci.interfaces.ocirecipe import ( | 33 | from lp.oci.interfaces.ocirecipe import ( |
513 | 23 | OCI_RECIPE_ALLOW_CREATE, | 34 | OCI_RECIPE_ALLOW_CREATE, |
514 | 24 | OCI_RECIPE_WEBHOOKS_FEATURE_FLAG, | 35 | OCI_RECIPE_WEBHOOKS_FEATURE_FLAG, |
515 | @@ -27,6 +38,7 @@ from lp.oci.interfaces.ocirecipebuildjob import ( | |||
516 | 27 | IOCIRecipeBuildJob, | 38 | IOCIRecipeBuildJob, |
517 | 28 | IOCIRegistryUploadJob, | 39 | IOCIRegistryUploadJob, |
518 | 29 | ) | 40 | ) |
519 | 41 | from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource | ||
520 | 30 | from lp.oci.interfaces.ociregistryclient import ( | 42 | from lp.oci.interfaces.ociregistryclient import ( |
521 | 31 | BlobUploadFailed, | 43 | BlobUploadFailed, |
522 | 32 | IOCIRegistryClient, | 44 | IOCIRegistryClient, |
523 | @@ -37,12 +49,17 @@ from lp.oci.model.ocirecipebuildjob import ( | |||
524 | 37 | OCIRecipeBuildJobType, | 49 | OCIRecipeBuildJobType, |
525 | 38 | OCIRegistryUploadJob, | 50 | OCIRegistryUploadJob, |
526 | 39 | ) | 51 | ) |
527 | 52 | from lp.services.compat import mock | ||
528 | 40 | from lp.services.config import config | 53 | from lp.services.config import config |
529 | 41 | from lp.services.features.testing import FeatureFixture | 54 | from lp.services.features.testing import FeatureFixture |
530 | 55 | from lp.services.job.interfaces.job import JobStatus | ||
531 | 42 | from lp.services.job.runner import JobRunner | 56 | from lp.services.job.runner import JobRunner |
532 | 43 | from lp.services.webapp import canonical_url | 57 | from lp.services.webapp import canonical_url |
533 | 44 | from lp.services.webhooks.testing import LogsScheduledWebhooks | 58 | from lp.services.webhooks.testing import LogsScheduledWebhooks |
535 | 45 | from lp.testing import TestCaseWithFactory | 59 | from lp.testing import ( |
536 | 60 | admin_logged_in, | ||
537 | 61 | TestCaseWithFactory, | ||
538 | 62 | ) | ||
539 | 46 | from lp.testing.dbuser import dbuser | 63 | from lp.testing.dbuser import dbuser |
540 | 47 | from lp.testing.fakemethod import FakeMethod | 64 | from lp.testing.fakemethod import FakeMethod |
541 | 48 | from lp.testing.fixture import ZopeUtilityFixture | 65 | from lp.testing.fixture import ZopeUtilityFixture |
542 | @@ -69,6 +86,7 @@ class FakeRegistryClient: | |||
543 | 69 | 86 | ||
544 | 70 | def __init__(self): | 87 | def __init__(self): |
545 | 71 | self.upload = FakeMethod() | 88 | self.upload = FakeMethod() |
546 | 89 | self.uploadManifestList = FakeMethod() | ||
547 | 72 | 90 | ||
548 | 73 | 91 | ||
549 | 74 | class FakeOCIBuildJob(OCIRecipeBuildJobDerived): | 92 | class FakeOCIBuildJob(OCIRecipeBuildJobDerived): |
550 | @@ -122,22 +140,28 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory): | |||
551 | 122 | ocibuild = self.factory.makeOCIRecipeBuild( | 140 | ocibuild = self.factory.makeOCIRecipeBuild( |
552 | 123 | builder=self.factory.makeBuilder(), **kwargs) | 141 | builder=self.factory.makeBuilder(), **kwargs) |
553 | 124 | ocibuild.updateStatus(BuildStatus.FULLYBUILT) | 142 | ocibuild.updateStatus(BuildStatus.FULLYBUILT) |
556 | 125 | self.factory.makeWebhook( | 143 | self.makeWebhook(ocibuild.recipe) |
555 | 126 | target=ocibuild.recipe, event_types=["oci-recipe:build:0.1"]) | ||
557 | 127 | return ocibuild | 144 | return ocibuild |
558 | 128 | 145 | ||
559 | 146 | def makeWebhook(self, recipe): | ||
560 | 147 | self.factory.makeWebhook( | ||
561 | 148 | target=recipe, event_types=["oci-recipe:build:0.1"]) | ||
562 | 149 | |||
563 | 129 | def assertWebhookDeliveries(self, ocibuild, | 150 | def assertWebhookDeliveries(self, ocibuild, |
564 | 130 | expected_registry_upload_statuses, logger): | 151 | expected_registry_upload_statuses, logger): |
565 | 131 | hook = ocibuild.recipe.webhooks.one() | 152 | hook = ocibuild.recipe.webhooks.one() |
566 | 132 | deliveries = list(hook.deliveries) | 153 | deliveries = list(hook.deliveries) |
567 | 133 | deliveries.reverse() | 154 | deliveries.reverse() |
568 | 155 | build_req_url = ( | ||
569 | 156 | None if ocibuild.build_request is None | ||
570 | 157 | else canonical_url(ocibuild.build_request, force_local_path=True)) | ||
571 | 134 | expected_payloads = [{ | 158 | expected_payloads = [{ |
572 | 135 | "recipe_build": Equals( | 159 | "recipe_build": Equals( |
573 | 136 | canonical_url(ocibuild, force_local_path=True)), | 160 | canonical_url(ocibuild, force_local_path=True)), |
574 | 137 | "action": Equals("status-changed"), | 161 | "action": Equals("status-changed"), |
575 | 138 | "recipe": Equals( | 162 | "recipe": Equals( |
576 | 139 | canonical_url(ocibuild.recipe, force_local_path=True)), | 163 | canonical_url(ocibuild.recipe, force_local_path=True)), |
578 | 140 | "build_request": Is(None), | 164 | "build_request": Equals(build_req_url), |
579 | 141 | "status": Equals("Successfully built"), | 165 | "status": Equals("Successfully built"), |
580 | 142 | "registry_upload_status": Equals(expected), | 166 | "registry_upload_status": Equals(expected), |
581 | 143 | } for expected in expected_registry_upload_statuses] | 167 | } for expected in expected_registry_upload_statuses] |
582 | @@ -165,22 +189,242 @@ class TestOCIRegistryUploadJob(TestCaseWithFactory): | |||
583 | 165 | job = OCIRegistryUploadJob.create(ocibuild) | 189 | job = OCIRegistryUploadJob.create(ocibuild) |
584 | 166 | self.assertProvides(job, IOCIRegistryUploadJob) | 190 | self.assertProvides(job, IOCIRegistryUploadJob) |
585 | 167 | 191 | ||
586 | 192 | def makeRecipe(self, include_i386=True, include_amd64=True): | ||
587 | 193 | i386 = getUtility(IProcessorSet).getByName("386") | ||
588 | 194 | amd64 = getUtility(IProcessorSet).getByName("amd64") | ||
589 | 195 | recipe = self.factory.makeOCIRecipe() | ||
590 | 196 | distroseries = self.factory.makeDistroSeries( | ||
591 | 197 | distribution=recipe.oci_project.distribution) | ||
592 | 198 | distro_i386 = self.factory.makeDistroArchSeries( | ||
593 | 199 | distroseries=distroseries, architecturetag="i386", | ||
594 | 200 | processor=i386) | ||
595 | 201 | distro_i386.addOrUpdateChroot(self.factory.makeLibraryFileAlias()) | ||
596 | 202 | distro_amd64 = self.factory.makeDistroArchSeries( | ||
597 | 203 | distroseries=distroseries, architecturetag="amd64", | ||
598 | 204 | processor=amd64) | ||
599 | 205 | distro_amd64.addOrUpdateChroot(self.factory.makeLibraryFileAlias()) | ||
600 | 206 | |||
601 | 207 | archs = [] | ||
602 | 208 | if include_i386: | ||
603 | 209 | archs.append(i386) | ||
604 | 210 | if include_amd64: | ||
605 | 211 | archs.append(amd64) | ||
606 | 212 | recipe.setProcessors(archs) | ||
607 | 213 | return recipe | ||
608 | 214 | |||
609 | 215 | def makeBuildRequest(self, include_i386=True, include_amd64=True): | ||
610 | 216 | recipe = self.makeRecipe(include_i386, include_amd64) | ||
611 | 217 | # Creates a build request with a build in it. | ||
612 | 218 | build_request = recipe.requestBuilds(recipe.owner) | ||
613 | 219 | with admin_logged_in(): | ||
614 | 220 | jobs = getUtility(IOCIRecipeRequestBuildsJobSource).iterReady() | ||
615 | 221 | with dbuser(config.IOCIRecipeRequestBuildsJobSource.dbuser): | ||
616 | 222 | JobRunner(jobs).runAll() | ||
617 | 223 | return build_request | ||
618 | 224 | |||
619 | 168 | def test_run(self): | 225 | def test_run(self): |
620 | 169 | logger = self.useFixture(FakeLogger()) | 226 | logger = self.useFixture(FakeLogger()) |
622 | 170 | ocibuild = self.makeOCIRecipeBuild() | 227 | build_request = self.makeBuildRequest(include_i386=False) |
623 | 228 | recipe = build_request.recipe | ||
624 | 229 | |||
625 | 230 | self.assertEqual(1, build_request.builds.count()) | ||
626 | 231 | ocibuild = build_request.builds[0] | ||
627 | 232 | ocibuild.updateStatus(BuildStatus.FULLYBUILT) | ||
628 | 233 | self.makeWebhook(recipe) | ||
629 | 234 | |||
630 | 171 | self.assertContentEqual([], ocibuild.registry_upload_jobs) | 235 | self.assertContentEqual([], ocibuild.registry_upload_jobs) |
631 | 172 | job = OCIRegistryUploadJob.create(ocibuild) | 236 | job = OCIRegistryUploadJob.create(ocibuild) |
632 | 173 | client = FakeRegistryClient() | 237 | client = FakeRegistryClient() |
633 | 174 | self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient)) | 238 | self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient)) |
634 | 175 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): | 239 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): |
635 | 176 | run_isolated_jobs([job]) | 240 | run_isolated_jobs([job]) |
636 | 241 | |||
637 | 177 | self.assertEqual([((ocibuild,), {})], client.upload.calls) | 242 | self.assertEqual([((ocibuild,), {})], client.upload.calls) |
638 | 243 | self.assertEqual([((build_request,), {})], | ||
639 | 244 | client.uploadManifestList.calls) | ||
640 | 178 | self.assertContentEqual([job], ocibuild.registry_upload_jobs) | 245 | self.assertContentEqual([job], ocibuild.registry_upload_jobs) |
641 | 179 | self.assertIsNone(job.error_summary) | 246 | self.assertIsNone(job.error_summary) |
642 | 180 | self.assertIsNone(job.errors) | 247 | self.assertIsNone(job.errors) |
643 | 181 | self.assertEqual([], pop_notifications()) | 248 | self.assertEqual([], pop_notifications()) |
644 | 182 | self.assertWebhookDeliveries(ocibuild, ["Pending", "Uploaded"], logger) | 249 | self.assertWebhookDeliveries(ocibuild, ["Pending", "Uploaded"], logger) |
645 | 183 | 250 | ||
646 | 251 | def test_run_multiple_architectures(self): | ||
647 | 252 | build_request = self.makeBuildRequest() | ||
648 | 253 | builds = build_request.builds | ||
649 | 254 | self.assertEqual(2, builds.count()) | ||
650 | 255 | self.assertEqual(builds[0].build_request, builds[1].build_request) | ||
651 | 256 | |||
652 | 257 | upload_jobs = [] | ||
653 | 258 | for build in builds: | ||
654 | 259 | self.assertContentEqual([], build.registry_upload_jobs) | ||
655 | 260 | upload_jobs.append(OCIRegistryUploadJob.create(build)) | ||
656 | 261 | |||
657 | 262 | client = FakeRegistryClient() | ||
658 | 263 | self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient)) | ||
659 | 264 | |||
660 | 265 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): | ||
661 | 266 | JobRunner([upload_jobs[0]]).runAll() | ||
662 | 267 | self.assertEqual([((builds[0],), {})], client.upload.calls) | ||
663 | 268 | self.assertEqual([], client.uploadManifestList.calls) | ||
664 | 269 | |||
665 | 270 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): | ||
666 | 271 | JobRunner([upload_jobs[1]]).runAll() | ||
667 | 272 | self.assertEqual( | ||
668 | 273 | [((builds[0],), {}), ((builds[1],), {})], client.upload.calls) | ||
669 | 274 | self.assertEqual([((builds[1].build_request, ), {})], | ||
670 | 275 | client.uploadManifestList.calls) | ||
671 | 276 | |||
672 | 277 | def test_failing_upload_does_not_retries_automatically(self): | ||
673 | 278 | build_request = self.makeBuildRequest(include_i386=False) | ||
674 | 279 | builds = build_request.builds | ||
675 | 280 | self.assertEqual(1, builds.count()) | ||
676 | 281 | |||
677 | 282 | build = builds.one() | ||
678 | 283 | self.assertContentEqual([], build.registry_upload_jobs) | ||
679 | 284 | upload_job = OCIRegistryUploadJob.create(build) | ||
680 | 285 | |||
681 | 286 | client = mock.Mock() | ||
682 | 287 | client.upload.side_effect = Exception("Nope! Error.") | ||
683 | 288 | self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient)) | ||
684 | 289 | |||
685 | 290 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): | ||
686 | 291 | JobRunner([upload_job]).runAll() | ||
687 | 292 | self.assertEqual(1, client.upload.call_count) | ||
688 | 293 | self.assertEqual(0, client.uploadManifestList.call_count) | ||
689 | 294 | self.assertEqual(JobStatus.FAILED, upload_job.status) | ||
690 | 295 | self.assertFalse(upload_job.build_uploaded) | ||
691 | 296 | |||
692 | 297 | def test_failing_upload_manifest_list_retries(self): | ||
693 | 298 | build_request = self.makeBuildRequest(include_i386=False) | ||
694 | 299 | builds = build_request.builds | ||
695 | 300 | self.assertEqual(1, builds.count()) | ||
696 | 301 | |||
697 | 302 | build = builds.one() | ||
698 | 303 | self.assertContentEqual([], build.registry_upload_jobs) | ||
699 | 304 | upload_job = OCIRegistryUploadJob.create(build) | ||
700 | 305 | |||
701 | 306 | client = mock.Mock() | ||
702 | 307 | client.uploadManifestList.side_effect = ( | ||
703 | 308 | OCIRegistryUploadJob.ManifestListUploadError("Nope! Error.")) | ||
704 | 309 | self.useFixture(ZopeUtilityFixture(client, IOCIRegistryClient)) | ||
705 | 310 | |||
706 | 311 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): | ||
707 | 312 | JobRunner([upload_job]).runAll() | ||
708 | 313 | self.assertEqual(1, client.upload.call_count) | ||
709 | 314 | self.assertEqual(1, client.uploadManifestList.call_count) | ||
710 | 315 | self.assertEqual(JobStatus.WAITING, upload_job.status) | ||
711 | 316 | self.assertTrue(upload_job.is_pending) | ||
712 | 317 | self.assertTrue(upload_job.build_uploaded) | ||
713 | 318 | |||
714 | 319 | # Retry should skip client.upload and only run | ||
715 | 320 | # client.uploadManifestList: | ||
716 | 321 | client.uploadManifestList.side_effect = None | ||
717 | 322 | with dbuser(config.IOCIRegistryUploadJobSource.dbuser): | ||
718 | 323 | JobRunner([upload_job]).runAll() | ||
719 | 324 | self.assertEqual(1, client.upload.call_count) | ||
720 | 325 | self.assertEqual(2, client.uploadManifestList.call_count) | ||
721 | 326 | self.assertEqual(JobStatus.COMPLETED, upload_job.status) | ||
722 | 327 | self.assertTrue(upload_job.build_uploaded) | ||
723 | 328 | |||
724 | 329 | def test_allBuildsUploaded_lock_between_two_jobs(self): | ||
725 | 330 | """Simple test to ensure that allBuildsUploaded method locks | ||
726 | 331 | rows in the database and make concurrent calls wait for that. | ||
727 | 332 | |||
728 | 333 | This is not a 100% reliable way to check that concurrent calls to | ||
729 | 334 | allBuildsUploaded will queue up since it relies on the | ||
730 | 335 | execution time, but it's a "good enough" approach: this test might | ||
731 | 336 | pass if the machine running it is *really, really* slow, but a failure | ||
732 | 337 | here will indicate that something is for sure wrong. | ||
733 | 338 | """ | ||
734 | 339 | |||
735 | 340 | class AllBuildsUploadedChecker(threading.Thread): | ||
736 | 341 | """Thread to run upload_job.allBuildsUploaded tracking the time.""" | ||
737 | 342 | def __init__(self, build_request): | ||
738 | 343 | super(AllBuildsUploadedChecker, self).__init__() | ||
739 | 344 | self.build_request = build_request | ||
740 | 345 | self.upload_job = None | ||
741 | 346 | # Locks the measurement start until we finished running the | ||
742 | 347 | # bootstrap code. Parent thread should call waitBootstrap | ||
743 | 348 | # after self.start(). | ||
744 | 349 | self.bootstrap_lock = threading.Lock() | ||
745 | 350 | self.bootstrap_lock.acquire() | ||
746 | 351 | self.result = None | ||
747 | 352 | self.error = None | ||
748 | 353 | self.start_date = None | ||
749 | 354 | self.end_date = None | ||
750 | 355 | |||
751 | 356 | @property | ||
752 | 357 | def lock_duration(self): | ||
753 | 358 | return self.end_date - self.start_date | ||
754 | 359 | |||
755 | 360 | def waitBootstrap(self): | ||
756 | 361 | """Wait until self.bootstrap finishes running.""" | ||
757 | 362 | self.bootstrap_lock.acquire() | ||
758 | 363 | # We don't actually need the lock... just wanted to wait | ||
759 | 364 | # for it. let's release it then. | ||
760 | 365 | self.bootstrap_lock.release() | ||
761 | 366 | |||
762 | 367 | def bootstrap(self): | ||
763 | 368 | try: | ||
764 | 369 | build = self.build_request.builds[1] | ||
765 | 370 | self.upload_job = OCIRegistryUploadJob.create(build) | ||
766 | 371 | finally: | ||
767 | 372 | self.bootstrap_lock.release() | ||
768 | 373 | |||
769 | 374 | def run(self): | ||
770 | 375 | with admin_logged_in(): | ||
771 | 376 | self.bootstrap() | ||
772 | 377 | self.start_date = datetime.now() | ||
773 | 378 | try: | ||
774 | 379 | self.result = self.upload_job.allBuildsUploaded( | ||
775 | 380 | self.build_request) | ||
776 | 381 | except Exception as e: | ||
777 | 382 | self.error = e | ||
778 | 383 | self.end_date = datetime.now() | ||
779 | 384 | |||
780 | 385 | # Create a build request with 2 builds. | ||
781 | 386 | build_request = self.makeBuildRequest() | ||
782 | 387 | builds = build_request.builds | ||
783 | 388 | self.assertEqual(2, builds.count()) | ||
784 | 389 | |||
785 | 390 | # Create the upload job for the first build. | ||
786 | 391 | upload_job1 = OCIRegistryUploadJob.create(builds[0]) | ||
787 | 392 | upload_job1 = removeSecurityProxy(upload_job1) | ||
788 | 393 | |||
789 | 394 | # How long the lock will be held by the first job, in seconds. | ||
790 | 395 | # Adjust to minimize false positives: a number too small here might | ||
791 | 396 | # make the test pass even if the lock is not correctly implemented. | ||
792 | 397 | # A number too big will slow down the test execution... | ||
793 | 398 | waiting_time = 2 | ||
794 | 399 | # Start a clean transaction and lock the rows at database level. | ||
795 | 400 | transaction.commit() | ||
796 | 401 | self.assertFalse(upload_job1.allBuildsUploaded(build_request)) | ||
797 | 402 | |||
798 | 403 | # Start, in parallel, another upload job to run `allBuildsUploaded`. | ||
799 | 404 | concurrent_checker = AllBuildsUploadedChecker(build_request) | ||
800 | 405 | concurrent_checker.start() | ||
801 | 406 | # Wait until concurrent_checker is ready to measure the time waiting | ||
802 | 407 | # for the database lock. | ||
803 | 408 | concurrent_checker.waitBootstrap() | ||
804 | 409 | |||
805 | 410 | # Wait a bit and release the database lock by committing current | ||
806 | 411 | # transaction. | ||
807 | 412 | time.sleep(waiting_time) | ||
808 | 413 | # Let's force the first job to be finished, just to make sure the | ||
809 | 414 | # second job will realise it's the last one running. | ||
810 | 415 | upload_job1.start() | ||
811 | 416 | upload_job1.complete() | ||
812 | 417 | transaction.commit() | ||
813 | 418 | |||
814 | 419 | # Now, the concurrent checker should have already finished running, | ||
815 | 420 | # without any error and it should have taken at least the | ||
816 | 421 | # waiting_time to finish running (since it was waiting). | ||
817 | 422 | concurrent_checker.join() | ||
818 | 423 | self.assertIsNone(concurrent_checker.error) | ||
819 | 424 | self.assertTrue(concurrent_checker.result) | ||
820 | 425 | self.assertGreaterEqual( | ||
821 | 426 | concurrent_checker.lock_duration, timedelta(seconds=waiting_time)) | ||
822 | 427 | |||
823 | 184 | def test_run_failed_registry_error(self): | 428 | def test_run_failed_registry_error(self): |
824 | 185 | # A run that fails with a registry error sets the registry upload | 429 | # A run that fails with a registry error sets the registry upload |
825 | 186 | # status to FAILED, and stores the detailed errors. | 430 | # status to FAILED, and stores the detailed errors. |
826 | diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py | |||
827 | index 3c5aa8c..8de88b4 100644 | |||
828 | --- a/lib/lp/oci/tests/test_ociregistryclient.py | |||
829 | +++ b/lib/lp/oci/tests/test_ociregistryclient.py | |||
830 | @@ -11,6 +11,7 @@ from functools import partial | |||
831 | 11 | import io | 11 | import io |
832 | 12 | import json | 12 | import json |
833 | 13 | import os | 13 | import os |
834 | 14 | import re | ||
835 | 14 | import tarfile | 15 | import tarfile |
836 | 15 | import uuid | 16 | import uuid |
837 | 16 | 17 | ||
838 | @@ -33,13 +34,17 @@ from testtools.matchers import ( | |||
839 | 33 | Raises, | 34 | Raises, |
840 | 34 | ) | 35 | ) |
841 | 35 | import transaction | 36 | import transaction |
842 | 37 | from zope.component import getUtility | ||
843 | 36 | from zope.security.proxy import removeSecurityProxy | 38 | from zope.security.proxy import removeSecurityProxy |
844 | 37 | 39 | ||
845 | 40 | from lp.buildmaster.interfaces.processor import IProcessorSet | ||
846 | 41 | from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource | ||
847 | 38 | from lp.oci.interfaces.ociregistryclient import ( | 42 | from lp.oci.interfaces.ociregistryclient import ( |
848 | 39 | BlobUploadFailed, | 43 | BlobUploadFailed, |
849 | 40 | ManifestUploadFailed, | 44 | ManifestUploadFailed, |
850 | 41 | MultipleOCIRegistryError, | 45 | MultipleOCIRegistryError, |
851 | 42 | ) | 46 | ) |
852 | 47 | from lp.oci.model.ocirecipe import OCIRecipeBuildRequest | ||
853 | 43 | from lp.oci.model.ociregistryclient import ( | 48 | from lp.oci.model.ociregistryclient import ( |
854 | 44 | BearerTokenRegistryClient, | 49 | BearerTokenRegistryClient, |
855 | 45 | OCIRegistryAuthenticationError, | 50 | OCIRegistryAuthenticationError, |
856 | @@ -50,6 +55,7 @@ from lp.oci.model.ociregistryclient import ( | |||
857 | 50 | from lp.oci.tests.helpers import OCIConfigHelperMixin | 55 | from lp.oci.tests.helpers import OCIConfigHelperMixin |
858 | 51 | from lp.services.compat import mock | 56 | from lp.services.compat import mock |
859 | 52 | from lp.testing import TestCaseWithFactory | 57 | from lp.testing import TestCaseWithFactory |
860 | 58 | from lp.testing.fixture import ZopeUtilityFixture | ||
861 | 53 | from lp.testing.layers import ( | 59 | from lp.testing.layers import ( |
862 | 54 | DatabaseFunctionalLayer, | 60 | DatabaseFunctionalLayer, |
863 | 55 | LaunchpadZopelessLayer, | 61 | LaunchpadZopelessLayer, |
864 | @@ -142,6 +148,23 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
865 | 142 | 148 | ||
866 | 143 | transaction.commit() | 149 | transaction.commit() |
867 | 144 | 150 | ||
868 | 151 | def addManifestResponses(self, push_rule, status_code=201, json=None): | ||
869 | 152 | """Add responses for manifest upload URLs.""" | ||
870 | 153 | # PUT to "anonymous" architecture-specific manifest. | ||
871 | 154 | manifests_url = "{}/v2/{}/manifests/sha256:.*".format( | ||
872 | 155 | push_rule.registry_credentials.url, | ||
873 | 156 | push_rule.image_name | ||
874 | 157 | ) | ||
875 | 158 | responses.add( | ||
876 | 159 | "PUT", re.compile(manifests_url), status=status_code, json=json) | ||
877 | 160 | |||
878 | 161 | # PUT to tagged multi-arch manifest. | ||
879 | 162 | manifests_url = "{}/v2/{}/manifests/edge".format( | ||
880 | 163 | push_rule.registry_credentials.url, | ||
881 | 164 | push_rule.image_name | ||
882 | 165 | ) | ||
883 | 166 | responses.add("PUT", manifests_url, status=status_code, json=json) | ||
884 | 167 | |||
885 | 145 | @responses.activate | 168 | @responses.activate |
886 | 146 | def test_upload(self): | 169 | def test_upload(self): |
887 | 147 | self._makeFiles() | 170 | self._makeFiles() |
888 | @@ -154,11 +177,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
889 | 154 | push_rule = self.build.recipe.push_rules[0] | 177 | push_rule = self.build.recipe.push_rules[0] |
890 | 155 | responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200) | 178 | responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200) |
891 | 156 | 179 | ||
897 | 157 | manifests_url = "{}/v2/{}/manifests/edge".format( | 180 | self.addManifestResponses(push_rule) |
893 | 158 | push_rule.registry_credentials.url, | ||
894 | 159 | push_rule.image_name | ||
895 | 160 | ) | ||
896 | 161 | responses.add("PUT", manifests_url, status=201) | ||
898 | 162 | 181 | ||
899 | 163 | self.client.upload(self.build) | 182 | self.client.upload(self.build) |
900 | 164 | 183 | ||
901 | @@ -196,7 +215,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
902 | 196 | _upload_fixture = self.useFixture(MockPatch( | 215 | _upload_fixture = self.useFixture(MockPatch( |
903 | 197 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) | 216 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) |
904 | 198 | self.useFixture(MockPatch( | 217 | self.useFixture(MockPatch( |
906 | 199 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer")) | 218 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer", |
907 | 219 | return_value=999)) | ||
908 | 200 | 220 | ||
909 | 201 | self.push_rule.registry_credentials.setCredentials({ | 221 | self.push_rule.registry_credentials.setCredentials({ |
910 | 202 | "username": "test-username", | 222 | "username": "test-username", |
911 | @@ -206,9 +226,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
912 | 206 | push_rule = self.build.recipe.push_rules[0] | 226 | push_rule = self.build.recipe.push_rules[0] |
913 | 207 | responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200) | 227 | responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200) |
914 | 208 | 228 | ||
918 | 209 | manifests_url = "{}/v2/{}/manifests/edge".format( | 229 | self.addManifestResponses(push_rule) |
916 | 210 | push_rule.registry_credentials.url, push_rule.image_name) | ||
917 | 211 | responses.add("PUT", manifests_url, status=201) | ||
919 | 212 | 230 | ||
920 | 213 | self.client.upload(self.build) | 231 | self.client.upload(self.build) |
921 | 214 | 232 | ||
922 | @@ -222,7 +240,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
923 | 222 | upload_fixture = self.useFixture(MockPatch( | 240 | upload_fixture = self.useFixture(MockPatch( |
924 | 223 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) | 241 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) |
925 | 224 | self.useFixture(MockPatch( | 242 | self.useFixture(MockPatch( |
927 | 225 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer")) | 243 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer", |
928 | 244 | return_value=999)) | ||
929 | 226 | 245 | ||
930 | 227 | push_rules = [ | 246 | push_rules = [ |
931 | 228 | self.push_rule, | 247 | self.push_rule, |
932 | @@ -237,10 +256,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
933 | 237 | responses.add( | 256 | responses.add( |
934 | 238 | "GET", "%s/v2/" % push_rule.registry_url, status=200) | 257 | "GET", "%s/v2/" % push_rule.registry_url, status=200) |
935 | 239 | 258 | ||
936 | 240 | manifests_url = "{}/v2/{}/manifests/edge".format( | ||
937 | 241 | push_rule.registry_credentials.url, push_rule.image_name) | ||
938 | 242 | status = 400 if i < 2 else 201 | 259 | status = 400 if i < 2 else 201 |
940 | 243 | responses.add("PUT", manifests_url, status=status) | 260 | self.addManifestResponses(push_rule, status_code=status) |
941 | 244 | 261 | ||
942 | 245 | error = self.assertRaises( | 262 | error = self.assertRaises( |
943 | 246 | MultipleOCIRegistryError, self.client.upload, self.build) | 263 | MultipleOCIRegistryError, self.client.upload, self.build) |
944 | @@ -466,15 +483,13 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
945 | 466 | self.useFixture(MockPatch( | 483 | self.useFixture(MockPatch( |
946 | 467 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) | 484 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) |
947 | 468 | self.useFixture(MockPatch( | 485 | self.useFixture(MockPatch( |
949 | 469 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer")) | 486 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer", |
950 | 487 | return_value=999)) | ||
951 | 470 | 488 | ||
952 | 471 | push_rule = self.build.recipe.push_rules[0] | 489 | push_rule = self.build.recipe.push_rules[0] |
953 | 472 | responses.add( | 490 | responses.add( |
954 | 473 | "GET", "{}/v2/".format(push_rule.registry_url), status=200) | 491 | "GET", "{}/v2/".format(push_rule.registry_url), status=200) |
955 | 474 | 492 | ||
956 | 475 | manifests_url = "{}/v2/{}/manifests/edge".format( | ||
957 | 476 | push_rule.registry_credentials.url, | ||
958 | 477 | push_rule.image_name) | ||
959 | 478 | put_errors = [ | 493 | put_errors = [ |
960 | 479 | { | 494 | { |
961 | 480 | "code": "MANIFEST_INVALID", | 495 | "code": "MANIFEST_INVALID", |
962 | @@ -482,8 +497,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
963 | 482 | "detail": [], | 497 | "detail": [], |
964 | 483 | }, | 498 | }, |
965 | 484 | ] | 499 | ] |
968 | 485 | responses.add( | 500 | self.addManifestResponses( |
969 | 486 | "PUT", manifests_url, status=400, json={"errors": put_errors}) | 501 | push_rule, status_code=400, json={"errors": put_errors}) |
970 | 487 | 502 | ||
971 | 488 | expected_msg = "Failed to upload manifest for {} ({}) in {}".format( | 503 | expected_msg = "Failed to upload manifest for {} ({}) in {}".format( |
972 | 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) |
973 | @@ -503,16 +518,14 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
974 | 503 | self.useFixture(MockPatch( | 518 | self.useFixture(MockPatch( |
975 | 504 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) | 519 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) |
976 | 505 | self.useFixture(MockPatch( | 520 | self.useFixture(MockPatch( |
978 | 506 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer")) | 521 | "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer", |
979 | 522 | return_value=999)) | ||
980 | 507 | 523 | ||
981 | 508 | push_rule = self.build.recipe.push_rules[0] | 524 | push_rule = self.build.recipe.push_rules[0] |
982 | 509 | responses.add( | 525 | responses.add( |
983 | 510 | "GET", "{}/v2/".format(push_rule.registry_url), status=200) | 526 | "GET", "{}/v2/".format(push_rule.registry_url), status=200) |
984 | 511 | 527 | ||
989 | 512 | manifests_url = "{}/v2/{}/manifests/edge".format( | 528 | self.addManifestResponses(push_rule, status_code=200) |
986 | 513 | push_rule.registry_credentials.url, | ||
987 | 514 | push_rule.image_name) | ||
988 | 515 | responses.add("PUT", manifests_url, status=200) | ||
990 | 516 | 529 | ||
991 | 517 | expected_msg = "Failed to upload manifest for {} ({}) in {}".format( | 530 | expected_msg = "Failed to upload manifest for {} ({}) in {}".format( |
992 | 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) |
993 | @@ -526,6 +539,64 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | |||
994 | 526 | Equals(expected_msg)), | 539 | Equals(expected_msg)), |
995 | 527 | MatchesStructure(errors=Is(None)))))) | 540 | MatchesStructure(errors=Is(None)))))) |
996 | 528 | 541 | ||
997 | 542 | @responses.activate | ||
998 | 543 | def test_multi_arch_manifest_upload(self): | ||
999 | 544 | """Ensure that multi-arch manifest upload works and tags correctly | ||
1000 | 545 | the uploaded image.""" | ||
1001 | 546 | # Creates a build request with 2 builds. | ||
1002 | 547 | recipe = self.build.recipe | ||
1003 | 548 | build1 = self.build | ||
1004 | 549 | build2 = self.factory.makeOCIRecipeBuild( | ||
1005 | 550 | recipe=recipe) | ||
1006 | 551 | naked_build1 = removeSecurityProxy(build1) | ||
1007 | 552 | naked_build2 = removeSecurityProxy(build1) | ||
1008 | 553 | naked_build1.processor = getUtility(IProcessorSet).getByName('386') | ||
1009 | 554 | naked_build2.processor = getUtility(IProcessorSet).getByName('amd64') | ||
1010 | 555 | |||
1011 | 556 | # Creates a mock IOCIRecipeRequestBuildsJobSource, as it was created | ||
1012 | 557 | # by the celery job and triggered the 2 registry uploads already. | ||
1013 | 558 | job = mock.Mock() | ||
1014 | 559 | job.builds = [build1, build2] | ||
1015 | 560 | job.uploaded_manifests = { | ||
1016 | 561 | build1.id: {"digest": "build1digest", "size": 123}, | ||
1017 | 562 | build2.id: {"digest": "build2digest", "size": 321}, | ||
1018 | 563 | } | ||
1019 | 564 | job_source = mock.Mock() | ||
1020 | 565 | job_source.getByOCIRecipeAndID.return_value = job | ||
1021 | 566 | self.useFixture( | ||
1022 | 567 | ZopeUtilityFixture(job_source, IOCIRecipeRequestBuildsJobSource)) | ||
1023 | 568 | build_request = OCIRecipeBuildRequest(recipe, -1) | ||
1024 | 569 | |||
1025 | 570 | push_rule = self.build.recipe.push_rules[0] | ||
1026 | 571 | responses.add( | ||
1027 | 572 | "GET", "{}/v2/".format(push_rule.registry_url), status=200) | ||
1028 | 573 | self.addManifestResponses(push_rule, status_code=201) | ||
1029 | 574 | |||
1030 | 575 | self.client.uploadManifestList(build_request) | ||
1031 | 576 | self.assertEqual(2, len(responses.calls)) | ||
1032 | 577 | auth_call, manifest_call = responses.calls | ||
1033 | 578 | self.assertEndsWith( | ||
1034 | 579 | manifest_call.request.url, | ||
1035 | 580 | "/v2/%s/manifests/edge" % push_rule.image_name) | ||
1036 | 581 | self.assertEqual({ | ||
1037 | 582 | "schemaVersion": 2, | ||
1038 | 583 | "mediaType": "application/" | ||
1039 | 584 | "vnd.docker.distribution.manifest.list.v2+json", | ||
1040 | 585 | "manifests": [{ | ||
1041 | 586 | "platform": {"os": "linux", "architecture": "amd64"}, | ||
1042 | 587 | "mediaType": "application/" | ||
1043 | 588 | "vnd.docker.distribution.manifest.v2+json", | ||
1044 | 589 | "digest": "build1digest", | ||
1045 | 590 | "size": 123 | ||
1046 | 591 | }, { | ||
1047 | 592 | "platform": {"os": "linux", "architecture": "386"}, | ||
1048 | 593 | "mediaType": "application/" | ||
1049 | 594 | "vnd.docker.distribution.manifest.v2+json", | ||
1050 | 595 | "digest": "build2digest", | ||
1051 | 596 | "size": 321 | ||
1052 | 597 | }] | ||
1053 | 598 | }, json.loads(manifest_call.request.body)) | ||
1054 | 599 | |||
1055 | 529 | 600 | ||
1056 | 530 | class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin, | 601 | class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin, |
1057 | 531 | TestCaseWithFactory): | 602 | TestCaseWithFactory): |
I can't effectively review the actual manifest handling - it's probably best to ask Tom to check that over.