Merge ~pappacena/launchpad:oci-upload-manifest-upsert into launchpad: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)
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.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

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.

review: Approve
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
1diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
2index 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
121diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
122index 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,