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