Merge ~pappacena/launchpad:refactor-oci-push-auth into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: a0addb5c5e0fe4b3698c58466ca0b283a81fb782
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:refactor-oci-push-auth
Merge into: launchpad:master
Diff against target: 455 lines (+157/-83)
3 files modified
lib/lp/oci/interfaces/ocipushrule.py (+1/-1)
lib/lp/oci/model/ociregistryclient.py (+88/-38)
lib/lp/oci/tests/test_ociregistryclient.py (+68/-44)
Reviewer Review Type Date Requested Status
Tom Wardill (community) Approve
Colin Watson (community) Approve
Review via email: mp+384052@code.launchpad.net

Commit message

Refactoring OCIRegistryClient to prepare for DockerHub.

Dockerhub authentication seems to need token authorization, and the way OCIRegistryClient is structured today (with HTTP Basic auth) makes it a bit difficult to keep the token. This refactoring creates a couple of differente "HTTP clients": one for the registry protocol we have today, and another for DocherHub (to be implemented in another MP).

Description of the change

Summary of reorganization:

- Creation of RegistryHTTPClient helper class, that holds authentication information and does the HTTP requests to registry. That's where DockerHub auth will be implemented in the future.

- OCIRegistryClient.upload delegates to RegistryHTTPClient the authentication info, and stops dealing with tuple of (username, password)

- Changed the tests to use instances of RegistryHTTPClient instead of tuple of auth information

- Changed the tests to use RegistryHTTPClient's URL builder, instead of hardcoding it (DockerHub will need to use a different URL schema, so this was also delegated to RegistryHTTPClient class)

- Not really necessary for the tests we have, but I've changed the fake layers files to be a bit more realistic (it's helping me to debug things on dockerhub, and I guess it doesn't hurt to make it a bit closer to reality here).

In the end, a lot of lines got changes, but nothing should have changed in the behavior.

To post a comment you must log in.
a0addb5... by Thiago F. Pappacena

Fixing naming conventions

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Tom Wardill (twom) wrote :

I like the approach of wrapping urlfetch to handle the different authentication methods, this seems pretty extensible for the future.

However, I think there's a better way to determine which client and auth method to use rather than just hardcoding the registry url.

Doing `docker login` with the client makes a request to <registry url>/v2/ (See: https://docs.docker.com/registry/spec/api/#api-version-check). This is a call that requires authentication, so making it without the auth will fail. However, it does include the required authentication method in the response headers: `Www-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`.
A registry that uses Basic (like ROCKS) looks like: `Www-Authenticate: Basic realm="docker-registry"`

Given this, I think we can rename the `DockerHubHTTPClient` to something like `BearerTokenRegistryClient` or similar.

The flow will then look similar to how the cli client does:
1. Make request to /v2/ (`docker login`)
2. Work out which client to use from the response header
(3. Optionally get the bearer token if we're doing that method)
4. Make further requests

The spec does state that auth requirements can differ per resource requested, but I think we can miss that for now (It doesn't seem to apply to either ROCKS or DockerHub as yet)

review: Needs Fixing
Revision history for this message
Tom Wardill (twom) wrote :

Having looked at the DockerHubClient MP further, I note you've already got the header parsing, so I think it's probably as simple as swapping the `getInstance` method to something that uses those methods.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Thanks for the review, twom and cjwatson.

I'll make the requested changes (dockerhub url format, new class renaming and check the login flow at getInstance) in the next MP, keeping only the refactoring in this one.

Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Tom Wardill (twom) wrote :

LGTM, and sorting the auth things in the next MP makes sense.

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/interfaces/ocipushrule.py b/lib/lp/oci/interfaces/ocipushrule.py
2index 26f636d..5b43f25 100644
3--- a/lib/lp/oci/interfaces/ocipushrule.py
4+++ b/lib/lp/oci/interfaces/ocipushrule.py
5@@ -19,8 +19,8 @@ from lazr.restful.declarations import (
6 export_write_operation,
7 exported,
8 mutator_for,
9- operation_parameters,
10 operation_for_version,
11+ operation_parameters,
12 )
13 from lazr.restful.fields import Reference
14 from six.moves import http_client
15diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
16index 2bb82ab..c16e1a0 100644
17--- a/lib/lp/oci/model/ociregistryclient.py
18+++ b/lib/lp/oci/model/ociregistryclient.py
19@@ -18,9 +18,10 @@ import logging
20 import tarfile
21
22 from requests.exceptions import (
23- HTTPError,
24 ConnectionError,
25+ HTTPError,
26 )
27+from six.moves.urllib.parse import urlsplit
28 from tenacity import (
29 before_log,
30 retry,
31@@ -61,23 +62,20 @@ class OCIRegistryClient:
32 before=before_log(log, logging.INFO),
33 retry=retry_if_exception_type(ConnectionError),
34 stop=stop_after_attempt(5))
35- def _upload(cls, digest, push_rule, name, fileobj, auth):
36+ def _upload(cls, digest, push_rule, fileobj, http_client):
37 """Upload a blob to the registry, using a given digest.
38
39 :param digest: The digest to store the file under.
40 :param push_rule: `OCIPushRule` to use for the URL and credentials.
41- :param name: Name of the image the blob is part of.
42 :param fileobj: An object that looks like a buffer.
43
44 :raises BlobUploadFailed: if the registry does not accept the blob.
45 """
46 # Check if it already exists
47 try:
48- head_response = urlfetch(
49- "{}/v2/{}/blobs/{}".format(
50- push_rule.registry_credentials.url, name, digest),
51- method="HEAD",
52- auth=auth)
53+ head_response = http_client.requestPath(
54+ "/blobs/{}".format(digest),
55+ method="HEAD")
56 if head_response.status_code == 200:
57 log.info("{} already found".format(digest))
58 return
59@@ -86,27 +84,25 @@ class OCIRegistryClient:
60 if http_error.response.status_code != 404:
61 raise http_error
62
63- post_response = urlfetch(
64- "{}/v2/{}/blobs/uploads/".format(
65- push_rule.registry_credentials.url, name),
66- method="POST", auth=auth)
67+ post_response = http_client.requestPath(
68+ "/blobs/uploads/", method="POST")
69
70 post_location = post_response.headers["Location"]
71 query_parsed = {"digest": digest}
72
73- put_response = urlfetch(
74+ put_response = http_client.request(
75 post_location,
76 params=query_parsed,
77 data=fileobj,
78- method="PUT",
79- auth=auth)
80+ method="PUT")
81
82 if put_response.status_code != 201:
83- raise BlobUploadFailed(
84- "Upload of {} for {} failed".format(digest, name))
85+ msg = "Upload of {} for {} failed".format(
86+ digest, push_rule.image_name)
87+ raise BlobUploadFailed(msg)
88
89 @classmethod
90- def _upload_layer(cls, digest, push_rule, name, lfa, auth):
91+ def _upload_layer(cls, digest, push_rule, lfa, http_client):
92 """Upload a layer blob to the registry.
93
94 Uses _upload, but opens the LFA and extracts the necessary files
95@@ -114,7 +110,6 @@ class OCIRegistryClient:
96
97 :param digest: The digest to store the file under.
98 :param push_rule: `OCIPushRule` to use for the URL and credentials.
99- :param name: Name of the image the blob is part of.
100 :param lfa: The `LibraryFileAlias` for the layer.
101 """
102 lfa.open()
103@@ -124,7 +119,7 @@ class OCIRegistryClient:
104 if tarinfo.name != 'layer.tar':
105 continue
106 fileobj = un_zipped.extractfile(tarinfo)
107- cls._upload(digest, push_rule, name, fileobj, auth)
108+ cls._upload(digest, push_rule, fileobj, http_client)
109 finally:
110 lfa.close()
111
112@@ -223,15 +218,10 @@ class OCIRegistryClient:
113 preloaded_data = cls._preloadFiles(build, manifest, digests)
114
115 for push_rule in build.recipe.push_rules:
116- # Work out the auth details for the registry
117- auth = push_rule.registry_credentials.getCredentials()
118- if auth.get('username'):
119- auth_tuple = (auth['username'], auth.get('password'))
120- else:
121- auth_tuple = None
122+ http_client = RegistryHTTPClient.getInstance(push_rule)
123+
124 for section in manifest:
125 # Work out names and tags
126- image_name = push_rule.image_name
127 tag = cls._calculateTag(build, push_rule)
128 file_data = preloaded_data[section["Config"]]
129 config = file_data["config_file"]
130@@ -240,9 +230,8 @@ class OCIRegistryClient:
131 cls._upload_layer(
132 diff_id,
133 push_rule,
134- image_name,
135 file_data[diff_id],
136- auth_tuple)
137+ http_client)
138 # The config file is required in different forms, so we can
139 # calculate the sha, work these out and upload
140 config_json = json.dumps(config).encode("UTF-8")
141@@ -250,9 +239,8 @@ class OCIRegistryClient:
142 cls._upload(
143 "sha256:{}".format(config_sha),
144 push_rule,
145- image_name,
146 BytesIO(config_json),
147- auth_tuple)
148+ http_client)
149
150 # Build the registry manifest from the image manifest
151 # and associated configs
152@@ -261,20 +249,82 @@ class OCIRegistryClient:
153 preloaded_data[section["Config"]])
154
155 # Upload the registry manifest
156- manifest_response = urlfetch(
157- "{}/v2/{}/manifests/{}".format(
158- push_rule.registry_credentials.url,
159- image_name,
160- tag),
161+ manifest_response = http_client.requestPath(
162+ "/manifests/{}".format(tag),
163 json=registry_manifest,
164 headers={
165 "Content-Type":
166 "application/"
167 "vnd.docker.distribution.manifest.v2+json"
168 },
169- method="PUT",
170- auth=auth_tuple)
171+ method="PUT")
172 if manifest_response.status_code != 201:
173 raise ManifestUploadFailed(
174 "Failed to upload manifest for {} in {}".format(
175 build.recipe.name, build.id))
176+
177+
178+class RegistryHTTPClient:
179+ def __init__(self, push_rule):
180+ self.push_rule = push_rule
181+
182+ @property
183+ def credentials(self):
184+ """Returns a tuple of (username, password)."""
185+ auth = self.push_rule.registry_credentials.getCredentials()
186+ if auth.get('username'):
187+ return auth['username'], auth.get('password')
188+ return None, None
189+
190+ @property
191+ def api_url(self):
192+ """Returns the base API URL for this registry."""
193+ push_rule = self.push_rule
194+ return "{}/v2/{}".format(push_rule.registry_url, push_rule.image_name)
195+
196+ def request(self, url, *args, **request_kwargs):
197+ username, password = self.credentials
198+ if username is not None:
199+ request_kwargs.setdefault("auth", (username, password))
200+ return urlfetch(url, **request_kwargs)
201+
202+ def requestPath(self, path, *args, **request_kwargs):
203+ """Shortcut to do a request to {self.api_url}/{path}."""
204+ url = "{}{}".format(self.api_url, path)
205+ return self.request(url, *args, **request_kwargs)
206+
207+ @classmethod
208+ def getInstance(cls, push_rule):
209+ """Returns an instance of RegistryHTTPClient adapted to the
210+ given push rule."""
211+ split = urlsplit(push_rule.registry_url)
212+ if split.netloc.endswith('registry.hub.docker.com'):
213+ return DockerHubHTTPClient(push_rule)
214+ return RegistryHTTPClient(push_rule)
215+
216+
217+class DockerHubHTTPClient(RegistryHTTPClient):
218+ def __init__(self, push_rule):
219+ super(DockerHubHTTPClient, self).__init__(push_rule)
220+ self.auth_token = None
221+
222+ @property
223+ def api_url(self):
224+ """Get the base API URL for Dockerhub projects."""
225+ push_rule = self.push_rule
226+ return "{}/v2/{}/{}".format(
227+ push_rule.registry_url, push_rule.username, push_rule.image_name)
228+
229+ def authenticate(self, last_failure):
230+ """Tries to authenticate, considering the last HTTP 401 error."""
231+ raise NotImplementedError("DockerHub auth is not implemented yet.")
232+
233+ def request(self, url, auth_retry=True, *args, **request_kwargs):
234+ try:
235+ return super(DockerHubHTTPClient, self).request(
236+ url, *args, **request_kwargs)
237+ except HTTPError as e:
238+ if e.response.status_code == 401:
239+ self.authenticate(e.response)
240+ return self.request(url, auth_retry=False, **request_kwargs)
241+ raise
242diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
243index 2a01b33..6ba25c0 100644
244--- a/lib/lp/oci/tests/test_ociregistryclient.py
245+++ b/lib/lp/oci/tests/test_ociregistryclient.py
246@@ -8,6 +8,8 @@ from __future__ import absolute_import, print_function, unicode_literals
247 __metaclass__ = type
248
249 import json
250+import os
251+import tarfile
252
253 from fixtures import MockPatch
254 from requests.exceptions import (
255@@ -15,6 +17,7 @@ from requests.exceptions import (
256 HTTPError,
257 )
258 import responses
259+from six import StringIO
260 from tenacity import RetryError
261 from testtools.matchers import (
262 Equals,
263@@ -23,7 +26,10 @@ from testtools.matchers import (
264 )
265 import transaction
266
267-from lp.oci.model.ociregistryclient import OCIRegistryClient
268+from lp.oci.model.ociregistryclient import (
269+ OCIRegistryClient,
270+ RegistryHTTPClient,
271+ )
272 from lp.oci.tests.helpers import OCIConfigHelperMixin
273 from lp.testing import TestCaseWithFactory
274 from lp.testing.layers import LaunchpadZopelessLayer
275@@ -74,19 +80,29 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
276 filename='config_file_1.json'
277 )
278
279- # make layer files
280- self.layer_1_file = self.factory.makeOCIFile(
281- build=self.build,
282- content="digest_1",
283- filename="digest_1_filename",
284- layer_file_digest="digest_1"
285- )
286- self.layer_2_file = self.factory.makeOCIFile(
287- build=self.build,
288- content="digest_2",
289- filename="digest_2_filename",
290- layer_file_digest="digest_2"
291- )
292+ tmpdir = self.makeTemporaryDirectory()
293+ self.layer_files = []
294+ for i in range(1, 3):
295+ digest = 'digest_%i' % i
296+ file_name = 'digest_%s_filename' % i
297+ file_path = os.path.join(tmpdir, file_name)
298+
299+ with open(file_path, 'w') as fd:
300+ fd.write(digest)
301+
302+ fileout = StringIO()
303+ tar = tarfile.open(mode="w:gz", fileobj=fileout)
304+ tar.add(file_path, 'layer.tar')
305+ tar.close()
306+
307+ fileout.seek(0)
308+ # make layer files
309+ self.layer_files.append(self.factory.makeOCIFile(
310+ build=self.build,
311+ content=fileout.read(),
312+ filename=file_name,
313+ layer_file_digest=digest
314+ ))
315
316 transaction.commit()
317
318@@ -113,12 +129,14 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
319 "mediaType": Equals(
320 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
321 "digest": Equals("diff_id_1"),
322- "size": Equals(8)}),
323+ "size": Equals(
324+ self.layer_files[0].library_file.content.filesize)}),
325 MatchesDict({
326 "mediaType": Equals(
327 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
328 "digest": Equals("diff_id_2"),
329- "size": Equals(8)})
330+ "size": Equals(
331+ self.layer_files[1].library_file.content.filesize)})
332 ]),
333 "schemaVersion": Equals(2),
334 "config": MatchesDict({
335@@ -153,9 +171,9 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
336 responses.add("PUT", manifests_url, status=201)
337 self.client.upload(self.build)
338
339- self.assertIn(
340- ('test-username', 'test-password'),
341- _upload_fixture.mock.call_args_list[0][0])
342+ http_client = _upload_fixture.mock.call_args_list[0][0][-1]
343+ self.assertEquals(
344+ http_client.credentials, ('test-username', 'test-password'))
345
346 def test_preloadFiles(self):
347 self._makeFiles()
348@@ -165,8 +183,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
349 self.assertThat(files, MatchesDict({
350 'config_file_1.json': MatchesDict({
351 'config_file': Equals(self.config),
352- 'diff_id_1': Equals(self.layer_1_file.library_file),
353- 'diff_id_2': Equals(self.layer_2_file.library_file)})}))
354+ 'diff_id_1': Equals(self.layer_files[0].library_file),
355+ 'diff_id_2': Equals(self.layer_files[1].library_file)})}))
356
357 def test_calculateTag(self):
358 result = self.client._calculateTag(
359@@ -189,12 +207,14 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
360 "mediaType": Equals(
361 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
362 "digest": Equals("diff_id_1"),
363- "size": Equals(8)}),
364+ "size": Equals(
365+ self.layer_files[0].library_file.content.filesize)}),
366 MatchesDict({
367 "mediaType": Equals(
368 "application/vnd.docker.image.rootfs.diff.tar.gzip"),
369 "digest": Equals("diff_id_2"),
370- "size": Equals(8)})
371+ "size": Equals(
372+ self.layer_files[1].library_file.content.filesize)})
373 ]),
374 "schemaVersion": Equals(2),
375 "config": MatchesDict({
376@@ -209,44 +229,47 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
377
378 @responses.activate
379 def test_upload_handles_existing(self):
380- blobs_url = "{}/v2/{}/blobs/{}".format(
381- self.build.recipe.push_rules[0].registry_credentials.url,
382- "test-name",
383- "test-digest")
384+ push_rule = self.build.recipe.push_rules[0]
385+ http_client = RegistryHTTPClient(push_rule)
386+ blobs_url = "{}/blobs/{}".format(
387+ http_client.api_url, "test-digest")
388 responses.add("HEAD", blobs_url, status=200)
389+ push_rule = self.build.recipe.push_rules[0]
390+ push_rule.registry_credentials.setCredentials({})
391 self.client._upload(
392- "test-digest", self.build.recipe.push_rules[0],
393- "test-name", None, None)
394+ "test-digest", push_rule, None, http_client)
395 # There should be no auth headers for these calls
396 for call in responses.calls:
397 self.assertNotIn('Authorization', call.request.headers.keys())
398
399 @responses.activate
400 def test_upload_raises_non_404(self):
401- blobs_url = "{}/v2/{}/blobs/{}".format(
402- self.build.recipe.push_rules[0].registry_credentials.url,
403- "test-name",
404- "test-digest")
405+ push_rule = self.build.recipe.push_rules[0]
406+ http_client = RegistryHTTPClient(push_rule)
407+ blobs_url = "{}/blobs/{}".format(
408+ http_client.api_url, "test-digest")
409 responses.add("HEAD", blobs_url, status=500)
410+ push_rule = self.build.recipe.push_rules[0]
411 self.assertRaises(
412 HTTPError,
413 self.client._upload,
414 "test-digest",
415- self.build.recipe.push_rules[0],
416- "test-name",
417+ push_rule,
418 None,
419- None)
420+ http_client)
421
422 @responses.activate
423 def test_upload_passes_basic_auth(self):
424- blobs_url = "{}/v2/{}/blobs/{}".format(
425- self.build.recipe.push_rules[0].registry_credentials.url,
426- "test-name",
427- "test-digest")
428+ push_rule = self.build.recipe.push_rules[0]
429+ http_client = RegistryHTTPClient(push_rule)
430+ blobs_url = "{}/blobs/{}".format(
431+ http_client.api_url, "test-digest")
432 responses.add("HEAD", blobs_url, status=200)
433+ push_rule.registry_credentials.setCredentials({
434+ "username": "user", "password": "password"})
435 self.client._upload(
436- "test-digest", self.build.recipe.push_rules[0],
437- "test-name", None, ('user', 'password'))
438+ "test-digest", push_rule, None,
439+ http_client)
440
441 for call in responses.calls:
442 self.assertEqual(
443@@ -269,9 +292,10 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
444 self.client._upload.retry.sleep = lambda x: None
445
446 try:
447+ push_rule = self.build.recipe.push_rules[0]
448 self.client._upload(
449- "test-digest", self.build.recipe.push_rules[0],
450- "test-name", None, ('user', 'password'))
451+ "test-digest", push_rule,
452+ None, RegistryHTTPClient(push_rule))
453 except RetryError:
454 pass
455 # Check that tenacity and our counting agree