Merge ~cjwatson/launchpad:oci-registry-client-content-length into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: b342f4678ec4733184f8bc79b7093730a7af73a1
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:oci-registry-client-content-length
Merge into: launchpad:master
Diff against target: 179 lines (+52/-15)
2 files modified
lib/lp/oci/model/ociregistryclient.py (+16/-8)
lib/lp/oci/tests/test_ociregistryclient.py (+36/-7)
Reviewer Review Type Date Requested Status
Thiago F. Pappacena (community) Approve
Review via email: mp+399526@code.launchpad.net

Commit message

Send Content-Length when uploading OCI blobs to a registry

Description of the change

In Python 3.5, `tarfile.ExFileObject` has a `fileno` method that raises `AttributeError`, which is enough to confuse `requests` into thinking that it needs to do a chunked upload, which in turn causes `urllib3` not to use the configured proxy. Explicitly passing a Content-Length header is polite anyway, and works around this misbehaviour.

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

Good catch!

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 3ba3348..2ffe052 100644
3--- a/lib/lp/oci/model/ociregistryclient.py
4+++ b/lib/lp/oci/model/ociregistryclient.py
5@@ -104,12 +104,13 @@ class OCIRegistryClient:
6 before=before_log(log, logging.INFO),
7 retry=retry_if_exception_type(ConnectionError),
8 stop=stop_after_attempt(5))
9- def _upload(cls, digest, push_rule, fileobj, http_client):
10+ def _upload(cls, digest, push_rule, fileobj, length, http_client):
11 """Upload a blob to the registry, using a given digest.
12
13 :param digest: The digest to store the file under.
14 :param push_rule: `OCIPushRule` to use for the URL and credentials.
15 :param fileobj: An object that looks like a buffer.
16+ :param length: The length of the blob in bytes.
17
18 :raises BlobUploadFailed: if the registry does not accept the blob.
19 """
20@@ -137,6 +138,7 @@ class OCIRegistryClient:
21 post_location,
22 params=query_parsed,
23 data=fileobj,
24+ headers={"Content-Length": str(length)},
25 method="PUT")
26 except HTTPError as http_error:
27 put_response = http_error.response
28@@ -160,13 +162,18 @@ class OCIRegistryClient:
29 """
30 lfa.open()
31 try:
32- un_zipped = tarfile.open(fileobj=lfa, mode='r|gz')
33- for tarinfo in un_zipped:
34- if tarinfo.name != 'layer.tar':
35- continue
36- fileobj = un_zipped.extractfile(tarinfo)
37- cls._upload(digest, push_rule, fileobj, http_client)
38- return tarinfo.size
39+ with tarfile.open(fileobj=lfa, mode='r|gz') as un_zipped:
40+ for tarinfo in un_zipped:
41+ if tarinfo.name != 'layer.tar':
42+ continue
43+ fileobj = un_zipped.extractfile(tarinfo)
44+ try:
45+ cls._upload(
46+ digest, push_rule, fileobj, tarinfo.size,
47+ http_client)
48+ finally:
49+ fileobj.close()
50+ return tarinfo.size
51 finally:
52 lfa.close()
53
54@@ -328,6 +335,7 @@ class OCIRegistryClient:
55 "sha256:{}".format(config_sha),
56 push_rule,
57 BytesIO(config_json),
58+ len(config_json),
59 http_client)
60
61 # Build the registry manifest from the image manifest
62diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
63index dcf71a8..3e0d90f 100644
64--- a/lib/lp/oci/tests/test_ociregistryclient.py
65+++ b/lib/lp/oci/tests/test_ociregistryclient.py
66@@ -26,6 +26,7 @@ import responses
67 from tenacity import RetryError
68 from testtools.matchers import (
69 AfterPreprocessing,
70+ ContainsDict,
71 Equals,
72 Is,
73 MatchesAll,
74@@ -67,6 +68,7 @@ from lp.oci.tests.helpers import OCIConfigHelperMixin
75 from lp.registry.interfaces.series import SeriesStatus
76 from lp.services.compat import mock
77 from lp.services.features.testing import FeatureFixture
78+from lp.services.tarfile_helpers import LaunchpadWriteTarFile
79 from lp.testing import (
80 admin_logged_in,
81 person_logged_in,
82@@ -87,9 +89,11 @@ class SpyProxyCallsMixin:
83 self.proxy_call_count += 1
84 return proxy_urlfetch(*args, **kwargs)
85
86- self.useFixture(MockPatch(
87+ proxy_mock = self.useFixture(MockPatch(
88 'lp.oci.model.ociregistryclient.proxy_urlfetch',
89- side_effect=count_proxy_call_count))
90+ side_effect=count_proxy_call_count)).mock
91+ # Avoid reference cycles with file objects passed to urlfetch.
92+ self.addCleanup(proxy_mock.reset_mock)
93
94
95 class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
96@@ -459,7 +463,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
97 push_rule = self.build.recipe.push_rules[0]
98 push_rule.registry_credentials.setCredentials({})
99 self.client._upload(
100- "test-digest", push_rule, None, http_client)
101+ "test-digest", push_rule, None, 0, http_client)
102
103 self.assertEqual(len(responses.calls), self.proxy_call_count)
104 # There should be no auth headers for these calls
105@@ -481,6 +485,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
106 "test-digest",
107 push_rule,
108 None,
109+ 0,
110 http_client)
111
112 @responses.activate
113@@ -493,7 +498,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
114 push_rule.registry_credentials.setCredentials({
115 "username": "user", "password": "password"})
116 self.client._upload(
117- "test-digest", push_rule, None,
118+ "test-digest", push_rule, None, 0,
119 http_client)
120
121 self.assertEqual(len(responses.calls), self.proxy_call_count)
122@@ -521,7 +526,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
123 push_rule = self.build.recipe.push_rules[0]
124 self.client._upload(
125 "test-digest", push_rule,
126- None, RegistryHTTPClient(push_rule))
127+ None, 0, RegistryHTTPClient(push_rule))
128 except RetryError:
129 pass
130 # Check that tenacity and our counting agree
131@@ -552,7 +557,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
132 self.assertThat(
133 partial(
134 self.client._upload,
135- "test-digest", push_rule, None, http_client),
136+ "test-digest", push_rule, None, 0, http_client),
137 Raises(MatchesException(
138 BlobUploadFailed,
139 MatchesAll(
140@@ -578,7 +583,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
141 self.assertThat(
142 partial(
143 self.client._upload,
144- "test-digest", push_rule, None, http_client),
145+ "test-digest", push_rule, None, 0, http_client),
146 Raises(MatchesException(
147 BlobUploadFailed,
148 MatchesAll(
149@@ -652,6 +657,30 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
150 MatchesStructure(errors=Is(None))))))
151
152 @responses.activate
153+ def test_upload_layer_put_blob_sends_content_length(self):
154+ lfa = self.factory.makeLibraryFileAlias(
155+ content=LaunchpadWriteTarFile.files_to_bytes(
156+ {"layer.tar": b"test layer"}))
157+ transaction.commit()
158+ push_rule = self.build.recipe.push_rules[0]
159+ http_client = RegistryHTTPClient(push_rule)
160+ blobs_url = "{}/blobs/{}".format(
161+ http_client.api_url, "test-digest")
162+ uploads_url = "{}/blobs/uploads/".format(http_client.api_url)
163+ upload_url = "{}/blobs/uploads/{}".format(
164+ http_client.api_url, uuid.uuid4())
165+ responses.add("HEAD", blobs_url, status=404)
166+ responses.add("POST", uploads_url, headers={"Location": upload_url})
167+ responses.add("PUT", upload_url, status=201)
168+ self.client._upload_layer("test-digest", push_rule, lfa, http_client)
169+ self.assertThat(responses.calls[2].request, MatchesStructure(
170+ method=Equals("PUT"),
171+ headers=ContainsDict({
172+ "Content-Length": Equals(str(len(b"test layer"))),
173+ }),
174+ ))
175+
176+ @responses.activate
177 def test_multi_arch_manifest_upload_skips_superseded_builds(self):
178 recipe = self.build.recipe
179 processor = self.build.processor

Subscribers

People subscribed via source and target branches

to status/vote changes: