← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:refactor-oci-push-auth into launchpad:master.

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).

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/384052

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:refactor-oci-push-auth into launchpad:master.
diff --git a/lib/lp/oci/interfaces/ocipushrule.py b/lib/lp/oci/interfaces/ocipushrule.py
index 26f636d..5b43f25 100644
--- a/lib/lp/oci/interfaces/ocipushrule.py
+++ b/lib/lp/oci/interfaces/ocipushrule.py
@@ -19,8 +19,8 @@ from lazr.restful.declarations import (
     export_write_operation,
     exported,
     mutator_for,
-    operation_parameters,
     operation_for_version,
+    operation_parameters,
     )
 from lazr.restful.fields import Reference
 from six.moves import http_client
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 2bb82ab..c51d752 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -18,9 +18,10 @@ import logging
 import tarfile
 
 from requests.exceptions import (
-    HTTPError,
     ConnectionError,
+    HTTPError,
     )
+from six.moves.urllib.parse import urlsplit
 from tenacity import (
     before_log,
     retry,
@@ -61,23 +62,20 @@ class OCIRegistryClient:
         before=before_log(log, logging.INFO),
         retry=retry_if_exception_type(ConnectionError),
         stop=stop_after_attempt(5))
-    def _upload(cls, digest, push_rule, name, fileobj, auth):
+    def _upload(cls, digest, push_rule, fileobj, http_client):
         """Upload a blob to the registry, using a given digest.
 
         :param digest: The digest to store the file under.
         :param push_rule: `OCIPushRule` to use for the URL and credentials.
-        :param name: Name of the image the blob is part of.
         :param fileobj: An object that looks like a buffer.
 
         :raises BlobUploadFailed: if the registry does not accept the blob.
         """
         # Check if it already exists
         try:
-            head_response = urlfetch(
-                "{}/v2/{}/blobs/{}".format(
-                    push_rule.registry_credentials.url, name, digest),
-                method="HEAD",
-                auth=auth)
+            head_response = http_client.request_path(
+                "/blobs/{}".format(digest),
+                method="HEAD")
             if head_response.status_code == 200:
                 log.info("{} already found".format(digest))
                 return
@@ -86,27 +84,25 @@ class OCIRegistryClient:
             if http_error.response.status_code != 404:
                 raise http_error
 
-        post_response = urlfetch(
-            "{}/v2/{}/blobs/uploads/".format(
-                push_rule.registry_credentials.url, name),
-            method="POST", auth=auth)
+        post_response = http_client.request_path(
+            "/blobs/uploads/", method="POST")
 
         post_location = post_response.headers["Location"]
         query_parsed = {"digest": digest}
 
-        put_response = urlfetch(
+        put_response = http_client.request(
             post_location,
             params=query_parsed,
             data=fileobj,
-            method="PUT",
-            auth=auth)
+            method="PUT")
 
         if put_response.status_code != 201:
-            raise BlobUploadFailed(
-                "Upload of {} for {} failed".format(digest, name))
+            msg = "Upload of {} for {} failed".format(
+                digest, push_rule.image_name)
+            raise BlobUploadFailed(msg)
 
     @classmethod
-    def _upload_layer(cls, digest, push_rule, name, lfa, auth):
+    def _upload_layer(cls, digest, push_rule, lfa, http_client):
         """Upload a layer blob to the registry.
 
         Uses _upload, but opens the LFA and extracts the necessary files
@@ -114,7 +110,6 @@ class OCIRegistryClient:
 
         :param digest: The digest to store the file under.
         :param push_rule: `OCIPushRule` to use for the URL and credentials.
-        :param name: Name of the image the blob is part of.
         :param lfa: The `LibraryFileAlias` for the layer.
         """
         lfa.open()
@@ -124,7 +119,7 @@ class OCIRegistryClient:
                 if tarinfo.name != 'layer.tar':
                     continue
                 fileobj = un_zipped.extractfile(tarinfo)
-                cls._upload(digest, push_rule, name, fileobj, auth)
+                cls._upload(digest, push_rule, fileobj, http_client)
         finally:
             lfa.close()
 
@@ -223,15 +218,10 @@ class OCIRegistryClient:
         preloaded_data = cls._preloadFiles(build, manifest, digests)
 
         for push_rule in build.recipe.push_rules:
-            # Work out the auth details for the registry
-            auth = push_rule.registry_credentials.getCredentials()
-            if auth.get('username'):
-                auth_tuple = (auth['username'], auth.get('password'))
-            else:
-                auth_tuple = None
+            http_client = RegistryHTTPClient.get_instance(push_rule)
+
             for section in manifest:
                 # Work out names and tags
-                image_name = push_rule.image_name
                 tag = cls._calculateTag(build, push_rule)
                 file_data = preloaded_data[section["Config"]]
                 config = file_data["config_file"]
@@ -240,9 +230,8 @@ class OCIRegistryClient:
                     cls._upload_layer(
                         diff_id,
                         push_rule,
-                        image_name,
                         file_data[diff_id],
-                        auth_tuple)
+                        http_client)
                 # The config file is required in different forms, so we can
                 # calculate the sha, work these out and upload
                 config_json = json.dumps(config).encode("UTF-8")
@@ -250,9 +239,8 @@ class OCIRegistryClient:
                 cls._upload(
                     "sha256:{}".format(config_sha),
                     push_rule,
-                    image_name,
                     BytesIO(config_json),
-                    auth_tuple)
+                    http_client)
 
                 # Build the registry manifest from the image manifest
                 # and associated configs
@@ -261,20 +249,82 @@ class OCIRegistryClient:
                     preloaded_data[section["Config"]])
 
                 # Upload the registry manifest
-                manifest_response = urlfetch(
-                    "{}/v2/{}/manifests/{}".format(
-                        push_rule.registry_credentials.url,
-                        image_name,
-                        tag),
+                manifest_response = http_client.request_path(
+                    "/manifests/{}".format(tag),
                     json=registry_manifest,
                     headers={
                         "Content-Type":
                             "application/"
                             "vnd.docker.distribution.manifest.v2+json"
                         },
-                    method="PUT",
-                    auth=auth_tuple)
+                    method="PUT")
                 if manifest_response.status_code != 201:
                     raise ManifestUploadFailed(
                         "Failed to upload manifest for {} in {}".format(
                             build.recipe.name, build.id))
+
+
+class RegistryHTTPClient:
+    def __init__(self, push_rule):
+        self.push_rule = push_rule
+
+    @property
+    def credentials(self):
+        """Returns a tuple of (username, password)."""
+        auth = self.push_rule.registry_credentials.getCredentials()
+        if auth.get('username'):
+            return auth['username'], auth.get('password')
+        return None, None
+
+    @property
+    def api_url(self):
+        """Returns the base API URL for this registry."""
+        push_rule = self.push_rule
+        return "{}/v2/{}".format(push_rule.registry_url, push_rule.image_name)
+
+    def request(self, url, *args, **request_kwargs):
+        username, password = self.credentials
+        if username is not None:
+            request_kwargs.setdefault("auth", (username, password))
+        return urlfetch(url, **request_kwargs)
+
+    def request_path(self, path, *args, **request_kwargs):
+        """Shortcut to do a request to {self.api_url}/{path}."""
+        url = "{}{}".format(self.api_url, path)
+        return self.request(url, *args, **request_kwargs)
+
+    @classmethod
+    def get_instance(cls, push_rule):
+        """Returns an instance of RegistryHTTPClient adapted to the
+        given push rule."""
+        split = urlsplit(push_rule.registry_url)
+        if split.netloc.endswith('registry.hub.docker.com'):
+            return DockerHubHTTPClient(push_rule)
+        return RegistryHTTPClient(push_rule)
+
+
+class DockerHubHTTPClient(RegistryHTTPClient):
+    def __init__(self, push_rule):
+        super(DockerHubHTTPClient, self).__init__(push_rule)
+        self.auth_token = None
+
+    @property
+    def api_url(self):
+        """Get the base API URL for Dockerhub projects."""
+        push_rule = self.push_rule
+        return "{}/v2/{}/{}".format(
+            push_rule.registry_url, push_rule.username, push_rule.image_name)
+
+    def authenticate(self, last_failure):
+        """Tries to authenticate, considering the last HTTP 401 error."""
+        raise NotImplementedError("DockerHub auth is not implemented yet.")
+
+    def request(self, url, auth_retry=True, *args, **request_kwargs):
+        try:
+            return super(DockerHubHTTPClient, self).request(
+                url, *args, **request_kwargs)
+        except HTTPError as e:
+            if e.response.status_code == 401:
+                self.authenticate(e.response)
+                return self.request(url, auth_retry=False, **request_kwargs)
+            raise
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index 2a01b33..6ba25c0 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -8,6 +8,8 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 
 import json
+import os
+import tarfile
 
 from fixtures import MockPatch
 from requests.exceptions import (
@@ -15,6 +17,7 @@ from requests.exceptions import (
     HTTPError,
     )
 import responses
+from six import StringIO
 from tenacity import RetryError
 from testtools.matchers import (
     Equals,
@@ -23,7 +26,10 @@ from testtools.matchers import (
     )
 import transaction
 
-from lp.oci.model.ociregistryclient import OCIRegistryClient
+from lp.oci.model.ociregistryclient import (
+    OCIRegistryClient,
+    RegistryHTTPClient,
+    )
 from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.testing import TestCaseWithFactory
 from lp.testing.layers import LaunchpadZopelessLayer
@@ -74,19 +80,29 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
             filename='config_file_1.json'
         )
 
-        # make layer files
-        self.layer_1_file = self.factory.makeOCIFile(
-            build=self.build,
-            content="digest_1",
-            filename="digest_1_filename",
-            layer_file_digest="digest_1"
-        )
-        self.layer_2_file = self.factory.makeOCIFile(
-            build=self.build,
-            content="digest_2",
-            filename="digest_2_filename",
-            layer_file_digest="digest_2"
-        )
+        tmpdir = self.makeTemporaryDirectory()
+        self.layer_files = []
+        for i in range(1, 3):
+            digest = 'digest_%i' % i
+            file_name = 'digest_%s_filename' % i
+            file_path = os.path.join(tmpdir, file_name)
+
+            with open(file_path, 'w') as fd:
+                fd.write(digest)
+
+            fileout = StringIO()
+            tar = tarfile.open(mode="w:gz", fileobj=fileout)
+            tar.add(file_path, 'layer.tar')
+            tar.close()
+
+            fileout.seek(0)
+            # make layer files
+            self.layer_files.append(self.factory.makeOCIFile(
+                build=self.build,
+                content=fileout.read(),
+                filename=file_name,
+                layer_file_digest=digest
+            ))
 
         transaction.commit()
 
@@ -113,12 +129,14 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
                     "mediaType": Equals(
                         "application/vnd.docker.image.rootfs.diff.tar.gzip"),
                     "digest": Equals("diff_id_1"),
-                    "size": Equals(8)}),
+                    "size": Equals(
+                        self.layer_files[0].library_file.content.filesize)}),
                 MatchesDict({
                     "mediaType": Equals(
                         "application/vnd.docker.image.rootfs.diff.tar.gzip"),
                     "digest": Equals("diff_id_2"),
-                    "size": Equals(8)})
+                    "size": Equals(
+                        self.layer_files[1].library_file.content.filesize)})
             ]),
             "schemaVersion": Equals(2),
             "config": MatchesDict({
@@ -153,9 +171,9 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
         responses.add("PUT", manifests_url, status=201)
         self.client.upload(self.build)
 
-        self.assertIn(
-            ('test-username', 'test-password'),
-            _upload_fixture.mock.call_args_list[0][0])
+        http_client = _upload_fixture.mock.call_args_list[0][0][-1]
+        self.assertEquals(
+            http_client.credentials, ('test-username', 'test-password'))
 
     def test_preloadFiles(self):
         self._makeFiles()
@@ -165,8 +183,8 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
         self.assertThat(files, MatchesDict({
             'config_file_1.json': MatchesDict({
                 'config_file': Equals(self.config),
-                'diff_id_1': Equals(self.layer_1_file.library_file),
-                'diff_id_2': Equals(self.layer_2_file.library_file)})}))
+                'diff_id_1': Equals(self.layer_files[0].library_file),
+                'diff_id_2': Equals(self.layer_files[1].library_file)})}))
 
     def test_calculateTag(self):
         result = self.client._calculateTag(
@@ -189,12 +207,14 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
                     "mediaType": Equals(
                         "application/vnd.docker.image.rootfs.diff.tar.gzip"),
                     "digest": Equals("diff_id_1"),
-                    "size": Equals(8)}),
+                    "size": Equals(
+                        self.layer_files[0].library_file.content.filesize)}),
                 MatchesDict({
                     "mediaType": Equals(
                         "application/vnd.docker.image.rootfs.diff.tar.gzip"),
                     "digest": Equals("diff_id_2"),
-                    "size": Equals(8)})
+                    "size": Equals(
+                        self.layer_files[1].library_file.content.filesize)})
             ]),
             "schemaVersion": Equals(2),
             "config": MatchesDict({
@@ -209,44 +229,47 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
 
     @responses.activate
     def test_upload_handles_existing(self):
-        blobs_url = "{}/v2/{}/blobs/{}".format(
-            self.build.recipe.push_rules[0].registry_credentials.url,
-            "test-name",
-            "test-digest")
+        push_rule = self.build.recipe.push_rules[0]
+        http_client = RegistryHTTPClient(push_rule)
+        blobs_url = "{}/blobs/{}".format(
+            http_client.api_url, "test-digest")
         responses.add("HEAD", blobs_url, status=200)
+        push_rule = self.build.recipe.push_rules[0]
+        push_rule.registry_credentials.setCredentials({})
         self.client._upload(
-            "test-digest", self.build.recipe.push_rules[0],
-            "test-name", None, None)
+            "test-digest", push_rule, None, http_client)
         # There should be no auth headers for these calls
         for call in responses.calls:
             self.assertNotIn('Authorization', call.request.headers.keys())
 
     @responses.activate
     def test_upload_raises_non_404(self):
-        blobs_url = "{}/v2/{}/blobs/{}".format(
-            self.build.recipe.push_rules[0].registry_credentials.url,
-            "test-name",
-            "test-digest")
+        push_rule = self.build.recipe.push_rules[0]
+        http_client = RegistryHTTPClient(push_rule)
+        blobs_url = "{}/blobs/{}".format(
+            http_client.api_url, "test-digest")
         responses.add("HEAD", blobs_url, status=500)
+        push_rule = self.build.recipe.push_rules[0]
         self.assertRaises(
             HTTPError,
             self.client._upload,
             "test-digest",
-            self.build.recipe.push_rules[0],
-            "test-name",
+            push_rule,
             None,
-            None)
+            http_client)
 
     @responses.activate
     def test_upload_passes_basic_auth(self):
-        blobs_url = "{}/v2/{}/blobs/{}".format(
-            self.build.recipe.push_rules[0].registry_credentials.url,
-            "test-name",
-            "test-digest")
+        push_rule = self.build.recipe.push_rules[0]
+        http_client = RegistryHTTPClient(push_rule)
+        blobs_url = "{}/blobs/{}".format(
+            http_client.api_url, "test-digest")
         responses.add("HEAD", blobs_url, status=200)
+        push_rule.registry_credentials.setCredentials({
+            "username": "user", "password": "password"})
         self.client._upload(
-            "test-digest", self.build.recipe.push_rules[0],
-            "test-name", None, ('user', 'password'))
+            "test-digest", push_rule, None,
+            http_client)
 
         for call in responses.calls:
             self.assertEqual(
@@ -269,9 +292,10 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, TestCaseWithFactory):
         self.client._upload.retry.sleep = lambda x: None
 
         try:
+            push_rule = self.build.recipe.push_rules[0]
             self.client._upload(
-                "test-digest", self.build.recipe.push_rules[0],
-                "test-name", None, ('user', 'password'))
+                "test-digest", push_rule,
+                None, RegistryHTTPClient(push_rule))
         except RetryError:
             pass
         # Check that tenacity and our counting agree