Merge ~pappacena/launchpad:aws-registry-push into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 9df07c95fc891ef8dbdc30f5a3ca402ca3612643
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:aws-registry-push
Merge into: launchpad:master
Diff against target: 245 lines (+116/-4)
4 files modified
constraints.txt (+7/-3)
lib/lp/oci/model/ociregistryclient.py (+42/-1)
lib/lp/oci/tests/test_ociregistryclient.py (+66/-0)
setup.py (+1/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+392639@code.launchpad.net

Commit message

Adding OCI registry client for AWS' ECR

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
9df07c9... by Thiago F. Pappacena

Fixing import formatting

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/constraints.txt b/constraints.txt
index 4c33491..116d95e 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -81,7 +81,7 @@ python-gettext==4.0
81pytz==2019.381pytz==2019.3
82# Handled in setup-requirements.txt instead.82# Handled in setup-requirements.txt instead.
83#setuptools==44.0.083#setuptools==44.0.0
84six==1.14.084six==1.15.0
85transaction==3.0.085transaction==3.0.0
8686
87# zope.password needs these87# zope.password needs these
@@ -135,7 +135,7 @@ lxml==4.5.0
135repoze.sphinx.autointerface==0.8135repoze.sphinx.autointerface==0.8
136requests==2.23.0136requests==2.23.0
137certifi==2019.11.28137certifi==2019.11.28
138urllib3==1.25.8138urllib3==1.25.11
139idna==2.9139idna==2.9
140chardet==3.0.4140chardet==3.0.4
141sphinxcontrib-applehelp==1.0.2141sphinxcontrib-applehelp==1.0.2
@@ -179,6 +179,8 @@ billiard==3.5.0.5
179bleach==3.1.0179bleach==3.1.0
180breezy==3.0.1180breezy==3.0.1
181bson==0.5.9181bson==0.5.9
182boto3==1.16.2
183botocore==1.19.2
182# lp:~launchpad/bzr/lp184# lp:~launchpad/bzr/lp
183bzr==2.6.0.lp.4185bzr==2.6.0.lp.4
184celery==4.1.1186celery==4.1.1
@@ -204,7 +206,7 @@ fastimport==0.9.8
204feedparser==5.2.1206feedparser==5.2.1
205feedvalidator==0.0.0DEV-r1049207feedvalidator==0.0.0DEV-r1049
206FormEncode==1.3.1208FormEncode==1.3.1
207futures==3.2.0209futures==3.3.0
208geoip2==2.9.0210geoip2==2.9.0
209grokcore.component==3.1211grokcore.component==3.1
210gunicorn==19.8.1212gunicorn==19.8.1
@@ -216,6 +218,7 @@ incremental==17.5.0
216ipaddress==1.0.18218ipaddress==1.0.18
217ipython==0.13.2219ipython==0.13.2
218iso8601==0.1.12220iso8601==0.1.12
221jmespath==0.10.0
219jsautobuild==0.2222jsautobuild==0.2
220keyring==0.6.2223keyring==0.6.2
221kombu==4.4.0224kombu==4.4.0
@@ -288,6 +291,7 @@ rabbitfixture==0.4.2
288requests-file==1.4.3291requests-file==1.4.3
289requests-toolbelt==0.9.1292requests-toolbelt==0.9.1
290responses==0.9.0293responses==0.9.0
294s3transfer==0.3.3
291scandir==1.7295scandir==1.7
292service-identity==18.1.0296service-identity==18.1.0
293setproctitle==1.1.7297setproctitle==1.1.7
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 7c67b57..e3e095b 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -10,7 +10,7 @@ __all__ = [
10 'OCIRegistryClient'10 'OCIRegistryClient'
11]11]
1212
1313import base64
14from functools import partial14from functools import partial
15import hashlib15import hashlib
16from io import BytesIO16from io import BytesIO
@@ -22,6 +22,7 @@ except ImportError:
22import logging22import logging
23import tarfile23import tarfile
2424
25import boto3
25from requests.exceptions import (26from requests.exceptions import (
26 ConnectionError,27 ConnectionError,
27 HTTPError,28 HTTPError,
@@ -30,6 +31,7 @@ from six.moves.urllib.request import (
30 parse_http_list,31 parse_http_list,
31 parse_keqv_list,32 parse_keqv_list,
32 )33 )
34from six.moves.urllib.parse import urlparse
33from tenacity import (35from tenacity import (
34 before_log,36 before_log,
35 retry,37 retry,
@@ -45,6 +47,7 @@ from lp.oci.interfaces.ociregistryclient import (
45 MultipleOCIRegistryError,47 MultipleOCIRegistryError,
46 ManifestUploadFailed,48 ManifestUploadFailed,
47 )49 )
50from lp.services.propertycache import cachedproperty
48from lp.services.timeout import urlfetch51from lp.services.timeout import urlfetch
4952
5053
@@ -426,6 +429,9 @@ class RegistryHTTPClient:
426 def getInstance(cls, push_rule):429 def getInstance(cls, push_rule):
427 """Returns an instance of RegistryHTTPClient adapted to the430 """Returns an instance of RegistryHTTPClient adapted to the
428 given push rule and registry's authentication flow."""431 given push rule and registry's authentication flow."""
432 registry_domain = urlparse(push_rule.registry_url).netloc
433 if registry_domain.endswith(".amazonaws.com"):
434 return AWSRegistryHTTPClient(push_rule)
429 try:435 try:
430 proxy_urlfetch("{}/v2/".format(push_rule.registry_url))436 proxy_urlfetch("{}/v2/".format(push_rule.registry_url))
431 # No authorization error? Just return the basic RegistryHTTPClient.437 # No authorization error? Just return the basic RegistryHTTPClient.
@@ -516,3 +522,38 @@ class BearerTokenRegistryClient(RegistryHTTPClient):
516 url, auth_retry=False, headers=headers,522 url, auth_retry=False, headers=headers,
517 *args, **request_kwargs)523 *args, **request_kwargs)
518 raise524 raise
525
526
527class AWSRegistryHTTPClient(RegistryHTTPClient):
528
529 def _getRegion(self):
530 """Returns the region from the push URL domain."""
531 domain = urlparse(self.push_rule.registry_url).netloc
532 # The domain format should be something like
533 # 'xxx.dkr.ecr.sa-east-1.amazonaws.com'. 'sa-east-1' is the region.
534 return domain.split(".")[-3]
535
536 @cachedproperty
537 def credentials(self):
538 """Exchange aws_access_key_id and aws_secret_access_key with the
539 authentication token that should be used when talking to ECR."""
540 try:
541 auth = self.push_rule.registry_credentials.getCredentials()
542 username, password = auth['username'], auth.get('password')
543 region = self._getRegion()
544 log.info("Trying to authenticate with AWS in region %s" % region)
545 client = boto3.client('ecr', aws_access_key_id=username,
546 aws_secret_access_key=password,
547 region_name=region)
548 token = client.get_authorization_token()
549 auth_data = token["authorizationData"][0]
550 authorization_token = auth_data['authorizationToken']
551 username, password = base64.b64decode(
552 authorization_token).decode().split(':')
553 return username, password
554 except Exception as e:
555 log.error("Error trying to get authorization token for ECR "
556 "registry: %s(%s)" % (e.__class__, e))
557 raise OCIRegistryAuthenticationError(
558 "It was not possible to get AWS credentials for %s: %s" %
559 (self.push_rule.registry_url, e))
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index 8de88b4..93f33fb 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
77
8__metaclass__ = type8__metaclass__ = type
99
10import base64
10from functools import partial11from functools import partial
11import io12import io
12import json13import json
@@ -46,6 +47,7 @@ from lp.oci.interfaces.ociregistryclient import (
46 )47 )
47from lp.oci.model.ocirecipe import OCIRecipeBuildRequest48from lp.oci.model.ocirecipe import OCIRecipeBuildRequest
48from lp.oci.model.ociregistryclient import (49from lp.oci.model.ociregistryclient import (
50 AWSRegistryHTTPClient,
49 BearerTokenRegistryClient,51 BearerTokenRegistryClient,
50 OCIRegistryAuthenticationError,52 OCIRegistryAuthenticationError,
51 OCIRegistryClient,53 OCIRegistryClient,
@@ -668,6 +670,70 @@ class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
668 call = responses.calls[0]670 call = responses.calls[0]
669 self.assertEqual("%s/v2/" % push_rule.registry_url, call.request.url)671 self.assertEqual("%s/v2/" % push_rule.registry_url, call.request.url)
670672
673 @responses.activate
674 def test_get_aws_client_instance(self):
675 credentials = self.factory.makeOCIRegistryCredentials(
676 url="https://123456789.dkr.ecr.sa-east-1.amazonaws.com",
677 credentials={
678 'username': 'aws_access_key_id',
679 'password': "aws_secret_access_key"})
680 push_rule = removeSecurityProxy(self.factory.makeOCIPushRule(
681 registry_credentials=credentials,
682 image_name="ecr-test"))
683
684 instance = RegistryHTTPClient.getInstance(push_rule)
685 self.assertEqual(AWSRegistryHTTPClient, type(instance))
686 self.assertIsInstance(instance, RegistryHTTPClient)
687
688 @responses.activate
689 def test_aws_credentials(self):
690 boto_patch = self.useFixture(
691 MockPatch('lp.oci.model.ociregistryclient.boto3'))
692 boto = boto_patch.mock
693 get_authorization_token = (
694 boto.client.return_value.get_authorization_token)
695 get_authorization_token.return_value = {
696 "authorizationData": [{
697 "authorizationToken": base64.b64encode(
698 b"the-username:the-token")
699 }]
700 }
701
702 credentials = self.factory.makeOCIRegistryCredentials(
703 url="https://123456789.dkr.ecr.sa-east-1.amazonaws.com",
704 credentials={
705 'username': 'my_aws_access_key_id',
706 'password': "my_aws_secret_access_key"})
707 push_rule = removeSecurityProxy(self.factory.makeOCIPushRule(
708 registry_credentials=credentials,
709 image_name="ecr-test"))
710
711 instance = RegistryHTTPClient.getInstance(push_rule)
712 # Check the credentials twice, to make sure they are cached.
713 for _ in range(2):
714 http_user, http_passwd = instance.credentials
715 self.assertEqual("the-username", http_user)
716 self.assertEqual("the-token", http_passwd)
717 self.assertEqual(1, boto.client.call_count)
718 self.assertEqual(mock.call(
719 'ecr', aws_access_key_id="my_aws_access_key_id",
720 aws_secret_access_key="my_aws_secret_access_key",
721 region_name="sa-east-1"),
722 boto.client.call_args)
723
724 @responses.activate
725 def test_aws_malformed_url_region(self):
726 credentials = self.factory.makeOCIRegistryCredentials(
727 url="https://.amazonaws.com",
728 credentials={'username': 'aa', 'password': "bb"})
729 push_rule = removeSecurityProxy(self.factory.makeOCIPushRule(
730 registry_credentials=credentials,
731 image_name="ecr-test"))
732
733 instance = RegistryHTTPClient.getInstance(push_rule)
734 self.assertRaises(
735 OCIRegistryAuthenticationError, getattr, instance, 'credentials')
736
671737
672class TestBearerTokenRegistryClient(OCIConfigHelperMixin,738class TestBearerTokenRegistryClient(OCIConfigHelperMixin,
673 SpyProxyCallsMixin, TestCaseWithFactory):739 SpyProxyCallsMixin, TestCaseWithFactory):
diff --git a/setup.py b/setup.py
index 4e76a25..f6d5b6b 100644
--- a/setup.py
+++ b/setup.py
@@ -153,6 +153,7 @@ setup(
153 'ampoule',153 'ampoule',
154 'backports.lzma; python_version < "3.3"',154 'backports.lzma; python_version < "3.3"',
155 'beautifulsoup4[lxml]',155 'beautifulsoup4[lxml]',
156 'boto3',
156 'breezy',157 'breezy',
157 # XXX cjwatson 2020-08-07: This should eventually be removed158 # XXX cjwatson 2020-08-07: This should eventually be removed
158 # entirely, but we need to retain it until codeimport has been159 # entirely, but we need to retain it until codeimport has been