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
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 3ba3348..2ffe052 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -104,12 +104,13 @@ class OCIRegistryClient:
104 before=before_log(log, logging.INFO),104 before=before_log(log, logging.INFO),
105 retry=retry_if_exception_type(ConnectionError),105 retry=retry_if_exception_type(ConnectionError),
106 stop=stop_after_attempt(5))106 stop=stop_after_attempt(5))
107 def _upload(cls, digest, push_rule, fileobj, http_client):107 def _upload(cls, digest, push_rule, fileobj, length, http_client):
108 """Upload a blob to the registry, using a given digest.108 """Upload a blob to the registry, using a given digest.
109109
110 :param digest: The digest to store the file under.110 :param digest: The digest to store the file under.
111 :param push_rule: `OCIPushRule` to use for the URL and credentials.111 :param push_rule: `OCIPushRule` to use for the URL and credentials.
112 :param fileobj: An object that looks like a buffer.112 :param fileobj: An object that looks like a buffer.
113 :param length: The length of the blob in bytes.
113114
114 :raises BlobUploadFailed: if the registry does not accept the blob.115 :raises BlobUploadFailed: if the registry does not accept the blob.
115 """116 """
@@ -137,6 +138,7 @@ class OCIRegistryClient:
137 post_location,138 post_location,
138 params=query_parsed,139 params=query_parsed,
139 data=fileobj,140 data=fileobj,
141 headers={"Content-Length": str(length)},
140 method="PUT")142 method="PUT")
141 except HTTPError as http_error:143 except HTTPError as http_error:
142 put_response = http_error.response144 put_response = http_error.response
@@ -160,13 +162,18 @@ class OCIRegistryClient:
160 """162 """
161 lfa.open()163 lfa.open()
162 try:164 try:
163 un_zipped = tarfile.open(fileobj=lfa, mode='r|gz')165 with tarfile.open(fileobj=lfa, mode='r|gz') as un_zipped:
164 for tarinfo in un_zipped:166 for tarinfo in un_zipped:
165 if tarinfo.name != 'layer.tar':167 if tarinfo.name != 'layer.tar':
166 continue168 continue
167 fileobj = un_zipped.extractfile(tarinfo)169 fileobj = un_zipped.extractfile(tarinfo)
168 cls._upload(digest, push_rule, fileobj, http_client)170 try:
169 return tarinfo.size171 cls._upload(
172 digest, push_rule, fileobj, tarinfo.size,
173 http_client)
174 finally:
175 fileobj.close()
176 return tarinfo.size
170 finally:177 finally:
171 lfa.close()178 lfa.close()
172179
@@ -328,6 +335,7 @@ class OCIRegistryClient:
328 "sha256:{}".format(config_sha),335 "sha256:{}".format(config_sha),
329 push_rule,336 push_rule,
330 BytesIO(config_json),337 BytesIO(config_json),
338 len(config_json),
331 http_client)339 http_client)
332340
333 # Build the registry manifest from the image manifest341 # Build the registry manifest from the image manifest
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index dcf71a8..3e0d90f 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -26,6 +26,7 @@ import responses
26from tenacity import RetryError26from tenacity import RetryError
27from testtools.matchers import (27from testtools.matchers import (
28 AfterPreprocessing,28 AfterPreprocessing,
29 ContainsDict,
29 Equals,30 Equals,
30 Is,31 Is,
31 MatchesAll,32 MatchesAll,
@@ -67,6 +68,7 @@ from lp.oci.tests.helpers import OCIConfigHelperMixin
67from lp.registry.interfaces.series import SeriesStatus68from lp.registry.interfaces.series import SeriesStatus
68from lp.services.compat import mock69from lp.services.compat import mock
69from lp.services.features.testing import FeatureFixture70from lp.services.features.testing import FeatureFixture
71from lp.services.tarfile_helpers import LaunchpadWriteTarFile
70from lp.testing import (72from lp.testing import (
71 admin_logged_in,73 admin_logged_in,
72 person_logged_in,74 person_logged_in,
@@ -87,9 +89,11 @@ class SpyProxyCallsMixin:
87 self.proxy_call_count += 189 self.proxy_call_count += 1
88 return proxy_urlfetch(*args, **kwargs)90 return proxy_urlfetch(*args, **kwargs)
8991
90 self.useFixture(MockPatch(92 proxy_mock = self.useFixture(MockPatch(
91 'lp.oci.model.ociregistryclient.proxy_urlfetch',93 'lp.oci.model.ociregistryclient.proxy_urlfetch',
92 side_effect=count_proxy_call_count))94 side_effect=count_proxy_call_count)).mock
95 # Avoid reference cycles with file objects passed to urlfetch.
96 self.addCleanup(proxy_mock.reset_mock)
9397
9498
95class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,99class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
@@ -459,7 +463,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
459 push_rule = self.build.recipe.push_rules[0]463 push_rule = self.build.recipe.push_rules[0]
460 push_rule.registry_credentials.setCredentials({})464 push_rule.registry_credentials.setCredentials({})
461 self.client._upload(465 self.client._upload(
462 "test-digest", push_rule, None, http_client)466 "test-digest", push_rule, None, 0, http_client)
463467
464 self.assertEqual(len(responses.calls), self.proxy_call_count)468 self.assertEqual(len(responses.calls), self.proxy_call_count)
465 # There should be no auth headers for these calls469 # There should be no auth headers for these calls
@@ -481,6 +485,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
481 "test-digest",485 "test-digest",
482 push_rule,486 push_rule,
483 None,487 None,
488 0,
484 http_client)489 http_client)
485490
486 @responses.activate491 @responses.activate
@@ -493,7 +498,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
493 push_rule.registry_credentials.setCredentials({498 push_rule.registry_credentials.setCredentials({
494 "username": "user", "password": "password"})499 "username": "user", "password": "password"})
495 self.client._upload(500 self.client._upload(
496 "test-digest", push_rule, None,501 "test-digest", push_rule, None, 0,
497 http_client)502 http_client)
498503
499 self.assertEqual(len(responses.calls), self.proxy_call_count)504 self.assertEqual(len(responses.calls), self.proxy_call_count)
@@ -521,7 +526,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
521 push_rule = self.build.recipe.push_rules[0]526 push_rule = self.build.recipe.push_rules[0]
522 self.client._upload(527 self.client._upload(
523 "test-digest", push_rule,528 "test-digest", push_rule,
524 None, RegistryHTTPClient(push_rule))529 None, 0, RegistryHTTPClient(push_rule))
525 except RetryError:530 except RetryError:
526 pass531 pass
527 # Check that tenacity and our counting agree532 # Check that tenacity and our counting agree
@@ -552,7 +557,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
552 self.assertThat(557 self.assertThat(
553 partial(558 partial(
554 self.client._upload,559 self.client._upload,
555 "test-digest", push_rule, None, http_client),560 "test-digest", push_rule, None, 0, http_client),
556 Raises(MatchesException(561 Raises(MatchesException(
557 BlobUploadFailed,562 BlobUploadFailed,
558 MatchesAll(563 MatchesAll(
@@ -578,7 +583,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
578 self.assertThat(583 self.assertThat(
579 partial(584 partial(
580 self.client._upload,585 self.client._upload,
581 "test-digest", push_rule, None, http_client),586 "test-digest", push_rule, None, 0, http_client),
582 Raises(MatchesException(587 Raises(MatchesException(
583 BlobUploadFailed,588 BlobUploadFailed,
584 MatchesAll(589 MatchesAll(
@@ -652,6 +657,30 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
652 MatchesStructure(errors=Is(None))))))657 MatchesStructure(errors=Is(None))))))
653658
654 @responses.activate659 @responses.activate
660 def test_upload_layer_put_blob_sends_content_length(self):
661 lfa = self.factory.makeLibraryFileAlias(
662 content=LaunchpadWriteTarFile.files_to_bytes(
663 {"layer.tar": b"test layer"}))
664 transaction.commit()
665 push_rule = self.build.recipe.push_rules[0]
666 http_client = RegistryHTTPClient(push_rule)
667 blobs_url = "{}/blobs/{}".format(
668 http_client.api_url, "test-digest")
669 uploads_url = "{}/blobs/uploads/".format(http_client.api_url)
670 upload_url = "{}/blobs/uploads/{}".format(
671 http_client.api_url, uuid.uuid4())
672 responses.add("HEAD", blobs_url, status=404)
673 responses.add("POST", uploads_url, headers={"Location": upload_url})
674 responses.add("PUT", upload_url, status=201)
675 self.client._upload_layer("test-digest", push_rule, lfa, http_client)
676 self.assertThat(responses.calls[2].request, MatchesStructure(
677 method=Equals("PUT"),
678 headers=ContainsDict({
679 "Content-Length": Equals(str(len(b"test layer"))),
680 }),
681 ))
682
683 @responses.activate
655 def test_multi_arch_manifest_upload_skips_superseded_builds(self):684 def test_multi_arch_manifest_upload_skips_superseded_builds(self):
656 recipe = self.build.recipe685 recipe = self.build.recipe
657 processor = self.build.processor686 processor = self.build.processor

Subscribers

People subscribed via source and target branches

to status/vote changes: