From 75fa8f418f20db67a6ee00d78981a033c058abb7 Mon Sep 17 00:00:00 2001 From: rmondal00 Date: Wed, 19 Jul 2023 18:57:50 +0200 Subject: [PATCH] LITE-28130 Add capability to Install/Update product in CBC using PLM API --- CBCClientHowTo.md | 3 + connect_ext_ppr/client/client.py | 1 + connect_ext_ppr/client/exception.py | 5 + connect_ext_ppr/client/ns.py | 35 ++-- connect_ext_ppr/service.py | 31 ---- connect_ext_ppr/services/__init__.py | 0 connect_ext_ppr/services/cbc_extension.py | 37 ++++ connect_ext_ppr/services/cbc_hub.py | 74 ++++++++ tests/client/test_service.py | 12 +- tests/conftest.py | 131 +++++++++++++- tests/services/__init__.py | 0 tests/services/test_cbc_extension.py | 15 ++ tests/services/test_cbc_hub.py | 203 ++++++++++++++++++++++ tests/test_service.py | 15 -- 14 files changed, 498 insertions(+), 64 deletions(-) create mode 100644 connect_ext_ppr/services/__init__.py create mode 100644 connect_ext_ppr/services/cbc_extension.py create mode 100644 connect_ext_ppr/services/cbc_hub.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/test_cbc_extension.py create mode 100644 tests/services/test_cbc_hub.py diff --git a/CBCClientHowTo.md b/CBCClientHowTo.md index 299cee9..6e9a11a 100644 --- a/CBCClientHowTo.md +++ b/CBCClientHowTo.md @@ -12,11 +12,14 @@ endpoint = '******' client_id = '*****' # CBC OAuth Key client_secret = '*****' +# CBC Extension App ID +app_id = '*****' client = CBCClient( endpoint=endpoint, oauth_key=client_id, oauth_secret=client_secret, + app_id=app_id, ) ``` diff --git a/connect_ext_ppr/client/client.py b/connect_ext_ppr/client/client.py index 5562f5e..7a218be 100644 --- a/connect_ext_ppr/client/client.py +++ b/connect_ext_ppr/client/client.py @@ -94,6 +94,7 @@ def execute_request( raise ClientError( message=f'{type(e).__name__} : {str(e)}', status_code=response.status_code, + response=response, cause=e, ) else: diff --git a/connect_ext_ppr/client/exception.py b/connect_ext_ppr/client/exception.py index 4b1c6a9..828cc4c 100644 --- a/connect_ext_ppr/client/exception.py +++ b/connect_ext_ppr/client/exception.py @@ -1,10 +1,15 @@ +from requests import Response + + class ClientError(RuntimeError): def __init__( self, message: str, status_code: int = None, + response: Response = None, cause: Exception = None, ): self.message = message + self.response = response self.status_code = status_code self.cause = cause diff --git a/connect_ext_ppr/client/ns.py b/connect_ext_ppr/client/ns.py index 82e6e16..1f77c13 100644 --- a/connect_ext_ppr/client/ns.py +++ b/connect_ext_ppr/client/ns.py @@ -65,26 +65,31 @@ class Service( NSBase, ActionMixin, ): - def _get_service_path(self): - aps_type_object = self.client.execute_request( - method='GET', - path=f'{self.path}/aps/2/resources/?implementing({self.aps_type})', - ) - - if not aps_type_object: - raise TypeError(f'Not able to find out Service with APS Type: {self.aps_type}') - elif len(aps_type_object) != 1: - raise TypeError(f'Multiple instances found with APS Type: {self.aps_type}') - else: - service_id = aps_type_object[0]['aps']['id'] - return f'{self.path}/aps/2/resources/{service_id}' - def __init__(self, client, aps_type: str, path: str): super().__init__( client=client, path=path, ) self.aps_type = aps_type + self.__service_path = None + + @property + def service_path(self): + if not self.__service_path: + aps_type_object = self.client.execute_request( + method='GET', + path=f'{self.path}/aps/2/resources/?implementing({self.aps_type})', + ) + + if not aps_type_object: + raise TypeError(f'Not able to find out Service with APS Type: {self.aps_type}') + elif len(aps_type_object) != 1: + raise TypeError(f'Multiple instances found with APS Type: {self.aps_type}') + else: + service_id = aps_type_object[0]['aps']['id'] + self.__service_path = f'{self.path}/aps/2/resources/{service_id}' + + return self.__service_path def __getattr__(self, name): if '_' in name: @@ -101,7 +106,7 @@ def collection(self, name: str): return Collection( self.client, - f'{self._get_service_path()}/{name}', + f'{self.service_path}/{name}', ) def get(self, **kwargs): diff --git a/connect_ext_ppr/service.py b/connect_ext_ppr/service.py index 2c86c52..7b2f467 100644 --- a/connect_ext_ppr/service.py +++ b/connect_ext_ppr/service.py @@ -4,14 +4,11 @@ from connect_ext_ppr.errors import ExtensionValidationError from connect_ext_ppr.db import get_db_ctx_manager -from connect_ext_ppr.models.cbc_extenstion import HubCredential from connect_ext_ppr.models.deployment import Deployment from connect_ext_ppr.models.replicas import Product from connect_ext_ppr.utils import _parse_json_schema_error from connect_ext_ppr.constants import PPR_SCHEMA -from sqlalchemy import text - def insert_product_from_listing(db, listing_data, logger): product_data = listing_data['product'] @@ -93,31 +90,3 @@ def update_product(data, config, logger): product.version = data['version'] db.add(product) db.commit() - - -def get_hub_credentials(hub_id, db): - - query = text( - 'SELECT DISTINCT h.hub_id, g.app_instance_id, h.controller_uri, ' - 'c.oauth_key, c.oauth_secret ' - 'FROM hub_instances h ' - 'INNER JOIN global_app_configuration g ON g.hub_uuid = h.extension_resource_uid ' - 'INNER JOIN configuration c ON c.product_id = g.hub_uuid ' - 'WHERE h.hub_id = :hub_id', - ) - - query = query.columns( - HubCredential.hub_id, - HubCredential.app_id, - HubCredential.controller_url, - HubCredential.oauth_key, - HubCredential.oauth_secret, - ) - - return db.query( - HubCredential, - ).from_statement( - query, - ).params( - hub_id=hub_id, - ).first() diff --git a/connect_ext_ppr/services/__init__.py b/connect_ext_ppr/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/connect_ext_ppr/services/cbc_extension.py b/connect_ext_ppr/services/cbc_extension.py new file mode 100644 index 0000000..497e8f2 --- /dev/null +++ b/connect_ext_ppr/services/cbc_extension.py @@ -0,0 +1,37 @@ +from connect_ext_ppr.models.cbc_extenstion import HubCredential + +from sqlalchemy import text + + +HUB_CREDENTIAL_QUERY = ''' +SELECT DISTINCT h.hub_id, + g.app_instance_id, + h.controller_uri, + c.oauth_key, + c.oauth_secret +FROM hub_instances h +INNER JOIN global_app_configuration g ON g.hub_uuid = h.extension_resource_uid +INNER JOIN configuration c ON c.product_id = g.hub_uuid +WHERE h.hub_id = :hub_id +''' + + +def get_hub_credentials(hub_id, db): + + query = text(HUB_CREDENTIAL_QUERY) + + query = query.columns( + HubCredential.hub_id, + HubCredential.app_id, + HubCredential.controller_url, + HubCredential.oauth_key, + HubCredential.oauth_secret, + ) + + return db.query( + HubCredential, + ).from_statement( + query, + ).params( + hub_id=hub_id, + ).first() diff --git a/connect_ext_ppr/services/cbc_hub.py b/connect_ext_ppr/services/cbc_hub.py new file mode 100644 index 0000000..d723d13 --- /dev/null +++ b/connect_ext_ppr/services/cbc_hub.py @@ -0,0 +1,74 @@ +from connect_ext_ppr.client import CBCClient +from connect_ext_ppr.models.cbc_extenstion import HubCredential + + +class CBCService: + + PLM_TYPE = 'http://com.odin.platform/inhouse-products/application' + SUBSCRIPTION_TYPE = 'http://parallels.com/aps/types/pa/subscription' + + def __init__(self, hub_credential: HubCredential, verify_certificate: bool = False): + assert hub_credential, 'HubCredential can not be None!' + assert hub_credential.controller_url, 'Controller URL can not be empty!' + assert hub_credential.oauth_key, 'OAuth Key can not be empty!' + assert hub_credential.oauth_secret, 'OAuth Secret can not be empty!' + + self.hub_credential = hub_credential + self.client = CBCClient( + endpoint=hub_credential.controller_url, + oauth_key=hub_credential.oauth_key, + oauth_secret=hub_credential.oauth_secret, + app_id=hub_credential.app_id, + verify_certificate=verify_certificate, + ) + + self.__primary_subscription_id = None + self.__subscription_service = None + self.__plm_service = None + + @property + def primary_subscription_id(self): + if not self.__primary_subscription_id: + subscriptions = self.subscription_service.get( + subscriptionId=1, + ) + self.__primary_subscription_id = subscriptions[0]['aps']['id'] + + return self.__primary_subscription_id + + @property + def plm_service(self): + if not self.__plm_service: + self.__plm_service = self.client(self.PLM_TYPE) + + return self.__plm_service + + @property + def subscription_service(self): + if not self.__subscription_service: + self.__subscription_service = self.client(self.SUBSCRIPTION_TYPE) + + return self.__subscription_service + + def get_product_details(self, product_id: str): + return self.plm_service.appDetails[product_id].get( + fulfillmentSystem='connect', + ) + + def install_product(self, product_id: str): + self.plm_service.appDetails[product_id].action( + name='import', + payload={ + 'subscriptionId': self.primary_subscription_id, + 'fulfillmentSystem': 'connect', + }, + ) + + def update_product(self, product_id: str): + return self.plm_service.appDetails[product_id].action( + name='upgrade', + payload={ + 'subscriptionId': self.primary_subscription_id, + 'fulfillmentSystem': 'connect', + }, + ) diff --git a/tests/client/test_service.py b/tests/client/test_service.py index be31411..5f50073 100644 --- a/tests/client/test_service.py +++ b/tests/client/test_service.py @@ -91,7 +91,7 @@ def test_service_discovery_collection_with_underscore( @responses.activate -def test_service_discovery_collection_without_underscore( +def test_service_discovery_collection( cbc_endpoint, cbc_client, flat_catalog_type, @@ -104,10 +104,18 @@ def test_service_discovery_collection_without_underscore( json=flat_catalog_type_objects, ) - collection = cbc_client(flat_catalog_type).flatcatalog + service = cbc_client(flat_catalog_type) + + # First time - calls service discovery API + collection = service.flatcatalog assert collection.path == f'{cbc_endpoint}/aps/2/resources/{service_id}/flatcatalog' + # 2nd time - no call to service discovery API + collection = service.subscriptions + + assert collection.path == f'{cbc_endpoint}/aps/2/resources/{service_id}/subscriptions' + @responses.activate def test_service_discovery_collection_wrong_collection_value_type( diff --git a/tests/conftest.py b/tests/conftest.py index d6835f8..969fec8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ from connect_ext_ppr.models.file import File from connect_ext_ppr.models.replicas import Product from connect_ext_ppr.models.ppr import PPRVersion +from connect_ext_ppr.services.cbc_extension import get_hub_credentials from connect_ext_ppr.webapp import ConnectExtensionXvsWebApplication @@ -524,7 +525,7 @@ def cbc_db_session(): " 'HB-000-000'," " '39deb31d-d6ad-48bb-ba0f-82e99a88a7e9'," " 'e4608f13-0582-4780-876f-224add5fa4fd'," - " 'https://www.cbc-instance.com'" + " 'https://example.com/api/v1'" ")", ] for query in queries: @@ -533,3 +534,131 @@ def cbc_db_session(): yield db transaction.rollback() + + +@pytest.fixture +def hub_credentials(cbc_db_session): + return get_hub_credentials( + 'HB-000-000', + cbc_db_session, + ) + + +@pytest.fixture +def product_details(): + return { + 'id': 'PRD-000-000-000', + 'name': 'Product F9', + 'vendor': "Adrian's Inc", + 'isImporting': False, + 'isInstalled': False, + 'isUpdateAvailable': False, + 'version': '1', + 'isSyndicated': False, + 'availableCountries': [], + 'category': 'Finance', + 'vendorLinks': [ + { + 'description': 'Admin Manual', + 'linkUrl': 'https://example.com/manual/admin', + }, + ], + 'releaseInformation': [ + { + 'version': '1', + 'releaseNotes': '', + }, + ], + } + + +@pytest.fixture +def get_product_details_not_found_response(): + return { + 'error': 'com.ingrammicro.imcp.library.aps.exception.APSError', + 'packageId': '04f3fca3-9a61-4af5-b722-dd0e8e25b51b', + 'message': '404 Not Found ' + 'ConnectError(error_code=CNCT_001, errors=[Not found.], params=null)', + 'http_request': 'GET https://inhouse-products:8081/rest/application/' + '4b4b65ec-149a-4a8c-9897-dc32f2e9e379/appDetails/' + 'PRD-361-577-149?fulfillmentSystem=connect', + } + + +@pytest.fixture +def import_product_not_found_response(): + return { + 'error': 'com.ingrammicro.imcp.library.aps.exception.APSError', + 'packageId': '04f3fca3-9a61-4af5-b722-dd0e8e25b51b', + 'message': 'java.lang.NullPointerException: Cannot invoke ' + '"com.odin.platform.application.rest.FulfillmentProduct.getFulfillmentSystem()"' + ' because "product" is null', + 'http_request': 'POST https://inhouse-products:8081/rest/application/' + '4b4b65ec-149a-4a8c-9897-dc32f2e9e379/appDetails/PRD-361-577-149/import', + } + + +@pytest.fixture +def update_product_response(): + return { + 'aps': { + 'id': '25ef793d-9a6e-4176-83b2-55d4849bdddb', + 'type': 'http://aps-standard.org/inhouse-products/connectProduct/1.0', + 'status': 'aps:ready', + 'revision': 5, + 'modified': '2023-07-13T09:05:21Z', + }, + 'productId': 'PRD-000-000-000', + 'version': 1, + 'paAccountId': '2fe324f0-68bc-45fa-91d6-d081c76f29d6', + 'subscriptionId': '7c83c18d-1d3d-40e5-973c-26a26c6eac4f', + 'cartValidation': False, + 'tierConfigValidation': False, + 'tierConfigUpdateValidation': False, + 'changeRequestValidation': False, + 'editableOrderingParameters': False, + 'payAsYouGo': False, + 'dynamicPAYG': False, + 'createNotificationId': 'f9650d3f-77da-49de-a240-5575ab4df3ec', + 'status': 'installed', + 'tenant': { + 'aps': { + 'id': '2c638143-bf4f-4520-90c0-981e36a15acb', + }, + }, + } + + +@pytest.fixture +def product_not_installed_response(): + return { + 'error': 'com.ingrammicro.imcp.library.aps.exception.APSError', + 'packageId': '04f3fca3-9a61-4af5-b722-dd0e8e25b51b', + 'message': 'java.lang.NullPointerException: Cannot invoke ' + '"com.odin.platform.application.rest.FulfillmentProduct.getFulfillmentSystem()"' + ' because "product" is null', + 'http_request': 'POST https://inhouse-products:8081/rest/application/' + '4b4b65ec-149a-4a8c-9897-dc32f2e9e379/appDetails/PRD-361-577-149/import', + } + + +@pytest.fixture +def subscriptions(): + return [ + { + 'name': 'License key', + 'trial': False, + 'oneTime': False, + 'disabled': False, + 'description': 'Parallels Automation License Key', + 'subscriptionId': 1, + 'serviceTemplateId': 0, + 'aps': { + 'modified': '2023-07-13T06:57:12Z', + 'id': '7c83c18d-1d3d-40e5-973c-26a26c6eac4f', + 'type': 'http://parallels.com/aps/types/pa/subscription/1.0', + 'status': 'aps:ready', + 'revision': 1, + }, + }, + ] diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/test_cbc_extension.py b/tests/services/test_cbc_extension.py new file mode 100644 index 0000000..2395f27 --- /dev/null +++ b/tests/services/test_cbc_extension.py @@ -0,0 +1,15 @@ +from connect_ext_ppr.services.cbc_extension import get_hub_credentials + + +def test_get_hub_credentials(cbc_db_session, logger): + hub_id = 'HB-000-000' + hub_credentials = get_hub_credentials(hub_id, cbc_db_session) + + assert hub_credentials + + +def test_get_hub_credentials_none(cbc_db_session, logger): + hub_id = 'HB-000-001' + hub_credentials = get_hub_credentials(hub_id, cbc_db_session) + + assert not hub_credentials diff --git a/tests/services/test_cbc_hub.py b/tests/services/test_cbc_hub.py new file mode 100644 index 0000000..399f31f --- /dev/null +++ b/tests/services/test_cbc_hub.py @@ -0,0 +1,203 @@ +from unittest import TestCase + +import pytest +import responses + +from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.services.cbc_hub import CBCService + + +@responses.activate +def test_get_product_details_positive( + hub_credentials, + cbc_endpoint, + flat_catalog_type_objects, + product_details, +): + product_id = 'PRD-000-000-000' + service_id = flat_catalog_type_objects[0]['aps']['id'] + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?implementing({CBCService.PLM_TYPE})', + json=flat_catalog_type_objects, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/{service_id}/' + f'appDetails/{product_id}?fulfillmentSystem=connect', + json=product_details, + ) + + cbc_service = CBCService(hub_credentials) + product = cbc_service.get_product_details(product_id) + + TestCase().assertDictEqual(product, product_details) + + +@responses.activate +def test_get_product_details_not_found( + hub_credentials, + cbc_endpoint, + flat_catalog_type_objects, + get_product_details_not_found_response, +): + product_id = 'PRD-000-000-000' + service_id = flat_catalog_type_objects[0]['aps']['id'] + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?implementing({CBCService.PLM_TYPE})', + json=flat_catalog_type_objects, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/{service_id}/' + f'appDetails/{product_id}?fulfillmentSystem=connect', + status=500, + json=get_product_details_not_found_response, + ) + + cbc_service = CBCService(hub_credentials) + with pytest.raises(ClientError): + cbc_service.get_product_details(product_id) + + +@responses.activate +def test_install_product_positive( + hub_credentials, + cbc_endpoint, + flat_catalog_type_objects, + subscriptions, +): + product_id = 'PRD-000-000-000' + service_id = flat_catalog_type_objects[0]['aps']['id'] + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?implementing({CBCService.PLM_TYPE})', + json=flat_catalog_type_objects, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.SUBSCRIPTION_TYPE})&subscriptionId=1', + json=subscriptions, + ) + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/' + f'{service_id}/appDetails/{product_id}/import', + status=202, + ) + + cbc_service = CBCService(hub_credentials) + response = cbc_service.install_product(product_id) + + assert response is None + + +@responses.activate +def test_install_product_not_found( + hub_credentials, + cbc_endpoint, + flat_catalog_type_objects, + subscriptions, + import_product_not_found_response, +): + product_id = 'PRD-000-000-000' + service_id = flat_catalog_type_objects[0]['aps']['id'] + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?implementing({CBCService.PLM_TYPE})', + json=flat_catalog_type_objects, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.SUBSCRIPTION_TYPE})&subscriptionId=1', + json=subscriptions, + ) + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/' + f'{service_id}/appDetails/{product_id}/import', + status=500, + json=import_product_not_found_response, + ) + + cbc_service = CBCService(hub_credentials) + with pytest.raises(ClientError): + cbc_service.install_product(product_id) + + +@responses.activate +def test_update_product_positive( + hub_credentials, + cbc_endpoint, + flat_catalog_type_objects, + subscriptions, + update_product_response, +): + product_id = 'PRD-000-000-000' + service_id = flat_catalog_type_objects[0]['aps']['id'] + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?implementing({CBCService.PLM_TYPE})', + json=flat_catalog_type_objects, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.SUBSCRIPTION_TYPE})&subscriptionId=1', + json=subscriptions, + ) + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/' + f'{service_id}/appDetails/{product_id}/upgrade', + status=202, + json=update_product_response, + ) + + cbc_service = CBCService(hub_credentials) + response = cbc_service.update_product(product_id) + + TestCase().assertDictEqual(response, update_product_response) + + +@responses.activate +def test_update_product_negative_product_not_installed( + hub_credentials, + cbc_endpoint, + flat_catalog_type_objects, + subscriptions, + product_not_installed_response, +): + product_id = 'PRD-000-000-000' + service_id = flat_catalog_type_objects[0]['aps']['id'] + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?implementing({CBCService.PLM_TYPE})', + json=flat_catalog_type_objects, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.SUBSCRIPTION_TYPE})&subscriptionId=1', + json=subscriptions, + ) + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/' + f'{service_id}/appDetails/{product_id}/upgrade', + status=500, + json=product_not_installed_response, + ) + + cbc_service = CBCService(hub_credentials) + with pytest.raises(ClientError): + cbc_service.update_product(product_id) diff --git a/tests/test_service.py b/tests/test_service.py index 0a9e6ac..04d2f6e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -4,7 +4,6 @@ from connect_ext_ppr.models.deployment import Deployment from connect_ext_ppr.service import ( add_deployments, - get_hub_credentials, validate_ppr_schema, ) @@ -121,17 +120,3 @@ def test_extra_field_not_matching_pattern_allowed(ppr_valid_schema, not_allow): f"'Published', 'VendorTimezone', 'MPN']" ), ] - - -def test_get_hub_credentials(cbc_db_session, logger): - hub_id = 'HB-000-000' - hub_credentials = get_hub_credentials(hub_id, cbc_db_session) - - assert hub_credentials - - -def test_get_hub_credentials_none(cbc_db_session, logger): - hub_id = 'HB-000-001' - hub_credentials = get_hub_credentials(hub_id, cbc_db_session) - - assert not hub_credentials