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
1diff --git a/constraints.txt b/constraints.txt
2index 4c33491..116d95e 100644
3--- a/constraints.txt
4+++ b/constraints.txt
5@@ -81,7 +81,7 @@ python-gettext==4.0
6 pytz==2019.3
7 # Handled in setup-requirements.txt instead.
8 #setuptools==44.0.0
9-six==1.14.0
10+six==1.15.0
11 transaction==3.0.0
12
13 # zope.password needs these
14@@ -135,7 +135,7 @@ lxml==4.5.0
15 repoze.sphinx.autointerface==0.8
16 requests==2.23.0
17 certifi==2019.11.28
18-urllib3==1.25.8
19+urllib3==1.25.11
20 idna==2.9
21 chardet==3.0.4
22 sphinxcontrib-applehelp==1.0.2
23@@ -179,6 +179,8 @@ billiard==3.5.0.5
24 bleach==3.1.0
25 breezy==3.0.1
26 bson==0.5.9
27+boto3==1.16.2
28+botocore==1.19.2
29 # lp:~launchpad/bzr/lp
30 bzr==2.6.0.lp.4
31 celery==4.1.1
32@@ -204,7 +206,7 @@ fastimport==0.9.8
33 feedparser==5.2.1
34 feedvalidator==0.0.0DEV-r1049
35 FormEncode==1.3.1
36-futures==3.2.0
37+futures==3.3.0
38 geoip2==2.9.0
39 grokcore.component==3.1
40 gunicorn==19.8.1
41@@ -216,6 +218,7 @@ incremental==17.5.0
42 ipaddress==1.0.18
43 ipython==0.13.2
44 iso8601==0.1.12
45+jmespath==0.10.0
46 jsautobuild==0.2
47 keyring==0.6.2
48 kombu==4.4.0
49@@ -288,6 +291,7 @@ rabbitfixture==0.4.2
50 requests-file==1.4.3
51 requests-toolbelt==0.9.1
52 responses==0.9.0
53+s3transfer==0.3.3
54 scandir==1.7
55 service-identity==18.1.0
56 setproctitle==1.1.7
57diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
58index 7c67b57..e3e095b 100644
59--- a/lib/lp/oci/model/ociregistryclient.py
60+++ b/lib/lp/oci/model/ociregistryclient.py
61@@ -10,7 +10,7 @@ __all__ = [
62 'OCIRegistryClient'
63 ]
64
65-
66+import base64
67 from functools import partial
68 import hashlib
69 from io import BytesIO
70@@ -22,6 +22,7 @@ except ImportError:
71 import logging
72 import tarfile
73
74+import boto3
75 from requests.exceptions import (
76 ConnectionError,
77 HTTPError,
78@@ -30,6 +31,7 @@ from six.moves.urllib.request import (
79 parse_http_list,
80 parse_keqv_list,
81 )
82+from six.moves.urllib.parse import urlparse
83 from tenacity import (
84 before_log,
85 retry,
86@@ -45,6 +47,7 @@ from lp.oci.interfaces.ociregistryclient import (
87 MultipleOCIRegistryError,
88 ManifestUploadFailed,
89 )
90+from lp.services.propertycache import cachedproperty
91 from lp.services.timeout import urlfetch
92
93
94@@ -426,6 +429,9 @@ class RegistryHTTPClient:
95 def getInstance(cls, push_rule):
96 """Returns an instance of RegistryHTTPClient adapted to the
97 given push rule and registry's authentication flow."""
98+ registry_domain = urlparse(push_rule.registry_url).netloc
99+ if registry_domain.endswith(".amazonaws.com"):
100+ return AWSRegistryHTTPClient(push_rule)
101 try:
102 proxy_urlfetch("{}/v2/".format(push_rule.registry_url))
103 # No authorization error? Just return the basic RegistryHTTPClient.
104@@ -516,3 +522,38 @@ class BearerTokenRegistryClient(RegistryHTTPClient):
105 url, auth_retry=False, headers=headers,
106 *args, **request_kwargs)
107 raise
108+
109+
110+class AWSRegistryHTTPClient(RegistryHTTPClient):
111+
112+ def _getRegion(self):
113+ """Returns the region from the push URL domain."""
114+ domain = urlparse(self.push_rule.registry_url).netloc
115+ # The domain format should be something like
116+ # 'xxx.dkr.ecr.sa-east-1.amazonaws.com'. 'sa-east-1' is the region.
117+ return domain.split(".")[-3]
118+
119+ @cachedproperty
120+ def credentials(self):
121+ """Exchange aws_access_key_id and aws_secret_access_key with the
122+ authentication token that should be used when talking to ECR."""
123+ try:
124+ auth = self.push_rule.registry_credentials.getCredentials()
125+ username, password = auth['username'], auth.get('password')
126+ region = self._getRegion()
127+ log.info("Trying to authenticate with AWS in region %s" % region)
128+ client = boto3.client('ecr', aws_access_key_id=username,
129+ aws_secret_access_key=password,
130+ region_name=region)
131+ token = client.get_authorization_token()
132+ auth_data = token["authorizationData"][0]
133+ authorization_token = auth_data['authorizationToken']
134+ username, password = base64.b64decode(
135+ authorization_token).decode().split(':')
136+ return username, password
137+ except Exception as e:
138+ log.error("Error trying to get authorization token for ECR "
139+ "registry: %s(%s)" % (e.__class__, e))
140+ raise OCIRegistryAuthenticationError(
141+ "It was not possible to get AWS credentials for %s: %s" %
142+ (self.push_rule.registry_url, e))
143diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
144index 8de88b4..93f33fb 100644
145--- a/lib/lp/oci/tests/test_ociregistryclient.py
146+++ b/lib/lp/oci/tests/test_ociregistryclient.py
147@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
148
149 __metaclass__ = type
150
151+import base64
152 from functools import partial
153 import io
154 import json
155@@ -46,6 +47,7 @@ from lp.oci.interfaces.ociregistryclient import (
156 )
157 from lp.oci.model.ocirecipe import OCIRecipeBuildRequest
158 from lp.oci.model.ociregistryclient import (
159+ AWSRegistryHTTPClient,
160 BearerTokenRegistryClient,
161 OCIRegistryAuthenticationError,
162 OCIRegistryClient,
163@@ -668,6 +670,70 @@ class TestRegistryHTTPClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
164 call = responses.calls[0]
165 self.assertEqual("%s/v2/" % push_rule.registry_url, call.request.url)
166
167+ @responses.activate
168+ def test_get_aws_client_instance(self):
169+ credentials = self.factory.makeOCIRegistryCredentials(
170+ url="https://123456789.dkr.ecr.sa-east-1.amazonaws.com",
171+ credentials={
172+ 'username': 'aws_access_key_id',
173+ 'password': "aws_secret_access_key"})
174+ push_rule = removeSecurityProxy(self.factory.makeOCIPushRule(
175+ registry_credentials=credentials,
176+ image_name="ecr-test"))
177+
178+ instance = RegistryHTTPClient.getInstance(push_rule)
179+ self.assertEqual(AWSRegistryHTTPClient, type(instance))
180+ self.assertIsInstance(instance, RegistryHTTPClient)
181+
182+ @responses.activate
183+ def test_aws_credentials(self):
184+ boto_patch = self.useFixture(
185+ MockPatch('lp.oci.model.ociregistryclient.boto3'))
186+ boto = boto_patch.mock
187+ get_authorization_token = (
188+ boto.client.return_value.get_authorization_token)
189+ get_authorization_token.return_value = {
190+ "authorizationData": [{
191+ "authorizationToken": base64.b64encode(
192+ b"the-username:the-token")
193+ }]
194+ }
195+
196+ credentials = self.factory.makeOCIRegistryCredentials(
197+ url="https://123456789.dkr.ecr.sa-east-1.amazonaws.com",
198+ credentials={
199+ 'username': 'my_aws_access_key_id',
200+ 'password': "my_aws_secret_access_key"})
201+ push_rule = removeSecurityProxy(self.factory.makeOCIPushRule(
202+ registry_credentials=credentials,
203+ image_name="ecr-test"))
204+
205+ instance = RegistryHTTPClient.getInstance(push_rule)
206+ # Check the credentials twice, to make sure they are cached.
207+ for _ in range(2):
208+ http_user, http_passwd = instance.credentials
209+ self.assertEqual("the-username", http_user)
210+ self.assertEqual("the-token", http_passwd)
211+ self.assertEqual(1, boto.client.call_count)
212+ self.assertEqual(mock.call(
213+ 'ecr', aws_access_key_id="my_aws_access_key_id",
214+ aws_secret_access_key="my_aws_secret_access_key",
215+ region_name="sa-east-1"),
216+ boto.client.call_args)
217+
218+ @responses.activate
219+ def test_aws_malformed_url_region(self):
220+ credentials = self.factory.makeOCIRegistryCredentials(
221+ url="https://.amazonaws.com",
222+ credentials={'username': 'aa', 'password': "bb"})
223+ push_rule = removeSecurityProxy(self.factory.makeOCIPushRule(
224+ registry_credentials=credentials,
225+ image_name="ecr-test"))
226+
227+ instance = RegistryHTTPClient.getInstance(push_rule)
228+ self.assertRaises(
229+ OCIRegistryAuthenticationError, getattr, instance, 'credentials')
230+
231
232 class TestBearerTokenRegistryClient(OCIConfigHelperMixin,
233 SpyProxyCallsMixin, TestCaseWithFactory):
234diff --git a/setup.py b/setup.py
235index 4e76a25..f6d5b6b 100644
236--- a/setup.py
237+++ b/setup.py
238@@ -153,6 +153,7 @@ setup(
239 'ampoule',
240 'backports.lzma; python_version < "3.3"',
241 'beautifulsoup4[lxml]',
242+ 'boto3',
243 'breezy',
244 # XXX cjwatson 2020-08-07: This should eventually be removed
245 # entirely, but we need to retain it until codeimport has been