Merge ~pappacena/launchpad:oci-upload-manifest-upsert into launchpad:master
- Git
- lp:~pappacena/launchpad
- oci-upload-manifest-upsert
- Merge into master
Proposed by
Thiago F. Pappacena
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Thiago F. Pappacena | ||||
Approved revision: | 62fff65c0107947b0e5288526797b0db44f991b5 | ||||
Merge reported by: | Otto Co-Pilot | ||||
Merged at revision: | not available | ||||
Proposed branch: | ~pappacena/launchpad:oci-upload-manifest-upsert | ||||
Merge into: | launchpad:master | ||||
Diff against target: |
345 lines (+257/-21) 2 files modified
lib/lp/oci/model/ociregistryclient.py (+68/-16) lib/lp/oci/tests/test_ociregistryclient.py (+189/-5) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+394265@code.launchpad.net |
Commit message
Updating existing manifest (if any) when uploading OCI manifest, instead of totally replacing it.
Description of the change
To post a comment you must log in.
- 6db1b18... by Thiago F. Pappacena
-
Do not override current manifest on HTTP errors other than 404
Revision history for this message
Thiago F. Pappacena (pappacena) wrote : | # |
Pushed the requested changes.
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
- 62fff65... by Thiago F. Pappacena
-
More log details when 404 GETting existing OCI manifest
Revision history for this message
Thiago F. Pappacena (pappacena) : | # |
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py |
2 | index be370b4..56225da 100644 |
3 | --- a/lib/lp/oci/model/ociregistryclient.py |
4 | +++ b/lib/lp/oci/model/ociregistryclient.py |
5 | @@ -235,6 +235,18 @@ class OCIRegistryClient: |
6 | return "{}".format("edge") |
7 | |
8 | @classmethod |
9 | + def _getCurrentRegistryManifest(cls, http_client, push_rule): |
10 | + """Get the current manifest for the given push rule. If manifest |
11 | + doesn't exist, raises HTTPError. |
12 | + """ |
13 | + tag = cls._calculateTag(None, push_rule) |
14 | + url = "/manifests/{}".format(tag) |
15 | + accept = "application/vnd.docker.distribution.manifest.list.v2+json" |
16 | + response = http_client.requestPath( |
17 | + url, method="GET", headers={"Accept": accept}) |
18 | + return response.json() |
19 | + |
20 | + @classmethod |
21 | def _uploadRegistryManifest(cls, http_client, registry_manifest, |
22 | push_rule, build=None): |
23 | """Uploads the build manifest, returning its content information. |
24 | @@ -353,11 +365,46 @@ class OCIRegistryClient: |
25 | raise MultipleOCIRegistryError(exceptions) |
26 | |
27 | @classmethod |
28 | - def makeMultiArchManifest(cls, build_request, uploaded_builds): |
29 | + def makeMultiArchManifest(cls, http_client, push_rule, build_request, |
30 | + uploaded_builds): |
31 | """Returns the multi-arch manifest content including all uploaded |
32 | builds of the given build_request. |
33 | """ |
34 | - manifests = [] |
35 | + def get_manifest_for_architecture(manifests, arch): |
36 | + """Find, in the manifests list, the manifest for the given arch.""" |
37 | + expected_platform = {"architecture": arch, "os": "linux"} |
38 | + for m in manifests: |
39 | + if m["platform"] == expected_platform: |
40 | + return m |
41 | + return None |
42 | + |
43 | + try: |
44 | + current_manifest = cls._getCurrentRegistryManifest( |
45 | + http_client, push_rule) |
46 | + # Check if the current manifest is not an incompatible version. |
47 | + version = current_manifest.get("schemaVersion", 1) |
48 | + if version < 2 or "manifests" not in current_manifest: |
49 | + current_manifest = None |
50 | + except HTTPError as e: |
51 | + if e.response.status_code == 404: |
52 | + # If there is no manifest file (or it doesn't follow the |
53 | + # multi-arch spec), we should proceed adding our own |
54 | + # manifest file. |
55 | + current_manifest = None |
56 | + msg_tpl = ( |
57 | + "No multi-arch manifest on registry %s (image name: %s). " |
58 | + "Uploading a new one.") |
59 | + log.info(msg_tpl % ( |
60 | + push_rule.registry_url, push_rule.image_name)) |
61 | + else: |
62 | + raise |
63 | + if current_manifest is None: |
64 | + current_manifest = { |
65 | + "schemaVersion": 2, |
66 | + "mediaType": ("application/" |
67 | + "vnd.docker.distribution.manifest.list.v2+json"), |
68 | + "manifests": []} |
69 | + manifests = current_manifest["manifests"] |
70 | for build in uploaded_builds: |
71 | build_manifest = build_request.uploaded_manifests.get(build.id) |
72 | if not build_manifest: |
73 | @@ -365,28 +412,33 @@ class OCIRegistryClient: |
74 | digest = build_manifest["digest"] |
75 | size = build_manifest["size"] |
76 | arch = build.processor.name |
77 | - manifests.append({ |
78 | - "mediaType": ("application/" |
79 | - "vnd.docker.distribution.manifest.v2+json"), |
80 | - "size": size, |
81 | - "digest": digest, |
82 | - "platform": {"architecture": arch, "os": "linux"} |
83 | - }) |
84 | - return { |
85 | - "schemaVersion": 2, |
86 | - "mediaType": ("application/" |
87 | - "vnd.docker.distribution.manifest.list.v2+json"), |
88 | - "manifests": manifests} |
89 | + |
90 | + manifest = get_manifest_for_architecture(manifests, arch) |
91 | + if manifest is None: |
92 | + manifest = { |
93 | + "mediaType": ("application/" |
94 | + "vnd.docker.distribution.manifest.v2+json"), |
95 | + "size": size, |
96 | + "digest": digest, |
97 | + "platform": {"architecture": arch, "os": "linux"} |
98 | + } |
99 | + manifests.append(manifest) |
100 | + else: |
101 | + manifest["digest"] = digest |
102 | + manifest["size"] = size |
103 | + manifest["platform"]["architecture"] = arch |
104 | + |
105 | + return current_manifest |
106 | |
107 | @classmethod |
108 | def uploadManifestList(cls, build_request, uploaded_builds): |
109 | """Uploads to all build_request.recipe.push_rules the manifest list |
110 | for the builds in the given build_request. |
111 | """ |
112 | - multi_manifest_content = cls.makeMultiArchManifest( |
113 | - build_request, uploaded_builds) |
114 | for push_rule in build_request.recipe.push_rules: |
115 | http_client = RegistryHTTPClient.getInstance(push_rule) |
116 | + multi_manifest_content = cls.makeMultiArchManifest( |
117 | + http_client, push_rule, build_request, uploaded_builds) |
118 | cls._uploadRegistryManifest( |
119 | http_client, multi_manifest_content, push_rule, build=None) |
120 | |
121 | diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py |
122 | index 162de72..65e416b 100644 |
123 | --- a/lib/lp/oci/tests/test_ociregistryclient.py |
124 | +++ b/lib/lp/oci/tests/test_ociregistryclient.py |
125 | @@ -542,7 +542,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, |
126 | MatchesStructure(errors=Is(None)))))) |
127 | |
128 | @responses.activate |
129 | - def test_multi_arch_manifest_upload(self): |
130 | + def test_multi_arch_manifest_upload_new_manifest(self): |
131 | """Ensure that multi-arch manifest upload works and tags correctly |
132 | the uploaded image.""" |
133 | # Creates a build request with 2 builds. |
134 | @@ -576,15 +576,21 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, |
135 | |
136 | push_rule = self.build.recipe.push_rules[0] |
137 | responses.add( |
138 | + "GET", "{}/v2/{}/manifests/edge".format( |
139 | + push_rule.registry_url, push_rule.image_name), |
140 | + status=404) |
141 | + self.addManifestResponses(push_rule, status_code=201) |
142 | + |
143 | + responses.add( |
144 | "GET", "{}/v2/".format(push_rule.registry_url), status=200) |
145 | self.addManifestResponses(push_rule, status_code=201) |
146 | |
147 | # Let's try to generate the manifest for just 2 of the 3 builds: |
148 | self.client.uploadManifestList(build_request, [build1, build2]) |
149 | - self.assertEqual(2, len(responses.calls)) |
150 | - auth_call, manifest_call = responses.calls |
151 | + self.assertEqual(3, len(responses.calls)) |
152 | + auth_call, get_manifest_call, send_manifest_call = responses.calls |
153 | self.assertEndsWith( |
154 | - manifest_call.request.url, |
155 | + send_manifest_call.request.url, |
156 | "/v2/%s/manifests/edge" % push_rule.image_name) |
157 | self.assertEqual({ |
158 | "schemaVersion": 2, |
159 | @@ -603,7 +609,185 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, |
160 | "digest": "build2digest", |
161 | "size": 321 |
162 | }] |
163 | - }, json.loads(manifest_call.request.body)) |
164 | + }, json.loads(send_manifest_call.request.body)) |
165 | + |
166 | + @responses.activate |
167 | + def test_multi_arch_manifest_upload_update_manifest(self): |
168 | + """Makes sure we update only new architectures if there is already |
169 | + a manifest file in registry. |
170 | + """ |
171 | + current_manifest = { |
172 | + "schemaVersion": 2, |
173 | + "mediaType": "application/" |
174 | + "vnd.docker.distribution.manifest.list.v2+json", |
175 | + "manifests": [{ |
176 | + "platform": {"os": "linux", "architecture": "386"}, |
177 | + "mediaType": "application/" |
178 | + "vnd.docker.distribution.manifest.v2+json", |
179 | + "digest": "initial-386-digest", |
180 | + "size": 110 |
181 | + }, { |
182 | + "platform": {"os": "linux", "architecture": "amd64"}, |
183 | + "mediaType": "application/" |
184 | + "vnd.docker.distribution.manifest.v2+json", |
185 | + "digest": "initial-amd64-digest", |
186 | + "size": 220 |
187 | + }] |
188 | + } |
189 | + |
190 | + # Creates a build request with 2 builds: amd64 (which is already in |
191 | + # the manifest) and hppa (that should be added) |
192 | + recipe = self.build.recipe |
193 | + build1 = self.build |
194 | + build2 = self.factory.makeOCIRecipeBuild( |
195 | + recipe=recipe) |
196 | + naked_build1 = removeSecurityProxy(build1) |
197 | + naked_build2 = removeSecurityProxy(build2) |
198 | + naked_build1.processor = getUtility(IProcessorSet).getByName('amd64') |
199 | + naked_build2.processor = getUtility(IProcessorSet).getByName('hppa') |
200 | + |
201 | + # Creates a mock IOCIRecipeRequestBuildsJobSource, as it was created |
202 | + # by the celery job and triggered the 3 registry uploads already. |
203 | + job = mock.Mock() |
204 | + job.builds = [build1, build2] |
205 | + job.uploaded_manifests = { |
206 | + build1.id: {"digest": "new-build1-digest", "size": 1111}, |
207 | + build2.id: {"digest": "new-build2-digest", "size": 2222}, |
208 | + } |
209 | + job_source = mock.Mock() |
210 | + job_source.getByOCIRecipeAndID.return_value = job |
211 | + self.useFixture( |
212 | + ZopeUtilityFixture(job_source, IOCIRecipeRequestBuildsJobSource)) |
213 | + build_request = OCIRecipeBuildRequest(recipe, -1) |
214 | + |
215 | + push_rule = self.build.recipe.push_rules[0] |
216 | + responses.add( |
217 | + "GET", "{}/v2/{}/manifests/edge".format( |
218 | + push_rule.registry_url, push_rule.image_name), |
219 | + json=current_manifest, |
220 | + status=200) |
221 | + self.addManifestResponses(push_rule, status_code=201) |
222 | + |
223 | + responses.add( |
224 | + "GET", "{}/v2/".format(push_rule.registry_url), status=200) |
225 | + self.addManifestResponses(push_rule, status_code=201) |
226 | + |
227 | + self.client.uploadManifestList(build_request, [build1, build2]) |
228 | + self.assertEqual(3, len(responses.calls)) |
229 | + auth_call, get_manifest_call, send_manifest_call = responses.calls |
230 | + self.assertEndsWith( |
231 | + send_manifest_call.request.url, |
232 | + "/v2/%s/manifests/edge" % push_rule.image_name) |
233 | + self.assertEqual({ |
234 | + "schemaVersion": 2, |
235 | + "mediaType": "application/" |
236 | + "vnd.docker.distribution.manifest.list.v2+json", |
237 | + "manifests": [{ |
238 | + "platform": {"os": "linux", "architecture": "386"}, |
239 | + "mediaType": "application/" |
240 | + "vnd.docker.distribution.manifest.v2+json", |
241 | + "digest": "initial-386-digest", |
242 | + "size": 110 |
243 | + }, { |
244 | + "platform": {"os": "linux", "architecture": "amd64"}, |
245 | + "mediaType": "application/" |
246 | + "vnd.docker.distribution.manifest.v2+json", |
247 | + "digest": "new-build1-digest", |
248 | + "size": 1111 |
249 | + }, { |
250 | + "platform": {"os": "linux", "architecture": "hppa"}, |
251 | + "mediaType": "application/" |
252 | + "vnd.docker.distribution.manifest.v2+json", |
253 | + "digest": "new-build2-digest", |
254 | + "size": 2222 |
255 | + }] |
256 | + }, json.loads(send_manifest_call.request.body)) |
257 | + |
258 | + @responses.activate |
259 | + def test_multi_arch_manifest_upload_invalid_current_manifest(self): |
260 | + """Makes sure we create a new multi-arch manifest if existing manifest |
261 | + file is using another unknown format. |
262 | + """ |
263 | + current_manifest = {'schemaVersion': 1, 'layers': []} |
264 | + |
265 | + # Creates a build request with 1 build for amd64. |
266 | + recipe = self.build.recipe |
267 | + build1 = self.build |
268 | + naked_build1 = removeSecurityProxy(build1) |
269 | + naked_build1.processor = getUtility(IProcessorSet).getByName('amd64') |
270 | + |
271 | + job = mock.Mock() |
272 | + job.builds = [build1] |
273 | + job.uploaded_manifests = { |
274 | + build1.id: {"digest": "new-build1-digest", "size": 1111}, |
275 | + } |
276 | + job_source = mock.Mock() |
277 | + job_source.getByOCIRecipeAndID.return_value = job |
278 | + self.useFixture( |
279 | + ZopeUtilityFixture(job_source, IOCIRecipeRequestBuildsJobSource)) |
280 | + build_request = OCIRecipeBuildRequest(recipe, -1) |
281 | + |
282 | + push_rule = self.build.recipe.push_rules[0] |
283 | + responses.add( |
284 | + "GET", "{}/v2/{}/manifests/edge".format( |
285 | + push_rule.registry_url, push_rule.image_name), |
286 | + json=current_manifest, |
287 | + status=200) |
288 | + self.addManifestResponses(push_rule, status_code=201) |
289 | + |
290 | + responses.add( |
291 | + "GET", "{}/v2/".format(push_rule.registry_url), status=200) |
292 | + self.addManifestResponses(push_rule, status_code=201) |
293 | + |
294 | + self.client.uploadManifestList(build_request, [build1]) |
295 | + self.assertEqual(3, len(responses.calls)) |
296 | + auth_call, get_manifest_call, send_manifest_call = responses.calls |
297 | + self.assertEndsWith( |
298 | + send_manifest_call.request.url, |
299 | + "/v2/%s/manifests/edge" % push_rule.image_name) |
300 | + self.assertEqual({ |
301 | + "schemaVersion": 2, |
302 | + "mediaType": "application/" |
303 | + "vnd.docker.distribution.manifest.list.v2+json", |
304 | + "manifests": [{ |
305 | + "platform": {"os": "linux", "architecture": "amd64"}, |
306 | + "mediaType": "application/" |
307 | + "vnd.docker.distribution.manifest.v2+json", |
308 | + "digest": "new-build1-digest", |
309 | + "size": 1111 |
310 | + }] |
311 | + }, json.loads(send_manifest_call.request.body)) |
312 | + |
313 | + @responses.activate |
314 | + def test_multi_arch_manifest_upload_registry_error_fetching_current(self): |
315 | + """Makes sure we abort the image upload if we get an error that is |
316 | + not 404 when fetching the current manifest file. |
317 | + """ |
318 | + job = mock.Mock() |
319 | + job.builds = [self.build] |
320 | + job.uploaded_manifests = { |
321 | + self.build.id: {"digest": "new-build1-digest", "size": 1111}, |
322 | + } |
323 | + job_source = mock.Mock() |
324 | + job_source.getByOCIRecipeAndID.return_value = job |
325 | + self.useFixture( |
326 | + ZopeUtilityFixture(job_source, IOCIRecipeRequestBuildsJobSource)) |
327 | + build_request = OCIRecipeBuildRequest(self.build.recipe, -1) |
328 | + |
329 | + push_rule = self.build.recipe.push_rules[0] |
330 | + responses.add( |
331 | + "GET", "{}/v2/{}/manifests/edge".format( |
332 | + push_rule.registry_url, push_rule.image_name), |
333 | + json={"error": "Unknown"}, |
334 | + status=503) |
335 | + |
336 | + responses.add( |
337 | + "GET", "{}/v2/".format(push_rule.registry_url), status=200) |
338 | + self.addManifestResponses(push_rule, status_code=201) |
339 | + |
340 | + self.assertRaises( |
341 | + HTTPError, self.client.uploadManifestList, |
342 | + build_request, [self.build]) |
343 | |
344 | |
345 | class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin, |
LGTM in principle, but I'd definitely like to see a bit more thought on error handling before landing this, since at present it could be difficult to debug what's going on if there's some kind of outage and I'm not sure the implemented behaviour in that case is necessarily what we want.