diff --git a/qinling/api/controllers/v1/function.py b/qinling/api/controllers/v1/function.py index 328d3ef2..396e09b9 100644 --- a/qinling/api/controllers/v1/function.py +++ b/qinling/api/controllers/v1/function.py @@ -129,7 +129,6 @@ class FunctionsController(rest.RestController): pecan.response.headers['Content-Disposition'] = ( 'attachment; filename="%s"' % os.path.basename(func_db.name) ) - LOG.info("Downloaded function %s", id) @rest_utils.wrap_pecan_controller_exception @pecan.expose('json') diff --git a/qinling/api/controllers/v1/function_version.py b/qinling/api/controllers/v1/function_version.py index bb07959f..0ce35ff6 100644 --- a/qinling/api/controllers/v1/function_version.py +++ b/qinling/api/controllers/v1/function_version.py @@ -12,10 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections + from oslo_config import cfg from oslo_log import log as logging +from oslo_utils import strutils +import pecan from pecan import rest import tenacity +from webob.static import FileIter import wsmeext.pecan as wsme_pecan from qinling.api import access_control as acl @@ -137,3 +142,38 @@ class FunctionVersionsController(rest.RestController): for v in db_versions] return resources.FunctionVersions(function_versions=versions) + + @rest_utils.wrap_pecan_controller_exception + @pecan.expose() + def get(self, function_id, version): + ctx = context.get_ctx() + acl.enforce('function_version:get', ctx) + + download = strutils.bool_from_string( + pecan.request.GET.get('download', False) + ) + version = int(version) + + version_db = db_api.get_function_version(function_id, version) + + if not download: + LOG.info("Getting version %s for function %s.", version, + function_id) + pecan.override_template('json') + return resources.FunctionVersion.from_dict( + version_db.to_dict()).to_dict() + + LOG.info("Downloading version %s for function %s.", version, + function_id) + + f = self.storage_provider.retrieve(ctx.projectid, function_id, + None, version=version) + + if isinstance(f, collections.Iterable): + pecan.response.app_iter = f + else: + pecan.response.app_iter = FileIter(f) + pecan.response.headers['Content-Type'] = 'application/zip' + pecan.response.headers['Content-Disposition'] = ( + 'attachment; filename="%s_%s"' % (function_id, version) + ) diff --git a/qinling/db/api.py b/qinling/db/api.py index dc376b7e..d9a362d7 100644 --- a/qinling/db/api.py +++ b/qinling/db/api.py @@ -206,3 +206,7 @@ def delete_webhooks(**kwargs): def increase_function_version(function_id, old_version, **kwargs): """This function is meant to be invoked within locking section.""" return IMPL.increase_function_version(function_id, old_version, **kwargs) + + +def get_function_version(function_id, version): + return IMPL.get_function_version(function_id, version) diff --git a/qinling/db/sqlalchemy/api.py b/qinling/db/sqlalchemy/api.py index e0e23369..c17ce8ca 100644 --- a/qinling/db/sqlalchemy/api.py +++ b/qinling/db/sqlalchemy/api.py @@ -514,3 +514,16 @@ def increase_function_version(function_id, old_version, session=None, ) return version + + +@db_base.session_aware() +def get_function_version(function_id, version, session=None): + version_db = _secure_query(models.FunctionVersion).filter_by( + function_id=function_id, version_number=version).first() + if not version_db: + raise exc.DBEntityNotFoundError( + "FunctionVersion not found [function_id=%s, version_number=%s]" % + (function_id, version) + ) + + return version_db diff --git a/qinling/storage/base.py b/qinling/storage/base.py index ad345abc..299c536d 100644 --- a/qinling/storage/base.py +++ b/qinling/storage/base.py @@ -40,12 +40,13 @@ class PackageStorage(object): raise NotImplementedError @abc.abstractmethod - def retrieve(self, project_id, function, md5sum): + def retrieve(self, project_id, function, md5sum, version=None): """Get function package data. :param project_id: Project ID. :param function: Function ID. :param md5sum: The function MD5. + :param version: Optional. The function version number. :return: File descriptor that needs to close outside. """ raise NotImplementedError diff --git a/qinling/storage/file_system.py b/qinling/storage/file_system.py index dc47ffbc..6969952d 100644 --- a/qinling/storage/file_system.py +++ b/qinling/storage/file_system.py @@ -74,23 +74,42 @@ class FileSystemStorage(base.PackageStorage): os.rename(new_func_zip, func_zip) return md5_actual - def retrieve(self, project_id, function, md5sum): + def retrieve(self, project_id, function, md5sum, version=0): """Get function package data. + If version is not 0, return the package data of that specific function + version. + :param project_id: Project ID. :param function: Function ID. :param md5sum: The function MD5. + :param version: Optional. The function version number. :return: File descriptor that needs to close outside. """ LOG.debug( - 'Getting package data, function: %s, md5sum: %s, project: %s', - function, md5sum, project_id + 'Getting package data, function: %s, version: %s, md5sum: %s, ' + 'project: %s', + function, md5sum, version, project_id ) - func_zip = os.path.join( - self.base_path, - PACKAGE_PATH_TEMPLATE % (project_id, function, md5sum) - ) + if version != 0: + project_dir = os.path.join(self.base_path, project_id) + for filename in os.listdir(project_dir): + root, ext = os.path.splitext(filename) + if (root.startswith("%s_%d" % (function, version)) + and ext == '.zip'): + func_zip = os.path.join(project_dir, filename) + break + else: + raise exc.StorageNotFoundException( + 'Package of version %d function %s for project %s not ' + 'found.' % (version, function, project_id) + ) + else: + func_zip = os.path.join( + self.base_path, + PACKAGE_PATH_TEMPLATE % (project_id, function, md5sum) + ) if not os.path.exists(func_zip): raise exc.StorageNotFoundException( @@ -99,7 +118,8 @@ class FileSystemStorage(base.PackageStorage): ) f = open(func_zip, 'rb') - LOG.debug('Found package data for function %s', function) + LOG.debug('Found package data for function %s version %d', function, + version) return f diff --git a/qinling/tests/unit/api/controllers/v1/test_function_version.py b/qinling/tests/unit/api/controllers/v1/test_function_version.py index da3f6ff4..b2e12c4d 100644 --- a/qinling/tests/unit/api/controllers/v1/test_function_version.py +++ b/qinling/tests/unit/api/controllers/v1/test_function_version.py @@ -108,3 +108,12 @@ class TestFunctionVersionController(base.APITest): actual = self._assert_single_item(resp.json['function_versions'], version_number=1) self.assertEqual("version 1", actual.get('description')) + + def test_get(self): + db_api.increase_function_version(self.func_id, 0, + description="version 1") + + resp = self.app.get('/v1/functions/%s/versions/1' % self.func_id) + + self.assertEqual(200, resp.status_int) + self.assertEqual("version 1", resp.json.get('description')) diff --git a/qinling/tests/unit/storage/test_file_system.py b/qinling/tests/unit/storage/test_file_system.py index 97cd71c2..5e39ff82 100644 --- a/qinling/tests/unit/storage/test_file_system.py +++ b/qinling/tests/unit/storage/test_file_system.py @@ -150,6 +150,39 @@ class TestFileSystemStorage(base.BaseTest): ) exists_mock.assert_called_once_with(package_path) + @mock.patch('qinling.storage.file_system.open') + @mock.patch('os.path.exists') + @mock.patch('os.listdir') + def test_retrieve_version(self, mock_list, mock_exist, mock_open): + function = "fake_function_id" + version = 1 + md5 = "md5" + mock_list.return_value = ["%s_%s_%s.zip" % (function, version, md5)] + mock_exist.return_value = True + + self.storage.retrieve(self.project_id, function, None, + version=version) + + version_zip = os.path.join(FAKE_STORAGE_PATH, self.project_id, + "%s_%s_%s.zip" % (function, version, md5)) + + mock_exist.assert_called_once_with(version_zip) + + @mock.patch('os.listdir') + def test_retrieve_version_not_found(self, mock_list): + function = "fake_function_id" + version = 1 + mock_list.return_value = [""] + + self.assertRaises( + exc.StorageNotFoundException, + self.storage.retrieve, + function, + self.project_id, + None, + version=version + ) + @mock.patch('os.path.exists') @mock.patch('os.remove') def test_delete(self, remove_mock, exists_mock):