diff --git a/CHANGES.rst b/CHANGES.rst index aa58584d9b..132121311c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -98,6 +98,19 @@ Storage (GITHUB-1397) [Clemens Wolff - @c-w] +- [Azure Blobs] Implement ``get_object_cdn_url`` for the Azure Storage driver. + + Leveraging Azure storage service shared access signatures, the Azure Storage + driver can now be used to generate temporary URLs that grant clients read + access to objects. The URLs expire after a certain period of time, either + configured via the ``ex_expiry`` argument or the + ``LIBCLOUD_AZURE_STORAGE_CDN_URL_EXPIRY_HOURS`` environment variable + (default: 24 hours). + + Reported by @rvolykh. + (GITHUB-1403, GITHUB-1408) + [Clemens Wolff - @c-w] + Changes in Apache Libcloud v2.8.0 --------------------------------- diff --git a/libcloud/storage/drivers/azure_blobs.py b/libcloud/storage/drivers/azure_blobs.py index 3a2e2a3aa4..a7cc60131e 100644 --- a/libcloud/storage/drivers/azure_blobs.py +++ b/libcloud/storage/drivers/azure_blobs.py @@ -16,11 +16,15 @@ from __future__ import with_statement import base64 +import hashlib +import hmac import os import binascii +from datetime import datetime, timedelta from libcloud.utils.py3 import ET from libcloud.utils.py3 import httplib +from libcloud.utils.py3 import urlencode from libcloud.utils.py3 import urlquote from libcloud.utils.py3 import tostring from libcloud.utils.py3 import b @@ -65,6 +69,16 @@ AZURE_STORAGE_HOST_SUFFIX = 'blob.core.windows.net' +AZURE_STORAGE_CDN_URL_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' + +AZURE_STORAGE_CDN_URL_START_MINUTES = float( + os.getenv('LIBCLOUD_AZURE_STORAGE_CDN_URL_START_MINUTES', '5') +) + +AZURE_STORAGE_CDN_URL_EXPIRY_HOURS = float( + os.getenv('LIBCLOUD_AZURE_STORAGE_CDN_URL_EXPIRY_HOURS', '24') +) + class AzureBlobLease(object): """ @@ -180,7 +194,7 @@ def morph_action_hook(self, action): return action - API_VERSION = '2016-05-31' + API_VERSION = '2018-11-09' class AzureBlobsStorageDriver(StorageDriver): @@ -489,6 +503,70 @@ def get_object(self, container_name, object_name): raise ObjectDoesNotExistError(value=None, driver=self, object_name=object_name) + def get_object_cdn_url(self, obj, + ex_expiry=AZURE_STORAGE_CDN_URL_EXPIRY_HOURS): + """ + Return a SAS URL that enables reading the given object. + + :param obj: Object instance. + :type obj: :class:`Object` + + :param ex_expiry: The number of hours after which the URL expires. + Defaults to 24 hours. + :type ex_expiry: ``float`` + + :return: A SAS URL for the object. + :rtype: ``str`` + """ + object_path = self._get_object_path(obj.container, obj.name) + + now = datetime.utcnow() + start = now - timedelta(minutes=AZURE_STORAGE_CDN_URL_START_MINUTES) + expiry = now + timedelta(hours=ex_expiry) + + params = { + 'st': start.strftime(AZURE_STORAGE_CDN_URL_DATE_FORMAT), + 'se': expiry.strftime(AZURE_STORAGE_CDN_URL_DATE_FORMAT), + 'sp': 'r', + 'spr': 'https' if self.secure else 'http,https', + 'sv': self.connectionCls.API_VERSION, + 'sr': 'b', + } + + string_to_sign = '\n'.join(( + params['sp'], + params['st'], + params['se'], + '/blob/{}{}'.format(self.key, object_path), + '', # signedIdentifier + '', # signedIP + params['spr'], + params['sv'], + params['sr'], + '', # snapshot + '', # rscc + '', # rscd + '', # rsce + '', # rscl + '', # rsct + )) + + params['sig'] = base64.b64encode( + hmac.new( + self.secret, + string_to_sign.encode('utf-8'), + hashlib.sha256 + ).digest() + ).decode('utf-8') + + return '{scheme}://{host}:{port}{action}?{sas_token}'.format( + scheme='https' if self.secure else 'http', + host=self.connection.host, + port=self.connection.port, + action=self.connection.morph_action_hook(object_path), + sas_token=urlencode(params), + ) + def _get_container_path(self, container): """ Return a container path diff --git a/libcloud/test/storage/test_azure_blobs.py b/libcloud/test/storage/test_azure_blobs.py index 389d581088..acd10d110a 100644 --- a/libcloud/test/storage/test_azure_blobs.py +++ b/libcloud/test/storage/test_azure_blobs.py @@ -502,6 +502,16 @@ def test_get_container_success(self): self.assertTrue(container.extra['lease']['state'], 'available') self.assertTrue(container.extra['meta_data']['meta1'], 'value1') + def test_get_object_cdn_url(self): + obj = self.driver.get_object(container_name='test_container200', + object_name='test') + + url = urlparse.urlparse(self.driver.get_object_cdn_url(obj)) + query = urlparse.parse_qs(url.query) + + self.assertEqual(len(query['sig']), 1) + self.assertGreater(len(query['sig'][0]), 0) + def test_get_object_container_doesnt_exist(self): # This method makes two requests which makes mocking the response a bit # trickier