From f03b615bfe89dbb839606723067e00cbb983f0df Mon Sep 17 00:00:00 2001 From: Shubham Potale Date: Wed, 25 Sep 2019 20:08:15 +0530 Subject: [PATCH] Implement RestFul API to read VNFD of an on-boarded VNF package The VNFD can be implemented as a single file or as a collection of multiple files. If the VNFD is implemented in the form of multiple files, a ZIP file embedding these files shall be returned. If the VNFD is implemented as a single file, either that file or a ZIP file embedding that file shall be returned. Implemented below API:- * GET /vnf_packages/{vnfPkgId}/vnfd Change-Id: I4af9c8126fb7478da294bc99a176eabba3944564 Implements: bp enhance-vnf-package-support-part1 --- api-ref/source/v1/parameters.yaml | 8 ++ api-ref/source/v1/status.yaml | 5 + api-ref/source/v1/vnf_packages.inc | 56 ++++++++ tacker/api/vnfpkgm/v1/controller.py | 70 ++++++++++ tacker/api/vnfpkgm/v1/router.py | 6 + tacker/common/csar_utils.py | 4 +- tacker/common/exceptions.py | 4 + tacker/conductor/conductor_server.py | 77 +++++++++++ tacker/conductor/conductorrpc/vnf_pkgm_rpc.py | 9 ++ tacker/policies/vnf_package.py | 11 +- tacker/tests/functional/base.py | 6 +- .../functional/vnfpkgm/test_vnf_package.py | 73 +++++++++++ tacker/tests/unit/common/test_csar_utils.py | 24 ++-- .../conductorrpc/test_vnf_pkgm_rpc.py | 15 +++ tacker/tests/unit/conductor/fakes.py | 64 +++++++++ .../unit/conductor/test_conductor_server.py | 63 ++++++++- tacker/tests/unit/vnfpkgm/fakes.py | 53 +++++++- tacker/tests/unit/vnfpkgm/test_controller.py | 121 ++++++++++++++++++ tacker/wsgi.py | 13 +- 19 files changed, 656 insertions(+), 26 deletions(-) diff --git a/api-ref/source/v1/parameters.yaml b/api-ref/source/v1/parameters.yaml index d311b4793..31e2ae0bc 100644 --- a/api-ref/source/v1/parameters.yaml +++ b/api-ref/source/v1/parameters.yaml @@ -1,4 +1,12 @@ # variables in header +content_type: + description: | + If the VNFD is implemented in the form of multiple files, zip file will + be returned with Content-Type set as `application/zip` otherwise it will + be set to `text/plain` in the response header. + in: header + required: true + type: string # variables in path alias_path: diff --git a/api-ref/source/v1/status.yaml b/api-ref/source/v1/status.yaml index 250ac7342..fa186b9c9 100644 --- a/api-ref/source/v1/status.yaml +++ b/api-ref/source/v1/status.yaml @@ -46,6 +46,11 @@ 405: default: | Method is not valid for this endpoint. +406: + default: | + Not Acceptable, the requested resource is only capable of generating + content not acceptable according to the 'Accept' headers sent in the + request. 409: default: | This operation conflicted with another operation on this resource. diff --git a/api-ref/source/v1/vnf_packages.inc b/api-ref/source/v1/vnf_packages.inc index 75da39385..5ba5e6dec 100644 --- a/api-ref/source/v1/vnf_packages.inc +++ b/api-ref/source/v1/vnf_packages.inc @@ -305,3 +305,59 @@ Response Example .. literalinclude:: samples/vnf_packages/vnf-packages-patch-response.json :language: javascript + +Read VNFD of an individual VNF package +====================================== + +.. rest_method:: GET /vnfpkgm/v1/vnf_packages/{vnf_package_id}/vnfd + +Read VNFD of an on-boarded VNF package. + +The VNFD can be implemented as a single file or as a collection of multiple +files. If the VNFD is implemented in the form of multiple files, a ZIP file +embedding these files shall be returned. If the VNFD is implemented as a +single file, either that file or a ZIP file embedding that file shall be +returned. The selection of the format is controlled by the "Accept" HTTP +header passed in the GET request. + +If the "Accept" header contains only "text/plain" and the VNFD is implemented +as a single file, the file shall be returned; otherwise, an error message shall +be returned. +If the "Accept" header contains only "application/zip", the single file or +the multiple files that make up the VNFD shall be returned embedded in a ZIP +file. +If the "Accept" header contains both "text/plain" and "application/zip", it +is up to the NFVO to choose the format to return for a single-file VNFD; for a +multi-file VNFD, a ZIP file shall be returned. + +Response Codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 400 + - 401 + - 403 + - 404 + - 406 + - 409 + - 500 + +Request Parameters +------------------ + +.. rest_parameters:: parameters.yaml + + - vnf_package_id: vnf_package_id_path + - Accept: content_type + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - Content-Type: content_type diff --git a/tacker/api/vnfpkgm/v1/controller.py b/tacker/api/vnfpkgm/v1/controller.py index 4f34f6f62..df16b3602 100644 --- a/tacker/api/vnfpkgm/v1/controller.py +++ b/tacker/api/vnfpkgm/v1/controller.py @@ -13,13 +13,17 @@ # License for the specific language governing permissions and limitations # under the License. +from io import BytesIO from oslo_config import cfg from oslo_log import log as logging from oslo_utils import excutils from oslo_utils import uuidutils +import six from six.moves import http_client from six.moves import urllib import webob +import zipfile +from zipfile import ZipFile from tacker._i18n import _ from tacker.api.schemas import vnf_packages @@ -275,6 +279,72 @@ class VnfPkgmController(wsgi.Controller): return self._view_builder.patch(old_vnf_package, vnf_package) + @wsgi.response(http_client.OK) + @wsgi.expected_errors((http_client.BAD_REQUEST, http_client.FORBIDDEN, + http_client.NOT_FOUND, http_client.NOT_ACCEPTABLE, + http_client.CONFLICT, + http_client.INTERNAL_SERVER_ERROR)) + def get_vnf_package_vnfd(self, request, id): + context = request.environ['tacker.context'] + context.can(vnf_package_policies.VNFPKGM % 'get_vnf_package_vnfd') + + valid_accept_headers = ['application/zip', 'text/plain'] + accept_headers = request.headers['Accept'].split(',') + for header in accept_headers: + if header not in valid_accept_headers: + msg = _("Accept header %(accept)s is invalid, it should be one" + " of these values: %(valid_values)s") + raise webob.exc.HTTPNotAcceptable( + explanation=msg % {"accept": header, + "valid_values": ",".join( + valid_accept_headers)}) + + vnf_package = self._get_vnf_package(id, request) + + if vnf_package.onboarding_state != \ + fields.PackageOnboardingStateType.ONBOARDED: + msg = _("VNF Package %(id)s state is not " + "%(onboarded)s") + raise webob.exc.HTTPConflict(explanation=msg % {"id": id, + "onboarded": fields.PackageOnboardingStateType.ONBOARDED}) + + try: + vnfd_files_and_data = self.rpc_api.\ + get_vnf_package_vnfd(context, vnf_package) + except exceptions.FailedToGetVnfdData as e: + LOG.error(e.msg) + raise webob.exc.HTTPInternalServerError( + explanation=six.text_type(e.msg)) + + if 'text/plain' in accept_headers: + # Checking for yaml files only. This is required when there is + # TOSCA.meta file along with single yaml file. + # In such case we need to return single yaml file. + file_list = list(vnfd_files_and_data.keys()) + yaml_files = [file for file in file_list if file.endswith( + ('.yaml', '.yml'))] + if len(yaml_files) == 1: + request.response.headers['Content-Type'] = 'text/plain' + return vnfd_files_and_data[yaml_files[0]] + elif 'application/zip' in accept_headers: + request.response.headers['Content-Type'] = 'application/zip' + return self._create_vnfd_zip(vnfd_files_and_data) + else: + msg = _("VNFD is implemented as multiple yaml files," + " Accept header should be 'application/zip'.") + raise webob.exc.HTTPBadRequest(explanation=msg) + else: + request.response.headers['Content-Type'] = 'application/zip' + return self._create_vnfd_zip(vnfd_files_and_data) + + def _create_vnfd_zip(self, vnfd_files_and_data): + buff = BytesIO() + with ZipFile(buff, 'w', zipfile.ZIP_DEFLATED) as zip_archive: + for file_path, file_data in vnfd_files_and_data.items(): + zip_archive.writestr(file_path, file_data) + + return buff.getvalue() + def create_resource(): body_deserializers = { diff --git a/tacker/api/vnfpkgm/v1/router.py b/tacker/api/vnfpkgm/v1/router.py index bdfadd375..c9af7036d 100644 --- a/tacker/api/vnfpkgm/v1/router.py +++ b/tacker/api/vnfpkgm/v1/router.py @@ -74,3 +74,9 @@ class VnfpkgmAPIRouter(wsgi.Router): self._setup_route(mapper, "/vnf_packages/{id}/package_content/upload_from_uri", methods, controller, default_resource) + + # Allowed methods on /vnf_packages/{id}/vnfd + methods = {"GET": "get_vnf_package_vnfd"} + self._setup_route(mapper, + "/vnf_packages/{id}/vnfd", + methods, controller, default_resource) diff --git a/tacker/common/csar_utils.py b/tacker/common/csar_utils.py index 172f0e41c..0df5501ce 100644 --- a/tacker/common/csar_utils.py +++ b/tacker/common/csar_utils.py @@ -269,7 +269,7 @@ def _get_data_from_csar(tosca, context, id): return vnf_data, flavours -def _extract_csar_zip_file(file_path, extract_path): +def extract_csar_zip_file(file_path, extract_path): try: with zipfile.ZipFile(file_path, 'r') as zf: zf.extractall(extract_path) @@ -287,7 +287,7 @@ def load_csar_data(context, package_uuid, zip_path): extract_zip_path = os.path.join(CONF.vnf_package.vnf_package_csar_path, package_uuid) - _extract_csar_zip_file(zip_path, extract_zip_path) + extract_csar_zip_file(zip_path, extract_zip_path) try: tosca = ToscaTemplate(zip_path, None, True) diff --git a/tacker/common/exceptions.py b/tacker/common/exceptions.py index e878d08c1..d2738e2bf 100644 --- a/tacker/common/exceptions.py +++ b/tacker/common/exceptions.py @@ -240,6 +240,10 @@ class UploadFailedToGlanceStore(Invalid): "%(error)s") +class FailedToGetVnfdData(Invalid): + message = _("Failed to get csar zip file from glance store: %(error)s") + + class InvalidCSAR(Invalid): message = _("Invalid csar: %(error)s") diff --git a/tacker/conductor/conductor_server.py b/tacker/conductor/conductor_server.py index 56f60d48a..ffa722ff1 100644 --- a/tacker/conductor/conductor_server.py +++ b/tacker/conductor/conductor_server.py @@ -16,17 +16,21 @@ import datetime import functools import inspect +import io import os import shutil import sys +from glance_store import exceptions as store_exceptions from oslo_log import log as logging import oslo_messaging from oslo_service import periodic_task from oslo_service import service +from oslo_utils import encodeutils from oslo_utils import excutils from oslo_utils import timeutils from sqlalchemy.orm import exc as orm_exc +import yaml from tacker.common import csar_utils from tacker.common import exceptions @@ -255,6 +259,79 @@ class Conductor(manager.Manager): vnf_package.destroy(context) + def get_vnf_package_vnfd(self, context, vnf_package): + csar_path = os.path.join(CONF.vnf_package.vnf_package_csar_path, + vnf_package.id) + if not os.path.isdir(csar_path): + location = vnf_package.location_glance_store + try: + zip_path = glance_store.load_csar(vnf_package.id, location) + csar_utils.extract_csar_zip_file(zip_path, csar_path) + except (store_exceptions.GlanceStoreException) as e: + exc_msg = encodeutils.exception_to_unicode(e) + msg = (_("Exception raised from glance store can be " + "unrecoverable if it is not related to connection" + " error. Error: %s.") % exc_msg) + raise exceptions.FailedToGetVnfdData(error=msg) + try: + return self._read_vnfd_files(csar_path) + except Exception as e: + exc_msg = encodeutils.exception_to_unicode(e) + msg = (_("Exception raised while reading csar file" + " Error: %s.") % exc_msg) + raise exceptions.FailedToGetVnfdData(error=msg) + + def _read_vnfd_files(self, csar_path): + """Creating a dictionary with file path as key and file data as value. + + It will contain YAML files representing the VNFD, and information + necessary to navigate the ZIP file and to identify the file that is + the entry point for parsing the VNFD such as TOSCA-meta is included. + """ + + def _add_recursively_imported_files(imported_yamls, file_path_and_data, + dir_of_parent_definition_file=''): + for file in imported_yamls: + file_path = os.path.join( + csar_path, dir_of_parent_definition_file, file) + file_data = yaml.safe_load(io.open(file_path)) + dest_file_path = os.path.abspath(file_path).split( + csar_path + '/')[-1] + file_path_and_data[dest_file_path] = yaml.dump(file_data) + + if file_data.get('imports'): + dir_of_parent_definition_file = '/'.join( + file_path.split('/')[:-1]) + _add_recursively_imported_files( + file_data['imports'], file_path_and_data, + dir_of_parent_definition_file) + + file_path_and_data = {} + if 'TOSCA-Metadata' in os.listdir(csar_path) and os.path.isdir( + os.path.join(csar_path, 'TOSCA-Metadata')): + # This is CSAR containing a TOSCA-Metadata directory, which + # includes the TOSCA.meta metadata file providing an entry + # information for processing a CSAR file. + tosca_meta_data = yaml.safe_load(io.open(os.path.join( + csar_path, 'TOSCA-Metadata', 'TOSCA.meta'))) + file_path_and_data['TOSCA-Metadata/TOSCA.meta'] = yaml.dump( + tosca_meta_data) + entry_defination_file = tosca_meta_data['Entry-Definitions'] + _add_recursively_imported_files([entry_defination_file], + file_path_and_data) + else: + # This is a CSAR without a TOSCA-Metadata directory and containing + # a single yaml file with a .yml or .yaml extension at the root of + # the archive. + root_yaml_file = sorted( + os.listdir(csar_path), + key=lambda item: item.endswith(('yaml', '.yml')))[-1] + src_path = os.path.join(csar_path, root_yaml_file) + file_data = yaml.safe_load(io.open(src_path)) + file_path_and_data[root_yaml_file] = yaml.dump(file_data) + + return file_path_and_data + @periodic_task.periodic_task(spacing=CONF.vnf_package_delete_interval) def _run_cleanup_vnf_packages(self, context): """Delete orphan extracted csar zip and files from extracted path diff --git a/tacker/conductor/conductorrpc/vnf_pkgm_rpc.py b/tacker/conductor/conductorrpc/vnf_pkgm_rpc.py index f8f7d2389..3de218bf5 100644 --- a/tacker/conductor/conductorrpc/vnf_pkgm_rpc.py +++ b/tacker/conductor/conductorrpc/vnf_pkgm_rpc.py @@ -61,3 +61,12 @@ class VNFPackageRPCAPI(object): rpc_method = cctxt.cast if cast else cctxt.call return rpc_method(context, 'delete_vnf_package', vnf_package=vnf_package) + + def get_vnf_package_vnfd(self, context, vnf_package, cast=False): + serializer = objects_base.TackerObjectSerializer() + client = rpc.get_client(self.target, version_cap=None, + serializer=serializer) + cctxt = client.prepare() + rpc_method = cctxt.cast if cast else cctxt.call + return rpc_method(context, 'get_vnf_package_vnfd', + vnf_package=vnf_package) diff --git a/tacker/policies/vnf_package.py b/tacker/policies/vnf_package.py index c93a72ffe..4ebef2ce5 100644 --- a/tacker/policies/vnf_package.py +++ b/tacker/policies/vnf_package.py @@ -94,7 +94,16 @@ rules = [ 'path': '/vnf_packages/{vnf_package_id}' } ]), - + policy.DocumentedRuleDefault( + name=VNFPKGM % 'get_vnf_package_vnfd', + check_str=base.RULE_ADMIN_OR_OWNER, + description="reads the content of the VNFD within a VNF package.", + operations=[ + { + 'method': 'GET', + 'path': '/vnf_packages/{vnf_package_id}/vnfd' + } + ]), ] diff --git a/tacker/tests/functional/base.py b/tacker/tests/functional/base.py index 3b53fa14d..4936fd94b 100644 --- a/tacker/tests/functional/base.py +++ b/tacker/tests/functional/base.py @@ -60,7 +60,9 @@ class SessionClient(adapter.Adapter): def _decode_json(self, response): body = response.text - if body: + if body and response.headers['Content-Type'] == 'text/plain': + return body + elif body: return jsonutils.loads(body) else: return "" @@ -68,6 +70,8 @@ class SessionClient(adapter.Adapter): def do_request(self, url, method, **kwargs): kwargs.setdefault('authenticated', True) resp = self.request(url, method, **kwargs) + if resp.headers['Content-Type'] == 'application/zip': + return resp, resp.content body = self._decode_json(resp) return resp, body diff --git a/tacker/tests/functional/vnfpkgm/test_vnf_package.py b/tacker/tests/functional/vnfpkgm/test_vnf_package.py index c4d2ef050..69c9f6e47 100644 --- a/tacker/tests/functional/vnfpkgm/test_vnf_package.py +++ b/tacker/tests/functional/vnfpkgm/test_vnf_package.py @@ -13,14 +13,22 @@ # License for the specific language governing permissions and limitations # under the License. +import ddt import os +import tempfile import time +import zipfile from oslo_serialization import jsonutils +import tacker.conf from tacker.tests.functional import base +CONF = tacker.conf.CONF + + +@ddt.ddt class VnfPackageTest(base.BaseTackerTest): VNF_PACKAGE_DELETE_TIMEOUT = 120 @@ -169,3 +177,68 @@ class VnfPackageTest(base.BaseTackerTest): self.assertEqual(expected_result, resp_body) self._delete_vnf_package(vnf_package['id']) self._wait_for_delete(vnf_package['id']) + + def _create_and_onboard_vnf_package(self, file_name=None): + body = jsonutils.dumps({"userDefinedData": {"foo": "bar"}}) + vnf_package = self._create_vnf_package(body) + if file_name is None: + file_name = "sample_vnf_package_csar.zip" + file_path = self._get_csar_file_path(file_name) + with open(file_path, 'rb') as file_object: + resp, resp_body = self.http_client.do_request( + '{base_path}/{id}/package_content'.format( + id=vnf_package['id'], + base_path=self.base_url), + "PUT", body=file_object, content_type='application/zip') + self.assertEqual(202, resp.status_code) + self._wait_for_onboard(vnf_package['id']) + + return vnf_package['id'] + + def test_get_vnfd_from_onboarded_vnf_package_for_content_type_zip(self): + vnf_package_id = self._create_and_onboard_vnf_package() + self.addCleanup(self._delete_vnf_package, vnf_package_id) + resp, resp_body = self.http_client.do_request( + '{base_path}/{id}/vnfd'.format(id=vnf_package_id, + base_path=self.base_url), + "GET", content_type='application/zip') + self.assertEqual(200, resp.status_code) + self.assertEqual('application/zip', resp.headers['Content-Type']) + self.assert_resp_contents(resp) + + def assert_resp_contents(self, resp): + expected_file_list = ['Definitions/helloworld3_top.vnfd.yaml', + 'Definitions/helloworld3_df_simple.yaml', + 'Definitions/etsi_nfv_sol001_vnfd_types.yaml', + 'Definitions/etsi_nfv_sol001_common_types.yaml', + 'Definitions/helloworld3_types.yaml', + 'TOSCA-Metadata/TOSCA.meta'] + + tmp = tempfile.NamedTemporaryFile(delete=False) + try: + tmp.write(resp.content) + finally: + # checking response.content is valid zip file + self.assertTrue(zipfile.is_zipfile(tmp)) + with zipfile.ZipFile(tmp, 'r') as zipObj: + # Get list of files names in zip + actual_file_list = zipObj.namelist() + self.assertEqual(expected_file_list, actual_file_list) + + tmp.close() + + @ddt.data('text/plain', 'application/zip,text/plain') + def test_get_vnfd_from_onboarded_vnf_package_for_content_type_text( + self, accept_header): + # Uploading vnf package with single yaml file csar. + single_yaml_csar = "sample_vnfpkg_no_meta_single_vnfd.zip" + vnf_package_id = self._create_and_onboard_vnf_package( + single_yaml_csar) + self.addCleanup(self._delete_vnf_package, vnf_package_id) + resp, resp_body = self.http_client.do_request( + '{base_path}/{id}/vnfd'.format(id=vnf_package_id, + base_path=self.base_url), + "GET", content_type=accept_header) + self.assertEqual(200, resp.status_code) + self.assertEqual('text/plain', resp.headers['Content-Type']) + self.assertIsNotNone(resp.text) diff --git a/tacker/tests/unit/common/test_csar_utils.py b/tacker/tests/unit/common/test_csar_utils.py index dc6581e58..8a7e8a45c 100644 --- a/tacker/tests/unit/common/test_csar_utils.py +++ b/tacker/tests/unit/common/test_csar_utils.py @@ -34,7 +34,7 @@ class TestCSARUtils(testtools.TestCase): return os.path.join( self.base_path, "../../etc/samples", file_name) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data(self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path("sample_vnf_package_csar.zip") vnf_data, flavours = csar_utils.load_csar_data( @@ -44,7 +44,7 @@ class TestCSARUtils(testtools.TestCase): self.assertEqual(flavours[0]['flavour_id'], 'simple') self.assertIsNotNone(flavours[0]['sw_images']) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_with_single_yaml( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -56,7 +56,7 @@ class TestCSARUtils(testtools.TestCase): self.assertEqual(flavours[0]['flavour_id'], 'simple') self.assertIsNotNone(flavours[0]['sw_images']) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_without_instantiation_level( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -68,7 +68,7 @@ class TestCSARUtils(testtools.TestCase): ' "tosca.policies.nfv.InstantiationLevels is not defined.') self.assertEqual(msg, exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_with_invalid_instantiation_level( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -81,7 +81,7 @@ class TestCSARUtils(testtools.TestCase): "defined levels %s") % ",".join(sorted(levels)) self.assertEqual(msg, exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_with_invalid_default_instantiation_level( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -94,7 +94,7 @@ class TestCSARUtils(testtools.TestCase): "defined levels %s") % ",".join(sorted(levels)) self.assertEqual(msg, exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_without_vnfd_info( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -104,7 +104,7 @@ class TestCSARUtils(testtools.TestCase): self.context, constants.UUID, file_path) self.assertEqual("VNF properties are mandatory", exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_with_artifacts_and_without_sw_image_data( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -116,7 +116,7 @@ class TestCSARUtils(testtools.TestCase): ' type tosca.artifacts.nfv.SwImage for node VDU1.') self.assertEqual(msg, exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_with_multiple_sw_image_data( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -128,7 +128,7 @@ class TestCSARUtils(testtools.TestCase): ' is added more than one time for node VDU1.') self.assertEqual(msg, exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_csar_with_missing_sw_image_data_in_main_template( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -140,7 +140,7 @@ class TestCSARUtils(testtools.TestCase): ' type tosca.artifacts.nfv.SwImage for node VDU1.') self.assertEqual(msg, exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_without_flavour_info( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path("csar_without_flavour_info.zip") @@ -149,7 +149,7 @@ class TestCSARUtils(testtools.TestCase): self.context, constants.UUID, file_path) self.assertEqual("No VNF flavours are available", exc.format_message()) - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_without_flavour_info_in_main_template( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path( @@ -167,7 +167,7 @@ class TestCSARUtils(testtools.TestCase): mock_rmtree.assert_called() mock_remove.assert_called() - @mock.patch('tacker.common.csar_utils._extract_csar_zip_file') + @mock.patch('tacker.common.csar_utils.extract_csar_zip_file') def test_load_csar_data_without_policies( self, mock_extract_csar_zip_file): file_path = self._get_csar_file_path("csar_without_policies.zip") diff --git a/tacker/tests/unit/conductor/conductorrpc/test_vnf_pkgm_rpc.py b/tacker/tests/unit/conductor/conductorrpc/test_vnf_pkgm_rpc.py index 68a3db3be..407eb0737 100644 --- a/tacker/tests/unit/conductor/conductorrpc/test_vnf_pkgm_rpc.py +++ b/tacker/tests/unit/conductor/conductorrpc/test_vnf_pkgm_rpc.py @@ -79,3 +79,18 @@ class VnfPackageRPCTestCase(base.BaseTestCase): self.context, 'delete_vnf_package', vnf_package=vnf_package_obj) _test() + + def test_get_vnf_package_vnfd(self): + + @mock.patch.object(BackingOffClient, 'prepare') + def _test(prepare_mock): + prepare_mock.return_value = self.cctxt_mock + vnf_package_obj = vnf_package.VnfPackage(self.context, + **fakes.VNF_DATA) + self.rpc_api.get_vnf_package_vnfd(self.context, + vnf_package_obj, cast=False) + prepare_mock.assert_called() + self.cctxt_mock.call.assert_called_once_with( + self.context, 'get_vnf_package_vnfd', + vnf_package=vnf_package_obj) + _test() diff --git a/tacker/tests/unit/conductor/fakes.py b/tacker/tests/unit/conductor/fakes.py index ce4dd24b5..5c829b17b 100644 --- a/tacker/tests/unit/conductor/fakes.py +++ b/tacker/tests/unit/conductor/fakes.py @@ -13,6 +13,14 @@ # License for the specific language governing permissions and limitations # under the License. +import io +import os +from oslo_config import cfg +import shutil +import tempfile +import yaml +import zipfile + from tacker.tests import uuidsentinel @@ -49,3 +57,59 @@ VNF_PACKAGE_DATA = {'algorithm': None, 'hash': None, 'usage_state': 'NOT_IN_USE', 'user_data': {'abc': 'xyz'} } + + +def make_vnfd_files_list(csar_path): + files_list = [] + # Checking for directory exist + if not os.path.isdir(csar_path): + return + ext = ['.yaml', '.meta'] + for _, _, files in os.walk(csar_path): + for file in files: + if file.endswith(tuple(ext)): + files_list.append(file) + + return files_list + + +def create_fake_csar_dir(vnf_package_id, single_yaml_csar=False): + base_path = os.path.dirname(os.path.abspath(__file__)) + csar_file = ('sample_vnfpkg_no_meta_single_vnfd.zip' if single_yaml_csar + else 'sample_vnf_package_csar.zip') + sample_vnf_package_zip = os.path.join(base_path, "../../etc/samples", + csar_file) + tmpdir = tempfile.mkdtemp() + fake_csar = os.path.join('/tmp/', vnf_package_id) + os.rename(tmpdir, fake_csar) + + with zipfile.ZipFile(sample_vnf_package_zip, 'r') as zf: + zf.extractall(fake_csar) + cfg.CONF.set_override('vnf_package_csar_path', '/tmp', + group='vnf_package') + return fake_csar + + +def get_expected_vnfd_data(): + base_path = os.path.dirname(os.path.abspath(__file__)) + sample_vnf_package_zip = os.path.join( + base_path, "../../etc/samples/sample_vnf_package_csar.zip") + + csar_temp_dir = tempfile.mkdtemp() + + with zipfile.ZipFile(sample_vnf_package_zip, 'r') as zf: + zf.extractall(csar_temp_dir) + + file_names = ['TOSCA-Metadata/TOSCA.meta', + 'Definitions/etsi_nfv_sol001_vnfd_types.yaml', + 'Definitions/helloworld3_types.yaml', + 'Definitions/helloworld3_df_simple.yaml', + 'Definitions/helloworld3_top.vnfd.yaml', + 'Definitions/etsi_nfv_sol001_common_types.yaml'] + file_path_and_data = {} + for file_name in file_names: + file_path_and_data.update({file_name: yaml.dump(yaml.safe_load( + io.open(os.path.join(csar_temp_dir, file_name))))}) + + shutil.rmtree(csar_temp_dir) + return file_path_and_data diff --git a/tacker/tests/unit/conductor/test_conductor_server.py b/tacker/tests/unit/conductor/test_conductor_server.py index 5d8357387..e974fbda2 100644 --- a/tacker/tests/unit/conductor/test_conductor_server.py +++ b/tacker/tests/unit/conductor/test_conductor_server.py @@ -13,13 +13,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -import mock import os +from oslo_config import cfg import shutil import sys +from glance_store import exceptions as store_exceptions +import mock +import yaml + from tacker.common import csar_utils +from tacker.common import exceptions from tacker.conductor import conductor_server +import tacker.conf from tacker import context from tacker.glance_store import store as glance_store from tacker import objects @@ -28,6 +34,8 @@ from tacker.tests.unit.conductor import fakes from tacker.tests.unit.db.base import SqlTestCase from tacker.tests import uuidsentinel +CONF = tacker.conf.CONF + class TestConductor(SqlTestCase): @@ -92,6 +100,59 @@ class TestConductor(SqlTestCase): self.conductor.delete_vnf_package(self.context, self.vnf_package) mock_delete_csar.assert_called() + def test_get_vnf_package_vnfd_with_tosca_meta_file_in_csar(self): + fake_csar = fakes.create_fake_csar_dir(self.vnf_package.id) + expected_data = fakes.get_expected_vnfd_data() + result = self.conductor.get_vnf_package_vnfd(self.context, + self.vnf_package) + self.assertEqual(expected_data, result) + shutil.rmtree(fake_csar) + + def test_get_vnf_package_vnfd_with_single_yaml_csar(self): + fake_csar = fakes.create_fake_csar_dir(self.vnf_package.id, + single_yaml_csar=True) + result = self.conductor.get_vnf_package_vnfd(self.context, + self.vnf_package) + # only one key present in the result shows that it contains only one + # yaml file + self.assertEqual(1, len(result.keys())) + shutil.rmtree(fake_csar) + + @mock.patch.object(glance_store, 'load_csar') + def test_get_vnf_package_vnfd_download_from_glance_store(self, + mock_load_csar): + fake_csar = os.path.join('/tmp/', self.vnf_package.id) + cfg.CONF.set_override('vnf_package_csar_path', '/tmp', + group='vnf_package') + # Scenario in which csar path is not present in the local storage. + base_path = os.path.dirname(os.path.abspath(__file__)) + sample_vnf_package = os.path.join( + base_path, "../../etc/samples/sample_vnf_package_csar.zip") + mock_load_csar.return_value = sample_vnf_package + expected_data = fakes.get_expected_vnfd_data() + result = self.conductor.get_vnf_package_vnfd(self.context, + self.vnf_package) + self.assertEqual(expected_data, result) + shutil.rmtree(fake_csar) + + @mock.patch.object(glance_store, 'load_csar') + def test_get_vnf_package_vnfd_exception_from_glance_store(self, + mock_load_csar): + mock_load_csar.side_effect = store_exceptions.NotFound + self.assertRaises(exceptions.FailedToGetVnfdData, + self.conductor.get_vnf_package_vnfd, self.context, + self.vnf_package) + + @mock.patch.object(conductor_server.Conductor, '_read_vnfd_files') + def test_get_vnf_package_vnfd_exception_from_read_vnfd_files( + self, mock_read_vnfd_files): + fake_csar = fakes.create_fake_csar_dir(self.vnf_package.id) + mock_read_vnfd_files.side_effect = yaml.YAMLError + self.assertRaises(exceptions.FailedToGetVnfdData, + self.conductor.get_vnf_package_vnfd, self.context, + self.vnf_package) + shutil.rmtree(fake_csar) + @mock.patch.object(os, 'remove') @mock.patch.object(shutil, 'rmtree') @mock.patch.object(os.path, 'exists') diff --git a/tacker/tests/unit/vnfpkgm/fakes.py b/tacker/tests/unit/vnfpkgm/fakes.py index 7e99ca66e..b3ef226c8 100644 --- a/tacker/tests/unit/vnfpkgm/fakes.py +++ b/tacker/tests/unit/vnfpkgm/fakes.py @@ -15,8 +15,14 @@ import datetime +import io import iso8601 +import os +import shutil +import tempfile import webob +import yaml +import zipfile from tacker.api.vnfpkgm.v1.router import VnfpkgmAPIRouter from tacker import context @@ -117,7 +123,7 @@ def return_vnf_package_user_data(**updates): return model_obj -def return_vnf_package(**updates): +def return_vnf_package(onboarded=False, **updates): model_obj = models.VnfPackage() if 'user_data' in updates: metadata = [] @@ -126,14 +132,27 @@ def return_vnf_package(**updates): **{'key': key, 'value': value}) metadata.extend([vnf_package_user_data]) model_obj._metadata = metadata - model_obj.update(fake_vnf_package(**updates)) + + if onboarded: + updates = {'onboarding_state': 'ONBOARDED', + 'operational_state': 'ENABLED', + 'algorithm': 'test', + 'hash': 'test', + 'location_glance_store': 'file:test/path/pkg-uuid', + 'updated_at': datetime.datetime( + 1900, 1, 1, 1, 1, 1, tzinfo=iso8601.UTC)} + model_obj.update(fake_vnf_package(**updates)) + else: + model_obj.update(fake_vnf_package(**updates)) + return model_obj -def return_vnfpkg_obj(**updates): +def return_vnfpkg_obj(onboarded=False, **updates): vnf_package = vnf_package_obj.VnfPackage._from_db_object( context, vnf_package_obj.VnfPackage(), - return_vnf_package(**updates), expected_attrs=None) + return_vnf_package(onboarded=onboarded, **updates), + expected_attrs=None) return vnf_package @@ -151,3 +170,29 @@ def wsgi_app_v1(fake_auth_context=None): uuidsentinel.project_id, is_admin=True) api_v1 = InjectContext(ctxt, inner_app_v1) return api_v1 + + +def return_vnfd_data(multiple_yaml_files=True): + base_path = os.path.dirname(os.path.abspath(__file__)) + sample_vnf_package_zip = os.path.join( + base_path, "../../etc/samples/sample_vnf_package_csar.zip") + + csar_temp_dir = tempfile.mkdtemp() + + with zipfile.ZipFile(sample_vnf_package_zip, 'r') as zf: + zf.extractall(csar_temp_dir) + + file_names = ['Definitions/etsi_nfv_sol001_vnfd_types.yaml'] + if multiple_yaml_files: + file_names.extend(['TOSCA-Metadata/TOSCA.meta', + 'Definitions/helloworld3_types.yaml', + 'Definitions/helloworld3_df_simple.yaml', + 'Definitions/helloworld3_top.vnfd.yaml', + 'Definitions/etsi_nfv_sol001_common_types.yaml']) + file_path_and_data = {} + for file_name in file_names: + file_path_and_data.update({file_name: yaml.dump(yaml.safe_load( + io.open(os.path.join(csar_temp_dir, file_name))))}) + + shutil.rmtree(csar_temp_dir) + return file_path_and_data diff --git a/tacker/tests/unit/vnfpkgm/test_controller.py b/tacker/tests/unit/vnfpkgm/test_controller.py index 20a02ee7a..b7f3dc764 100644 --- a/tacker/tests/unit/vnfpkgm/test_controller.py +++ b/tacker/tests/unit/vnfpkgm/test_controller.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import ddt import mock from oslo_serialization import jsonutils from six.moves import http_client @@ -20,6 +21,7 @@ from six.moves import urllib from webob import exc from tacker.api.vnfpkgm.v1 import controller +from tacker.common import exceptions from tacker.conductor.conductorrpc.vnf_pkgm_rpc import VNFPackageRPCAPI from tacker.glance_store import store as glance_store from tacker import objects @@ -32,6 +34,7 @@ from tacker.tests.unit import fake_request from tacker.tests.unit.vnfpkgm import fakes +@ddt.ddt class TestController(base.TestCase): def setUp(self): @@ -413,3 +416,121 @@ class TestController(base.TestCase): self.assertRaises(exc.HTTPBadRequest, self.controller.patch, req, constants.UUID, body=body) + + @mock.patch.object(VNFPackageRPCAPI, "get_vnf_package_vnfd") + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + @ddt.data('application/zip', 'text/plain,application/zip', + 'application/zip,text/plain') + def test_get_vnf_package_vnfd_with_valid_accept_headers( + self, accept_headers, mock_vnf_by_id, mock_get_vnf_package_vnfd): + mock_vnf_by_id.return_value = fakes.return_vnfpkg_obj(onboarded=True) + mock_get_vnf_package_vnfd.return_value = fakes.return_vnfd_data() + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = accept_headers + req.method = 'GET' + resp = req.get_response(self.app) + self.assertEqual(http_client.OK, resp.status_code) + + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + def test_get_vnf_package_vnfd_with_invalid_accept_header( + self, mock_vnf_by_id): + mock_vnf_by_id.return_value = fakes.return_vnfpkg_obj(onboarded=True) + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = 'test-invalid-header' + req.method = 'GET' + self.assertRaises(exc.HTTPNotAcceptable, + self.controller.get_vnf_package_vnfd, + req, constants.UUID) + + @mock.patch.object(VNFPackageRPCAPI, "get_vnf_package_vnfd") + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + def test_get_vnf_package_vnfd_failed_with_bad_request( + self, mock_vnf_by_id, mock_get_vnf_package_vnfd): + mock_vnf_by_id.return_value = fakes.return_vnfpkg_obj(onboarded=True) + mock_get_vnf_package_vnfd.return_value = fakes.return_vnfd_data() + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = 'text/plain' + req.method = 'GET' + self.assertRaises(exc.HTTPBadRequest, + self.controller.get_vnf_package_vnfd, + req, constants.UUID) + + @mock.patch.object(VNFPackageRPCAPI, "get_vnf_package_vnfd") + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + def test_get_vnf_package_vnfd_for_content_type_text_plain(self, + mock_vnf_by_id, + mock_get_vnf_package_vnfd): + mock_vnf_by_id.return_value = fakes.return_vnfpkg_obj(onboarded=True) + fake_vnfd_data = fakes.return_vnfd_data(multiple_yaml_files=False) + mock_get_vnf_package_vnfd.return_value = fake_vnfd_data + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = 'text/plain' + req.method = 'GET' + resp = req.get_response(self.app) + self.assertEqual(http_client.OK, resp.status_code) + self.assertEqual('text/plain', resp.content_type) + self.assertEqual(fake_vnfd_data[list(fake_vnfd_data.keys())[0]], + resp.text) + + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + def test_get_vnf_package_vnfd_failed_with_invalid_status( + self, mock_vnf_by_id): + mock_vnf_by_id.return_value = fakes.return_vnfpkg_obj(onboarded=False) + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = 'application/zip' + req.method = 'GET' + resp = req.get_response(self.app) + self.assertEqual(http_client.CONFLICT, resp.status_code) + + def test_get_vnf_package_vnfd_with_invalid_uuid(self): + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.INVALID_UUID) + req.headers['Accept'] = 'application/zip' + req.method = 'GET' + exception = self.assertRaises(exc.HTTPNotFound, + self.controller.get_vnf_package_vnfd, + req, constants.INVALID_UUID) + self.assertEqual( + "Can not find requested vnf package: %s" % constants.INVALID_UUID, + exception.explanation) + + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + def test_get_vnf_package_vnfd_with_non_existing_vnf_packagee( + self, mock_vnf_by_id): + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = 'application/zip' + req.method = 'GET' + mock_vnf_by_id.side_effect = exceptions.VnfPackageNotFound + self.assertRaises(exc.HTTPNotFound, + self.controller.get_vnf_package_vnfd, req, + constants.UUID) + + @mock.patch.object(VNFPackageRPCAPI, "get_vnf_package_vnfd") + @mock.patch.object(vnf_package.VnfPackage, "get_by_id") + def test_get_vnf_package_vnfd_failed_with_internal_server_error( + self, mock_vnf_by_id, mock_get_vnf_package_vnfd): + mock_vnf_by_id.return_value = fakes.return_vnfpkg_obj(onboarded=True) + mock_get_vnf_package_vnfd.side_effect = exceptions.FailedToGetVnfdData + req = fake_request.HTTPRequest.blank( + '/vnf_packages/%s/vnfd' + % constants.UUID) + req.headers['Accept'] = 'application/zip' + req.method = 'GET' + resp = req.get_response(self.app) + self.assertRaises(exc.HTTPInternalServerError, + self.controller.get_vnf_package_vnfd, + req, constants.UUID) + self.assertEqual(http_client.INTERNAL_SERVER_ERROR, resp.status_code) diff --git a/tacker/wsgi.py b/tacker/wsgi.py index 18dd3ca42..bf765aa97 100644 --- a/tacker/wsgi.py +++ b/tacker/wsgi.py @@ -396,7 +396,7 @@ class Request(webob.Request): type_from_header = self.get_content_type() if type_from_header: return type_from_header - ctypes = ['application/json'] + ctypes = ['application/json', 'text/plain', 'application/zip'] # Finally search in Accept-* headers bm = self.accept.best_match(ctypes) @@ -935,9 +935,11 @@ class ResponseObject(object): """ serializer = self.serializer - - body = None - if self.obj is not None: + if self.obj is None: + body = None + elif content_type == 'text/plain': + body = self.obj + else: body = serializer.serialize(self.obj) response = webob.Response(body=body) response.status_int = self.code @@ -1032,7 +1034,8 @@ class Resource(Application): if not response: resp_obj = None - if type(action_result) is dict or action_result is None: + if isinstance(action_result, (dict, str)) \ + or action_result is None: resp_obj = ResponseObject(action_result) elif isinstance(action_result, ResponseObject): resp_obj = action_result